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:
2026-09-04 14:47:46 +01:00
co-authored by Claude Sonnet 5
parent 8f8ad8593d
commit c82bdbbf0e
22 changed files with 277 additions and 727 deletions
+69 -87
View File
@@ -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
872 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"
+6
View File
@@ -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;
+4 -3
View File
@@ -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
{
+14 -91
View File
@@ -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];
}
}
+2 -2
View File
@@ -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);
}
+6 -23
View File
@@ -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
*/
+33 -9
View File
@@ -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(
-3
View File
@@ -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 (
+9 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
-3
View File
@@ -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>
-6
View File
@@ -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
View File
@@ -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,
}
})
-20
View File
@@ -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 {
-7
View File
@@ -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
View File
@@ -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>
+7 -61
View File
@@ -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="/">&larr; Back to lists</RouterLink></p>
<p><RouterLink to="/">&larr; 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 }}
-75
View File
@@ -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>
+3 -5
View File
@@ -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>