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 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-05-22 19:45:21 +02:00
parent 1882edfff6
commit 3b7d362600
13 changed files with 1163 additions and 2 deletions
+149 -1
View File
@@ -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 }),
})
}