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,188 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { characterApi } from '../api/endpoints';
import { Bar } from '../components/Bar';
import { Zap, Heart, Star, Coins, Sword, Shield } from 'lucide-react';
const STATS = ['force', 'agilite', 'intelligence', 'chance', 'vitalite'] as const;
const STAT_LABELS: Record<string, string> = {
force: 'Force', agilite: 'Agilité', intelligence: 'Intelligence', chance: 'Chance', vitalite: 'Vitalité',
};
function CreateCharacter() {
const qc = useQueryClient();
const [name, setName] = useState('');
const [pts, setPts] = useState<Record<string, number>>({ force:1, agilite:1, intelligence:1, chance:1, vitalite:1 });
const used = Object.values(pts).reduce((a, b) => a + b, 0) - 5;
const remaining = 5 - used;
const mut = useMutation({
mutationFn: () => characterApi.create(name, pts),
onSuccess: () => qc.invalidateQueries({ queryKey: ['character'] }),
});
const adjust = (stat: string, delta: number) => {
const next = (pts[stat] ?? 1) + delta;
if (next < 1 || next > 10) return;
if (delta > 0 && remaining <= 0) return;
setPts(p => ({ ...p, [stat]: next }));
};
return (
<div style={{ maxWidth: 420, margin: '4rem auto' }}>
<div className="card card-gold" style={{ padding: '1.5rem' }}>
<h2 style={{ margin: '0 0 4px', color: '#f4c94e', fontSize: 20 }}>Créer ton personnage</h2>
<p style={{ margin: '0 0 1.25rem', color: '#6b7a99', fontSize: 13 }}>
{remaining > 0 ? `${remaining} point${remaining > 1 ? 's' : ''} à répartir` : 'Tous les points répartis'}
</p>
<input
className="input-rpg"
placeholder="Nom du personnage"
value={name}
onChange={e => setName(e.target.value)}
style={{ marginBottom: '1rem' }}
maxLength={30}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: '1.25rem' }}>
{STATS.map(s => (
<div key={s} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, width: 110, color: '#dce4f0' }}>{STAT_LABELS[s]}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button className="btn btn-ghost" style={{ padding: '0.15rem 0.5rem', fontSize: 14 }} onClick={() => adjust(s, -1)}></button>
<span style={{ width: 20, textAlign: 'center', fontWeight: 700, color: '#f4c94e' }}>{pts[s]}</span>
<button className="btn btn-ghost" style={{ padding: '0.15rem 0.5rem', fontSize: 14 }} onClick={() => adjust(s, +1)}>+</button>
</div>
</div>
))}
</div>
<button
className="btn btn-gold"
style={{ width: '100%' }}
disabled={!name.trim() || remaining !== 0 || mut.isPending}
onClick={() => mut.mutate()}
>
{mut.isPending ? 'Création…' : 'Commencer l\'aventure ⚔️'}
</button>
{mut.isError && <p style={{ color: '#e84040', fontSize: 12, marginTop: 8 }}>{(mut.error as Error).message}</p>}
</div>
</div>
);
}
export function DashboardPage() {
const { data: char, isLoading, isError } = useQuery({
queryKey: ['character'],
queryFn: characterApi.me,
retry: 1,
});
if (isLoading) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement</div>;
if (isError || !char) return <CreateCharacter />;
const xpNext = Math.round(100 * Math.pow(char.level, 1.5));
return (
<div>
{/* Header perso */}
<div className="card card-gold" style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '1rem', padding: '1rem 1.25rem' }}>
<div style={{ fontSize: 48 }}>🐸</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
<h2 style={{ margin: 0, fontSize: 22, color: '#f4c94e' }}>{char.name}</h2>
<span style={{ fontSize: 13, color: '#6b7a99' }}>Niveau {char.level}</span>
</div>
<div style={{ display: 'flex', gap: 16, marginTop: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: '#6b7a99', display: 'flex', alignItems: 'center', gap: 4 }}>
<Coins size={12} color="#f4c94e" /> {char.gold} or
</span>
<span style={{ fontSize: 12, color: '#6b7a99', display: 'flex', alignItems: 'center', gap: 4 }}>
<Star size={12} color="#a78bfa" /> {char.xp} / {xpNext} XP
</span>
{(char as any).statPoints > 0 && (
<span className="badge badge-gold">+{(char as any).statPoints} pts à répartir</span>
)}
</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
{/* Barres vitales */}
<div className="card">
<p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>État</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 12, color: '#e84040', display: 'flex', alignItems: 'center', gap: 4 }}>
<Heart size={11} /> PV
</span>
<span style={{ fontSize: 11, color: '#6b7a99' }}>{char.hpCurrent} / {char.hpMax}</span>
</div>
<Bar value={char.hpCurrent} max={char.hpMax} type="hp" showValues={false} />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 12, color: '#5ba4f5', display: 'flex', alignItems: 'center', gap: 4 }}>
<Zap size={11} /> Endurance
</span>
<span style={{ fontSize: 11, color: '#6b7a99' }}>{char.endurance} / {char.enduranceMax}</span>
</div>
<Bar value={char.endurance} max={char.enduranceMax} type="end" showValues={false} />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 12, color: '#a78bfa', display: 'flex', alignItems: 'center', gap: 4 }}>
<Star size={11} /> XP
</span>
<span style={{ fontSize: 11, color: '#6b7a99' }}>{char.xp} / {xpNext}</span>
</div>
<Bar value={char.xp} max={xpNext} type="xp" showValues={false} />
</div>
</div>
</div>
{/* Stats */}
<div className="card">
<p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>Statistiques</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 12px' }}>
{STATS.map(s => (
<div key={s} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, color: '#6b7a99' }}>{STAT_LABELS[s]}</span>
<span style={{ fontSize: 13, fontWeight: 700, color: '#dce4f0' }}>{char[s]}</span>
</div>
))}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, color: '#e84040', display:'flex', alignItems:'center', gap:3 }}><Heart size={10}/> PV max</span>
<span style={{ fontSize: 13, fontWeight: 700, color: '#dce4f0' }}>{char.hpMax}</span>
</div>
</div>
</div>
{/* Équipement résumé */}
<div className="card" style={{ gridColumn: '1 / -1' }}>
<p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>Combat actuel</p>
<div style={{ display: 'flex', gap: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Sword size={14} color="#f4c94e" />
<span style={{ fontSize: 13, color: '#6b7a99' }}>Attaque : </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{Math.floor(char.force * 1.5)}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Shield size={14} color="#5ba4f5" />
<span style={{ fontSize: 13, color: '#6b7a99' }}>Critique : </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{(5 + char.chance * 0.2).toFixed(1)}%</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Zap size={14} color="#3ddc84" />
<span style={{ fontSize: 13, color: '#6b7a99' }}>Esquive : </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{(5 + char.chance * 0.1).toFixed(1)}%</span>
</div>
</div>
</div>
</div>
</div>
);
}