From 3b7d362600153463dc94f8cac54ca8467f1c63b7 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Fri, 22 May 2026 19:45:21 +0200 Subject: [PATCH] feat(02-02): frontend auth store, router guard, Login/Register views - frontend/src/stores/auth.js: useAuthStore with accessToken in memory only (never browser storage); login() accepts options.backupCode - frontend/src/api/client.js: extended with Bearer token injection, 401 auto-refresh retry, all auth/admin API functions, changePassword - frontend/src/router/index.js: auth routes added (/login, /register, /password-reset, /account, /admin); beforeEach guard redirects unauthenticated users to /login with redirect param - frontend/src/layouts/AuthLayout.vue: centered bare layout for auth pages - frontend/src/views/auth/LoginView.vue: three-step flow (password, TOTP, backup code); "Use a backup code instead" link; UI-SPEC copywriting - frontend/src/views/auth/RegisterView.vue: registration with PasswordStrengthBar; HIBP error display; UI-SPEC copywriting - frontend/src/components/auth/PasswordStrengthBar.vue: 4-segment bar - frontend/src/components/ui/AppSpinner.vue: animate-spin SVG spinner - Stub views: PasswordResetView, NewPasswordView, AccountView, AdminView - .gitignore: exclude frontend/node_modules, dist, package-lock.json npm run build exits 0. All acceptance criteria verified. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 + frontend/src/api/client.js | 150 +++++++++- .../components/auth/PasswordStrengthBar.vue | 68 +++++ frontend/src/components/ui/AppSpinner.vue | 23 ++ frontend/src/layouts/AuthLayout.vue | 12 + frontend/src/router/index.js | 41 ++- frontend/src/stores/auth.js | 136 +++++++++ frontend/src/views/AccountView.vue | 153 ++++++++++ frontend/src/views/AdminView.vue | 39 +++ frontend/src/views/auth/LoginView.vue | 266 ++++++++++++++++++ frontend/src/views/auth/NewPasswordView.vue | 70 +++++ frontend/src/views/auth/PasswordResetView.vue | 72 +++++ frontend/src/views/auth/RegisterView.vue | 132 +++++++++ 13 files changed, 1163 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/auth/PasswordStrengthBar.vue create mode 100644 frontend/src/components/ui/AppSpinner.vue create mode 100644 frontend/src/layouts/AuthLayout.vue create mode 100644 frontend/src/stores/auth.js create mode 100644 frontend/src/views/AccountView.vue create mode 100644 frontend/src/views/AdminView.vue create mode 100644 frontend/src/views/auth/LoginView.vue create mode 100644 frontend/src/views/auth/NewPasswordView.vue create mode 100644 frontend/src/views/auth/PasswordResetView.vue create mode 100644 frontend/src/views/auth/RegisterView.vue diff --git a/.gitignore b/.gitignore index d17aa93..3fcbcf1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .env backend/data/ +frontend/node_modules/ +frontend/dist/ +frontend/package-lock.json diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index a7bb06d..774d00a 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -1,10 +1,37 @@ /** * API client using native Fetch API. * All requests go to /api (proxied to backend by Vite in dev, or nginx in prod). + * + * Phase 2 additions (D-11): + * - Injects Authorization: Bearer header from useAuthStore().accessToken + * - On 401: calls authStore.refresh() and retries once (_retry guard) + * - On refresh failure: clears accessToken, throws 'Session expired' */ async function request(path, options = {}) { - const res = await fetch(path, options) + // Lazy import to avoid circular dependency (stores/auth.js → api/client.js → stores/auth.js) + const { useAuthStore } = await import('../stores/auth.js') + const authStore = useAuthStore() + + const headers = { ...(options.headers || {}) } + if (authStore.accessToken) { + headers['Authorization'] = `Bearer ${authStore.accessToken}` + } + + const res = await fetch(path, { ...options, headers, credentials: 'include' }) + + // 401 → attempt refresh → retry once + if (res.status === 401 && !options._retry) { + try { + await authStore.refresh() + return request(path, { ...options, _retry: true }) + } catch { + authStore.accessToken = null + authStore.user = null + throw new Error('Session expired') + } + } + if (!res.ok) { let msg = `HTTP ${res.status}` try { msg = (await res.json()).detail || msg } catch {} @@ -103,3 +130,124 @@ export function testProvider(provider) { export function getDefaultPrompt() { return request('/api/settings/default-prompt') } + +// ── Auth ───────────────────────────────────────────────────────────────────── + +export function login(body) { + return request('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +export function register(body) { + return request('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +export function refreshToken() { + // No body — httpOnly cookie sent automatically via credentials: 'include' + return request('/api/auth/refresh', { method: 'POST' }) +} + +export function logout() { + return request('/api/auth/logout', { method: 'POST' }) +} + +export function logoutAll() { + return request('/api/auth/logout-all', { method: 'POST' }) +} + +export function getMe() { + return request('/api/auth/me') +} + +export function changePassword(body) { + return request('/api/auth/change-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +// ── TOTP ────────────────────────────────────────────────────────────────────── + +export function totpSetup() { + return request('/api/auth/totp/setup') +} + +export function totpEnable(code) { + return request('/api/auth/totp/enable', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }) +} + +export function totpDisable() { + return request('/api/auth/totp', { method: 'DELETE' }) +} + +// ── Password reset ──────────────────────────────────────────────────────────── + +export function passwordResetRequest(email) { + return request('/api/auth/password-reset', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }) +} + +export function passwordResetConfirm(token, newPassword) { + return request('/api/auth/password-reset/confirm', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, new_password: newPassword }), + }) +} + +// ── Admin ───────────────────────────────────────────────────────────────────── + +export function adminListUsers() { + return request('/api/admin/users') +} + +export function adminCreateUser(body) { + return request('/api/admin/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +export function adminDeactivateUser(id) { + return request(`/api/admin/users/${id}/deactivate`, { method: 'POST' }) +} + +export function adminReactivateUser(id) { + return request(`/api/admin/users/${id}/reactivate`, { method: 'POST' }) +} + +export function adminResetUserPassword(id) { + return request(`/api/admin/users/${id}/reset-password`, { method: 'POST' }) +} + +export function adminUpdateQuota(id, limitBytes) { + return request(`/api/admin/users/${id}/quota`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ limit_bytes: limitBytes }), + }) +} + +export function adminUpdateAiConfig(id, provider, model) { + return request(`/api/admin/users/${id}/ai-config`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider, model }), + }) +} diff --git a/frontend/src/components/auth/PasswordStrengthBar.vue b/frontend/src/components/auth/PasswordStrengthBar.vue new file mode 100644 index 0000000..2d9e560 --- /dev/null +++ b/frontend/src/components/auth/PasswordStrengthBar.vue @@ -0,0 +1,68 @@ + + + diff --git a/frontend/src/components/ui/AppSpinner.vue b/frontend/src/components/ui/AppSpinner.vue new file mode 100644 index 0000000..d24924c --- /dev/null +++ b/frontend/src/components/ui/AppSpinner.vue @@ -0,0 +1,23 @@ + diff --git a/frontend/src/layouts/AuthLayout.vue b/frontend/src/layouts/AuthLayout.vue new file mode 100644 index 0000000..bb2739b --- /dev/null +++ b/frontend/src/layouts/AuthLayout.vue @@ -0,0 +1,12 @@ + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index ece1f8d..ce5b038 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,18 +1,57 @@ import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '../stores/auth.js' import HomeView from '../views/HomeView.vue' import TopicsView from '../views/TopicsView.vue' import DocumentView from '../views/DocumentView.vue' import SettingsView from '../views/SettingsView.vue' const routes = [ + // Existing routes { path: '/', component: HomeView }, { path: '/topics', component: TopicsView }, { path: '/topics/:name', component: TopicsView }, { path: '/document/:id', component: DocumentView }, { path: '/settings', component: SettingsView }, + + // Phase 2 — public auth routes (no guard) + { + path: '/login', + component: () => import('../views/auth/LoginView.vue'), + meta: { public: true }, + }, + { + path: '/register', + component: () => import('../views/auth/RegisterView.vue'), + meta: { public: true }, + }, + { + path: '/password-reset', + component: () => import('../views/auth/PasswordResetView.vue'), + meta: { public: true }, + }, + { + path: '/password-reset/confirm', + component: () => import('../views/auth/NewPasswordView.vue'), + meta: { public: true }, + }, + + // Phase 2 — authenticated routes + { path: '/account', component: () => import('../views/AccountView.vue') }, + { path: '/admin', component: () => import('../views/AdminView.vue') }, ] -export default createRouter({ +const router = createRouter({ history: createWebHistory(), routes, }) + +// Navigation guard (D-10): redirect unauthenticated users to /login. +// Preserves the intended destination via ?redirect= query param. +router.beforeEach((to) => { + const authStore = useAuthStore() + if (!to.meta.public && !authStore.accessToken) { + return { path: '/login', query: { redirect: to.fullPath } } + } +}) + +export default router diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js new file mode 100644 index 0000000..d01133c --- /dev/null +++ b/frontend/src/stores/auth.js @@ -0,0 +1,136 @@ +/** + * useAuthStore — Pinia auth store for DocuVault. + * + * Security invariants (CLAUDE.md): + * - accessToken lives ONLY in ref() memory — never written to browser storage + * - Refresh token is an httpOnly cookie managed by the backend + * - On 401: api/client.js calls authStore.refresh() (uses httpOnly cookie) + * + * State: + * accessToken ref(null) — JWT access token, memory only + * user ref(null) — { id, handle, email, role, totp_enabled } + * loading ref(false) + * error ref(null) + */ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import * as api from '../api/client.js' + +export const useAuthStore = defineStore('auth', () => { + // State — accessToken in memory only (CLAUDE.md rule: no browser storage writes) + const accessToken = ref(null) + const user = ref(null) + const loading = ref(false) + const error = ref(null) + + /** + * Register a new account. + * Does NOT auto-login — caller should redirect to /login after success. + */ + async function register(handle, email, password) { + loading.value = true + error.value = null + try { + const data = await api.register({ handle, email, password }) + return data + } catch (e) { + error.value = e.message + throw e + } finally { + loading.value = false + } + } + + /** + * Login with email + password, optionally with TOTP or backup code. + * + * options: + * { totpCode?: string, backupCode?: string } + * + * Return values: + * { requires_totp: true } — TOTP challenge (no tokens issued) + * { requires_password_change: true, user_id: string } — must change pw first + * undefined — full success (accessToken + user set) + */ + async function login(email, password, options = {}) { + loading.value = true + error.value = null + try { + const data = await api.login({ + email, + password, + totp_code: options.totpCode ?? null, + backup_code: options.backupCode ?? null, + }) + + if (data.requires_totp) { + return { requires_totp: true } + } + + if (data.requires_password_change) { + return { requires_password_change: true, user_id: data.user_id } + } + + // Full login success + accessToken.value = data.access_token + user.value = data.user + } catch (e) { + error.value = e.message + throw e + } finally { + loading.value = false + } + } + + /** + * Refresh the access token using the httpOnly refresh cookie. + * Called automatically by api/client.js on 401. + * Throws on failure (session expired — caller should redirect to /login). + */ + async function refresh() { + const data = await api.refreshToken() + accessToken.value = data.access_token + user.value = data.user + } + + /** + * Logout: revoke the current refresh token and clear local state. + * Always clears accessToken + user, even if the API call fails. + */ + async function logout() { + try { + await api.logout() + } catch { + // Ignore errors — clear state regardless + } finally { + accessToken.value = null + user.value = null + } + } + + /** + * Sign out of all devices: revoke ALL refresh tokens for the current user. + */ + async function logoutAll() { + try { + await api.logoutAll() + } catch { + // Ignore errors + } finally { + accessToken.value = null + user.value = null + } + } + + return { + accessToken, + user, + loading, + error, + register, + login, + logout, + logoutAll, + refresh, + } +}) diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue new file mode 100644 index 0000000..53e059f --- /dev/null +++ b/frontend/src/views/AccountView.vue @@ -0,0 +1,153 @@ + + + diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue new file mode 100644 index 0000000..0ddcc53 --- /dev/null +++ b/frontend/src/views/AdminView.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue new file mode 100644 index 0000000..1a28b62 --- /dev/null +++ b/frontend/src/views/auth/LoginView.vue @@ -0,0 +1,266 @@ + + + diff --git a/frontend/src/views/auth/NewPasswordView.vue b/frontend/src/views/auth/NewPasswordView.vue new file mode 100644 index 0000000..2ef1e17 --- /dev/null +++ b/frontend/src/views/auth/NewPasswordView.vue @@ -0,0 +1,70 @@ + + + diff --git a/frontend/src/views/auth/PasswordResetView.vue b/frontend/src/views/auth/PasswordResetView.vue new file mode 100644 index 0000000..f64d519 --- /dev/null +++ b/frontend/src/views/auth/PasswordResetView.vue @@ -0,0 +1,72 @@ + + + diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue new file mode 100644 index 0000000..22d3797 --- /dev/null +++ b/frontend/src/views/auth/RegisterView.vue @@ -0,0 +1,132 @@ + + +