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:
@@ -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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user