- LoginView.vue: add rememberMe ref(false) + "Stay signed in for 30 days" checkbox in password step; thread rememberMe.value through all 3 submit handlers (submitPassword, submitTotp, submitBackupCode) (D-12) - stores/auth.js: login() forwards options.rememberMe as remember_me in api.login body (D-12) - api/client.js: no change needed — already forwards full body verbatim - Frontend build passes (vite build exits 0)
168 lines
4.6 KiB
JavaScript
168 lines
4.6 KiB
JavaScript
/**
|
|
* 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,
|
|
}
|
|
})
|