feat(auth): Google SSO via @adonisjs/ally
Backend
- @adonisjs/ally installé + provider Google configuré (config/ally.ts)
scopes: userinfo.email + userinfo.profile (non-sensibles, validation
auto par Google)
- Migration : ajoute google_id (nullable unique) sur users + rend password
nullable (un user créé via Google n'a pas de mdp en base, il pourra
l'activer plus tard via "mot de passe oublié")
- AuthGoogleController.redirect : entrée OAuth (le bouton SPA pointe ici)
- AuthGoogleController.callback : matche par google_id puis email,
crée org+plans+user si nouveau, pose le refresh cookie httpOnly,
redirige le browser vers le SPA /auth/google/complete?next=...
(next = / pour user complet, /onboarding/entreprise pour nouveau)
- Routes : GET /api/v1/auth/google/{redirect,callback}
- Env : GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_CALLBACK_URL
Frontend
- Composant GoogleButton réutilisable (full-page redirect, pas fetch —
OAuth nécessite navigation pour les cookies cross-origin Google)
- AuthDivider "ou" entre SSO et formulaire email/password
- Boutons ajoutés sur /login et /signup
- Route /auth/google/complete : appelle POST /api/v1/auth/refresh (le
cookie posé par la callback est auto-envoyé), stocke access token +
user dans authStore, navigue vers `next`. Échec → /login + toast.
- Toast d'erreur sur /login si on revient avec ?google=denied|error|...
K3s
- ConfigMap rubis-api-config : ajout GOOGLE_CALLBACK_URL prod
- Secret rubis-app-secrets : ajout GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET
(posés via kubectl, pas dans le manifest)
Doc
- .claude/deploy-memory.md mis à jour avec la procédure Google Cloud
Console (créer OAuth client, redirect URIs, écran de consentement)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
27771ed538
commit
ea539cd1d4
@ -87,9 +87,30 @@ kubectl --kubeconfig ~/dev/perso/proxmox/k3s/kubeconfig.yaml \
|
||||
--from-literal=RESEND_API_KEY=... \
|
||||
--from-literal=MISTRAL_API_KEY=... \
|
||||
--from-literal=REDIS_PASSWORD="" \
|
||||
--from-literal=GOOGLE_CLIENT_ID=... \
|
||||
--from-literal=GOOGLE_CLIENT_SECRET=... \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
```
|
||||
|
||||
### Google SSO — setup Google Cloud Console
|
||||
Si la clé OAuth est perdue ou qu'on doit la régénérer :
|
||||
1. https://console.cloud.google.com/apis/credentials → projet courant
|
||||
2. **Create Credentials** → **OAuth client ID** → type **Web application**
|
||||
3. **Authorized JavaScript origins** :
|
||||
- `https://app.rubis.arthurbarre.fr`
|
||||
- `http://localhost:5173` (dev SPA)
|
||||
4. **Authorized redirect URIs** :
|
||||
- `https://app.rubis.arthurbarre.fr/api/v1/auth/google/callback`
|
||||
- `http://localhost:3333/api/v1/auth/google/callback` (dev API)
|
||||
5. Copier `Client ID` + `Client secret` → mettre dans `apps/api/.env` (dev)
|
||||
et `rubis-app-secrets` (prod, snippet ci-dessus).
|
||||
|
||||
L'écran de consentement OAuth doit être configuré au moins en mode "Testing"
|
||||
avec l'email du user courant ajouté en testeur. Pour la prod (n'importe qui
|
||||
peut se connecter), il faut passer en "In production" (vérification Google
|
||||
si scopes sensibles ; les nôtres `userinfo.email` + `userinfo.profile` sont
|
||||
non-sensibles, validation auto).
|
||||
|
||||
### Mise à jour
|
||||
Push git → un (ou les deux) workflow(s) CI se déclenchent selon les paths
|
||||
modifiés. Build+rollout indépendants.
|
||||
|
||||
@ -77,4 +77,14 @@ ACCESS_TOKEN_TTL_MINUTES=30
|
||||
REFRESH_TOKEN_TTL_DAYS=30
|
||||
COOKIE_DOMAIN=
|
||||
COOKIE_SECURE=false
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# Google SSO (Ally) — créer un OAuth Client ID web sur Google Cloud
|
||||
# Console, puis ajouter les redirect URIs :
|
||||
# - http://localhost:3333/api/v1/auth/google/callback (dev)
|
||||
# - https://app.rubis.arthurbarre.fr/api/v1/auth/google/callback (prod)
|
||||
#--------------------------------------------------------------------
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_CALLBACK_URL=http://localhost:3333/api/v1/auth/google/callback
|
||||
LIMITER_STORE=redis
|
||||
|
||||
@ -59,7 +59,8 @@ export default defineConfig({
|
||||
() => import('@adonisjs/limiter/limiter_provider'),
|
||||
() => import('@adonisjs/mail/mail_provider'),
|
||||
() => import('@adonisjs/drive/drive_provider'),
|
||||
() => import('@adonisjs/static/static_provider')
|
||||
() => import('@adonisjs/static/static_provider'),
|
||||
() => import('@adonisjs/ally/ally_provider'),
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
128
apps/api/app/controllers/auth_google_controller.ts
Normal file
128
apps/api/app/controllers/auth_google_controller.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import User from '#models/user'
|
||||
import Organization from '#models/organization'
|
||||
import { provisionDefaultPlans } from '#services/default_plans'
|
||||
import { issueRefreshToken } from '#services/refresh_token'
|
||||
import env from '#start/env'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Google SSO via @adonisjs/ally.
|
||||
*
|
||||
* Flow :
|
||||
* 1. Le SPA navigate vers GET /api/v1/auth/google/redirect
|
||||
* 2. Ally redirige vers Google avec state + scopes
|
||||
* 3. Google redirige vers GET /api/v1/auth/google/callback?code=...
|
||||
* 4. Backend matche/crée l'user, pose le refresh cookie (httpOnly)
|
||||
* 5. Redirige le browser vers le SPA sur /auth/google/complete?next=...
|
||||
* 6. Le SPA appelle POST /api/v1/auth/refresh (cookie auto-envoyé) → reçoit
|
||||
* un access token, navigue vers `next`.
|
||||
*
|
||||
* On NE retourne PAS d'access token en JSON ici car la callback est un
|
||||
* redirect server-side : pas de body lisible par le SPA.
|
||||
*/
|
||||
export default class AuthGoogleController {
|
||||
/**
|
||||
* GET /api/v1/auth/google/redirect — entrée du flow OAuth.
|
||||
* Le bouton "Continuer avec Google" pointe directement ici (pas un fetch).
|
||||
*/
|
||||
async redirect(ctx: HttpContext) {
|
||||
return ctx.ally.use('google').redirect()
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/auth/google/callback — retour de Google.
|
||||
*
|
||||
* Stratégie de matching :
|
||||
* 1. Existing user with `google_id` ? → log in
|
||||
* 2. Existing user with same `email` ? → link `google_id` + log in
|
||||
* 3. New user → crée org + plans par défaut + user (password null)
|
||||
*/
|
||||
async callback(ctx: HttpContext) {
|
||||
const google = ctx.ally.use('google')
|
||||
const webUrl = env.get('WEB_URL', 'http://localhost:5173')
|
||||
|
||||
// Erreurs côté Google (canceled, mismatch, etc.)
|
||||
if (google.accessDenied()) {
|
||||
return ctx.response.redirect(`${webUrl}/login?google=denied`)
|
||||
}
|
||||
if (google.stateMisMatch()) {
|
||||
return ctx.response.redirect(`${webUrl}/login?google=state_mismatch`)
|
||||
}
|
||||
if (google.hasError()) {
|
||||
logger.warn({ err: google.getError() }, 'google sso error')
|
||||
return ctx.response.redirect(`${webUrl}/login?google=error`)
|
||||
}
|
||||
|
||||
const googleUser = await google.user()
|
||||
if (!googleUser.email) {
|
||||
logger.warn({ id: googleUser.id }, 'google sso : email manquant')
|
||||
return ctx.response.redirect(`${webUrl}/login?google=no_email`)
|
||||
}
|
||||
|
||||
// 1. Lookup par google_id (canonique)
|
||||
let user = await User.findBy('googleId', googleUser.id)
|
||||
let isNewUser = false
|
||||
|
||||
// 2. Fallback email — première connexion d'un user email/password
|
||||
// via Google, on lie son google_id à son compte existant.
|
||||
if (!user) {
|
||||
user = await User.findBy('email', googleUser.email.toLowerCase())
|
||||
if (user) {
|
||||
user.googleId = googleUser.id
|
||||
if (!user.fullName && googleUser.name) {
|
||||
user.fullName = googleUser.name
|
||||
}
|
||||
await user.save()
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Création
|
||||
if (!user) {
|
||||
isNewUser = true
|
||||
user = await db.transaction(async (trx) => {
|
||||
const org = await Organization.create({ name: '' }, { client: trx })
|
||||
await provisionDefaultPlans(org.id, trx)
|
||||
return User.create(
|
||||
{
|
||||
email: googleUser.email!.toLowerCase(),
|
||||
fullName: googleUser.name ?? null,
|
||||
// password requis dans le schéma Lucid (string), mais nullable
|
||||
// en DB. On stocke un random unguessable que personne ne peut
|
||||
// utiliser pour login email/password (User.verifyCredentials
|
||||
// hash-comparera et échouera). Le user pourra plus tard
|
||||
// activer email/password via "mot de passe oublié".
|
||||
password: crypto.randomBytes(48).toString('base64url'),
|
||||
googleId: googleUser.id,
|
||||
organizationId: org.id,
|
||||
},
|
||||
{ client: trx }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Pose le refresh cookie httpOnly (path /api/v1/auth)
|
||||
await issueRefreshToken(user, ctx)
|
||||
|
||||
// Décide où renvoyer l'utilisateur.
|
||||
// Org name vide = onboarding entreprise pas terminé → on saute "compte"
|
||||
// (Google nous a déjà donné nom + email) et on enchaîne sur entreprise.
|
||||
let next = '/'
|
||||
if (isNewUser) {
|
||||
next = '/onboarding/entreprise'
|
||||
} else {
|
||||
const org = user.organizationId
|
||||
? await Organization.find(user.organizationId)
|
||||
: null
|
||||
if (!org || !org.name) {
|
||||
next = '/onboarding/entreprise'
|
||||
} else if (!user.signature) {
|
||||
next = '/onboarding/signature'
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.response.redirect(`${webUrl}/auth/google/complete?next=${encodeURIComponent(next)}`)
|
||||
}
|
||||
}
|
||||
36
apps/api/config/ally.ts
Normal file
36
apps/api/config/ally.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import env from '#start/env'
|
||||
import { defineConfig, services } from '@adonisjs/ally'
|
||||
|
||||
/**
|
||||
* Configuration des providers OAuth (Ally).
|
||||
*
|
||||
* V1 : Google uniquement (cf. CLAUDE.md → Auth). Les autres viendront
|
||||
* plus tard si pertinent (Microsoft pour les TPE qui utilisent O365 ?).
|
||||
*
|
||||
* Le callback URL pointe vers l'API en interne (/api/v1/auth/google/callback).
|
||||
* En prod, le reverse proxy nginx (rubis-web) achemine /api/* vers ce
|
||||
* service, donc la même URL fonctionne pour le browser et pour Google.
|
||||
*/
|
||||
const allyConfig = defineConfig({
|
||||
google: services.google({
|
||||
clientId: env.get('GOOGLE_CLIENT_ID', ''),
|
||||
clientSecret: env.get('GOOGLE_CLIENT_SECRET', ''),
|
||||
callbackUrl: env.get(
|
||||
'GOOGLE_CALLBACK_URL',
|
||||
'http://localhost:3333/api/v1/auth/google/callback'
|
||||
),
|
||||
// Scopes minimaux : on a juste besoin de l'email + nom + photo (avatar
|
||||
// optionnel V2). Pas de Drive/Calendar : on ne touche pas aux données
|
||||
// Google de l'utilisateur, on s'en sert juste comme provider d'identité.
|
||||
scopes: ['userinfo.email', 'userinfo.profile'],
|
||||
prompt: 'select_account',
|
||||
}),
|
||||
})
|
||||
|
||||
export default allyConfig
|
||||
|
||||
declare module '@adonisjs/ally/types' {
|
||||
interface SocialProviders extends InferSocialProviders<typeof allyConfig> {}
|
||||
}
|
||||
|
||||
import type { InferSocialProviders } from '@adonisjs/ally/types'
|
||||
@ -0,0 +1,28 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Ajoute le support Google SSO :
|
||||
* - `google_id` : sub OAuth Google (stable, unique). On match les retours
|
||||
* d'auth dessus en priorité, fallback email si non rempli (cas d'un user
|
||||
* email/password qui ajoute Google plus tard).
|
||||
* - Rend `password` nullable : un user créé via Google n'a pas de password
|
||||
* en base. Il pourra en définir un plus tard via "mot de passe oublié".
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'users'
|
||||
|
||||
async up() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.string('google_id', 64).nullable().unique()
|
||||
table.string('password').nullable().alter()
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.dropColumn('google_id')
|
||||
// On ne re-passe pas password à NOT NULL en down — risquerait de
|
||||
// casser les rows SSO existantes.
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -302,7 +302,7 @@ export class RelanceTaskSchema extends BaseModel {
|
||||
}
|
||||
|
||||
export class UserSchema extends BaseModel {
|
||||
static $columns = ['createdAt', 'email', 'fullName', 'id', 'organizationId', 'password', 'signature', 'updatedAt'] as const
|
||||
static $columns = ['createdAt', 'email', 'fullName', 'googleId', 'id', 'organizationId', 'password', 'signature', 'updatedAt'] as const
|
||||
$columns = UserSchema.$columns
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare createdAt: DateTime
|
||||
@ -310,12 +310,14 @@ export class UserSchema extends BaseModel {
|
||||
declare email: string
|
||||
@column()
|
||||
declare fullName: string | null
|
||||
@column()
|
||||
declare googleId: string | null
|
||||
@column({ isPrimary: true })
|
||||
declare id: string
|
||||
@column()
|
||||
declare organizationId: string | null
|
||||
@column({ serializeAs: null })
|
||||
declare password: string
|
||||
declare password: string | null
|
||||
@column()
|
||||
declare signature: string | null
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
|
||||
@ -59,6 +59,7 @@
|
||||
"youch": "^4.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@adonisjs/ally": "^6.3.0",
|
||||
"@adonisjs/auth": "^10.1.0",
|
||||
"@adonisjs/bouncer": "^4.0.0",
|
||||
"@adonisjs/core": "^7.3.1",
|
||||
|
||||
@ -68,6 +68,11 @@ export default await Env.create(new URL('../', import.meta.url), {
|
||||
COOKIE_DOMAIN: Env.schema.string.optional(),
|
||||
COOKIE_SECURE: Env.schema.boolean.optional(),
|
||||
|
||||
// Google SSO (Ally)
|
||||
GOOGLE_CLIENT_ID: Env.schema.string.optional(),
|
||||
GOOGLE_CLIENT_SECRET: Env.schema.string.optional(),
|
||||
GOOGLE_CALLBACK_URL: Env.schema.string.optional({ format: 'url', tld: false }),
|
||||
|
||||
/*
|
||||
|----------------------------------------------------------
|
||||
| Variables for configuring the limiter package
|
||||
|
||||
@ -30,12 +30,19 @@ router
|
||||
/**
|
||||
* Auth — public. /refresh utilise le cookie httpOnly `rubis_refresh`
|
||||
* posé par signup/login pour émettre une nouvelle AuthSession.
|
||||
* /google/* → SSO via Ally, callback pose aussi le refresh cookie.
|
||||
*/
|
||||
router
|
||||
.group(() => {
|
||||
router.post('signup', [controllers.NewAccount, 'store']).as('signup')
|
||||
router.post('login', [controllers.AccessTokens, 'store']).as('login')
|
||||
router.post('refresh', [controllers.Refresh, 'handle']).as('refresh')
|
||||
router
|
||||
.get('google/redirect', [controllers.AuthGoogle, 'redirect'])
|
||||
.as('google.redirect')
|
||||
router
|
||||
.get('google/callback', [controllers.AuthGoogle, 'callback'])
|
||||
.as('google.callback')
|
||||
})
|
||||
.prefix('auth')
|
||||
.as('auth')
|
||||
|
||||
77
apps/web/src/components/auth/GoogleButton.tsx
Normal file
77
apps/web/src/components/auth/GoogleButton.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Bouton "Continuer avec Google".
|
||||
*
|
||||
* IMPORTANT — c'est un `<a href>`, PAS un bouton fetch :
|
||||
* OAuth nécessite un full-page redirect (le browser doit naviguer vers
|
||||
* l'écran de consentement Google). Un fetch ne peut pas suivre les
|
||||
* redirections cross-origin avec cookies.
|
||||
*
|
||||
* L'URL est relative — nginx (rubis-web) proxy /api/* vers rubis-api,
|
||||
* donc même origine pour le browser → cookie refresh posé par la
|
||||
* callback est lisible côté SPA.
|
||||
*/
|
||||
export function GoogleButton({
|
||||
label = "Continuer avec Google",
|
||||
className,
|
||||
}: {
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href="/api/v1/auth/google/redirect"
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2.5 w-full",
|
||||
"h-11 px-5 rounded-default border border-line bg-white",
|
||||
"font-sans text-[14px] font-semibold text-ink",
|
||||
"transition-[transform,background,box-shadow] duration-150",
|
||||
"hover:bg-cream-2 hover:-translate-y-px hover:shadow-soft",
|
||||
"active:translate-y-0",
|
||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<GoogleLogo aria-hidden="true" />
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** Logo Google officiel — 4 couleurs, taille fixe 18px. */
|
||||
function GoogleLogo(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" {...props}>
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.836.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Séparateur "ou" entre le bouton SSO et le formulaire email/password. */
|
||||
export function AuthDivider({ label = "ou" }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 my-4">
|
||||
<div className="h-px flex-1 bg-line" />
|
||||
<span className="text-[11px] uppercase tracking-[0.1em] text-ink-3">
|
||||
{label}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-line" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
apps/web/src/routes/auth.google.complete.tsx
Normal file
69
apps/web/src/routes/auth.google.complete.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { AuthSession } from "@rubis/shared";
|
||||
import { api } from "@/lib/api";
|
||||
import { authStore } from "@/lib/auth";
|
||||
import { Gem } from "@/components/brand/Gem";
|
||||
|
||||
/**
|
||||
* Callback Google côté SPA — se charge :
|
||||
* 1. d'appeler POST /api/v1/auth/refresh (le cookie httpOnly posé par
|
||||
* /api/v1/auth/google/callback est auto-envoyé)
|
||||
* 2. de stocker l'access token + user dans authStore
|
||||
* 3. de naviguer vers `?next=...` (envoyé par le backend selon l'état
|
||||
* d'onboarding : "/" pour user complet, "/onboarding/entreprise"
|
||||
* pour un nouveau)
|
||||
*
|
||||
* En cas d'échec du refresh, on renvoie sur /login avec un toast.
|
||||
*/
|
||||
const searchSchema = z.object({
|
||||
next: z.string().default("/"),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/auth/google/complete")({
|
||||
validateSearch: searchSchema,
|
||||
component: GoogleCompletePage,
|
||||
});
|
||||
|
||||
function GoogleCompletePage() {
|
||||
const { next } = Route.useSearch();
|
||||
const navigate = useNavigate();
|
||||
// Strict-mode protect : avoid double-firing the refresh in dev.
|
||||
const triggered = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (triggered.current) return;
|
||||
triggered.current = true;
|
||||
(async () => {
|
||||
try {
|
||||
const session = await api.post<AuthSession>(
|
||||
"/api/v1/auth/refresh",
|
||||
undefined,
|
||||
{ anonymous: true },
|
||||
);
|
||||
authStore.setSession(session.accessToken, session.user);
|
||||
const firstName = session.user.fullName?.split(" ")[0];
|
||||
toast.success(firstName ? `Bonjour ${firstName}.` : "Connecté.");
|
||||
// Sécurité : si `next` n'est pas un chemin relatif, on renvoie sur "/".
|
||||
const target = next.startsWith("/") && !next.startsWith("//") ? next : "/";
|
||||
void navigate({ to: target });
|
||||
} catch {
|
||||
toast.error("Connexion Google échouée. Réessayez.");
|
||||
void navigate({ to: "/login" });
|
||||
}
|
||||
})();
|
||||
}, [next, navigate]);
|
||||
|
||||
// UI minimale pendant l'aller-retour réseau (max 1-2s en pratique).
|
||||
return (
|
||||
<main className="min-h-screen bg-cream flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3 text-ink-2">
|
||||
<Gem size={28} className="animate-pulse" />
|
||||
<p className="text-[14px] font-medium">Connexion en cours…</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
@ -15,11 +16,20 @@ import { Field } from "@/components/ui/Field";
|
||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||
import { Brand } from "@/components/brand/Brand";
|
||||
import { Gem } from "@/components/brand/Gem";
|
||||
import { GoogleButton, AuthDivider } from "@/components/auth/GoogleButton";
|
||||
|
||||
const searchSchema = z.object({
|
||||
redirect: z.string().optional(),
|
||||
google: z.enum(["denied", "state_mismatch", "error", "no_email"]).optional(),
|
||||
});
|
||||
|
||||
const GOOGLE_ERROR_MESSAGES: Record<string, string> = {
|
||||
denied: "Connexion Google annulée.",
|
||||
state_mismatch: "Session expirée, réessayez la connexion Google.",
|
||||
error: "Connexion Google impossible. Réessayez dans un instant.",
|
||||
no_email: "Votre compte Google n'a pas d'email associé.",
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
validateSearch: searchSchema,
|
||||
component: LoginPage,
|
||||
@ -29,6 +39,13 @@ function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const search = Route.useSearch();
|
||||
|
||||
// Toast d'erreur si on revient d'un échec Google SSO (?google=denied|...).
|
||||
useEffect(() => {
|
||||
if (search.google && GOOGLE_ERROR_MESSAGES[search.google]) {
|
||||
toast.error(GOOGLE_ERROR_MESSAGES[search.google]!);
|
||||
}
|
||||
}, [search.google]);
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async (input: LoginInput) =>
|
||||
api.post<AuthSession>("/api/v1/auth/login", input, { anonymous: true }),
|
||||
@ -121,13 +138,18 @@ function LoginPage() {
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div className="mt-7">
|
||||
<GoogleButton />
|
||||
<AuthDivider />
|
||||
</div>
|
||||
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
className="mt-7 flex flex-col gap-5"
|
||||
className="flex flex-col gap-5"
|
||||
>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
|
||||
@ -19,6 +19,7 @@ import { Card } from "@/components/ui/Card";
|
||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||
import { Brand } from "@/components/brand/Brand";
|
||||
import { Gem } from "@/components/brand/Gem";
|
||||
import { GoogleButton, AuthDivider } from "@/components/auth/GoogleButton";
|
||||
|
||||
export const Route = createFileRoute("/signup")({
|
||||
component: SignupPage,
|
||||
@ -127,13 +128,18 @@ function SignupPage() {
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div className="mt-7">
|
||||
<GoogleButton label="S'inscrire avec Google" />
|
||||
<AuthDivider />
|
||||
</div>
|
||||
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
className="mt-7 flex flex-col gap-5"
|
||||
className="flex flex-col gap-5"
|
||||
>
|
||||
<form.Field name="fullName">
|
||||
{(field) => (
|
||||
|
||||
@ -130,3 +130,8 @@ data:
|
||||
|
||||
ACCESS_TOKEN_TTL_MINUTES: '30'
|
||||
REFRESH_TOKEN_TTL_DAYS: '30'
|
||||
|
||||
# Google SSO — GOOGLE_CLIENT_ID/SECRET sont dans rubis-app-secrets.
|
||||
# Le callback URL doit matcher EXACTEMENT ce qui est configuré dans
|
||||
# Google Cloud Console (OAuth Client → Authorized redirect URIs).
|
||||
GOOGLE_CALLBACK_URL: 'https://app.rubis.arthurbarre.fr/api/v1/auth/google/callback'
|
||||
|
||||
39
pnpm-lock.yaml
generated
39
pnpm-lock.yaml
generated
@ -32,6 +32,9 @@ importers:
|
||||
|
||||
apps/api:
|
||||
dependencies:
|
||||
'@adonisjs/ally':
|
||||
specifier: ^6.3.0
|
||||
version: 6.3.0(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@adonisjs/session@8.1.0(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@adonisjs/lucid@22.4.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@vinejs/vine@4.4.0)(better-sqlite3@12.9.0)(luxon@3.7.2)(pg@8.20.0))(@japa/api-client@3.2.1(@japa/assert@4.2.0(@japa/runner@5.3.0))(@japa/runner@5.3.0))(@japa/plugin-adonisjs@5.2.0(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@japa/api-client@3.2.1(@japa/assert@4.2.0(@japa/runner@5.3.0))(@japa/runner@5.3.0))(@japa/runner@5.3.0)))
|
||||
'@adonisjs/auth':
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(cb463dcab987fc365459355e33b96486)
|
||||
@ -314,6 +317,25 @@ packages:
|
||||
peerDependencies:
|
||||
youch: ^4.1.0-beta.11 || ^4.1.0
|
||||
|
||||
'@adonisjs/ally@6.3.0':
|
||||
resolution: {integrity: sha512-yxw9mpQHexRwKx3qpR651yn/a+gYYHne0Cc6fjMxVQ8rOaBZS2e1025wmuhHg7gProLCv8lpSbqF7I9Hk7qWAQ==}
|
||||
engines: {node: '>=20.6.0'}
|
||||
peerDependencies:
|
||||
'@adonisjs/assembler': ^8.0.0
|
||||
'@adonisjs/core': ^7.0.0-next.8 || ^7.0.0
|
||||
'@adonisjs/i18n': ^3.0.0
|
||||
'@adonisjs/inertia': ^4.2.0
|
||||
'@adonisjs/session': ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@adonisjs/assembler':
|
||||
optional: true
|
||||
'@adonisjs/i18n':
|
||||
optional: true
|
||||
'@adonisjs/inertia':
|
||||
optional: true
|
||||
'@adonisjs/session':
|
||||
optional: true
|
||||
|
||||
'@adonisjs/application@9.0.0':
|
||||
resolution: {integrity: sha512-iQpq/JRJsnrqOMHfu72CYjmlkH5FwT28DhUKEOjktccmFh8OLdVZ2Sieb8b2/qNv4c+w8Yo7keOGEzOYUrU+kA==}
|
||||
engines: {node: '>=24.0.0'}
|
||||
@ -1539,6 +1561,10 @@ packages:
|
||||
'@poppinss/multiparty@3.0.0':
|
||||
resolution: {integrity: sha512-z9jchUzsv7E+7sa4tWHb0+95Byx7w0ydlPGxg3nzyb7h3QlRdeW8/QkU9SexUY4lsT12do93AfNBAhSuOoVqjA==}
|
||||
|
||||
'@poppinss/oauth-client@7.2.0':
|
||||
resolution: {integrity: sha512-LUCd/fIm2oOeTltnZRSJIxZL9uy9+qHpgtNtxIO+wp8E/1Dd7ogbqIWUFYMufCxTekOoYyQlWOcOQ9rmUw5D5g==}
|
||||
engines: {node: '>=24.0.0'}
|
||||
|
||||
'@poppinss/object-builder@1.1.0':
|
||||
resolution: {integrity: sha512-FOrOq52l7u8goR5yncX14+k+Ewi5djnrt1JwXeS/FvnwAPOiveFhiczCDuvXdssAwamtrV2hp5Rw9v+n2T7hQg==}
|
||||
engines: {node: '>=20.6.0'}
|
||||
@ -5754,6 +5780,14 @@ snapshots:
|
||||
yargs-parser: 22.0.0
|
||||
youch: 4.1.1
|
||||
|
||||
'@adonisjs/ally@6.3.0(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@adonisjs/session@8.1.0(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@adonisjs/lucid@22.4.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@vinejs/vine@4.4.0)(better-sqlite3@12.9.0)(luxon@3.7.2)(pg@8.20.0))(@japa/api-client@3.2.1(@japa/assert@4.2.0(@japa/runner@5.3.0))(@japa/runner@5.3.0))(@japa/plugin-adonisjs@5.2.0(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@japa/api-client@3.2.1(@japa/assert@4.2.0(@japa/runner@5.3.0))(@japa/runner@5.3.0))(@japa/runner@5.3.0)))':
|
||||
dependencies:
|
||||
'@adonisjs/core': 7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1)
|
||||
'@poppinss/oauth-client': 7.2.0
|
||||
optionalDependencies:
|
||||
'@adonisjs/assembler': 8.4.0(typescript@6.0.3)
|
||||
'@adonisjs/session': 8.1.0(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@adonisjs/lucid@22.4.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@vinejs/vine@4.4.0)(better-sqlite3@12.9.0)(luxon@3.7.2)(pg@8.20.0))(@japa/api-client@3.2.1(@japa/assert@4.2.0(@japa/runner@5.3.0))(@japa/runner@5.3.0))(@japa/plugin-adonisjs@5.2.0(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@japa/api-client@3.2.1(@japa/assert@4.2.0(@japa/runner@5.3.0))(@japa/runner@5.3.0))(@japa/runner@5.3.0))
|
||||
|
||||
'@adonisjs/application@9.0.0(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@adonisjs/config@6.1.0)(@adonisjs/fold@11.0.0)':
|
||||
dependencies:
|
||||
'@adonisjs/config': 6.1.0
|
||||
@ -7204,6 +7238,11 @@ snapshots:
|
||||
dependencies:
|
||||
http-errors: 2.0.1
|
||||
|
||||
'@poppinss/oauth-client@7.2.0':
|
||||
dependencies:
|
||||
'@poppinss/exception': 1.2.3
|
||||
ky: 1.14.3
|
||||
|
||||
'@poppinss/object-builder@1.1.0': {}
|
||||
|
||||
'@poppinss/prompts@3.1.6':
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user