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:
2026-09-04 10:29:13 +01:00
co-authored by Claude Sonnet 5
parent 64ba21795b
commit be592f38fc
10 changed files with 195 additions and 21 deletions
+11 -5
View File
@@ -61,8 +61,8 @@ server response replaces local state.
## Auth flow
- The token from `POST /api/auth/register` or `/login` is kept in `localStorage`
and sent as `Authorization: Bearer …`.
- The token from register / login / opening a magic link is kept in
`localStorage` and sent as `Authorization: Bearer …`.
- On load, `fetchMe()` validates the stored token via `GET /api/me`; a failure
clears it.
- Routes with `meta.requiresAuth` redirect to `/login` (preserving the intended
@@ -70,12 +70,18 @@ server response replaces local state.
- Registration signs the user in immediately; the new account's email is
unverified (`user.email_verified === false`). The header shows a "verify
email" badge linking to `/profile`.
- `LoginView` defaults to **magic link**: an email field and a "Log in with
email" button that calls `POST /api/auth/magic-link`. A "Log in with password"
link reveals the password field and switches the button to a plain "Log in"
(`POST /api/auth/login`); the link then reads "Get a magic link" to switch
back.
## Email verification & profile
- The registration email links to `/verify-email?token=…`. `VerifyEmailView`
POSTs the token to the API, which returns a session — so opening the link both
verifies the address and signs the user in — then redirects to the lists.
- `/verify-email?token=…` is the target for every magic link (verification,
passwordless login, email change). `VerifyEmailView` POSTs the token to the
API, which returns a session — so opening any link both verifies the address
and signs the user in — then redirects to the lists.
- `/profile` (`ProfileView`) shows the address and verification status. When
unverified it offers a **Resend** button; the API throttles to once a minute,
and the button shows a live countdown (driven by `retry_after`, and by `429`
+6
View File
@@ -63,6 +63,11 @@ export const useAuthStore = defineStore('auth', () => {
user.value = null
}
/** Ask for a passwordless login link to be emailed. */
async function requestLoginLink(email: string): Promise<void> {
await apiRequest('/auth/magic-link', { method: 'POST', body: { email } })
}
/** Verify an email address from a magic-link token; the response logs the user in. */
async function verifyEmail(magicToken: string): Promise<void> {
adopt(
@@ -121,6 +126,7 @@ export const useAuthStore = defineStore('auth', () => {
login,
logout,
fetchMe,
requestLoginLink,
verifyEmail,
resendVerification,
requestEmailChange,
+34 -8
View File
@@ -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>
+3 -3
View File
@@ -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>