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