feat: PKCE auth + CI/CD deploy
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 1m2s

- Frontend: PKCE flow (oauth.ts, AuthCallback code exchange, 401 interceptor)
- Backend: token introspection via SuperOAuth (no more JWT secret)
- User model: superOauthId (unified) replaces oauthId+provider
- Cookies httpOnly session + refresh token
- POST /auth/refresh endpoint
- Gitea CI workflow (vps-runner pattern)
- DB_SYNC env var for initial schema creation
This commit is contained in:
2026-03-24 13:01:14 +01:00
parent c1bf793234
commit 8c6777c980
61 changed files with 5850 additions and 66 deletions

View File

@@ -0,0 +1,196 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { combatApi } from '../api/endpoints';
import type { Monster, CombatResult } from '../api/types';
import { Swords, Trophy, Skull, Clock } from 'lucide-react';
const ATTACK_TYPES = [
{ id: 'melee', label: 'Mêlée', emoji: '⚔️', stat: 'Force × 1.5' },
{ id: 'ranged', label: 'Distance', emoji: '🏹', stat: 'Agilité × 1.5' },
{ id: 'magic', label: 'Magie', emoji: '✨', stat: 'Intelligence × 1.5' },
];
function MonsterCard({ m, selected, onSelect }: { m: Monster; selected: boolean; onSelect: () => void }) {
return (
<div
className={`card card-hover ${selected ? 'card-gold' : ''}`}
onClick={onSelect}
style={{ cursor: 'pointer', transition: 'all 0.15s' }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 6 }}>
<span style={{ fontWeight: 700, fontSize: 14, color: selected ? '#f4c94e' : '#dce4f0' }}>{m.name}</span>
<span className="badge badge-red" style={{ fontSize: 10 }}>Niv. {m.levelMin}{m.levelMax}</span>
</div>
<div style={{ display: 'flex', gap: 12, fontSize: 12, color: '#6b7a99' }}>
<span> {m.hp}</span>
<span> {m.attack}</span>
<span>🛡 {m.defense}</span>
<span> {m.xpReward} XP</span>
<span>💰 {m.goldMin}{m.goldMax}</span>
</div>
</div>
);
}
function CombatLog({ result }: { result: CombatResult }) {
const won = result.winner === 'player';
return (
<div className="card" style={{ marginTop: '1rem' }}>
{/* Résultat */}
<div style={{ textAlign: 'center', padding: '0.75rem 0', marginBottom: '0.75rem', borderBottom: '1px solid #2a3448' }}>
{won
? <div style={{ color: '#3ddc84', fontWeight: 800, fontSize: 18 }}>
<Trophy size={20} style={{ display: 'inline', marginRight: 8 }} />
Victoire ! +{result.xpGained} XP +{result.goldGained} or
</div>
: <div style={{ color: '#e84040', fontWeight: 800, fontSize: 18 }}>
<Skull size={20} style={{ display: 'inline', marginRight: 8 }} />
Défaite 50 endurance
</div>
}
{result.loot && (
<div style={{ fontSize: 13, color: '#f4c94e', marginTop: 4 }}>
🎁 Loot : {result.loot.material.name} ×{result.loot.quantity}
</div>
)}
</div>
{/* Log de combat */}
<p style={{ margin: '0 0 6px', fontSize: 12, fontWeight: 700, color: '#6b7a99' }}>
Log {result.rounds.length} tour{result.rounds.length > 1 ? 's' : ''}
</p>
<div className="combat-log">
{result.rounds.flatMap(r =>
r.log.map((line, i) => {
const cls = line.includes('frappe') && !line.includes('Monstre') ? 'log-player'
: line.includes('Monstre') || line.includes('frappe') ? 'log-monster'
: line.includes('CRITIQUE') ? 'log-crit'
: 'log-system';
return <div key={`${r.round}-${i}`} className={cls}>[T{r.round}] {line}</div>;
})
)}
{won
? <div className="log-system"> Victoire </div>
: <div className="log-monster"> Défaite </div>
}
</div>
</div>
);
}
export function CombatPage() {
const qc = useQueryClient();
const [selectedMonster, setSelectedMonster] = useState<Monster | null>(null);
const [attackType, setAttackType] = useState('melee');
const [lastResult, setLastResult] = useState<CombatResult | null>(null);
const { data: monsters, isLoading } = useQuery({
queryKey: ['monsters'],
queryFn: combatApi.monsters,
});
const { data: history } = useQuery({
queryKey: ['combatHistory'],
queryFn: combatApi.history,
});
const fight = useMutation({
mutationFn: () => combatApi.start(selectedMonster!.id, attackType),
onSuccess: (result) => {
setLastResult(result);
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['combatHistory'] });
},
});
if (isLoading) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement des monstres</div>;
return (
<div>
<h2 style={{ margin: '0 0 1rem', color: '#f4c94e', fontSize: 20 }}> Combat</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '1rem' }}>
{/* Choix monstre */}
<div>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
Adversaire
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{monsters?.map(m => (
<MonsterCard
key={m.id}
m={m}
selected={selectedMonster?.id === m.id}
onSelect={() => setSelectedMonster(m)}
/>
))}
</div>
</div>
{/* Panneau droite */}
<div>
{/* Type d'attaque */}
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
Type d'attaque
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: '1rem' }}>
{ATTACK_TYPES.map(a => (
<div
key={a.id}
className={`card card-hover ${attackType === a.id ? 'card-gold' : ''}`}
onClick={() => setAttackType(a.id)}
style={{ display: 'flex', alignItems: 'center', gap: 10 }}
>
<span style={{ fontSize: 18 }}>{a.emoji}</span>
<div>
<div style={{ fontWeight: 700, fontSize: 13, color: attackType === a.id ? '#f4c94e' : '#dce4f0' }}>{a.label}</div>
<div style={{ fontSize: 11, color: '#6b7a99' }}>{a.stat}</div>
</div>
</div>
))}
</div>
{/* Bouton combattre */}
<button
className="btn btn-red"
style={{ width: '100%', fontSize: 15, padding: '0.75rem' }}
disabled={!selectedMonster || fight.isPending}
onClick={() => fight.mutate()}
>
{fight.isPending ? (
<span><Swords size={14} style={{ display: 'inline', marginRight: 6 }} />Combat…</span>
) : (
<span>⚔️ Combattre {selectedMonster ? `— ${selectedMonster.name}` : ''}</span>
)}
</button>
{fight.isError && (
<p style={{ color: '#e84040', fontSize: 12, marginTop: 8 }}>{(fight.error as Error).message}</p>
)}
{/* Historique récent */}
{history && history.length > 0 && (
<div style={{ marginTop: '1rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase', display:'flex', alignItems:'center', gap:4 }}>
<Clock size={11} /> Historique récent
</p>
<div className="card" style={{ padding: '0.75rem' }}>
{history.slice(0, 5).map(h => (
<div key={h.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '3px 0', borderBottom: '1px solid #1e2535' }}>
<span style={{ color: h.winner === 'player' ? '#3ddc84' : '#e84040' }}>
{h.winner === 'player' ? '' : ''} {h.monsterName ?? 'Monstre'}
</span>
<span style={{ color: '#6b7a99' }}>+{h.xpGained}xp +{h.goldGained}or</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Résultat du dernier combat */}
{lastResult && <CombatLog result={lastResult} />}
</div>
);
}