/** * 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) const quota = ref({ used_bytes: 0, limit_bytes: 0 }) // Deduplicates concurrent refresh() calls so a single cookie rotation handles // multiple simultaneous 401s (e.g. QuotaBar + App.vue firing on page reload). let _refreshInFlight = 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, remember_me: options.rememberMe ?? false, }) 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). * * Concurrent calls share one in-flight promise so the refresh cookie is * rotated exactly once even when multiple 401s fire simultaneously. */ function refresh() { if (_refreshInFlight) return _refreshInFlight _refreshInFlight = api.refreshToken() .then(data => { accessToken.value = data.access_token user.value = data.user }) .finally(() => { _refreshInFlight = null }) return _refreshInFlight } /** * 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 } } /** * Fetch current user quota from the server and update quota ref. * Silently ignores errors — QuotaBar handles error display by hiding itself (UI-SPEC). */ async function fetchQuota() { try { const data = await api.getMyQuota() quota.value = { used_bytes: data.used_bytes, limit_bytes: data.limit_bytes } } catch { // Silently ignore — QuotaBar hides itself on fetch error (UI-SPEC) } } return { accessToken, user, loading, error, quota, register, login, logout, logoutAll, refresh, fetchQuota, } })