Passwordless-only auth: drop registration and passwords entirely
There is now one way in: POST /api/auth/magic-link with an email address. It
creates the account (unverified) if the address is new -- that's the only
"sign up" -- and emails a sign-in link either way, subject to the existing
60s-per-user resend throttle. Opening the link (POST /api/auth/verify-email,
unchanged) is what actually creates the session, and marks the address
verified the first time. Since a session can now only ever come from an
opened link, "authenticated" implies "verified" -- there's no more
authenticated-but-unverified state, so the resend-verification endpoint and
all the "verify your email" nagging UI are gone too.
Backend
- migrations/008: ALTER TABLE users DROP COLUMN password_hash.
- UserRepository: create() takes only an email; new findOrCreateByEmail()
(race-safe) backs the magic-link endpoint.
- AuthController: register()/login() removed; requestLoginLink() now
find-or-creates before sending.
- EmailVerificationController: resend() removed (dead -- you can't be
authenticated and unverified); requestChange() drops the password check,
now just { email }.
- EmailVerifier: sendVerification() removed (unused once register() and
resend() are gone); sendLoginLink() is the one email people get.
- Routes: POST /auth/register, POST /auth/login, POST /email/verification
all gone.
Frontend
- LoginView: email field + "Send sign-in link" button, nothing else.
RegisterView and the /register route are gone.
- auth store: register()/login()/resendVerification() removed;
requestEmailChange() drops the password param.
- ProfileView: password field and the "verify your email" section removed,
leaving just the change-email form.
- App.vue: the "verify email" header badge is gone; DashboardView's
unverified-address notice is gone.
- Now-dead .badge/.badge--warn/a.badge CSS removed.
Tests: AuthTest and EmailVerificationTest rewritten for the new flow (52
tests total, down from 58 -- consolidated, not reduced coverage).
ApiTestCase::authHeader() signs in via the real magic-link -> verify flow.
Verified end-to-end against the rebuilt container and the dev server: a brand
new address gets an account + session from one link; /auth/register,
/auth/login and /email/verification all 404; the UI shows no password field
anywhere and no verification nagging. Also fixed the README's "Try it" curl
snippets, which had been silently broken since JSON_PRETTY_PRINT was added
(grep patterns didn't tolerate the space after ':').
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,10 +19,11 @@ Each user owns **projects**, and each project holds ordered **cards**.
|
||||
| 9 | Per-project card statuses ("To do" / "Doing" / "Done"); status chip, new cards start with none | ✅ done |
|
||||
| 10 | Project view — full-width, tabbed: alphabetical "All tasks" list + "Kanban" board, per-column drag ordering | ✅ done |
|
||||
| 11 | Persistent left sidebar (Dashboard link + project list/new-project form); dashboard = grid of projects with their "New" inbox cards | ✅ done |
|
||||
| 12 | Passwordless-only auth — registration and password login removed; a magic link is the sole way in, and creates the account if needed | ✅ done |
|
||||
|
||||
Registration signs the user in immediately and emails a magic link that verifies
|
||||
the address; `user.email_verified` stays `false` until the link is opened. See
|
||||
[Email verification](#email-verification--profile).
|
||||
There is no password. Signing in is entering an email address and opening the
|
||||
magic link sent to it — the same step creates the account the first time. See
|
||||
[Auth](#auth).
|
||||
|
||||
## Run with Docker
|
||||
|
||||
@@ -96,8 +97,9 @@ npm install
|
||||
npm run dev # http://localhost:5173, proxies /api to localhost:8080
|
||||
```
|
||||
|
||||
Unauthenticated visitors are redirected to `/login`; `/register` creates an
|
||||
account and signs in immediately. See [web/README.md](web/README.md).
|
||||
Unauthenticated visitors are redirected to `/login` — enter an email address
|
||||
and open the link that arrives; there is no separate sign-up. See
|
||||
[web/README.md](web/README.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -132,23 +134,45 @@ Base path: `/api`. All request and response bodies are JSON; send
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
### `POST /api/auth/register`
|
||||
### Auth
|
||||
|
||||
Request:
|
||||
There is no password and no separate registration endpoint. Entering an email
|
||||
address and opening the link sent to it is the entire flow, for a brand-new
|
||||
address and a returning one alike.
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|------|------|---------|
|
||||
| `POST` | `/api/auth/magic-link` | — | email a one-time sign-in link, creating the account first if the address is new |
|
||||
| `POST` | `/api/auth/verify-email` | — | consume the token: sign in, and (the first time) mark the address verified, or apply a pending email change |
|
||||
| `GET` | `/api/me` | ✔ | the current user |
|
||||
| `POST` | `/api/email/change` | ✔ | request a **deferred** email change |
|
||||
|
||||
#### `POST /api/auth/magic-link`
|
||||
|
||||
Request: `{ "email": "ada@example.com" }`.
|
||||
|
||||
Emails a one-time sign-in link (`<APP_URL>/verify-email?token=…`, 15-minute
|
||||
expiry) and always returns `202` with the same message. If the address has no
|
||||
account yet, one is created (unverified) right here — that's the only "sign
|
||||
up" there is. A link is only actually (re-)sent if this address hasn't been
|
||||
emailed one in the last 60 seconds. `422` if the address is malformed.
|
||||
|
||||
```json
|
||||
{ "email": "ada@example.com", "password": "correct horse battery staple" }
|
||||
{ "message": "Check your email for a link to sign in." }
|
||||
```
|
||||
|
||||
`201 Created`:
|
||||
#### `POST /api/auth/verify-email`
|
||||
|
||||
Body: `{ "token": "..." }`. A missing/invalid, already-used, or expired token is
|
||||
`400` (distinct messages). Success signs the caller in:
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": 1,
|
||||
"email": "ada@example.com",
|
||||
"email_verified": false,
|
||||
"email_verified_at": null,
|
||||
"email_verified": true,
|
||||
"email_verified_at": "2026-09-03T12:00:00Z",
|
||||
"pending_email": null,
|
||||
"created_at": "2026-09-03T12:00:00Z"
|
||||
},
|
||||
@@ -157,87 +181,37 @@ Request:
|
||||
}
|
||||
```
|
||||
|
||||
New accounts are created with an unverified email (`email_verified: false`).
|
||||
Opening a link is the only way to obtain a session, so an authenticated request
|
||||
is always for a verified address — `email_verified` is `true` from the first
|
||||
token a user's browser ever holds.
|
||||
|
||||
Errors: `422` invalid input, `409` email already registered.
|
||||
#### `GET /api/me`
|
||||
|
||||
Validation: `email` must be a valid address (≤ 255 chars); `password` must be
|
||||
8–72 characters.
|
||||
Requires `Authorization: Bearer <jwt>`. `200 OK`: the same `user` object shown
|
||||
above. `pending_email` is the address a still-valid email-change link is
|
||||
waiting on, or `null`. `401` if the header is missing, malformed, or the token
|
||||
is invalid/expired.
|
||||
|
||||
### `POST /api/auth/login`
|
||||
#### `POST /api/email/change`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{ "email": "ada@example.com", "password": "correct horse battery staple" }
|
||||
```
|
||||
|
||||
`200 OK`: same shape as register. `401` on bad credentials (the message does not
|
||||
say whether it was the email or the password that was wrong).
|
||||
|
||||
### `POST /api/auth/magic-link`
|
||||
|
||||
Request: `{ "email": "ada@example.com" }`.
|
||||
|
||||
Emails a one-time login link (`<APP_URL>/verify-email?token=…`, 15-minute
|
||||
expiry). Always returns `202` with the same message regardless of whether the
|
||||
address is registered, so accounts can't be enumerated; a link is only actually
|
||||
sent when the account exists and hasn't been emailed in the last 60 seconds.
|
||||
Opening the link (`POST /api/auth/verify-email`) signs the user in and verifies
|
||||
the address if it wasn't already. `422` if the address is malformed.
|
||||
|
||||
### `GET /api/me`
|
||||
|
||||
Requires `Authorization: Bearer <jwt>`.
|
||||
|
||||
`200 OK`:
|
||||
Requires `Authorization: Bearer <jwt>`. Body: `{ "email": "new@example.com" }`.
|
||||
The address must be free (`409`) and different from the current one (`422`).
|
||||
Throttled to **once per 60 seconds** (shared with `/api/auth/magic-link`'s
|
||||
resend window, per user) — `429` with `error.details.retry_after` when too
|
||||
soon. On success, `202` with `retry_after` and `pending_email`:
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": 1,
|
||||
"email": "ada@example.com",
|
||||
"email_verified": false,
|
||||
"email_verified_at": null,
|
||||
"pending_email": null,
|
||||
"created_at": "2026-09-03T12:00:00Z"
|
||||
}
|
||||
"message": "Confirmation email sent to the new address.",
|
||||
"pending_email": "new@example.com",
|
||||
"retry_after": 60
|
||||
}
|
||||
```
|
||||
|
||||
`pending_email` is the address a still-valid email-change link is waiting on, or
|
||||
`null`.
|
||||
|
||||
`401` if the header is missing, malformed, or the token is invalid/expired.
|
||||
|
||||
### Email verification & profile
|
||||
|
||||
Magic links — `<APP_URL>/verify-email?token=<opaque>` — expire **15 minutes**
|
||||
after they are sent; only a hash of the token is stored. The same link/route
|
||||
backs three things: verifying a new account, [passwordless
|
||||
login](#post-apiauthmagic-link), and confirming an email change.
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|------|------|---------|
|
||||
| `POST` | `/api/auth/verify-email` | — | consume a token: verify the address (or apply a pending change), then return a session so the caller is logged in |
|
||||
| `POST` | `/api/auth/magic-link` | — | email a passwordless login link (see above) |
|
||||
| `POST` | `/api/email/verification` | ✔ | resend the verification email; `409` if already verified |
|
||||
| `POST` | `/api/email/change` | ✔ | request a **deferred** email change |
|
||||
|
||||
`POST /api/auth/verify-email` body: `{ "token": "..." }`. Success returns the
|
||||
same `{ user, token, expires_at }` envelope as login. A missing/invalid, already
|
||||
used, or expired token is `400` (distinct messages).
|
||||
|
||||
`POST /api/email/verification` and `/api/email/change` are throttled to **once
|
||||
per 60 seconds** per user (shared window). When throttled they return `429` with
|
||||
`error.details.retry_after` (seconds). On success they return `202` with
|
||||
`retry_after`, and `/api/email/change` also returns `pending_email`.
|
||||
|
||||
`POST /api/email/change` body: `{ "email": "new@example.com", "password": "<current>" }`.
|
||||
The current password is required (`422` if wrong). The address must be free
|
||||
(`409`) and different from the current one (`422`). The change is **not applied
|
||||
until** the magic link sent to the new address is opened — until then `GET
|
||||
/api/me` shows the old address with `pending_email` set.
|
||||
The change is **not applied until** the magic link sent to the new address is
|
||||
opened — until then `GET /api/me` still shows the old address, with
|
||||
`pending_email` set. Opening that link both changes the address and re-verifies
|
||||
it, via the same `/api/auth/verify-email`.
|
||||
|
||||
### Projects
|
||||
|
||||
@@ -377,23 +351,31 @@ Every error response looks like:
|
||||
|
||||
## Try it
|
||||
|
||||
Signing in needs the link the API emails, so this pulls it back out of the
|
||||
bundled Mailpit catcher (adjust if you've pointed `MAIL_TRANSPORT` elsewhere).
|
||||
The API pretty-prints its JSON, so responses are piped through `tr -d ' \n'`
|
||||
before grep (Mailpit's own JSON doesn't need that):
|
||||
|
||||
```bash
|
||||
BASE=http://localhost:8080
|
||||
|
||||
curl -s -X POST $BASE/api/auth/register \
|
||||
curl -s -X POST $BASE/api/auth/magic-link \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"email":"ada@example.com","password":"password123"}'
|
||||
-d '{"email":"ada@example.com"}'
|
||||
|
||||
TOKEN=$(curl -s -X POST $BASE/api/auth/login \
|
||||
MSG_ID=$(curl -s "http://localhost:8025/api/v1/messages?limit=1" | grep -o '"ID":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
LINK_TOKEN=$(curl -s "http://localhost:8025/api/v1/message/$MSG_ID" | grep -o 'token=[a-f0-9]*' | head -1 | cut -d= -f2)
|
||||
|
||||
TOKEN=$(curl -s -X POST $BASE/api/auth/verify-email \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"email":"ada@example.com","password":"password123"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
|
||||
-d "{\"token\":\"$LINK_TOKEN\"}" | tr -d ' \n' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
curl -s $BASE/api/me -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
PROJECT=$(curl -s -X POST $BASE/api/projects \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"title":"Website relaunch","description":"Q3"}' \
|
||||
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||
| tr -d ' \n' | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||
|
||||
curl -s $BASE/api/projects/$PROJECT/statuses -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user