Compare commits
6 Commits
32b9af7b02
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 05c39640d0 | |||
| 2c54257c94 | |||
| e04666865d | |||
| 8309400466 | |||
| d68041e2f1 | |||
| 7932659a73 |
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
40
frontend/src/components/auth/LoginButton.tsx
Normal file
40
frontend/src/components/auth/LoginButton.tsx
Normal 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
119
frontend/src/lib/oauth.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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 été 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">
|
||||||
|
|||||||
@@ -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 qualité.
|
et faites grandir votre communauté.
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user