Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion apps/app/.env.example
Original file line number Diff line number Diff line change
@@ -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
68 changes: 68 additions & 0 deletions apps/app/composables/useAuth.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createAuthClient> | 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<string | null>(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<boolean> {
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<void> {
pending.value = true
try {
await client.signOut()
} finally {
pending.value = false
}
}

return { session, signIn, signOut, pending: readonly(pending), error: readonly(error) }
}
17 changes: 13 additions & 4 deletions apps/app/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
})
77 changes: 77 additions & 0 deletions apps/app/pages/connexion.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<script setup lang="ts">
definePageMeta({ layout: false }) // page plein-écran, pas de nav connectée

const { signIn, pending, error } = useAuth()

const email = ref('')
const sent = ref(false)

async function handleSubmit() {
if (!email.value) return
const ok = await signIn(email.value)
if (ok) sent.value = true
}
</script>

<template>
<div class="min-h-screen flex items-center justify-center bg-surface-muted px-4">
<div class="w-full max-w-sm bg-surface rounded-card shadow p-8 space-y-6">
<div class="text-center space-y-1">
<h1 class="text-xl font-semibold text-ink-900">Connexion</h1>
<p class="text-sm text-ink-500">CV Optimizer</p>
</div>

<template v-if="!sent">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div class="space-y-1">
<label for="email" class="block text-sm font-medium text-ink-700">
Adresse e-mail
</label>
<input
id="email"
v-model="email"
type="email"
required
autocomplete="email"
placeholder="vous@exemple.fr"
class="w-full rounded-card border border-ink-500/30 bg-surface px-3 py-2 text-sm text-ink-900 placeholder-ink-500 focus:outline-none focus:ring-2 focus:ring-brand-500 disabled:opacity-50"
:disabled="pending"
/>
</div>

<p v-if="error" role="alert" class="text-sm text-danger-500">{{ error }}</p>

<button
type="submit"
:disabled="pending || !email"
class="w-full rounded-card bg-brand-600 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<span v-if="pending">Envoi en cours…</span>
<span v-else>Recevoir un lien de connexion</span>
</button>
</form>

<p class="text-xs text-center text-ink-500">
Pas de mot de passe. Vous recevrez un lien valable 10 minutes.
</p>
</template>

<template v-else>
<div class="text-center space-y-3">
<p class="text-success-500 font-medium">✓ Lien envoyé !</p>
<p class="text-sm text-ink-700">
Consultez votre boîte e-mail (<strong>{{ email }}</strong>) et cliquez sur le lien pour
vous connecter.
</p>
<p class="text-xs text-ink-500">Le lien est valable 10 minutes.</p>
<button
class="text-sm text-brand-600 hover:underline"
@click="sent = false"
>
Utiliser une autre adresse
</button>
</div>
</template>
</div>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -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;
71 changes: 67 additions & 4 deletions apps/app/prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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.
9 changes: 9 additions & 0 deletions apps/app/server/api/auth/[...all].ts
Original file line number Diff line number Diff line change
@@ -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))
})
43 changes: 43 additions & 0 deletions apps/app/server/utils/auth.ts
Original file line number Diff line number Diff line change
@@ -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
Loading