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
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
pm2 restart originsdigital-backend || pm2 start /var/www/originsdigital/backend/dist/index.js --name originsdigital-backend
pm2 save
su - tetardtek-brain -c 'pm2 reload originsdigital-backend --update-env'
# ── Frontend ─────────────────────────────────────────────────────────────
- name: Install & build frontend
working-directory: frontend
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_OAUTH_URL: ${{ secrets.VITE_SUPEROAUTH_URL }}
VITE_OAUTH_CLIENT_ID: origins
run: |
npm ci
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.
*/
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) {
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 });
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 } });
} catch (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>
# Valeur : voir brain/MYSECRETS section originsdigital
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 { useNavigate } from 'react-router-dom';
import { apiFetch } from '../lib/api';
import { exchangeCode, loadVerifier } from '../lib/oauth';
import { useAuthContext } from '../context/AuthContext';
import type { User } from '../context/AuthContext';
@@ -9,31 +10,130 @@ interface SessionResponse {
data: { user: User };
}
type PendingState =
| { kind: 'verification_pending'; email: string }
| { kind: 'merge_pending'; email: string; provider: string };
export default function CallbackPage() {
const navigate = useNavigate();
const { setUser } = useAuthContext();
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState<PendingState | null>(null);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
if (!token) {
navigate('/', { replace: true });
// --- Erreur OAuth explicite ---
const oauthError = params.get('error');
if (oauthError) {
const desc = params.get('error_description') ?? oauthError;
setError(`Erreur OAuth : ${desc}`);
return;
}
apiFetch<SessionResponse>('/auth/session', {
method: 'POST',
body: JSON.stringify({ token }),
})
.then((res) => {
setUser(res.data.user);
navigate('/', { replace: true });
// --- Pending states (verification / merge) ---
const status = params.get('status');
if (status === 'verification_pending') {
setPending({ kind: 'verification_pending', email: params.get('email') ?? '' });
return;
}
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]);
// --- 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) {
return (
<div className="flex flex-col items-center gap-4 pt-20">

View File

@@ -1,66 +1,5 @@
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() {
return (
<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">
<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">
SuperOAuth Tier 3 multi-tenant natif
Vidéos · Playlists · Communauté
</span>
</div>
{/* Headline */}
<h1 className="font-display text-5xl font-bold leading-[1.1] tracking-tight text-od-text">
La plateforme des{' '}
<span className="text-od-accent">studios indépendants.</span>
Partagez vos vidéos.{' '}
<span className="text-od-accent">Construisez votre audience.</span>
</h1>
{/* Sous-titre */}
<p className="text-base text-od-muted leading-relaxed max-w-xl">
Lancez votre vitrine de contenu en quelques minutes.
Auth multi-tenant, domaine custom, white-label complet
sans compromis sur la quali.
OriginsDigital est une plateforme de partage vidéo pensée pour les créateurs.
Organisez votre contenu en playlists, proposez du contenu libre ou premium,
et faites grandir votre communau.
</p>
{/* CTAs */}
<div className="flex items-center gap-4 pt-2">
<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"
>
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
</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>
{/* 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>
{/* ── Différenciateur ───────────────────────────────────────────────── */}
<section className="grid grid-cols-3 gap-4">
{/* ── Features ──────────────────────────────────────────────────────── */}
<section className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{[
{
label: 'Auth multi-tenant',
desc: 'SuperOAuth Tier 3 intégré nativement. Per-tenant providers, isolation complète des données.',
label: 'Playlists organisé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',
desc: 'Logo, domaine, emails, couleurs — votre marque, pas la nôtre. CNAME custom inclus dès Starter.',
label: 'Contenu libre & premium',
desc: 'Proposez du contenu en accès libre pour attirer, et du contenu premium pour fidéliser. Vous décidez.',
},
{
label: 'Prévisible',
desc: 'Abonnement fixe, pas de commission, pas de per-seat. Votre croissance ne nous rémunère pas.',
label: 'Fait pour les créateurs',
desc: 'Interface épurée, recherche rapide, profil personnalisé. Le contenu au centre, pas la distraction.',
},
].map(({ label, desc }) => (
<div
@@ -139,103 +73,48 @@ export default function LandingPage() {
))}
</section>
{/* ── Pricing ───────────────────────────────────────────────────────── */}
<section id="pricing" className="flex flex-col gap-8 scroll-mt-20">
{/* ── Comment ça marche ─────────────────────────────────────────────── */}
<section className="flex flex-col gap-8">
<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">
Simple. Sans surprise.
Simple et direct.
</h2>
<p className="text-sm text-od-muted max-w-md">
Abonnement mensuel sans engagement. Pas de commission sur vos revenus.
</p>
</div>
<div className="grid grid-cols-3 gap-4">
{TIERS.map((tier) => (
<PricingCard key={tier.name} tier={tier} />
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
{[
{ 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>
</section>
{/* Enterprise mention */}
<div className="flex items-center justify-between rounded border border-od-border bg-od-surface px-6 py-4">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-semibold text-od-text">Enterprise</span>
<span className="text-xs text-od-muted">SLA, déploiement dédié, onboarding personnalisé</span>
</div>
<a
href="mailto:contact@originsdigital.com"
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"
{/* ── CTA final ─────────────────────────────────────────────────────── */}
<section className="flex flex-col items-center gap-4 rounded border border-od-border bg-od-surface px-8 py-10 text-center">
<h2 className="font-display text-2xl font-bold text-od-text">
Prêt à découvrir ?
</h2>
<p className="text-sm text-od-muted max-w-md">
Pas besoin de compte pour explorer. Connectez-vous quand vous êtes prêt.
</p>
<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
</a>
Explorer les vidéos
</Link>
</div>
</section>
</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 { apiFetch } from '../lib/api';
import { useAuthContext, type User } from '../context/AuthContext';
import { buildAuthUrl, saveVerifier } from '../lib/oauth';
const PROVIDERS = [
{ id: 'discord', label: 'Discord' },
@@ -15,8 +16,6 @@ export default function LoginPage() {
const location = useLocation();
const { setUser } = useAuthContext();
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 [password, setPassword] = useState('');
@@ -24,10 +23,18 @@ export default function LoginPage() {
const [error, setError] = useState<string | null>(null);
const [oauthLoading, setOauthLoading] = useState<string | null>(null);
function handleOAuth(providerId: string) {
async function handleOAuth(providerId: string) {
if (oauthLoading) return;
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) {