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"
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Passwordless auth: the only way in is a magic link emailed to an address
|
||||
-- (see EmailVerifier / AuthController::requestLoginLink). Opening a link both
|
||||
-- creates the account (if it's new) and signs the user in, so an authenticated
|
||||
-- session now always implies a verified email -- there is no more
|
||||
-- authenticated-but-unverified state to nag about.
|
||||
ALTER TABLE users DROP COLUMN password_hash;
|
||||
@@ -7,9 +7,10 @@ namespace App\Auth;
|
||||
use App\Repository\EmailVerificationRepository;
|
||||
|
||||
/**
|
||||
* Builds the JSON representation of a user and the session envelope returned by
|
||||
* register / login / email verification. Shared so every entry point agrees on
|
||||
* the shape.
|
||||
* Builds the JSON representation of a user and the session envelope returned
|
||||
* when a magic link is opened (sign-up, sign-in, and email-change confirmation
|
||||
* all go through the same verify-email endpoint). Shared so every entry point
|
||||
* agrees on the shape.
|
||||
*/
|
||||
final class SessionPayload
|
||||
{
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Auth\SessionPayload;
|
||||
use App\Exception\ApiException;
|
||||
use App\Exception\ValidationException;
|
||||
use App\Mail\EmailVerifier;
|
||||
use App\Mail\MailException;
|
||||
@@ -13,13 +12,13 @@ use App\Repository\UserRepository;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
|
||||
/**
|
||||
* Passwordless auth: there is no register/login pair. An email address is
|
||||
* turned into an account (if it isn't one already) and sent a magic link;
|
||||
* opening that link is what actually signs the caller in.
|
||||
*/
|
||||
final class AuthController extends Controller
|
||||
{
|
||||
private const PASSWORD_MIN = 8;
|
||||
|
||||
// bcrypt (password_hash's current default) only considers the first 72 bytes.
|
||||
private const PASSWORD_MAX = 72;
|
||||
|
||||
private const EMAIL_MAX = 255;
|
||||
|
||||
public function __construct(
|
||||
@@ -29,54 +28,14 @@ final class AuthController extends Controller
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/register
|
||||
*/
|
||||
public function register(Request $request, Response $response): Response
|
||||
{
|
||||
[$email, $password] = $this->credentials($request);
|
||||
|
||||
if ($this->users->findByEmail($email) !== null) {
|
||||
throw new ApiException('That email address is already registered.', 409);
|
||||
}
|
||||
|
||||
$user = $this->users->create($email, password_hash($password, PASSWORD_DEFAULT));
|
||||
|
||||
// Best effort: a failed send must not fail registration — the user can
|
||||
// resend from their profile.
|
||||
try {
|
||||
$this->verifier->sendVerification($user);
|
||||
} catch (MailException $e) {
|
||||
error_log('Verification email failed for user ' . $user['id'] . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->json($response, $this->session->forUser($user), 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
public function login(Request $request, Response $response): Response
|
||||
{
|
||||
[$email, $password] = $this->credentials($request);
|
||||
|
||||
$user = $this->users->findByEmail($email);
|
||||
|
||||
if ($user === null || !password_verify($password, $user['password_hash'])) {
|
||||
// Same message either way so we don't reveal which emails are registered.
|
||||
throw new ApiException('Invalid email or password.', 401);
|
||||
}
|
||||
|
||||
return $this->json($response, $this->session->forUser($user));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/magic-link (public)
|
||||
*
|
||||
* Emails a one-time login link for the given address. Always responds the
|
||||
* same way so registered addresses can't be enumerated; a link is only sent
|
||||
* when the account exists and hasn't been sent one in the last minute.
|
||||
* Opening the link signs the user in and verifies the address.
|
||||
* Emails a one-time sign-in link for the given address, creating the
|
||||
* account first if it doesn't exist yet. Always responds the same way; a
|
||||
* link is only actually (re-)sent when one hasn't gone out in the last
|
||||
* minute. Opening the link creates the session and, the first time, marks
|
||||
* the address verified.
|
||||
*/
|
||||
public function requestLoginLink(Request $request, Response $response): Response
|
||||
{
|
||||
@@ -87,8 +46,9 @@ final class AuthController extends Controller
|
||||
throw new ValidationException(['email' => ['Enter a valid email address.']]);
|
||||
}
|
||||
|
||||
$user = $this->users->findByEmail($email);
|
||||
if ($user !== null && !$this->recentlyEmailed($user)) {
|
||||
$user = $this->users->findOrCreateByEmail($email);
|
||||
|
||||
if (!$this->recentlyEmailed($user)) {
|
||||
try {
|
||||
$this->verifier->sendLoginLink($user);
|
||||
} catch (MailException $e) {
|
||||
@@ -97,7 +57,7 @@ final class AuthController extends Controller
|
||||
}
|
||||
|
||||
return $this->json($response, [
|
||||
'message' => 'If that address has an account, a login link is on its way.',
|
||||
'message' => 'Check your email for a link to sign in.',
|
||||
], 202);
|
||||
}
|
||||
|
||||
@@ -119,41 +79,4 @@ final class AuthController extends Controller
|
||||
return $lastSent !== null
|
||||
&& (time() - (int) strtotime($lastSent)) < EmailVerifier::RESEND_INTERVAL_SECONDS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and validate the email/password pair from the request body.
|
||||
*
|
||||
* @return array{0: string, 1: string} Normalised email and raw password.
|
||||
*/
|
||||
private function credentials(Request $request): array
|
||||
{
|
||||
$body = (array) ($request->getParsedBody() ?? []);
|
||||
|
||||
$email = is_string($body['email'] ?? null) ? trim($body['email']) : '';
|
||||
$password = is_string($body['password'] ?? null) ? $body['password'] : '';
|
||||
|
||||
$errors = [];
|
||||
|
||||
if ($email === '') {
|
||||
$errors['email'][] = 'Email is required.';
|
||||
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors['email'][] = 'Email must be a valid address.';
|
||||
} elseif (strlen($email) > self::EMAIL_MAX) {
|
||||
$errors['email'][] = sprintf('Email must be at most %d characters.', self::EMAIL_MAX);
|
||||
}
|
||||
|
||||
if ($password === '') {
|
||||
$errors['password'][] = 'Password is required.';
|
||||
} elseif (strlen($password) < self::PASSWORD_MIN) {
|
||||
$errors['password'][] = sprintf('Password must be at least %d characters.', self::PASSWORD_MIN);
|
||||
} elseif (strlen($password) > self::PASSWORD_MAX) {
|
||||
$errors['password'][] = sprintf('Password must be at most %d characters.', self::PASSWORD_MAX);
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
throw new ValidationException($errors);
|
||||
}
|
||||
|
||||
return [mb_strtolower($email), $password];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,11 +43,11 @@ abstract class Controller
|
||||
/**
|
||||
* The authenticated user row attached by AuthMiddleware.
|
||||
*
|
||||
* @return array{id: int, email: string, password_hash: string, email_verified_at: string|null, verification_email_sent_at: string|null, created_at: string, updated_at: string}
|
||||
* @return array{id: int, email: string, email_verified_at: string|null, verification_email_sent_at: string|null, created_at: string, updated_at: string}
|
||||
*/
|
||||
protected function user(Request $request): array
|
||||
{
|
||||
/** @var array{id: int, email: string, password_hash: string, email_verified_at: string|null, verification_email_sent_at: string|null, created_at: string, updated_at: string} $user */
|
||||
/** @var array{id: int, email: string, email_verified_at: string|null, verification_email_sent_at: string|null, created_at: string, updated_at: string} $user */
|
||||
$user = $request->getAttribute('user');
|
||||
|
||||
return $user;
|
||||
|
||||
@@ -18,7 +18,6 @@ final class EmailVerificationController extends Controller
|
||||
{
|
||||
private const RESEND_INTERVAL_SECONDS = EmailVerifier::RESEND_INTERVAL_SECONDS;
|
||||
private const EMAIL_MAX = 255;
|
||||
private const PASSWORD_MAX = 72;
|
||||
|
||||
public function __construct(
|
||||
private readonly UserRepository $users,
|
||||
@@ -78,31 +77,6 @@ final class EmailVerificationController extends Controller
|
||||
return $this->json($response, $this->session->forUser($user));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/email/verification (auth) — resend the verification email.
|
||||
*/
|
||||
public function resend(Request $request, Response $response): Response
|
||||
{
|
||||
$user = $this->user($request);
|
||||
|
||||
if (($user['email_verified_at'] ?? null) !== null) {
|
||||
throw new ApiException('Your email address is already verified.', 409);
|
||||
}
|
||||
|
||||
$this->guardResendInterval($user);
|
||||
|
||||
try {
|
||||
$this->verifier->sendVerification($user);
|
||||
} catch (MailException) {
|
||||
throw new ApiException('Could not send the email right now. Please try again shortly.', 502);
|
||||
}
|
||||
|
||||
return $this->json($response, [
|
||||
'message' => 'Verification email sent.',
|
||||
'retry_after' => self::RESEND_INTERVAL_SECONDS,
|
||||
], 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/email/change (auth) — request a deferred email change. The new
|
||||
* address only takes effect once its magic link is opened.
|
||||
@@ -113,7 +87,6 @@ final class EmailVerificationController extends Controller
|
||||
$body = $this->body($request);
|
||||
|
||||
$newEmail = is_string($body['email'] ?? null) ? mb_strtolower(trim($body['email'])) : '';
|
||||
$password = is_string($body['password'] ?? null) ? $body['password'] : '';
|
||||
|
||||
$errors = [];
|
||||
if ($newEmail === '') {
|
||||
@@ -123,18 +96,10 @@ final class EmailVerificationController extends Controller
|
||||
} elseif ($newEmail === mb_strtolower($user['email'])) {
|
||||
$errors['email'][] = 'That is already your email address.';
|
||||
}
|
||||
if ($password === '' || strlen($password) > self::PASSWORD_MAX) {
|
||||
$errors['password'][] = 'Your current password is required.';
|
||||
}
|
||||
if ($errors !== []) {
|
||||
throw new ValidationException($errors);
|
||||
}
|
||||
|
||||
$full = $this->users->findById($user['id']);
|
||||
if ($full === null || !password_verify($password, $full['password_hash'])) {
|
||||
throw new ValidationException(['password' => ['That password is incorrect.']]);
|
||||
}
|
||||
|
||||
if ($this->users->findByEmail($newEmail) !== null) {
|
||||
throw new ApiException('That email address is already in use.', 409);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ use App\Repository\EmailVerificationRepository;
|
||||
use App\Repository\UserRepository;
|
||||
|
||||
/**
|
||||
* Issues a magic-link token and emails it, for both "verify your address" and
|
||||
* "confirm your new address" flows.
|
||||
* Issues a magic-link token and emails it: a sign-in link (which also creates
|
||||
* the account and verifies the address, the first time) or a "confirm your new
|
||||
* address" link for a pending email change.
|
||||
*/
|
||||
final class EmailVerifier
|
||||
{
|
||||
@@ -25,27 +26,9 @@ final class EmailVerifier
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a link that verifies the user's current address.
|
||||
*
|
||||
* @param array{id: int, email: string} $user
|
||||
*/
|
||||
public function sendVerification(array $user): void
|
||||
{
|
||||
$link = $this->issue((int) $user['id'], null);
|
||||
|
||||
$this->mailer->send(
|
||||
$user['email'],
|
||||
'Verify your email address',
|
||||
"Welcome!\n\n"
|
||||
. "Confirm this email address by opening the link below. It expires in 15 minutes.\n\n"
|
||||
. $link . "\n\n"
|
||||
. "If you didn't create an account, you can ignore this message.\n",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a passwordless login link. Opening it signs the user in and, as a
|
||||
* side effect, verifies the address if it wasn't already.
|
||||
* Send a passwordless sign-in link. Opening it creates the session and, the
|
||||
* first time, verifies the address -- this is also how an account is
|
||||
* created, so it doubles as the "welcome" email for a new address.
|
||||
*
|
||||
* @param array{id: int, email: string} $user
|
||||
*/
|
||||
|
||||
@@ -5,11 +5,14 @@ declare(strict_types=1);
|
||||
namespace App\Repository;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
/**
|
||||
* Data access for the `users` table. Rows are returned as associative arrays.
|
||||
* There is no password: an account is created (if needed) and authenticated
|
||||
* entirely by opening an emailed magic link.
|
||||
*
|
||||
* @phpstan-type UserRow array{id: int, email: string, password_hash: string, email_verified_at: string|null, verification_email_sent_at: string|null, created_at: string, updated_at: string}
|
||||
* @phpstan-type UserRow array{id: int, email: string, email_verified_at: string|null, verification_email_sent_at: string|null, created_at: string, updated_at: string}
|
||||
*/
|
||||
final class UserRepository
|
||||
{
|
||||
@@ -46,15 +49,10 @@ final class UserRepository
|
||||
/**
|
||||
* @return UserRow
|
||||
*/
|
||||
public function create(string $email, string $passwordHash): array
|
||||
public function create(string $email): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO users (email, password_hash) VALUES (:email, :password_hash)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'email' => $email,
|
||||
'password_hash' => $passwordHash,
|
||||
]);
|
||||
$stmt = $this->pdo->prepare('INSERT INTO users (email) VALUES (:email)');
|
||||
$stmt->execute(['email' => $email]);
|
||||
|
||||
/** @var UserRow $user */
|
||||
$user = $this->findById((int) $this->pdo->lastInsertId());
|
||||
@@ -62,6 +60,32 @@ final class UserRepository
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user for this address, creating one (unverified) if it doesn't exist
|
||||
* yet -- this is the only "sign up".
|
||||
*
|
||||
* @return UserRow
|
||||
*/
|
||||
public function findOrCreateByEmail(string $email): array
|
||||
{
|
||||
$existing = $this->findByEmail($email);
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->create($email);
|
||||
} catch (PDOException $e) {
|
||||
// Lost a race with a concurrent request for the same address.
|
||||
$row = $this->findByEmail($email);
|
||||
if ($row === null) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
public function markEmailVerified(int $id): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
|
||||
@@ -78,13 +78,10 @@ $app->group('/api', function (RouteCollectorProxy $group) use (
|
||||
return $response->withHeader('Content-Type', 'application/json');
|
||||
});
|
||||
|
||||
$group->post('/auth/register', [$authController, 'register']);
|
||||
$group->post('/auth/login', [$authController, 'login']);
|
||||
$group->post('/auth/magic-link', [$authController, 'requestLoginLink']);
|
||||
$group->post('/auth/verify-email', [$emailController, 'verify']);
|
||||
|
||||
$group->get('/me', [$authController, 'me'])->add($authMiddleware);
|
||||
$group->post('/email/verification', [$emailController, 'resend'])->add($authMiddleware);
|
||||
$group->post('/email/change', [$emailController, 'requestChange'])->add($authMiddleware);
|
||||
|
||||
$group->group('/projects', function (RouteCollectorProxy $projects) use (
|
||||
|
||||
@@ -107,14 +107,21 @@ abstract class ApiTestCase extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a fresh user and return an `Authorization` header for them.
|
||||
* Sign in (creating the account if needed) via the real magic-link flow and
|
||||
* return an `Authorization` header for the resulting session.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function authHeader(string $email = 'user@example.com'): array
|
||||
{
|
||||
// Clear any resend throttle so this works even if the address was just
|
||||
// emailed a link earlier in the same test.
|
||||
$this->db()->prepare('UPDATE users SET verification_email_sent_at = NULL WHERE email = :email')
|
||||
->execute(['email' => $email]);
|
||||
|
||||
$this->request('POST', '/api/auth/magic-link', ['email' => $email]);
|
||||
$token = $this->decode(
|
||||
$this->request('POST', '/api/auth/register', ['email' => $email, 'password' => 'password123']),
|
||||
$this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail()]),
|
||||
)['token'];
|
||||
|
||||
return ['Authorization' => 'Bearer ' . $token];
|
||||
|
||||
+44
-59
@@ -4,79 +4,69 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
/**
|
||||
* There is no register/login pair -- POST /api/auth/magic-link is the only way
|
||||
* in, for both a brand-new address and a returning one.
|
||||
*/
|
||||
final class AuthTest extends ApiTestCase
|
||||
{
|
||||
public function test_registration_returns_a_user_and_token(): void
|
||||
public function test_a_magic_link_request_always_responds_202(): void
|
||||
{
|
||||
$response = $this->request('POST', '/api/auth/register', [
|
||||
'email' => 'Ada@example.com',
|
||||
'password' => 'correct horse battery',
|
||||
]);
|
||||
$response = $this->request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']);
|
||||
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
self::assertSame(202, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function test_a_magic_link_request_validates_the_address(): void
|
||||
{
|
||||
$response = $this->request('POST', '/api/auth/magic-link', ['email' => 'not-an-email']);
|
||||
|
||||
self::assertSame(422, $response->getStatusCode());
|
||||
self::assertArrayHasKey('email', $this->decode($response)['error']['details']);
|
||||
}
|
||||
|
||||
public function test_opening_the_link_creates_the_account_and_signs_in(): void
|
||||
{
|
||||
$this->request('POST', '/api/auth/magic-link', ['email' => 'Ada@example.com']);
|
||||
|
||||
$response = $this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail()]);
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
$body = $this->decode($response);
|
||||
self::assertSame('ada@example.com', $body['user']['email']);
|
||||
self::assertFalse($body['user']['email_verified']);
|
||||
self::assertNull($body['user']['email_verified_at']);
|
||||
self::assertTrue($body['user']['email_verified']);
|
||||
self::assertNotNull($body['user']['email_verified_at']);
|
||||
self::assertNull($body['user']['pending_email']);
|
||||
self::assertArrayNotHasKey('password', $body['user']);
|
||||
self::assertArrayNotHasKey('password_hash', $body['user']);
|
||||
self::assertNotEmpty($body['token']);
|
||||
}
|
||||
|
||||
public function test_registration_rejects_a_duplicate_email(): void
|
||||
public function test_a_second_request_signs_in_the_same_account(): void
|
||||
{
|
||||
$payload = ['email' => 'dupe@example.com', 'password' => 'password123'];
|
||||
$this->request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']);
|
||||
$firstId = $this->decode(
|
||||
$this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail()]),
|
||||
)['user']['id'];
|
||||
|
||||
$this->request('POST', '/api/auth/register', $payload);
|
||||
$response = $this->request('POST', '/api/auth/register', $payload);
|
||||
$this->db()->prepare('UPDATE users SET verification_email_sent_at = NULL WHERE email = :e')
|
||||
->execute(['e' => 'ada@example.com']);
|
||||
$this->request('POST', '/api/auth/magic-link', ['email' => 'ADA@EXAMPLE.COM']);
|
||||
$secondId = $this->decode(
|
||||
$this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail()]),
|
||||
)['user']['id'];
|
||||
|
||||
self::assertSame(409, $response->getStatusCode());
|
||||
self::assertSame($firstId, $secondId);
|
||||
}
|
||||
|
||||
public function test_registration_validates_input(): void
|
||||
public function test_the_link_is_not_sent_again_within_the_resend_interval(): void
|
||||
{
|
||||
$response = $this->request('POST', '/api/auth/register', [
|
||||
'email' => 'not-an-email',
|
||||
'password' => 'short',
|
||||
]);
|
||||
$this->request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']);
|
||||
|
||||
self::assertSame(422, $response->getStatusCode());
|
||||
$response = $this->request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']);
|
||||
|
||||
$body = $this->decode($response);
|
||||
self::assertArrayHasKey('email', $body['error']['details']);
|
||||
self::assertArrayHasKey('password', $body['error']['details']);
|
||||
}
|
||||
|
||||
public function test_login_succeeds_with_correct_password(): void
|
||||
{
|
||||
$this->request('POST', '/api/auth/register', [
|
||||
'email' => 'grace@example.com',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$response = $this->request('POST', '/api/auth/login', [
|
||||
'email' => 'grace@example.com',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertNotEmpty($this->decode($response)['token']);
|
||||
}
|
||||
|
||||
public function test_login_fails_with_wrong_password(): void
|
||||
{
|
||||
$this->request('POST', '/api/auth/register', [
|
||||
'email' => 'grace@example.com',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$response = $this->request('POST', '/api/auth/login', [
|
||||
'email' => 'grace@example.com',
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
self::assertSame(401, $response->getStatusCode());
|
||||
self::assertSame(202, $response->getStatusCode());
|
||||
self::assertCount(1, $this->sentEmails());
|
||||
}
|
||||
|
||||
public function test_me_requires_a_valid_token(): void
|
||||
@@ -84,12 +74,7 @@ final class AuthTest extends ApiTestCase
|
||||
$unauthorised = $this->request('GET', '/api/me');
|
||||
self::assertSame(401, $unauthorised->getStatusCode());
|
||||
|
||||
$token = $this->decode($this->request('POST', '/api/auth/register', [
|
||||
'email' => 'linus@example.com',
|
||||
'password' => 'password123',
|
||||
]))['token'];
|
||||
|
||||
$response = $this->request('GET', '/api/me', null, ['Authorization' => 'Bearer ' . $token]);
|
||||
$response = $this->request('GET', '/api/me', null, $this->authHeader('linus@example.com'));
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertSame('linus@example.com', $this->decode($response)['user']['email']);
|
||||
|
||||
+31
-119
@@ -6,9 +6,9 @@ namespace Tests;
|
||||
|
||||
final class EmailVerificationTest extends ApiTestCase
|
||||
{
|
||||
public function test_registration_sends_a_verification_email(): void
|
||||
public function test_the_magic_link_email_contains_a_verify_link(): void
|
||||
{
|
||||
$this->request('POST', '/api/auth/register', ['email' => 'ada@example.com', 'password' => 'password123']);
|
||||
$this->request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']);
|
||||
|
||||
$email = $this->lastEmail();
|
||||
self::assertSame('ada@example.com', $email['to']);
|
||||
@@ -16,21 +16,6 @@ final class EmailVerificationTest extends ApiTestCase
|
||||
self::assertStringContainsString('https://app.test/verify-email?token=', $email['body']);
|
||||
}
|
||||
|
||||
public function test_opening_the_magic_link_verifies_and_logs_in(): void
|
||||
{
|
||||
$this->request('POST', '/api/auth/register', ['email' => 'ada@example.com', 'password' => 'password123']);
|
||||
|
||||
$response = $this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail()]);
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
$body = $this->decode($response);
|
||||
self::assertTrue($body['user']['email_verified']);
|
||||
self::assertNotEmpty($body['token']);
|
||||
|
||||
$me = $this->decode($this->request('GET', '/api/me', null, ['Authorization' => 'Bearer ' . $body['token']]));
|
||||
self::assertTrue($me['user']['email_verified']);
|
||||
}
|
||||
|
||||
public function test_an_invalid_token_is_rejected(): void
|
||||
{
|
||||
$response = $this->request('POST', '/api/auth/verify-email', ['token' => 'not-a-real-token']);
|
||||
@@ -39,7 +24,7 @@ final class EmailVerificationTest extends ApiTestCase
|
||||
|
||||
public function test_a_token_cannot_be_used_twice(): void
|
||||
{
|
||||
$this->request('POST', '/api/auth/register', ['email' => 'ada@example.com', 'password' => 'password123']);
|
||||
$this->request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']);
|
||||
$token = $this->tokenFromEmail();
|
||||
|
||||
self::assertSame(200, $this->request('POST', '/api/auth/verify-email', ['token' => $token])->getStatusCode());
|
||||
@@ -51,7 +36,7 @@ final class EmailVerificationTest extends ApiTestCase
|
||||
|
||||
public function test_an_expired_token_is_rejected(): void
|
||||
{
|
||||
$this->request('POST', '/api/auth/register', ['email' => 'ada@example.com', 'password' => 'password123']);
|
||||
$this->request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']);
|
||||
$token = $this->tokenFromEmail();
|
||||
|
||||
$this->db()->prepare('UPDATE email_verifications SET expires_at = :past WHERE token_hash = :hash')->execute([
|
||||
@@ -64,94 +49,15 @@ final class EmailVerificationTest extends ApiTestCase
|
||||
self::assertStringContainsString('expired', $this->decode($response)['error']['message']);
|
||||
}
|
||||
|
||||
public function test_resend_is_throttled_immediately_after_registration(): void
|
||||
public function test_a_fresh_link_can_be_requested_once_the_interval_has_passed(): void
|
||||
{
|
||||
$auth = $this->authHeader('ada@example.com');
|
||||
|
||||
$response = $this->request('POST', '/api/email/verification', null, $auth);
|
||||
|
||||
self::assertSame(429, $response->getStatusCode());
|
||||
self::assertArrayHasKey('retry_after', $this->decode($response)['error']['details']);
|
||||
}
|
||||
|
||||
public function test_resend_works_once_the_interval_has_passed(): void
|
||||
{
|
||||
$auth = $this->authHeader('ada@example.com');
|
||||
$this->cooldownElapsed('ada@example.com');
|
||||
|
||||
$response = $this->request('POST', '/api/email/verification', null, $auth);
|
||||
|
||||
self::assertSame(202, $response->getStatusCode());
|
||||
self::assertCount(2, $this->sentEmails());
|
||||
self::assertSame('ada@example.com', $this->lastEmail()['to']);
|
||||
}
|
||||
|
||||
public function test_resend_conflicts_when_already_verified(): void
|
||||
{
|
||||
$auth = $this->authHeader('ada@example.com');
|
||||
$this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail()]);
|
||||
$this->cooldownElapsed('ada@example.com');
|
||||
|
||||
$response = $this->request('POST', '/api/email/verification', null, $auth);
|
||||
self::assertSame(409, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function test_magic_link_login_emails_a_link_that_signs_the_user_in(): void
|
||||
{
|
||||
$this->request('POST', '/api/auth/register', ['email' => 'ada@example.com', 'password' => 'password123']);
|
||||
$this->cooldownElapsed('ada@example.com');
|
||||
|
||||
$requested = $this->request('POST', '/api/auth/magic-link', ['email' => 'ADA@example.com']);
|
||||
self::assertSame(202, $requested->getStatusCode());
|
||||
|
||||
$login = $this->lastEmail();
|
||||
self::assertSame('ada@example.com', $login['to']);
|
||||
self::assertSame('Your login link', $login['subject']);
|
||||
|
||||
$session = $this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail($login)]);
|
||||
self::assertSame(200, $session->getStatusCode());
|
||||
$body = $this->decode($session);
|
||||
self::assertSame('ada@example.com', $body['user']['email']);
|
||||
self::assertTrue($body['user']['email_verified']);
|
||||
self::assertNotEmpty($body['token']);
|
||||
}
|
||||
|
||||
public function test_magic_link_login_verifies_an_unverified_account(): void
|
||||
{
|
||||
$this->request('POST', '/api/auth/register', ['email' => 'ada@example.com', 'password' => 'password123']);
|
||||
$this->cooldownElapsed('ada@example.com');
|
||||
|
||||
$this->request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']);
|
||||
$body = $this->decode(
|
||||
$this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail()]),
|
||||
);
|
||||
|
||||
self::assertTrue($body['user']['email_verified']);
|
||||
}
|
||||
|
||||
public function test_magic_link_login_is_silent_for_an_unknown_address(): void
|
||||
{
|
||||
$response = $this->request('POST', '/api/auth/magic-link', ['email' => 'nobody@example.com']);
|
||||
|
||||
self::assertSame(202, $response->getStatusCode());
|
||||
self::assertSame([], $this->sentEmails());
|
||||
}
|
||||
|
||||
public function test_magic_link_login_does_not_resend_within_the_interval(): void
|
||||
{
|
||||
// Registration already sent a verification email moments ago.
|
||||
$this->request('POST', '/api/auth/register', ['email' => 'ada@example.com', 'password' => 'password123']);
|
||||
$this->cooldownElapsed('ada@example.com');
|
||||
|
||||
$response = $this->request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']);
|
||||
|
||||
self::assertSame(202, $response->getStatusCode());
|
||||
self::assertCount(1, $this->sentEmails()); // still just the registration email
|
||||
}
|
||||
|
||||
public function test_magic_link_login_validates_the_address(): void
|
||||
{
|
||||
$response = $this->request('POST', '/api/auth/magic-link', ['email' => 'not-an-email']);
|
||||
self::assertSame(422, $response->getStatusCode());
|
||||
self::assertCount(2, $this->sentEmails());
|
||||
}
|
||||
|
||||
public function test_email_change_is_deferred_until_the_new_address_is_confirmed(): void
|
||||
@@ -159,10 +65,7 @@ final class EmailVerificationTest extends ApiTestCase
|
||||
$auth = $this->authHeader('old@example.com');
|
||||
$this->cooldownElapsed('old@example.com');
|
||||
|
||||
$change = $this->request('POST', '/api/email/change', [
|
||||
'email' => 'New@example.com',
|
||||
'password' => 'password123',
|
||||
], $auth);
|
||||
$change = $this->request('POST', '/api/email/change', ['email' => 'New@example.com'], $auth);
|
||||
|
||||
self::assertSame(202, $change->getStatusCode());
|
||||
self::assertSame('new@example.com', $this->decode($change)['pending_email']);
|
||||
@@ -182,18 +85,21 @@ final class EmailVerificationTest extends ApiTestCase
|
||||
self::assertNull($body['user']['pending_email']);
|
||||
}
|
||||
|
||||
public function test_email_change_requires_the_current_password(): void
|
||||
public function test_email_change_requires_authentication(): void
|
||||
{
|
||||
$response = $this->request('POST', '/api/email/change', ['email' => 'new@example.com']);
|
||||
self::assertSame(401, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function test_email_change_validates_the_new_address(): void
|
||||
{
|
||||
$auth = $this->authHeader('old@example.com');
|
||||
$this->cooldownElapsed('old@example.com');
|
||||
|
||||
$response = $this->request('POST', '/api/email/change', [
|
||||
'email' => 'new@example.com',
|
||||
'password' => 'wrong-password',
|
||||
], $auth);
|
||||
$response = $this->request('POST', '/api/email/change', ['email' => 'not-an-email'], $auth);
|
||||
|
||||
self::assertSame(422, $response->getStatusCode());
|
||||
self::assertArrayHasKey('password', $this->decode($response)['error']['details']);
|
||||
self::assertArrayHasKey('email', $this->decode($response)['error']['details']);
|
||||
}
|
||||
|
||||
public function test_email_change_rejects_an_address_already_in_use(): void
|
||||
@@ -202,10 +108,7 @@ final class EmailVerificationTest extends ApiTestCase
|
||||
$auth = $this->authHeader('mine@example.com');
|
||||
$this->cooldownElapsed('mine@example.com');
|
||||
|
||||
$response = $this->request('POST', '/api/email/change', [
|
||||
'email' => 'taken@example.com',
|
||||
'password' => 'password123',
|
||||
], $auth);
|
||||
$response = $this->request('POST', '/api/email/change', ['email' => 'taken@example.com'], $auth);
|
||||
|
||||
self::assertSame(409, $response->getStatusCode());
|
||||
}
|
||||
@@ -215,15 +118,24 @@ final class EmailVerificationTest extends ApiTestCase
|
||||
$auth = $this->authHeader('same@example.com');
|
||||
$this->cooldownElapsed('same@example.com');
|
||||
|
||||
$response = $this->request('POST', '/api/email/change', [
|
||||
'email' => 'same@example.com',
|
||||
'password' => 'password123',
|
||||
], $auth);
|
||||
$response = $this->request('POST', '/api/email/change', ['email' => 'same@example.com'], $auth);
|
||||
|
||||
self::assertSame(422, $response->getStatusCode());
|
||||
self::assertArrayHasKey('email', $this->decode($response)['error']['details']);
|
||||
}
|
||||
|
||||
public function test_email_change_is_throttled_immediately_after_signing_in(): void
|
||||
{
|
||||
// authHeader() just sent this user a magic link, so the resend window
|
||||
// (shared with /email/change) hasn't elapsed yet.
|
||||
$auth = $this->authHeader('throttle@example.com');
|
||||
|
||||
$response = $this->request('POST', '/api/email/change', ['email' => 'new@example.com'], $auth);
|
||||
|
||||
self::assertSame(429, $response->getStatusCode());
|
||||
self::assertArrayHasKey('retry_after', $this->decode($response)['error']['details']);
|
||||
}
|
||||
|
||||
/** Push the user's last-sent timestamp far enough back to clear the throttle. */
|
||||
private function cooldownElapsed(string $email): void
|
||||
{
|
||||
|
||||
+24
-31
@@ -31,19 +31,17 @@ build app` from the parent directory.
|
||||
```
|
||||
src/main.ts App bootstrap; resolves the stored session before mount
|
||||
src/router/index.ts Routes + guard (redirects to /login when unauthenticated)
|
||||
src/stores/auth.ts Pinia store: token in localStorage, register/login/fetchMe
|
||||
src/stores/auth.ts Pinia store: token in localStorage, magic-link + fetchMe
|
||||
src/stores/projects.ts Pinia store: the user's projects (fetch + create)
|
||||
src/stores/cards.ts Pinia store: one project's cards (CRUD + reorderColumn)
|
||||
src/lib/api.ts fetch wrapper, bearer token, typed ApiError
|
||||
src/components/AppSidebar.vue left nav: Dashboard link, divider, project list + new-project form
|
||||
src/components/CardRow.vue editable text + status chip + delete, one card
|
||||
src/components/KanbanCard.vue small draggable card for the board columns
|
||||
src/views/ DashboardView, ProjectView, LoginView, RegisterView,
|
||||
ProfileView, VerifyEmailView
|
||||
src/views/ DashboardView, ProjectView, LoginView, ProfileView,
|
||||
VerifyEmailView
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
Signed-in "app" routes (`meta.requiresAuth`) render inside a persistent shell:
|
||||
the top bar, then a left **sidebar** (`AppSidebar.vue`) beside the routed view.
|
||||
The sidebar stays mounted across navigation — it holds a **Dashboard** link, a
|
||||
@@ -55,7 +53,7 @@ sidebar → project → project navigation always does a fresh load.
|
||||
`/` redirects to `/dashboard` (`DashboardView.vue`), a full-width grid of
|
||||
project cards — each shows the project name and, under a **New** heading, its
|
||||
inbox cards (`status_id === null`), fetched per project. Signed-out routes
|
||||
(`/login`, `/register`, `/verify-email`) render without the sidebar.
|
||||
(`/login`, `/verify-email`) render without the sidebar.
|
||||
|
||||
## Project detail
|
||||
|
||||
@@ -93,32 +91,27 @@ new cards have no status, so they land straight in it.
|
||||
|
||||
## Auth flow
|
||||
|
||||
- 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.
|
||||
There is no password and no separate sign-up — `LoginView` is just an email
|
||||
field and a "Send sign-in link" button (`POST /api/auth/magic-link`), for a new
|
||||
address or a returning one alike. On success it shows a "check your email"
|
||||
message; it does not sign the caller in itself.
|
||||
|
||||
- `/verify-email?token=…` is the target for every magic link (sign-in and
|
||||
email-change confirmation both). `VerifyEmailView` POSTs the token via
|
||||
`auth.verifyEmail()`, which returns a session — opening the link is what
|
||||
actually signs the caller in — then redirects to the dashboard.
|
||||
- The token is kept in `localStorage` and sent as `Authorization: Bearer …`.
|
||||
On load, `fetchMe()` validates it via `GET /api/me`; a failure clears it.
|
||||
- Routes with `meta.requiresAuth` redirect to `/login` (preserving the intended
|
||||
path) when there is no authenticated user.
|
||||
- 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.
|
||||
- Because the only way to get a session is opening a link, `user.email_verified`
|
||||
is always `true` for a signed-in user — the frontend doesn't show any
|
||||
verification nagging or resend UI.
|
||||
|
||||
## Email verification & profile
|
||||
## Profile
|
||||
|
||||
- `/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 projects.
|
||||
- `/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`
|
||||
responses).
|
||||
- The **Change email** form takes the new address and the current password.
|
||||
On success the API has emailed a confirmation link to the *new* address and
|
||||
set `user.pending_email`; the change only lands when that link is opened. The
|
||||
resend and change actions share the one-minute cooldown.
|
||||
`/profile` (`ProfileView`) shows the current address and a **Change email**
|
||||
form (new address only, no password). On success the API has emailed a
|
||||
confirmation link to the *new* address and set `user.pending_email` (shown as a
|
||||
notice until it's opened); the change only lands once that link is opened. The
|
||||
button shows a live countdown driven by `retry_after` and by `429` responses.
|
||||
|
||||
@@ -30,9 +30,6 @@ async function onLogout() {
|
||||
<span class="app__brand">Projects</span>
|
||||
|
||||
<div v-if="auth.isAuthenticated" class="app__account">
|
||||
<RouterLink v-if="!auth.emailVerified" to="/profile" class="badge badge--warn">
|
||||
verify email
|
||||
</RouterLink>
|
||||
<RouterLink to="/profile" class="app__email">{{ auth.user?.email }}</RouterLink>
|
||||
<button type="button" class="link" @click="onLogout">Log out</button>
|
||||
</div>
|
||||
|
||||
@@ -35,12 +35,6 @@ const router = createRouter({
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: () => import('../views/RegisterView.vue'),
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: { name: 'dashboard' } },
|
||||
],
|
||||
})
|
||||
|
||||
+6
-36
@@ -22,7 +22,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
setAuthToken(token.value)
|
||||
|
||||
const isAuthenticated = computed(() => token.value !== null && user.value !== null)
|
||||
const emailVerified = computed(() => user.value?.email_verified ?? false)
|
||||
|
||||
function setToken(value: string | null): void {
|
||||
token.value = value
|
||||
@@ -40,35 +39,20 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
user.value = response.user
|
||||
}
|
||||
|
||||
async function register(email: string, password: string): Promise<void> {
|
||||
adopt(
|
||||
await apiRequest<AuthResponse>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: { email, password },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function login(email: string, password: string): Promise<void> {
|
||||
adopt(
|
||||
await apiRequest<AuthResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: { email, password },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function logout(): void {
|
||||
setToken(null)
|
||||
user.value = null
|
||||
}
|
||||
|
||||
/** Ask for a passwordless login link to be emailed. */
|
||||
/**
|
||||
* Ask for a sign-in link to be emailed. Creates the account first if this
|
||||
* address is new — there is no separate registration step.
|
||||
*/
|
||||
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. */
|
||||
/** Consume a magic-link token; the response signs the user in. */
|
||||
async function verifyEmail(magicToken: string): Promise<void> {
|
||||
adopt(
|
||||
await apiRequest<AuthResponse>('/auth/verify-email', {
|
||||
@@ -78,24 +62,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
)
|
||||
}
|
||||
|
||||
/** Resend the verification email. Returns the seconds to wait before the next request. */
|
||||
async function resendVerification(): Promise<number> {
|
||||
const { retry_after } = await apiRequest<{ retry_after: number }>('/email/verification', {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
})
|
||||
return retry_after
|
||||
}
|
||||
|
||||
/** Request a deferred email change. Returns the pending address and cooldown. */
|
||||
async function requestEmailChange(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ pending_email: string; retry_after: number }> {
|
||||
const result = await apiRequest<{ pending_email: string; retry_after: number }>('/email/change', {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: { email, password },
|
||||
body: { email },
|
||||
})
|
||||
await fetchMe() // pick up user.pending_email
|
||||
return result
|
||||
@@ -121,14 +95,10 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
user,
|
||||
loading,
|
||||
isAuthenticated,
|
||||
emailVerified,
|
||||
register,
|
||||
login,
|
||||
logout,
|
||||
fetchMe,
|
||||
requestLoginLink,
|
||||
verifyEmail,
|
||||
resendVerification,
|
||||
requestEmailChange,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -68,10 +68,6 @@ body {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.badge {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app__body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -288,22 +284,6 @@ h1 {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.badge--warn {
|
||||
border-color: var(--warn-border);
|
||||
background: var(--warn-bg);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* --- project detail: cards ------------------------------------------ */
|
||||
|
||||
.cards {
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ApiError, apiRequest } from '../lib/api'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import type { Card } from '../types'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const projects = useProjectsStore()
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -40,11 +38,6 @@ async function load() {
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<div v-if="!auth.emailVerified" class="notice">
|
||||
Your email address <strong>{{ auth.user?.email }}</strong> has not been
|
||||
verified yet.
|
||||
</div>
|
||||
|
||||
<p v-if="loadError" class="form-error">{{ loadError }}</p>
|
||||
<p v-else-if="loading" class="muted">Loading…</p>
|
||||
<p v-else-if="projects.projects.length === 0" class="muted">
|
||||
|
||||
+19
-50
@@ -1,46 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ref } from 'vue'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
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 sent = ref(false)
|
||||
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 {
|
||||
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.'
|
||||
}
|
||||
await auth.requestLoginLink(email.value)
|
||||
sent.value = true
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e : new ApiError('Something went wrong.', 0)
|
||||
} finally {
|
||||
@@ -52,8 +27,19 @@ async function onSubmit() {
|
||||
<template>
|
||||
<section class="card">
|
||||
<h1>Log in</h1>
|
||||
<p class="muted">
|
||||
Enter your email and we'll send you a link to sign in — no password
|
||||
needed. New here? The same link creates your account.
|
||||
</p>
|
||||
|
||||
<form class="form" @submit.prevent="onSubmit">
|
||||
<template v-if="sent">
|
||||
<p>Check <strong>{{ email }}</strong> for a sign-in link. It expires in 15 minutes.</p>
|
||||
<p class="muted">
|
||||
<button type="button" class="link" @click="sent = false">Use a different address</button>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<form v-else class="form" @submit.prevent="onSubmit">
|
||||
<label>
|
||||
<span>Email</span>
|
||||
<input v-model="email" type="email" autocomplete="email" required />
|
||||
@@ -62,30 +48,13 @@ async function onSubmit() {
|
||||
</small>
|
||||
</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">
|
||||
{{ error.fieldError('password') }}
|
||||
</small>
|
||||
</label>
|
||||
|
||||
<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">{{ buttonLabel }}</button>
|
||||
</form>
|
||||
|
||||
<p class="muted">
|
||||
<button type="button" class="link" @click="toggleMode">
|
||||
{{ mode === 'magic' ? 'Log in with password' : 'Get a magic link' }}
|
||||
<button type="submit" :disabled="submitting">
|
||||
{{ submitting ? 'Sending…' : 'Send sign-in link' }}
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p class="muted">
|
||||
No account? <RouterLink to="/register">Create one</RouterLink>.
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
@@ -19,34 +19,8 @@ function startCooldown(seconds: number) {
|
||||
|
||||
onBeforeUnmount(() => clearInterval(timer))
|
||||
|
||||
// --- resend verification -------------------------------------------------
|
||||
const resending = ref(false)
|
||||
const resendMessage = ref('')
|
||||
const resendError = ref('')
|
||||
|
||||
async function onResend() {
|
||||
resending.value = true
|
||||
resendMessage.value = ''
|
||||
resendError.value = ''
|
||||
try {
|
||||
const retryAfter = await auth.resendVerification()
|
||||
resendMessage.value = `Sent. Check ${auth.user?.email}.`
|
||||
startCooldown(retryAfter)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
resendError.value = e.message
|
||||
if (e.status === 429) startCooldown(e.retryAfter ?? 60)
|
||||
} else {
|
||||
resendError.value = 'Could not send the email.'
|
||||
}
|
||||
} finally {
|
||||
resending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- change email ------------------------------------------------------
|
||||
const newEmail = ref('')
|
||||
const password = ref('')
|
||||
const changing = ref(false)
|
||||
const changeMessage = ref('')
|
||||
const changeError = ref<ApiError | null>(null)
|
||||
@@ -56,10 +30,9 @@ async function onChangeEmail() {
|
||||
changeMessage.value = ''
|
||||
changeError.value = null
|
||||
try {
|
||||
const { pending_email, retry_after } = await auth.requestEmailChange(newEmail.value, password.value)
|
||||
const { pending_email, retry_after } = await auth.requestEmailChange(newEmail.value)
|
||||
changeMessage.value = `Confirmation link sent to ${pending_email}. Your address changes once you open it.`
|
||||
newEmail.value = ''
|
||||
password.value = ''
|
||||
startCooldown(retry_after)
|
||||
} catch (e) {
|
||||
changeError.value = e instanceof ApiError ? e : new ApiError('Could not request the change.', 0)
|
||||
@@ -68,25 +41,14 @@ async function onChangeEmail() {
|
||||
changing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resendLabel = computed(() => {
|
||||
if (resending.value) return 'Sending…'
|
||||
if (cooldown.value > 0) return `Resend in ${cooldown.value}s`
|
||||
return 'Resend verification email'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card">
|
||||
<p><RouterLink to="/">← Back to lists</RouterLink></p>
|
||||
<p><RouterLink to="/">← Dashboard</RouterLink></p>
|
||||
<h1>Your profile</h1>
|
||||
|
||||
<p><strong>Email:</strong> {{ auth.user?.email }}</p>
|
||||
<p>
|
||||
<strong>Status:</strong>
|
||||
<span v-if="auth.emailVerified">verified</span>
|
||||
<span v-else class="badge badge--warn">not verified</span>
|
||||
</p>
|
||||
|
||||
<div v-if="auth.user?.pending_email" class="notice">
|
||||
A change to <strong>{{ auth.user.pending_email }}</strong> is pending. Open the
|
||||
@@ -94,21 +56,12 @@ const resendLabel = computed(() => {
|
||||
after it was sent.
|
||||
</div>
|
||||
|
||||
<section v-if="!auth.emailVerified">
|
||||
<h2>Verify your email</h2>
|
||||
<p class="muted">
|
||||
We sent a link to {{ auth.user?.email }}. It expires 15 minutes after
|
||||
it's sent. You can resend it once a minute.
|
||||
</p>
|
||||
<button type="button" :disabled="resending || cooldown > 0" @click="onResend">
|
||||
{{ resendLabel }}
|
||||
</button>
|
||||
<p v-if="resendMessage" class="muted">{{ resendMessage }}</p>
|
||||
<p v-if="resendError" class="form-error">{{ resendError }}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Change email address</h2>
|
||||
<p class="muted">
|
||||
We'll email a confirmation link to the new address; the change only
|
||||
takes effect once you open it.
|
||||
</p>
|
||||
<form class="form" @submit.prevent="onChangeEmail">
|
||||
<label>
|
||||
<span>New email</span>
|
||||
@@ -117,13 +70,6 @@ const resendLabel = computed(() => {
|
||||
{{ changeError.fieldError('email') }}
|
||||
</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Current password</span>
|
||||
<input v-model="password" type="password" autocomplete="current-password" required />
|
||||
<small v-if="changeError?.fieldError('password')" class="field-error">
|
||||
{{ changeError.fieldError('password') }}
|
||||
</small>
|
||||
</label>
|
||||
|
||||
<p v-if="changeError && Object.keys(changeError.details).length === 0" class="form-error">
|
||||
{{ changeError.message }}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const error = ref<ApiError | null>(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
async function onSubmit() {
|
||||
submitting.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await auth.register(email.value, password.value)
|
||||
// Registration signs the user straight in (with an unverified email).
|
||||
await router.push('/')
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e : new ApiError('Something went wrong.', 0)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card">
|
||||
<h1>Create an account</h1>
|
||||
<p class="muted">
|
||||
You will be signed in immediately. Your email address starts out
|
||||
unverified.
|
||||
</p>
|
||||
|
||||
<form class="form" @submit.prevent="onSubmit">
|
||||
<label>
|
||||
<span>Email</span>
|
||||
<input v-model="email" type="email" autocomplete="email" required />
|
||||
<small v-if="error?.fieldError('email')" class="field-error">
|
||||
{{ error.fieldError('email') }}
|
||||
</small>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
minlength="8"
|
||||
required
|
||||
/>
|
||||
<small v-if="error?.fieldError('password')" class="field-error">
|
||||
{{ error.fieldError('password') }}
|
||||
</small>
|
||||
<small v-else class="hint">At least 8 characters.</small>
|
||||
</label>
|
||||
|
||||
<p v-if="error && Object.keys(error.details).length === 0" class="form-error">
|
||||
{{ error.message }}
|
||||
</p>
|
||||
|
||||
<button type="submit" :disabled="submitting">
|
||||
{{ submitting ? 'Creating…' : 'Create account' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="muted">
|
||||
Already registered? <RouterLink to="/login">Log in</RouterLink>.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -37,17 +37,15 @@ onMounted(async () => {
|
||||
<p v-if="state === 'working'" class="muted">One moment…</p>
|
||||
|
||||
<template v-else-if="state === 'done'">
|
||||
<p>You're signed in. Your email address is verified.</p>
|
||||
<p class="muted">Taking you to your lists…</p>
|
||||
<p>You're signed in.</p>
|
||||
<p class="muted">Taking you to your dashboard…</p>
|
||||
<RouterLink to="/">Go now</RouterLink>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="form-error">{{ message }}</p>
|
||||
<p class="muted">
|
||||
Request a fresh link from your
|
||||
<RouterLink to="/profile">profile</RouterLink>, or
|
||||
<RouterLink to="/login">log in</RouterLink>.
|
||||
<RouterLink to="/login">Request a fresh link</RouterLink>.
|
||||
</p>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user