diff --git a/apps/app/.env.example b/apps/app/.env.example index a864657..29a8dac 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -1,3 +1,18 @@ # Copy to apps/app/.env for local dev (never commit .env). -# DATABASE_URL est lue côté serveur Nitro uniquement. +# Toutes ces variables sont lues côté serveur Nitro uniquement (jamais exposées au client). + +# Base de données (correspond au docker-compose.yml local). DATABASE_URL=postgresql://cvo:cvo@localhost:5432/cvo?schema=public + +# Better Auth — clé secrète (min 32 chars, changer en prod). +BETTER_AUTH_SECRET=dev-secret-change-in-prod-min32chars!! + +# URL de base de l'app (utilisée pour générer les magic-links). +APP_URL=http://localhost:3000 + +# SMTP (optionnel en dev — si absent, le magic-link est loggé dans la console). +# SMTP_HOST=smtp.example.fr +# SMTP_PORT=587 +# SMTP_USER=no-reply@cv-optimizer.fr +# SMTP_PASS=changeme +# MAIL_FROM=no-reply@cv-optimizer.fr diff --git a/apps/app/composables/useAuth.ts b/apps/app/composables/useAuth.ts new file mode 100644 index 0000000..41f4e76 --- /dev/null +++ b/apps/app/composables/useAuth.ts @@ -0,0 +1,68 @@ +/** + * Composable côté client pour l'authentification (Better Auth magic-link). + * Expose : session réactive, signIn (envoi du magic-link), signOut. + * + * Utilisation : + * const { session, signIn, signOut, pending } = useAuth() + * await signIn('user@example.com') // envoie le magic-link + * await signOut() + */ +import { createAuthClient } from 'better-auth/client' +import { magicLinkClient } from 'better-auth/client/plugins' + +// Singleton par page (SSR-safe : le composable est appelé côté client uniquement). +let _client: ReturnType | null = null + +function getClient() { + if (!_client) { + _client = createAuthClient({ + baseURL: '/api/auth', + plugins: [magicLinkClient()], + }) + } + return _client +} + +export function useAuth() { + const client = getClient() + const pending = ref(false) + const error = ref(null) + + /** + * Session Better Auth (null si non connecté). + * Better Auth client expose useSession en tant que composable Vue 3 — + * on le délègue directement pour que la réactivité fonctionne. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = (client as any).useSession ?? ref(null) + + /** + * Envoie un magic-link à l'adresse e-mail. + * Dev : le lien apparaît dans la console serveur (pas de SMTP requis). + */ + async function signIn(email: string): Promise { + pending.value = true + error.value = null + try { + // @ts-expect-error — Better Auth client plugin types are dynamic + await client.signIn.magicLink({ email, callbackURL: '/profil' }) + return true + } catch (e) { + error.value = e instanceof Error ? e.message : 'Erreur lors de l\'envoi du lien.' + return false + } finally { + pending.value = false + } + } + + async function signOut(): Promise { + pending.value = true + try { + await client.signOut() + } finally { + pending.value = false + } + } + + return { session, signIn, signOut, pending: readonly(pending), error: readonly(error) } +} diff --git a/apps/app/nuxt.config.ts b/apps/app/nuxt.config.ts index be2e847..92e029b 100644 --- a/apps/app/nuxt.config.ts +++ b/apps/app/nuxt.config.ts @@ -34,13 +34,22 @@ export default defineNuxtConfig({ }, runtimeConfig: { - // DATABASE_URL est lue côté serveur uniquement (jamais exposée au client). + // Serveur uniquement (jamais exposées au client). databaseUrl: process.env.DATABASE_URL, + // Clé secrète Better Auth (BETTER_AUTH_SECRET en prod, min 32 chars). + authSecret: process.env.BETTER_AUTH_SECRET ?? 'dev-secret-change-in-prod-min32chars!!', + // SMTP optionnel (dev : log console ; prod : fournir SMTP_HOST). + smtpHost: process.env.SMTP_HOST ?? '', + smtpPort: process.env.SMTP_PORT ?? '587', + smtpUser: process.env.SMTP_USER ?? '', + smtpPass: process.env.SMTP_PASS ?? '', + mailFrom: process.env.MAIL_FROM ?? '', + public: { - // URL canonique du site (SEO/OG/sitemap). Hébergement à arbitrer (CTO) : - // override via NUXT_PUBLIC_SITE_URL au déploiement. Placeholder tant que - // le domaine n'est pas tranché. + // URL canonique du site (SEO/OG/sitemap). Override via NUXT_PUBLIC_SITE_URL au déploiement. siteUrl: process.env.NUXT_PUBLIC_SITE_URL ?? 'https://cv-optimizer.example.com', + // URL de base de l'app (utilisée par Better Auth pour générer les liens magic-link). + appUrl: process.env.APP_URL ?? 'http://localhost:3000', }, }, }) diff --git a/apps/app/pages/connexion.vue b/apps/app/pages/connexion.vue new file mode 100644 index 0000000..4d25059 --- /dev/null +++ b/apps/app/pages/connexion.vue @@ -0,0 +1,77 @@ + + + diff --git a/apps/app/prisma/migrations/20260609101221_auth_better_auth_tables/migration.sql b/apps/app/prisma/migrations/20260609101221_auth_better_auth_tables/migration.sql new file mode 100644 index 0000000..6c362f5 --- /dev/null +++ b/apps/app/prisma/migrations/20260609101221_auth_better_auth_tables/migration.sql @@ -0,0 +1,56 @@ +-- CreateTable +CREATE TABLE "sessions" ( + "id" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "token" TEXT NOT NULL, + "ipAddress" TEXT, + "userAgent" TEXT, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "accounts" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "accessToken" TEXT, + "refreshToken" TEXT, + "idToken" TEXT, + "accessTokenExpiresAt" TIMESTAMP(3), + "refreshTokenExpiresAt" TIMESTAMP(3), + "scope" TEXT, + "password" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "verifications" ( + "id" TEXT NOT NULL, + "identifier" TEXT NOT NULL, + "value" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "verifications_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_token_key" ON "sessions"("token"); + +-- CreateIndex +CREATE INDEX "verifications_identifier_idx" ON "verifications"("identifier"); + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "accounts" ADD CONSTRAINT "accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/app/prisma/schema.prisma b/apps/app/prisma/schema.prisma index b74de32..f475aa3 100644 --- a/apps/app/prisma/schema.prisma +++ b/apps/app/prisma/schema.prisma @@ -1,8 +1,8 @@ // Couche d'accès PostgreSQL (Prisma), utilisée côté serveur Nitro. // Modèle métier « profil candidat » (THI-123, WS-Auth+Profil) — data model THI-120 §2. -// Périmètre de ce WS : users / profiles / experiences / skills / education. -// Hors périmètre (WS dédiés) : job_offers + generations (THI-124), usage_* (THI-126), -// tables satellites d'auth (sessions/accounts/verification → ajoutées par le WS Auth). +// Périmètre de ce WS : users / profiles / experiences / skills / education + +// tables satellites Better Auth : sessions / accounts / verifications (magic-link tokens). +// Hors périmètre (WS dédiés) : job_offers + generations (THI-124), usage_* (THI-126). generator client { provider = "prisma-client-js" @@ -72,7 +72,9 @@ model User { updatedAt DateTime @updatedAt deletedAt DateTime? // soft-delete RGPD (droit à l'effacement) - profile Profile? + profile Profile? + sessions Session[] + accounts Account[] @@index([deletedAt]) @@map("users") @@ -211,3 +213,64 @@ model UsageCounter { @@unique([userId, period]) @@map("usage_counters") } + +// ───────────────────────────────────────────────────────────────────────────── +// Tables satellites Better Auth (THI-131, WS Auth magic-link) +// +// Schéma requis par Better Auth v1 (prismaAdapter, provider postgresql). +// Noms de champs camelCase imposés par l'adaptateur. +// Le magic-link plugin réutilise la table `verifications` pour stocker les tokens. +// Ces tables ne portent PAS de soft-delete — Better Auth les gère en dur. +// ───────────────────────────────────────────────────────────────────────────── + +// Sessions actives (expiration côté Better Auth). +model Session { + id String @id + expiresAt DateTime + token String @unique + ipAddress String? + userAgent String? + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("sessions") +} + +// Comptes OAuth/credentials liés à un utilisateur (email = magic-link). +model Account { + id String @id + accountId String + providerId String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + accessToken String? + refreshToken String? + idToken String? + accessTokenExpiresAt DateTime? + refreshTokenExpiresAt DateTime? + scope String? + password String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("accounts") +} + +// Tokens de vérification (magic-link, email verification…). +model Verification { + id String @id + identifier String + value String + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([identifier]) + @@map("verifications") +} + +// Back-relations Better Auth sur User (ajoutées ici pour éviter de modifier +// le bloc User déjà posé dans le data model slice 1). +// Prisma les génère via la relation inverse côté Session/Account. diff --git a/apps/app/server/api/auth/[...all].ts b/apps/app/server/api/auth/[...all].ts new file mode 100644 index 0000000..cd7a4fe --- /dev/null +++ b/apps/app/server/api/auth/[...all].ts @@ -0,0 +1,9 @@ +/** + * Route Nitro catch-all → délègue toutes les requêtes /api/auth/** à Better Auth. + * Couvre : POST /api/auth/magic-link/sign-in, GET /api/auth/magic-link/verify, etc. + */ +import { auth } from '../../utils/auth' + +export default defineEventHandler((event) => { + return auth.handler(toWebRequest(event)) +}) diff --git a/apps/app/server/utils/auth.ts b/apps/app/server/utils/auth.ts new file mode 100644 index 0000000..cf19a19 --- /dev/null +++ b/apps/app/server/utils/auth.ts @@ -0,0 +1,43 @@ +/** + * Instance Better Auth (serveur Nitro uniquement — jamais exposée au client). + * Plugin magic-link : l'utilisateur reçoit un lien par e-mail, pas de mot de passe. + * Données auth hébergées en FR/UE (RGPD). OAuth Google = phase 2 (hors scope). + */ +import { betterAuth } from 'better-auth' +import { prismaAdapter } from 'better-auth/adapters/prisma' +import { magicLink } from 'better-auth/plugins/magic-link' +import { prisma } from './prisma' +import { sendMagicLinkEmail } from './mailer' + +const config = useRuntimeConfig() + +export const auth = betterAuth({ + secret: config.authSecret, + baseURL: config.public.appUrl, + + database: prismaAdapter(prisma, { provider: 'postgresql' }), + + // FR-first : labels et messages côté e-mail en français. + user: { + additionalFields: { + deletedAt: { + type: 'date', + required: false, + defaultValue: null, + input: false, + }, + }, + }, + + plugins: [ + magicLink({ + sendMagicLink: async ({ email, url }) => { + await sendMagicLinkEmail(email, url) + }, + // Durée de validité du lien (10 min par défaut Better Auth). + expiresIn: 600, + }), + ], +}) + +export type Auth = typeof auth diff --git a/apps/app/server/utils/mailer.ts b/apps/app/server/utils/mailer.ts new file mode 100644 index 0000000..38a8039 --- /dev/null +++ b/apps/app/server/utils/mailer.ts @@ -0,0 +1,36 @@ +/** + * Envoi d'e-mail côté serveur Nitro. + * - Dev : log dans la console (pas de vraie livraison). + * - Prod : SMTP via nodemailer (SMTP_HOST/PORT/USER/PASS configurés dans .env). + * + * Le provider SMTP de prod n'est pas cablé ici (hors scope MVP technique). + * Remplacer le bloc ELSE par le transport SMTP réel lors du déploiement. + */ +import nodemailer from 'nodemailer' + +export async function sendMagicLinkEmail(email: string, magicLinkUrl: string): Promise { + const config = useRuntimeConfig() + + if (config.smtpHost) { + const transport = nodemailer.createTransport({ + host: config.smtpHost, + port: Number(config.smtpPort ?? 587), + secure: Number(config.smtpPort ?? 587) === 465, + auth: { + user: config.smtpUser, + pass: config.smtpPass, + }, + }) + + await transport.sendMail({ + from: config.mailFrom ?? 'no-reply@cv-optimizer.fr', + to: email, + subject: 'Votre lien de connexion — CV Optimizer', + text: `Bonjour,\n\nCliquez sur ce lien pour vous connecter (valable 10 minutes) :\n${magicLinkUrl}\n\nSi vous n'avez pas demandé ce lien, ignorez cet e-mail.\n\n— L'équipe CV Optimizer`, + html: `

Bonjour,

Cliquez sur ce lien pour vous connecter (valable 10 minutes) :

${magicLinkUrl}

Si vous n'avez pas demandé ce lien, ignorez cet e-mail.

— L'équipe CV Optimizer

`, + }) + } else { + // Dev : affiche le lien dans la console pour test sans SMTP. + console.log(`\n[DEV] Magic-link pour ${email}:\n ${magicLinkUrl}\n`) + } +}