Add stage 8: passwordless magic-link login
Backend
- POST /api/auth/magic-link (public): emails a one-time login link for an
address. Always 202 with the same body so accounts can't be enumerated; a
link is sent only when the account exists and wasn't emailed in the last
60s. Opening it (existing verify-email endpoint) returns a session and, as a
side effect, verifies the address. New EmailVerifier::sendLoginLink; the
60s interval is now EmailVerifier::RESEND_INTERVAL_SECONDS, shared.
Frontend
- LoginView defaults to magic-link mode: email only, "Log in with email". A
"Log in with password" link reveals the password field, changes the button
to "Log in", and itself becomes "Get a magic link" to switch back.
- VerifyEmailView copy is now login-neutral ("Signing you in").
Tests: 5 new (magic-link login, implicit verification, enumeration-safety,
throttle, validation). Suite: 37 passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
@@ -8,18 +8,39 @@ const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const mode = ref<'magic' | 'password'>('magic')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const error = ref<ApiError | null>(null)
|
||||
const sentMessage = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
const buttonLabel = computed(() => {
|
||||
if (submitting.value) return mode.value === 'magic' ? 'Sending…' : 'Logging in…'
|
||||
return mode.value === 'magic' ? 'Log in with email' : 'Log in'
|
||||
})
|
||||
|
||||
function toggleMode() {
|
||||
mode.value = mode.value === 'magic' ? 'password' : 'magic'
|
||||
error.value = null
|
||||
sentMessage.value = ''
|
||||
password.value = ''
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
submitting.value = true
|
||||
error.value = null
|
||||
sentMessage.value = ''
|
||||
try {
|
||||
await auth.login(email.value, password.value)
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
|
||||
await router.push(redirect)
|
||||
if (mode.value === 'password') {
|
||||
await auth.login(email.value, password.value)
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
|
||||
await router.push(redirect)
|
||||
} else {
|
||||
await auth.requestLoginLink(email.value)
|
||||
sentMessage.value =
|
||||
'If that address has an account, a login link is on its way. It expires in 15 minutes.'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e : new ApiError('Something went wrong.', 0)
|
||||
} finally {
|
||||
@@ -41,7 +62,7 @@ async function onSubmit() {
|
||||
</small>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<label v-if="mode === 'password'">
|
||||
<span>Password</span>
|
||||
<input v-model="password" type="password" autocomplete="current-password" required />
|
||||
<small v-if="error?.fieldError('password')" class="field-error">
|
||||
@@ -52,12 +73,17 @@ async function onSubmit() {
|
||||
<p v-if="error && Object.keys(error.details).length === 0" class="form-error">
|
||||
{{ error.message }}
|
||||
</p>
|
||||
<p v-if="sentMessage" class="muted">{{ sentMessage }}</p>
|
||||
|
||||
<button type="submit" :disabled="submitting">
|
||||
{{ submitting ? 'Logging in…' : 'Log in' }}
|
||||
</button>
|
||||
<button type="submit" :disabled="submitting">{{ buttonLabel }}</button>
|
||||
</form>
|
||||
|
||||
<p class="muted">
|
||||
<button type="button" class="link" @click="toggleMode">
|
||||
{{ mode === 'magic' ? 'Log in with password' : 'Get a magic link' }}
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p class="muted">
|
||||
No account? <RouterLink to="/register">Create one</RouterLink>.
|
||||
</p>
|
||||
|
||||
@@ -32,12 +32,12 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<section class="card">
|
||||
<h1>Email verification</h1>
|
||||
<h1>Signing you in</h1>
|
||||
|
||||
<p v-if="state === 'working'" class="muted">Verifying…</p>
|
||||
<p v-if="state === 'working'" class="muted">One moment…</p>
|
||||
|
||||
<template v-else-if="state === 'done'">
|
||||
<p>Your email address is verified and you're signed in.</p>
|
||||
<p>You're signed in. Your email address is verified.</p>
|
||||
<p class="muted">Taking you to your lists…</p>
|
||||
<RouterLink to="/">Go now</RouterLink>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user