From ea539cd1d48fa7dabe30fa8968c3d0093ccc4917 Mon Sep 17 00:00:00 2001
From: ordinarthur <@arthurbarre.js@gmail.com>
Date: Thu, 7 May 2026 09:24:27 +0200
Subject: [PATCH] feat(auth): Google SSO via @adonisjs/ally
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.claude/deploy-memory.md | 21 +++
apps/api/.env.example | 10 ++
apps/api/adonisrc.ts | 3 +-
.../app/controllers/auth_google_controller.ts | 128 ++++++++++++++++++
apps/api/config/ally.ts | 36 +++++
...080001300_add_google_sso_to_users_table.ts | 28 ++++
apps/api/database/schema.ts | 6 +-
apps/api/package.json | 1 +
apps/api/start/env.ts | 5 +
apps/api/start/routes.ts | 7 +
apps/web/src/components/auth/GoogleButton.tsx | 77 +++++++++++
apps/web/src/routes/auth.google.complete.tsx | 69 ++++++++++
apps/web/src/routes/login.tsx | 24 +++-
apps/web/src/routes/signup.tsx | 8 +-
k3s/app/api.yml | 5 +
pnpm-lock.yaml | 39 ++++++
16 files changed, 462 insertions(+), 5 deletions(-)
create mode 100644 apps/api/app/controllers/auth_google_controller.ts
create mode 100644 apps/api/config/ally.ts
create mode 100644 apps/api/database/migrations/1778080001300_add_google_sso_to_users_table.ts
create mode 100644 apps/web/src/components/auth/GoogleButton.tsx
create mode 100644 apps/web/src/routes/auth.google.complete.tsx
diff --git a/.claude/deploy-memory.md b/.claude/deploy-memory.md
index f40325d..51704fe 100644
--- a/.claude/deploy-memory.md
+++ b/.claude/deploy-memory.md
@@ -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.
diff --git a/apps/api/.env.example b/apps/api/.env.example
index 6c53faa..11c5a2f 100644
--- a/apps/api/.env.example
+++ b/apps/api/.env.example
@@ -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
diff --git a/apps/api/adonisrc.ts b/apps/api/adonisrc.ts
index 54324b2..5281eba 100644
--- a/apps/api/adonisrc.ts
+++ b/apps/api/adonisrc.ts
@@ -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'),
],
/*
diff --git a/apps/api/app/controllers/auth_google_controller.ts b/apps/api/app/controllers/auth_google_controller.ts
new file mode 100644
index 0000000..ce5e110
--- /dev/null
+++ b/apps/api/app/controllers/auth_google_controller.ts
@@ -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)}`)
+ }
+}
diff --git a/apps/api/config/ally.ts b/apps/api/config/ally.ts
new file mode 100644
index 0000000..0c3426a
--- /dev/null
+++ b/apps/api/config/ally.ts
@@ -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 {}
+}
+
+import type { InferSocialProviders } from '@adonisjs/ally/types'
diff --git a/apps/api/database/migrations/1778080001300_add_google_sso_to_users_table.ts b/apps/api/database/migrations/1778080001300_add_google_sso_to_users_table.ts
new file mode 100644
index 0000000..f47ef31
--- /dev/null
+++ b/apps/api/database/migrations/1778080001300_add_google_sso_to_users_table.ts
@@ -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.
+ })
+ }
+}
diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts
index f6945a0..fc7cdd4 100644
--- a/apps/api/database/schema.ts
+++ b/apps/api/database/schema.ts
@@ -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 })
diff --git a/apps/api/package.json b/apps/api/package.json
index 68efef0..eb3401a 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -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",
diff --git a/apps/api/start/env.ts b/apps/api/start/env.ts
index 73b58c0..570cffd 100644
--- a/apps/api/start/env.ts
+++ b/apps/api/start/env.ts
@@ -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
diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts
index e7dd191..4594beb 100644
--- a/apps/api/start/routes.ts
+++ b/apps/api/start/routes.ts
@@ -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')
diff --git a/apps/web/src/components/auth/GoogleButton.tsx b/apps/web/src/components/auth/GoogleButton.tsx
new file mode 100644
index 0000000..8309ee9
--- /dev/null
+++ b/apps/web/src/components/auth/GoogleButton.tsx
@@ -0,0 +1,77 @@
+import { cn } from "@/lib/utils";
+
+/**
+ * Bouton "Continuer avec Google".
+ *
+ * IMPORTANT — c'est un ``, 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 (
+
+
+ {label}
+
+ );
+}
+
+/** Logo Google officiel — 4 couleurs, taille fixe 18px. */
+function GoogleLogo(props: React.SVGProps) {
+ return (
+
+ );
+}
+
+/** Séparateur "ou" entre le bouton SSO et le formulaire email/password. */
+export function AuthDivider({ label = "ou" }: { label?: string }) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/routes/auth.google.complete.tsx b/apps/web/src/routes/auth.google.complete.tsx
new file mode 100644
index 0000000..09f8553
--- /dev/null
+++ b/apps/web/src/routes/auth.google.complete.tsx
@@ -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(
+ "/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 (
+
+
+
+
Connexion en cours…
+
+
+ );
+}
diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx
index 74fa48a..71429cb 100644
--- a/apps/web/src/routes/login.tsx
+++ b/apps/web/src/routes/login.tsx
@@ -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 = {
+ 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("/api/v1/auth/login", input, { anonymous: true }),
@@ -121,13 +138,18 @@ function LoginPage() {
+
+
{(field) => (
diff --git a/apps/web/src/routes/signup.tsx b/apps/web/src/routes/signup.tsx
index 6f87b70..6281843 100644
--- a/apps/web/src/routes/signup.tsx
+++ b/apps/web/src/routes/signup.tsx
@@ -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() {
+
+
{(field) => (
diff --git a/k3s/app/api.yml b/k3s/app/api.yml
index 67f439c..114c667 100644
--- a/k3s/app/api.yml
+++ b/k3s/app/api.yml
@@ -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'
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 08c6eae..943f21a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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':