Compare commits

...

6 Commits

Author SHA1 Message Date
05c39640d0 fix: VITE_API_URL fallback to include /api suffix
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 27s
Secret was missing /api — frontend called /auth/session instead of
/api/auth/session, Apache SPA fallback returned index.html instead
of proxying to Express backend.
2026-03-23 03:09:16 +01:00
2c54257c94 fix: CI/CD add missing VITE_OAUTH vars + pm2 reload via tetardtek-brain
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 29s
VITE_OAUTH_URL and VITE_OAUTH_CLIENT_ID were missing from build env,
causing empty client_id in PKCE flow. pm2 reload via su - tetardtek-brain
(same pattern as SuperOAuth post ssh-hardening).
2026-03-23 02:53:51 +01:00
e04666865d fix: CallbackPage handles verification_pending and merge_pending states
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 38s
2026-03-23 01:14:50 +01:00
8309400466 feat(landing): repositionner plateforme vidéo — supprimer pitch B2B SaaS
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 44s
Landing reécrite : vidéos, playlists, créateurs.
Supprimé : pricing, white-label, mentions SuperOAuth, PricingCard component.
CTA principal → /app (explorer les vidéos).
2026-03-22 16:15:02 +01:00
d68041e2f1 feat(auth): PKCE client refinements + backend refresh token support
- oauth.ts: provider param, TokenResponse typing, exchangeCode returns full response
- LoginPage: fully async handleOAuth with buildAuthUrl
- CallbackPage: dual-mode PKCE (code) + legacy (token), refresh token forwarding
- LoginButton: provider prop support
- auth.routes: POST /auth/session accepts refreshToken, sets od_refresh cookie
2026-03-22 16:14:55 +01:00
7932659a73 feat(auth): PKCE flow preparation + CallbackPage dual-mode
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 29s
- Add oauth.ts — PKCE helpers (code verifier/challenge, token exchange)
- Add LoginButton — "Se connecter avec SuperOAuth" component
- Update CallbackPage — handles both PKCE (?code) and legacy (?token) flows
- Update .env.example — VITE_OAUTH_URL + VITE_OAUTH_CLIENT_ID

PKCE flow ready for when SuperOAuth exposes /oauth/authorize endpoint.
Legacy flow (redirect + token query param) remains active in production.
2026-03-22 12:50:07 +01:00
8 changed files with 344 additions and 191 deletions

View File

@@ -32,15 +32,16 @@ jobs:
- name: Restart pm2 - name: Restart pm2
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: | run: |
pm2 restart originsdigital-backend || pm2 start /var/www/originsdigital/backend/dist/index.js --name originsdigital-backend su - tetardtek-brain -c 'pm2 reload originsdigital-backend --update-env'
pm2 save
# ── Frontend ───────────────────────────────────────────────────────────── # ── Frontend ─────────────────────────────────────────────────────────────
- name: Install & build frontend - name: Install & build frontend
working-directory: frontend working-directory: frontend
env: env:
VITE_API_URL: ${{ secrets.VITE_API_URL }} VITE_API_URL: ${{ secrets.VITE_API_URL || 'https://origins.tetardtek.com/api' }}
VITE_SUPEROAUTH_URL: ${{ secrets.VITE_SUPEROAUTH_URL }} VITE_SUPEROAUTH_URL: ${{ secrets.VITE_SUPEROAUTH_URL }}
VITE_OAUTH_URL: ${{ secrets.VITE_SUPEROAUTH_URL }}
VITE_OAUTH_CLIENT_ID: origins
run: | run: |
npm ci npm ci
npm run build npm run build

View File

@@ -92,7 +92,7 @@ router.post("/login", async (req: Request, res: Response): Promise<void> => {
* le valide, puis le pose en httpOnly cookie. * le valide, puis le pose en httpOnly cookie.
*/ */
router.post("/session", async (req: Request, res: Response): Promise<void> => { router.post("/session", async (req: Request, res: Response): Promise<void> => {
const { token } = req.body as { token?: string }; const { token, refreshToken } = req.body as { token?: string; refreshToken?: string };
if (!token) { if (!token) {
res.status(400).json({ success: false, error: "MISSING_TOKEN" }); res.status(400).json({ success: false, error: "MISSING_TOKEN" });
@@ -126,6 +126,9 @@ router.post("/session", async (req: Request, res: Response): Promise<void> => {
await upsertUser(data.data.user as { id: string; email: string | null; nickname: string }); await upsertUser(data.data.user as { id: string; email: string | null; nickname: string });
res.cookie(COOKIE_NAME, token, COOKIE_OPTIONS); res.cookie(COOKIE_NAME, token, COOKIE_OPTIONS);
if (refreshToken) {
res.cookie(REFRESH_COOKIE_NAME, refreshToken, REFRESH_COOKIE_OPTIONS);
}
res.json({ success: true, data: { user: data.data.user } }); res.json({ success: true, data: { user: data.data.user } });
} catch (err) { } catch (err) {
logger.error("POST /auth/session — auth service unavailable", { err }); logger.error("POST /auth/session — auth service unavailable", { err });

View File

@@ -2,3 +2,7 @@
# Le flow : /api/v1/auth/oauth/:provider?redirectUrl=<callback_url> # Le flow : /api/v1/auth/oauth/:provider?redirectUrl=<callback_url>
# Valeur : voir brain/MYSECRETS section originsdigital # Valeur : voir brain/MYSECRETS section originsdigital
VITE_SUPEROAUTH_URL= VITE_SUPEROAUTH_URL=
# SuperOAuth PKCE (Step 3) — flow authorization_code avec PKCE
VITE_OAUTH_URL=https://oauth.tetardtek.com
VITE_OAUTH_CLIENT_ID=originsdigital

View File

@@ -0,0 +1,40 @@
import { useState } from 'react';
import { buildAuthUrl, saveVerifier } from '../../lib/oauth';
interface LoginButtonProps {
className?: string;
provider?: string;
}
export default function LoginButton({ className, provider = 'discord' }: LoginButtonProps) {
const [loading, setLoading] = useState(false);
async function handleClick() {
if (loading) return;
setLoading(true);
try {
const redirectUri = `${window.location.origin}/callback`;
const { url, verifier } = await buildAuthUrl(redirectUri, provider);
saveVerifier(verifier);
window.location.href = url;
} catch {
setLoading(false);
}
}
return (
<button
onClick={handleClick}
disabled={loading}
className={[
'rounded border border-[#c9a84c] px-4 py-2 font-mono text-xs text-[#c9a84c]',
'transition-all duration-150',
'hover:bg-[#c9a84c] hover:text-od-bg',
'disabled:opacity-40 disabled:cursor-not-allowed',
className ?? '',
].join(' ')}
>
{loading ? '…' : 'Se connecter avec SuperOAuth'}
</button>
);
}

119
frontend/src/lib/oauth.ts Normal file
View File

@@ -0,0 +1,119 @@
// OAuth 2.0 PKCE client — SuperOAuth Tier 3 multi-tenant
// Token stocké en sessionStorage (pas localStorage — sécurité, scope onglet)
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || '';
const OAUTH_CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID || '';
const SESSION_KEY_TOKEN = 'od_access_token';
const SESSION_KEY_VERIFIER = 'od_pkce_verifier';
// --- PKCE helpers ---
function base64UrlEncode(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
export function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array.buffer);
}
export async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(digest);
}
// --- Auth URL ---
export async function buildAuthUrl(
redirectUri: string,
provider: string,
scope = 'openid profile email',
clientId = OAUTH_CLIENT_ID,
): Promise<{ url: string; verifier: string }> {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)).buffer);
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope,
state,
provider,
code_challenge: challenge,
code_challenge_method: 'S256',
});
return {
url: `${OAUTH_URL}/oauth/authorize?${params.toString()}`,
verifier,
};
}
// --- Token exchange ---
export interface TokenResponse {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
scope?: string;
}
export async function exchangeCode(
code: string,
verifier: string,
redirectUri: string,
clientId = OAUTH_CLIENT_ID,
): Promise<TokenResponse> {
const response = await fetch(`${OAUTH_URL}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
code,
code_verifier: verifier,
redirect_uri: redirectUri,
}).toString(),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`OAuth token exchange failed (${response.status}): ${text}`);
}
const data = await response.json() as TokenResponse;
if (!data.access_token) throw new Error('No access_token in OAuth response');
return data;
}
// --- Token accessors ---
export function getAccessToken(): string | null {
return sessionStorage.getItem(SESSION_KEY_TOKEN);
}
export function clearAccessToken(): void {
sessionStorage.removeItem(SESSION_KEY_TOKEN);
sessionStorage.removeItem(SESSION_KEY_VERIFIER);
}
// --- PKCE verifier persistence (avant redirect) ---
export function saveVerifier(verifier: string): void {
sessionStorage.setItem(SESSION_KEY_VERIFIER, verifier);
}
export function loadVerifier(): string | null {
return sessionStorage.getItem(SESSION_KEY_VERIFIER);
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { apiFetch } from '../lib/api'; import { apiFetch } from '../lib/api';
import { exchangeCode, loadVerifier } from '../lib/oauth';
import { useAuthContext } from '../context/AuthContext'; import { useAuthContext } from '../context/AuthContext';
import type { User } from '../context/AuthContext'; import type { User } from '../context/AuthContext';
@@ -9,31 +10,130 @@ interface SessionResponse {
data: { user: User }; data: { user: User };
} }
type PendingState =
| { kind: 'verification_pending'; email: string }
| { kind: 'merge_pending'; email: string; provider: string };
export default function CallbackPage() { export default function CallbackPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { setUser } = useAuthContext(); const { setUser } = useAuthContext();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState<PendingState | null>(null);
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const token = params.get('token');
if (!token) { // --- Erreur OAuth explicite ---
navigate('/', { replace: true }); const oauthError = params.get('error');
if (oauthError) {
const desc = params.get('error_description') ?? oauthError;
setError(`Erreur OAuth : ${desc}`);
return; return;
} }
apiFetch<SessionResponse>('/auth/session', { // --- Pending states (verification / merge) ---
method: 'POST', const status = params.get('status');
body: JSON.stringify({ token }), if (status === 'verification_pending') {
}) setPending({ kind: 'verification_pending', email: params.get('email') ?? '' });
.then((res) => { return;
setUser(res.data.user); }
navigate('/', { replace: true }); if (status === 'merge_pending') {
setPending({
kind: 'merge_pending',
email: params.get('email') ?? '',
provider: params.get('provider') ?? '',
});
return;
}
// --- Flow PKCE : ?code= présent ---
const code = params.get('code');
if (code) {
const verifier = loadVerifier();
if (!verifier) {
setError('Session PKCE expirée. Recommence la connexion.');
return;
}
const redirectUri = `${window.location.origin}/callback`;
exchangeCode(code, verifier, redirectUri)
.then((tokens) => {
return apiFetch<SessionResponse>('/auth/session', {
method: 'POST',
body: JSON.stringify({
token: tokens.access_token,
refreshToken: tokens.refresh_token,
}),
});
})
.then((res) => {
setUser(res.data.user);
navigate('/', { replace: true });
})
.catch(() => setError("Échec de l'échange de code OAuth. Réessaie."));
return;
}
// --- Flow session (token JWT en query param) ---
const token = params.get('token');
if (token) {
apiFetch<SessionResponse>('/auth/session', {
method: 'POST',
body: JSON.stringify({ token }),
}) })
.catch(() => setError("Échec de l'authentification. Réessaie.")); .then((res) => {
setUser(res.data.user);
navigate('/', { replace: true });
})
.catch(() => setError("Échec de l'authentification. Réessaie."));
return;
}
// Aucun paramètre reconnu → retour accueil
navigate('/', { replace: true });
}, [navigate, setUser]); }, [navigate, setUser]);
// --- Pending UI ---
if (pending) {
return (
<div className="flex flex-col items-center gap-6 pt-20 max-w-md mx-auto text-center">
{pending.kind === 'verification_pending' ? (
<>
<div className="text-4xl">📧</div>
<h2 className="text-lg font-semibold text-od-text">Vérifie ton email</h2>
<p className="text-sm text-od-muted">
Un email de vérification a é envoyé à{' '}
<span className="text-od-text font-mono">{pending.email}</span>.
<br />
Clique sur le lien pour activer ton compte.
</p>
</>
) : (
<>
<div className="text-4xl">🔗</div>
<h2 className="text-lg font-semibold text-od-text">Fusion de compte</h2>
<p className="text-sm text-od-muted">
Un compte existe déjà avec l'email{' '}
<span className="text-od-text font-mono">{pending.email}</span>.
<br />
Un email a été envoyé pour fusionner ton compte{' '}
<span className="text-od-accent capitalize">{pending.provider}</span>.
<br />
Clique sur le lien dans l'email pour confirmer.
</p>
</>
)}
<a
href="/login"
className="font-mono text-xs text-od-muted hover:text-od-text transition-colors"
>
Retour à la connexion
</a>
</div>
);
}
// --- Error UI ---
if (error) { if (error) {
return ( return (
<div className="flex flex-col items-center gap-4 pt-20"> <div className="flex flex-col items-center gap-4 pt-20">

View File

@@ -1,66 +1,5 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
// ─── Pricing data ─────────────────────────────────────────────────────────────
interface Tier {
name: string;
price: string;
period: string;
tagline: string;
features: string[];
cta: string;
highlighted: boolean;
}
const TIERS: Tier[] = [
{
name: 'Starter',
price: '29€',
period: '/mois',
tagline: 'Pour démarrer proprement.',
features: [
'1 projet',
'Domaine custom',
'White-label basique',
'Support communauté',
],
cta: 'Commencer',
highlighted: false,
},
{
name: 'Studio',
price: '99€',
period: '/mois',
tagline: 'Pour les studios actifs.',
features: [
'5 projets',
'Analytics intégrés',
'SuperOAuth Tier 3',
'White-label complet',
'Support email',
],
cta: 'Choisir Studio',
highlighted: true,
},
{
name: 'Pro',
price: '249€',
period: '/mois',
tagline: 'Pour les opérations à fort volume.',
features: [
'Projets illimités',
'API access complet',
'Support prioritaire',
'SuperOAuth Tier 3',
'SLA 99.9%',
],
cta: 'Choisir Pro',
highlighted: false,
},
];
// ─── Component ────────────────────────────────────────────────────────────────
export default function LandingPage() { export default function LandingPage() {
return ( return (
<div className="flex flex-col gap-24 pb-16"> <div className="flex flex-col gap-24 pb-16">
@@ -72,60 +11,55 @@ export default function LandingPage() {
<div className="inline-flex w-fit items-center gap-2 rounded border border-od-border bg-od-surface px-3 py-1"> <div className="inline-flex w-fit items-center gap-2 rounded border border-od-border bg-od-surface px-3 py-1">
<span className="h-1.5 w-1.5 rounded-full bg-od-accent" /> <span className="h-1.5 w-1.5 rounded-full bg-od-accent" />
<span className="font-mono text-2xs text-od-muted tracking-widest uppercase"> <span className="font-mono text-2xs text-od-muted tracking-widest uppercase">
SuperOAuth Tier 3 multi-tenant natif Vidéos · Playlists · Communauté
</span> </span>
</div> </div>
{/* Headline */} {/* Headline */}
<h1 className="font-display text-5xl font-bold leading-[1.1] tracking-tight text-od-text"> <h1 className="font-display text-5xl font-bold leading-[1.1] tracking-tight text-od-text">
La plateforme des{' '} Partagez vos vidéos.{' '}
<span className="text-od-accent">studios indépendants.</span> <span className="text-od-accent">Construisez votre audience.</span>
</h1> </h1>
{/* Sous-titre */} {/* Sous-titre */}
<p className="text-base text-od-muted leading-relaxed max-w-xl"> <p className="text-base text-od-muted leading-relaxed max-w-xl">
Lancez votre vitrine de contenu en quelques minutes. OriginsDigital est une plateforme de partage vidéo pensée pour les créateurs.
Auth multi-tenant, domaine custom, white-label complet Organisez votre contenu en playlists, proposez du contenu libre ou premium,
sans compromis sur la quali. et faites grandir votre communau.
</p> </p>
{/* CTAs */} {/* CTAs */}
<div className="flex items-center gap-4 pt-2"> <div className="flex items-center gap-4 pt-2">
<Link <Link
to="/login" to="/app"
className="inline-flex items-center gap-2 rounded border border-od-accent bg-od-accent px-6 py-2.5 font-mono text-sm font-semibold text-od-bg transition-all duration-150 hover:bg-od-accent-dim hover:border-od-accent-dim" className="inline-flex items-center gap-2 rounded border border-od-accent bg-od-accent px-6 py-2.5 font-mono text-sm font-semibold text-od-bg transition-all duration-150 hover:bg-od-accent-dim hover:border-od-accent-dim"
>
Explorer les vidéos
</Link>
<Link
to="/login"
className="inline-flex items-center gap-2 rounded border border-od-border px-6 py-2.5 font-mono text-sm text-od-muted transition-all duration-150 hover:border-od-border-hi hover:text-od-text"
> >
Se connecter Se connecter
</Link> </Link>
<a
href="#pricing"
className="inline-flex items-center gap-2 rounded border border-od-border px-6 py-2.5 font-mono text-sm text-od-muted transition-all duration-150 hover:border-od-border-hi hover:text-od-text"
>
Voir les tarifs
</a>
</div> </div>
{/* Social proof minimal */}
<p className="font-mono text-2xs text-od-muted pt-2">
Intégration SuperOAuth Tier 3 · Auth per-tenant · CNAME custom
</p>
</section> </section>
{/* ── Différenciateur ───────────────────────────────────────────────── */} {/* ── Features ──────────────────────────────────────────────────────── */}
<section className="grid grid-cols-3 gap-4"> <section className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{[ {[
{ {
label: 'Auth multi-tenant', label: 'Playlists organisées',
desc: 'SuperOAuth Tier 3 intégré nativement. Per-tenant providers, isolation complète des données.', desc: 'Regroupez vos vidéos par thème, série ou parcours. Vos spectateurs retrouvent facilement ce qui les intéresse.',
}, },
{ {
label: 'White-label total', label: 'Contenu libre & premium',
desc: 'Logo, domaine, emails, couleurs — votre marque, pas la nôtre. CNAME custom inclus dès Starter.', desc: 'Proposez du contenu en accès libre pour attirer, et du contenu premium pour fidéliser. Vous décidez.',
}, },
{ {
label: 'Prévisible', label: 'Fait pour les créateurs',
desc: 'Abonnement fixe, pas de commission, pas de per-seat. Votre croissance ne nous rémunère pas.', desc: 'Interface épurée, recherche rapide, profil personnalisé. Le contenu au centre, pas la distraction.',
}, },
].map(({ label, desc }) => ( ].map(({ label, desc }) => (
<div <div
@@ -139,103 +73,48 @@ export default function LandingPage() {
))} ))}
</section> </section>
{/* ── Pricing ───────────────────────────────────────────────────────── */} {/* ── Comment ça marche ─────────────────────────────────────────────── */}
<section id="pricing" className="flex flex-col gap-8 scroll-mt-20"> <section className="flex flex-col gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="font-mono text-2xs uppercase tracking-widest text-od-muted">Tarifs</span> <span className="font-mono text-2xs uppercase tracking-widest text-od-muted">Comment ça marche</span>
<h2 className="font-display text-3xl font-bold text-od-text tracking-tight"> <h2 className="font-display text-3xl font-bold text-od-text tracking-tight">
Simple. Sans surprise. Simple et direct.
</h2> </h2>
<p className="text-sm text-od-muted max-w-md">
Abonnement mensuel sans engagement. Pas de commission sur vos revenus.
</p>
</div> </div>
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
{TIERS.map((tier) => ( {[
<PricingCard key={tier.name} tier={tier} /> { step: '01', title: 'Connectez-vous', desc: 'Via Discord, GitHub, Google ou Twitch. Un clic, pas de formulaire.' },
{ step: '02', title: 'Explorez', desc: 'Parcourez les vidéos libres, cherchez par thème, découvrez les playlists.' },
{ step: '03', title: 'Accédez au premium', desc: 'Débloquez les formations complètes et le contenu exclusif.' },
].map(({ step, title, desc }) => (
<div key={step} className="flex flex-col gap-2">
<span className="font-mono text-lg font-bold text-od-accent">{step}</span>
<p className="text-sm font-semibold text-od-text">{title}</p>
<p className="text-xs text-od-muted leading-relaxed">{desc}</p>
</div>
))} ))}
</div> </div>
</section>
{/* Enterprise mention */} {/* ── CTA final ─────────────────────────────────────────────────────── */}
<div className="flex items-center justify-between rounded border border-od-border bg-od-surface px-6 py-4"> <section className="flex flex-col items-center gap-4 rounded border border-od-border bg-od-surface px-8 py-10 text-center">
<div className="flex flex-col gap-0.5"> <h2 className="font-display text-2xl font-bold text-od-text">
<span className="text-sm font-semibold text-od-text">Enterprise</span> Prêt à découvrir ?
<span className="text-xs text-od-muted">SLA, déploiement dédié, onboarding personnalisé</span> </h2>
</div> <p className="text-sm text-od-muted max-w-md">
<a Pas besoin de compte pour explorer. Connectez-vous quand vous êtes prêt.
href="mailto:contact@originsdigital.com" </p>
className="rounded border border-od-border px-4 py-2 font-mono text-xs text-od-muted hover:border-od-accent hover:text-od-accent transition-all duration-150" <div className="flex items-center gap-4 pt-2">
<Link
to="/app"
className="inline-flex items-center gap-2 rounded border border-od-accent bg-od-accent px-6 py-2.5 font-mono text-sm font-semibold text-od-bg transition-all duration-150 hover:bg-od-accent-dim hover:border-od-accent-dim"
> >
Nous contacter Explorer les vidéos
</a> </Link>
</div> </div>
</section> </section>
</div> </div>
); );
} }
// ─── PricingCard ──────────────────────────────────────────────────────────────
function PricingCard({ tier }: { tier: Tier }) {
const base =
'relative flex flex-col gap-5 rounded border p-6 transition-all duration-150';
const highlighted =
tier.highlighted
? 'border-od-accent bg-od-surface shadow-accent-glow'
: 'border-od-border bg-od-surface hover:border-od-border-hi';
return (
<div className={`${base} ${highlighted}`}>
{tier.highlighted && (
<div className="absolute -top-px left-0 right-0 h-px bg-od-accent" />
)}
{tier.highlighted && (
<div className="absolute -top-3 right-4 rounded-full border border-od-accent bg-od-bg px-2 py-0.5">
<span className="font-mono text-2xs text-od-accent tracking-wider">Populaire</span>
</div>
)}
{/* Header */}
<div className="flex flex-col gap-1">
<span className="font-mono text-xs uppercase tracking-widest text-od-muted">{tier.name}</span>
<div className="flex items-baseline gap-1">
<span className="font-display text-4xl font-bold text-od-text">{tier.price}</span>
<span className="font-mono text-xs text-od-muted">{tier.period}</span>
</div>
<span className="text-xs text-od-muted">{tier.tagline}</span>
</div>
{/* Separator */}
<div className="border-t border-od-border" />
{/* Features */}
<ul className="flex flex-col gap-2.5 flex-1">
{tier.features.map((f) => (
<li key={f} className="flex items-start gap-2 text-xs text-od-muted">
<span className="mt-0.5 text-od-accent"></span>
<span>{f}</span>
</li>
))}
</ul>
{/* CTA */}
<Link
to="/login"
className={
tier.highlighted
? 'inline-flex items-center justify-center rounded border border-od-accent bg-od-accent px-4 py-2 font-mono text-xs font-semibold text-od-bg transition-all duration-150 hover:bg-od-accent-dim hover:border-od-accent-dim'
: 'inline-flex items-center justify-center rounded border border-od-border px-4 py-2 font-mono text-xs text-od-muted transition-all duration-150 hover:border-od-accent hover:text-od-accent'
}
>
{tier.cta}
</Link>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { apiFetch } from '../lib/api'; import { apiFetch } from '../lib/api';
import { useAuthContext, type User } from '../context/AuthContext'; import { useAuthContext, type User } from '../context/AuthContext';
import { buildAuthUrl, saveVerifier } from '../lib/oauth';
const PROVIDERS = [ const PROVIDERS = [
{ id: 'discord', label: 'Discord' }, { id: 'discord', label: 'Discord' },
@@ -15,8 +16,6 @@ export default function LoginPage() {
const location = useLocation(); const location = useLocation();
const { setUser } = useAuthContext(); const { setUser } = useAuthContext();
const from = (location.state as { from?: Location })?.from?.pathname ?? '/'; const from = (location.state as { from?: Location })?.from?.pathname ?? '/';
const base = import.meta.env.VITE_SUPEROAUTH_URL;
const redirectUrl = encodeURIComponent(window.location.origin + '/callback');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -24,10 +23,18 @@ export default function LoginPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [oauthLoading, setOauthLoading] = useState<string | null>(null); const [oauthLoading, setOauthLoading] = useState<string | null>(null);
function handleOAuth(providerId: string) { async function handleOAuth(providerId: string) {
if (oauthLoading) return; if (oauthLoading) return;
setOauthLoading(providerId); setOauthLoading(providerId);
window.location.href = `${base}/api/v1/oauth/${providerId}?redirectUrl=${redirectUrl}&tenantId=origins`; try {
const redirectUri = `${window.location.origin}/callback`;
const { url, verifier } = await buildAuthUrl(redirectUri, providerId);
saveVerifier(verifier);
window.location.href = url;
} catch {
setOauthLoading(null);
setError('Impossible de démarrer la connexion OAuth.');
}
} }
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {