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
+153
View File
@@ -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>
+39
View File
@@ -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>
+266
View File
@@ -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>
+132
View File
@@ -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>