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:
@@ -1,2 +1,5 @@
|
|||||||
.env
|
.env
|
||||||
backend/data/
|
backend/data/
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/package-lock.json
|
||||||
|
|||||||
+149
-1
@@ -1,10 +1,37 @@
|
|||||||
/**
|
/**
|
||||||
* API client using native Fetch API.
|
* API client using native Fetch API.
|
||||||
* All requests go to /api (proxied to backend by Vite in dev, or nginx in prod).
|
* 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 = {}) {
|
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) {
|
if (!res.ok) {
|
||||||
let msg = `HTTP ${res.status}`
|
let msg = `HTTP ${res.status}`
|
||||||
try { msg = (await res.json()).detail || msg } catch {}
|
try { msg = (await res.json()).detail || msg } catch {}
|
||||||
@@ -103,3 +130,124 @@ export function testProvider(provider) {
|
|||||||
export function getDefaultPrompt() {
|
export function getDefaultPrompt() {
|
||||||
return request('/api/settings/default-prompt')
|
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 }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="password" class="mt-2 space-y-1">
|
||||||
|
<!-- Strength bar: 4 segments -->
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<div
|
||||||
|
v-for="i in 4"
|
||||||
|
:key="i"
|
||||||
|
class="h-1 flex-1 rounded transition-colors duration-200"
|
||||||
|
:class="i <= strength ? segmentColor : 'bg-gray-200'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Label -->
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<span class="text-xs font-semibold" :class="labelColor">{{ label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
password: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strength score 0–4:
|
||||||
|
* +1 if length >= 12
|
||||||
|
* +1 if contains uppercase
|
||||||
|
* +1 if contains digit
|
||||||
|
* +1 if contains special character
|
||||||
|
* Matches backend AUTH-01 rules.
|
||||||
|
*/
|
||||||
|
const strength = computed(() => {
|
||||||
|
const p = props.password
|
||||||
|
if (!p) return 0
|
||||||
|
let score = 0
|
||||||
|
if (p.length >= 12) score++
|
||||||
|
if (/[A-Z]/.test(p)) score++
|
||||||
|
if (/[0-9]/.test(p)) score++
|
||||||
|
if (/[^A-Za-z0-9]/.test(p)) score++
|
||||||
|
return score
|
||||||
|
})
|
||||||
|
|
||||||
|
const segmentColor = computed(() => {
|
||||||
|
if (strength.value === 1) return 'bg-red-500'
|
||||||
|
if (strength.value === 2) return 'bg-amber-500'
|
||||||
|
if (strength.value === 3) return 'bg-amber-400'
|
||||||
|
return 'bg-green-500'
|
||||||
|
})
|
||||||
|
|
||||||
|
const labelColor = computed(() => {
|
||||||
|
if (strength.value === 1) return 'text-red-500'
|
||||||
|
if (strength.value === 2) return 'text-amber-500'
|
||||||
|
if (strength.value === 3) return 'text-amber-400'
|
||||||
|
return 'text-green-500'
|
||||||
|
})
|
||||||
|
|
||||||
|
const label = computed(() => {
|
||||||
|
if (strength.value === 1) return 'Too weak'
|
||||||
|
if (strength.value === 2) return 'Weak'
|
||||||
|
if (strength.value === 3) return 'Fair'
|
||||||
|
return 'Strong'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
class="animate-spin h-4 w-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div class="w-full max-w-sm">
|
||||||
|
<!-- Brand logo -->
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<h1 class="text-xl font-semibold text-indigo-600 tracking-tight">DocuVault</h1>
|
||||||
|
</div>
|
||||||
|
<!-- Auth card content -->
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,18 +1,57 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth.js'
|
||||||
import HomeView from '../views/HomeView.vue'
|
import HomeView from '../views/HomeView.vue'
|
||||||
import TopicsView from '../views/TopicsView.vue'
|
import TopicsView from '../views/TopicsView.vue'
|
||||||
import DocumentView from '../views/DocumentView.vue'
|
import DocumentView from '../views/DocumentView.vue'
|
||||||
import SettingsView from '../views/SettingsView.vue'
|
import SettingsView from '../views/SettingsView.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
|
// Existing routes
|
||||||
{ path: '/', component: HomeView },
|
{ path: '/', component: HomeView },
|
||||||
{ path: '/topics', component: TopicsView },
|
{ path: '/topics', component: TopicsView },
|
||||||
{ path: '/topics/:name', component: TopicsView },
|
{ path: '/topics/:name', component: TopicsView },
|
||||||
{ path: '/document/:id', component: DocumentView },
|
{ path: '/document/:id', component: DocumentView },
|
||||||
{ path: '/settings', component: SettingsView },
|
{ 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(),
|
history: createWebHistory(),
|
||||||
routes,
|
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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-8 max-w-2xl mx-auto">
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-900 mb-6">Account settings</h2>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Account information -->
|
||||||
|
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||||
|
<h3 class="font-semibold text-gray-800 mb-4">Account information</h3>
|
||||||
|
<div class="space-y-2 text-sm text-gray-700">
|
||||||
|
<div><span class="text-gray-500">Email:</span> {{ authStore.user?.email }}</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-gray-500">Role:</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2 py-1 rounded text-xs font-semibold"
|
||||||
|
:class="authStore.user?.role === 'admin'
|
||||||
|
? 'bg-indigo-100 text-indigo-700'
|
||||||
|
: 'bg-gray-100 text-gray-600'"
|
||||||
|
>
|
||||||
|
{{ authStore.user?.role }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Change password -->
|
||||||
|
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||||
|
<h3 class="font-semibold text-gray-800 mb-4">Change password</h3>
|
||||||
|
|
||||||
|
<form @submit.prevent="changePassword" class="space-y-4 max-w-sm">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-1">Current password</label>
|
||||||
|
<input
|
||||||
|
v-model="currentPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="block w-full rounded-lg px-3 py-3 text-sm border border-gray-300 bg-white text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-1">New password</label>
|
||||||
|
<input
|
||||||
|
v-model="newPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="block w-full rounded-lg px-3 py-3 text-sm border border-gray-300 bg-white text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<PasswordStrengthBar :password="newPassword" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="passwordError" class="p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700">
|
||||||
|
{{ passwordError }}
|
||||||
|
</div>
|
||||||
|
<div v-if="passwordSuccess" class="text-sm text-green-600">{{ passwordSuccess }}</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="changingPassword"
|
||||||
|
class="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white rounded-lg text-sm font-semibold hover:bg-indigo-700 transition-colors disabled:opacity-75"
|
||||||
|
>
|
||||||
|
<AppSpinner v-if="changingPassword" />
|
||||||
|
{{ changingPassword ? 'Updating…' : 'Update password' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Sessions -->
|
||||||
|
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||||
|
<h3 class="font-semibold text-gray-800 mb-4">Sessions</h3>
|
||||||
|
|
||||||
|
<template v-if="!confirmSignOutAll">
|
||||||
|
<button
|
||||||
|
@click="confirmSignOutAll = true"
|
||||||
|
class="text-sm px-4 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
Sign out all devices
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<p class="text-sm text-gray-700 mb-3">
|
||||||
|
This will sign you out of all devices, including this one. You will need to sign in again.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
@click="confirmSignOutAll = false"
|
||||||
|
class="text-sm text-gray-600 hover:text-gray-800"
|
||||||
|
>
|
||||||
|
Keep signed in
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="signOutAll"
|
||||||
|
:disabled="signingOutAll"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg disabled:opacity-75"
|
||||||
|
>
|
||||||
|
<AppSpinner v-if="signingOutAll" />
|
||||||
|
Sign out all devices
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth.js'
|
||||||
|
import * as api from '../api/client.js'
|
||||||
|
import PasswordStrengthBar from '../components/auth/PasswordStrengthBar.vue'
|
||||||
|
import AppSpinner from '../components/ui/AppSpinner.vue'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const currentPassword = ref('')
|
||||||
|
const newPassword = ref('')
|
||||||
|
const changingPassword = ref(false)
|
||||||
|
const passwordError = ref(null)
|
||||||
|
const passwordSuccess = ref(null)
|
||||||
|
|
||||||
|
const confirmSignOutAll = ref(false)
|
||||||
|
const signingOutAll = ref(false)
|
||||||
|
|
||||||
|
async function changePassword() {
|
||||||
|
changingPassword.value = true
|
||||||
|
passwordError.value = null
|
||||||
|
passwordSuccess.value = null
|
||||||
|
try {
|
||||||
|
await api.changePassword({
|
||||||
|
current_password: currentPassword.value,
|
||||||
|
new_password: newPassword.value,
|
||||||
|
})
|
||||||
|
passwordSuccess.value = 'Password updated.'
|
||||||
|
currentPassword.value = ''
|
||||||
|
newPassword.value = ''
|
||||||
|
} catch (e) {
|
||||||
|
passwordError.value = e.message
|
||||||
|
} finally {
|
||||||
|
changingPassword.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOutAll() {
|
||||||
|
signingOutAll.value = true
|
||||||
|
try {
|
||||||
|
await authStore.logoutAll()
|
||||||
|
await router.push('/login')
|
||||||
|
} finally {
|
||||||
|
signingOutAll.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-8 max-w-4xl mx-auto">
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-900 mb-4">Admin panel</h2>
|
||||||
|
|
||||||
|
<!-- Sub-navigation -->
|
||||||
|
<div class="flex border-b border-gray-200 mb-6">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.id"
|
||||||
|
@click="activeTab = tab.id"
|
||||||
|
class="px-4 py-2 text-sm font-semibold border-b-2 transition-colors"
|
||||||
|
:class="activeTab === tab.id
|
||||||
|
? 'text-indigo-600 border-indigo-600'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 border-transparent'"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab content (stub — wired in Phase 2 admin plan) -->
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
{{ activeTab === 'users' ? 'User management coming soon.' :
|
||||||
|
activeTab === 'quotas' ? 'Quota management coming soon.' :
|
||||||
|
'AI config management coming soon.' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'users', label: 'Users' },
|
||||||
|
{ id: 'quotas', label: 'Quotas' },
|
||||||
|
{ id: 'ai', label: 'AI Config' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const activeTab = ref('users')
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-8 w-full">
|
||||||
|
<!-- Step: password -->
|
||||||
|
<template v-if="step === 'password'">
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-900 mb-6">Sign in to DocuVault</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="submitPassword" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
class="block w-full rounded-lg px-3 py-3 text-sm border border-gray-300 bg-white text-gray-900 transition-colors focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="block w-full rounded-lg px-3 py-3 text-sm border border-gray-300 bg-white text-gray-900 transition-colors focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form-level error -->
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="w-full flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-lg text-sm font-semibold hover:bg-indigo-700 transition-colors disabled:opacity-75 min-h-[44px]"
|
||||||
|
>
|
||||||
|
<AppSpinner v-if="loading" />
|
||||||
|
{{ loading ? 'Signing in…' : 'Sign in' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="text-center text-sm text-gray-500">
|
||||||
|
<router-link to="/password-reset" class="text-indigo-600 hover:underline">
|
||||||
|
Forgot your password?
|
||||||
|
</router-link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-center text-sm text-gray-500">
|
||||||
|
Don't have an account?
|
||||||
|
<router-link to="/register" class="text-indigo-600 hover:underline">
|
||||||
|
Create one
|
||||||
|
</router-link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Step: TOTP -->
|
||||||
|
<template v-else-if="step === 'totp'">
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-900 mb-1">Two-factor authentication</h2>
|
||||||
|
<p class="text-sm text-gray-500 mb-6">Enter the 6-digit code from your authenticator app.</p>
|
||||||
|
|
||||||
|
<form @submit.prevent="submitTotp" class="space-y-4">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<input
|
||||||
|
v-model="totpInput"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="6"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
placeholder="000000"
|
||||||
|
class="block w-36 rounded-lg px-3 py-3 text-sm text-center border border-gray-300 bg-white text-gray-900 transition-colors focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form-level error -->
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="w-full flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-lg text-sm font-semibold hover:bg-indigo-700 transition-colors disabled:opacity-75 min-h-[44px]"
|
||||||
|
>
|
||||||
|
<AppSpinner v-if="loading" />
|
||||||
|
{{ loading ? 'Verifying…' : 'Verify code' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="step = 'backup'"
|
||||||
|
class="text-sm text-indigo-600 hover:underline"
|
||||||
|
>
|
||||||
|
Use a backup code instead
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="resetToPassword"
|
||||||
|
class="text-sm text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Back to sign in
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Step: backup code -->
|
||||||
|
<template v-else-if="step === 'backup'">
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-900 mb-1">Two-factor authentication</h2>
|
||||||
|
<p class="text-sm text-gray-500 mb-6">Enter a backup code.</p>
|
||||||
|
|
||||||
|
<form @submit.prevent="submitBackupCode" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-1">Backup code</label>
|
||||||
|
<input
|
||||||
|
v-model="backupCodeInput"
|
||||||
|
type="text"
|
||||||
|
placeholder="XXXXXXXX"
|
||||||
|
autocomplete="off"
|
||||||
|
class="block w-full rounded-lg px-3 py-3 text-sm border border-gray-300 bg-white text-gray-900 font-mono transition-colors focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form-level error -->
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="w-full flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-lg text-sm font-semibold hover:bg-indigo-700 transition-colors disabled:opacity-75 min-h-[44px]"
|
||||||
|
>
|
||||||
|
<AppSpinner v-if="loading" />
|
||||||
|
{{ loading ? 'Verifying…' : 'Sign in with backup code' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="step = 'totp'"
|
||||||
|
class="text-sm text-indigo-600 hover:underline"
|
||||||
|
>
|
||||||
|
Use authenticator app instead
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="resetToPassword"
|
||||||
|
class="text-sm text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Back to sign in
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../../stores/auth.js'
|
||||||
|
import AppSpinner from '../../components/ui/AppSpinner.vue'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const totpInput = ref('')
|
||||||
|
const backupCodeInput = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
// Step: 'password' | 'totp' | 'backup'
|
||||||
|
const step = ref('password')
|
||||||
|
|
||||||
|
function resetToPassword() {
|
||||||
|
step.value = 'password'
|
||||||
|
totpInput.value = ''
|
||||||
|
backupCodeInput.value = ''
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLoginResult(result) {
|
||||||
|
if (!result) {
|
||||||
|
// Full success — tokens set in store
|
||||||
|
const redirect = route.query.redirect || '/'
|
||||||
|
await router.push(redirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.requires_totp) {
|
||||||
|
error.value = null
|
||||||
|
step.value = 'totp'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.requires_password_change) {
|
||||||
|
// User must change password before accessing the app
|
||||||
|
await router.push('/account')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPassword() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const result = await authStore.login(email.value, password.value)
|
||||||
|
await handleLoginResult(result)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTotp() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const result = await authStore.login(email.value, password.value, {
|
||||||
|
totpCode: totpInput.value,
|
||||||
|
})
|
||||||
|
await handleLoginResult(result)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBackupCode() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const result = await authStore.login(email.value, password.value, {
|
||||||
|
backupCode: backupCodeInput.value,
|
||||||
|
})
|
||||||
|
await handleLoginResult(result)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message || 'Invalid or already used code'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-8 w-full">
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-900 mb-1">Set a new password</h2>
|
||||||
|
<p class="text-sm text-gray-500 mb-6">Choose a strong password for your account.</p>
|
||||||
|
|
||||||
|
<template v-if="!done">
|
||||||
|
<form @submit.prevent="submit" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-1">New password</label>
|
||||||
|
<input
|
||||||
|
v-model="newPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="block w-full rounded-lg px-3 py-3 text-sm border border-gray-300 bg-white text-gray-900 transition-colors focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<PasswordStrengthBar :password="newPassword" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="w-full flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-lg text-sm font-semibold hover:bg-indigo-700 transition-colors disabled:opacity-75 min-h-[44px]"
|
||||||
|
>
|
||||||
|
<AppSpinner v-if="loading" />
|
||||||
|
{{ loading ? 'Setting password…' : 'Set password' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="p-4 bg-green-50 border border-green-200 text-sm text-green-800 rounded-lg mb-4">
|
||||||
|
Password updated. Please sign in.
|
||||||
|
</div>
|
||||||
|
<router-link to="/login" class="text-indigo-600 hover:underline text-sm">Sign in</router-link>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import * as api from '../../api/client.js'
|
||||||
|
import PasswordStrengthBar from '../../components/auth/PasswordStrengthBar.vue'
|
||||||
|
import AppSpinner from '../../components/ui/AppSpinner.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const newPassword = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
const done = ref(false)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
const token = route.query.token
|
||||||
|
try {
|
||||||
|
await api.passwordResetConfirm(token, newPassword.value)
|
||||||
|
done.value = true
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-8 w-full">
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-900 mb-1">Reset your password</h2>
|
||||||
|
<p class="text-sm text-gray-500 mb-6">Enter your email and we'll send you a reset link.</p>
|
||||||
|
|
||||||
|
<template v-if="!sent">
|
||||||
|
<form @submit.prevent="submit" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
class="block w-full rounded-lg px-3 py-3 text-sm border border-gray-300 bg-white text-gray-900 transition-colors focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="w-full flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-lg text-sm font-semibold hover:bg-indigo-700 transition-colors disabled:opacity-75 min-h-[44px]"
|
||||||
|
>
|
||||||
|
<AppSpinner v-if="loading" />
|
||||||
|
{{ loading ? 'Sending…' : 'Send reset link' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="text-center text-sm text-gray-500">
|
||||||
|
<router-link to="/login" class="text-indigo-600 hover:underline">Back to sign in</router-link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="p-4 bg-green-50 border border-green-200 text-sm text-green-800 rounded-lg">
|
||||||
|
If an account exists for that email, you will receive a reset link shortly. Check your inbox.
|
||||||
|
</div>
|
||||||
|
<p class="text-center text-sm text-gray-500 mt-4">
|
||||||
|
<router-link to="/login" class="text-indigo-600 hover:underline">Back to sign in</router-link>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import * as api from '../../api/client.js'
|
||||||
|
import AppSpinner from '../../components/ui/AppSpinner.vue'
|
||||||
|
|
||||||
|
const email = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
const sent = ref(false)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
await api.passwordResetRequest(email.value)
|
||||||
|
sent.value = true
|
||||||
|
} catch {
|
||||||
|
// Anti-enumeration: always show success message (D-02)
|
||||||
|
sent.value = true
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-8 w-full">
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-900 mb-1">Create your account</h2>
|
||||||
|
<p class="text-sm text-gray-500 mb-6">Start managing your documents securely.</p>
|
||||||
|
|
||||||
|
<form @submit.prevent="submit" class="space-y-4">
|
||||||
|
<!-- Handle -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-1">Username</label>
|
||||||
|
<input
|
||||||
|
v-model="handle"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autocomplete="username"
|
||||||
|
class="block w-full rounded-lg px-3 py-3 text-sm border border-gray-300 bg-white text-gray-900 transition-colors focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
class="block w-full rounded-lg px-3 py-3 text-sm border border-gray-300 bg-white text-gray-900 transition-colors focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="block w-full rounded-lg px-3 py-3 text-sm border border-gray-300 bg-white text-gray-900 transition-colors focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<!-- Password strength indicator -->
|
||||||
|
<PasswordStrengthBar :password="password" />
|
||||||
|
<!-- HIBP breach inline error -->
|
||||||
|
<p v-if="breachError" class="mt-1 text-xs text-red-600">{{ breachError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirm password -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-1">Confirm password</label>
|
||||||
|
<input
|
||||||
|
v-model="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="block w-full rounded-lg px-3 py-3 text-sm border border-gray-300 bg-white text-gray-900 transition-colors focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<p v-if="confirmError" class="mt-1 text-xs text-red-600">{{ confirmError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form-level error -->
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="w-full flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-lg text-sm font-semibold hover:bg-indigo-700 transition-colors disabled:opacity-75 min-h-[44px]"
|
||||||
|
>
|
||||||
|
<AppSpinner v-if="loading" />
|
||||||
|
{{ loading ? 'Creating account…' : 'Create account' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="text-center text-sm text-gray-500">
|
||||||
|
Already have an account?
|
||||||
|
<router-link to="/login" class="text-indigo-600 hover:underline">
|
||||||
|
Sign in
|
||||||
|
</router-link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../../stores/auth.js'
|
||||||
|
import PasswordStrengthBar from '../../components/auth/PasswordStrengthBar.vue'
|
||||||
|
import AppSpinner from '../../components/ui/AppSpinner.vue'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handle = ref('')
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
const breachError = ref(null)
|
||||||
|
const confirmError = ref(null)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
// Client-side confirm check
|
||||||
|
confirmError.value = null
|
||||||
|
breachError.value = null
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
if (password.value !== confirmPassword.value) {
|
||||||
|
confirmError.value = 'Passwords do not match.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await authStore.register(handle.value, email.value, password.value)
|
||||||
|
await router.push('/login')
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e.message || ''
|
||||||
|
if (msg.toLowerCase().includes('breach') || msg.toLowerCase().includes('pwned')) {
|
||||||
|
breachError.value = msg
|
||||||
|
} else {
|
||||||
|
error.value = msg
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user