- 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)
281 lines
8.7 KiB
Vue
281 lines
8.7 KiB
Vue
<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>
|
|
|
|
<!-- Stay signed in checkbox (D-12) -->
|
|
<div class="flex items-center gap-2">
|
|
<input
|
|
v-model="rememberMe"
|
|
id="remember-me"
|
|
type="checkbox"
|
|
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
|
/>
|
|
<label for="remember-me" class="text-sm text-gray-600">Stay signed in for 30 days</label>
|
|
</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)
|
|
const rememberMe = ref(false)
|
|
|
|
// 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, { rememberMe: rememberMe.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,
|
|
rememberMe: rememberMe.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,
|
|
rememberMe: rememberMe.value,
|
|
})
|
|
await handleLoginResult(result)
|
|
} catch (e) {
|
|
error.value = e.message || 'Invalid or already used code'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
</script>
|