diff --git a/README.md b/README.md index a7efa93..8e4e71d 100644 --- a/README.md +++ b/README.md @@ -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 (`/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 `. `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 (`/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 `. - -`200 OK`: +Requires `Authorization: Bearer `. 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 — `/verify-email?token=` — 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": "" }`. -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" diff --git a/migrations/008_drop_user_passwords.sql b/migrations/008_drop_user_passwords.sql new file mode 100644 index 0000000..940f9ba --- /dev/null +++ b/migrations/008_drop_user_passwords.sql @@ -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; diff --git a/src/Auth/SessionPayload.php b/src/Auth/SessionPayload.php index fcd1a36..be58e86 100644 --- a/src/Auth/SessionPayload.php +++ b/src/Auth/SessionPayload.php @@ -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 { diff --git a/src/Http/Controllers/AuthController.php b/src/Http/Controllers/AuthController.php index 180aa3e..dee5bdb 100644 --- a/src/Http/Controllers/AuthController.php +++ b/src/Http/Controllers/AuthController.php @@ -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]; - } } diff --git a/src/Http/Controllers/Controller.php b/src/Http/Controllers/Controller.php index 41a5081..3e152d3 100644 --- a/src/Http/Controllers/Controller.php +++ b/src/Http/Controllers/Controller.php @@ -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; diff --git a/src/Http/Controllers/EmailVerificationController.php b/src/Http/Controllers/EmailVerificationController.php index adc7f2c..7f2451d 100644 --- a/src/Http/Controllers/EmailVerificationController.php +++ b/src/Http/Controllers/EmailVerificationController.php @@ -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); } diff --git a/src/Mail/EmailVerifier.php b/src/Mail/EmailVerifier.php index fb86b9d..6704fda 100644 --- a/src/Mail/EmailVerifier.php +++ b/src/Mail/EmailVerifier.php @@ -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 */ diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 831b8bc..bd6192d 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -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( diff --git a/src/bootstrap.php b/src/bootstrap.php index dea8251..d22e7ed 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -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 ( diff --git a/tests/ApiTestCase.php b/tests/ApiTestCase.php index 94d6839..3017d6c 100644 --- a/tests/ApiTestCase.php +++ b/tests/ApiTestCase.php @@ -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 */ 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]; diff --git a/tests/AuthTest.php b/tests/AuthTest.php index 57a85d5..341a59d 100644 --- a/tests/AuthTest.php +++ b/tests/AuthTest.php @@ -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']); diff --git a/tests/EmailVerificationTest.php b/tests/EmailVerificationTest.php index 25a2cfc..1c5552b 100644 --- a/tests/EmailVerificationTest.php +++ b/tests/EmailVerificationTest.php @@ -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 { diff --git a/web/README.md b/web/README.md index 3569e21..881f43f 100644 --- a/web/README.md +++ b/web/README.md @@ -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. diff --git a/web/src/App.vue b/web/src/App.vue index 9973a31..50a1a0b 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -30,9 +30,6 @@ async function onLogout() { Projects diff --git a/web/src/router/index.ts b/web/src/router/index.ts index ff4970c..1246880 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -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' } }, ], }) diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index d771b06..f5614f2 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -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 { - adopt( - await apiRequest('/auth/register', { - method: 'POST', - body: { email, password }, - }), - ) - } - - async function login(email: string, password: string): Promise { - adopt( - await apiRequest('/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 { 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 { adopt( await apiRequest('/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 { - 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, } }) diff --git a/web/src/style.css b/web/src/style.css index c179148..a04d2bc 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -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 { diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue index 364fe6b..68d65ef 100644 --- a/web/src/views/DashboardView.vue +++ b/web/src/views/DashboardView.vue @@ -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() {