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>
421 lines
16 KiB
Markdown
421 lines
16 KiB
Markdown
# PHP Project Manager
|
||
|
||
A small project-management application: a REST API written in PHP (Slim 4)
|
||
backed by an SQLite file, plus a Vue 3 + TypeScript PWA frontend in [web/](web/).
|
||
Each user owns **projects**, and each project holds ordered **cards**.
|
||
|
||
## Status
|
||
|
||
| Stage | Scope | State |
|
||
|-------|-------|-------|
|
||
| 1 | Auth API — register, login, `GET /me` | ✅ done |
|
||
| 2 | Frontend shell — Vite PWA, auth-gated routing, register/login pages | ✅ done |
|
||
| 3 | Project + card CRUD API | ✅ done |
|
||
| 4 | Frontend projects view — project index + create form | ✅ done |
|
||
| 5 | Frontend project detail — cards UI with drag-and-drop reorder | ✅ done |
|
||
| 6 | Project view — inline title/description editing, delete via a Manage menu | ✅ done |
|
||
| 7 | Email verification (magic links) + profile page (resend, change email) | ✅ done |
|
||
| 8 | Passwordless login — magic-link by default, password login behind a toggle | ✅ done |
|
||
| 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 |
|
||
|
||
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
|
||
|
||
The only requirement is Docker with the Compose plugin.
|
||
|
||
```bash
|
||
docker compose up -d
|
||
```
|
||
|
||
This runs a multi-stage build — a Node stage compiles the Vue frontend, then a
|
||
PHP 8.3 + Apache stage bakes in the PHP source and the built SPA — applies
|
||
migrations, and serves the whole app at <http://localhost:8080>: the SPA at `/`
|
||
(assets and all) and the REST API under `/api` (e.g.
|
||
`curl http://localhost:8080/api/health`). Unknown paths fall back to the SPA
|
||
shell for client-side routing.
|
||
|
||
- A **[Mailpit](https://mailpit.axllent.org/)** container (the maintained MailHog
|
||
successor — one ~15 MB Go binary, messages kept in memory) also starts. The API
|
||
sends all email to it; read it at <http://localhost:8025>. Set
|
||
`MAIL_TRANSPORT=mail` or `=smtp` (with `MAIL_SMTP_*`) to send for real.
|
||
- The image is the artifact: PHP source and the compiled frontend are copied in
|
||
at build time, not bind-mounted. Rebuild to pick up any code change:
|
||
`docker compose up -d --build`. For iterating on the frontend, run the Vite
|
||
dev server on the host instead (see [Frontend](#frontend)).
|
||
- The SQLite database and the generated JWT signing key live in the `storage`
|
||
named volume, mounted at `/var/www/storage`, so they survive
|
||
`docker compose restart` / `down` + `up`.
|
||
- `docker compose down -v` removes the volume and gives you a clean database.
|
||
- Override settings via the environment or a `.env` file in this directory
|
||
(Compose substitutes `APP_DEBUG`, `JWT_SECRET`, `JWT_TTL` — see
|
||
[docker-compose.yml](docker-compose.yml)).
|
||
|
||
## Run without Docker
|
||
|
||
Requires PHP 8.1+ with the `pdo_sqlite` and `mbstring` extensions, plus
|
||
[Composer](https://getcomposer.org/). On Fedora:
|
||
|
||
```bash
|
||
sudo dnf install php-cli php-pdo php-mbstring composer
|
||
```
|
||
|
||
### Setup
|
||
|
||
```bash
|
||
composer install
|
||
cp .env.example .env # optional; sane defaults are used without it
|
||
composer migrate # creates storage/database.sqlite and its tables
|
||
```
|
||
|
||
### Running
|
||
|
||
```bash
|
||
composer serve # http://localhost:8080 (php -S localhost:8080 -t public)
|
||
```
|
||
|
||
Any web server can serve the API as long as the document root is `public/` and
|
||
unknown paths fall through to `public/index.php`. `public/.htaccess` also serves
|
||
a built frontend from `public/` (copy `web/dist/` there) and only falls back to
|
||
`index.php` for `/api` and when no `index.html` is present.
|
||
|
||
## Frontend
|
||
|
||
The Vue/TypeScript PWA lives in [web/](web/). The production build is compiled
|
||
into the Docker image and served from the `app` container at `/`. For frontend
|
||
development, run the Vite dev server on the host — with the API container running
|
||
(`docker compose up -d`):
|
||
|
||
```bash
|
||
cd web
|
||
npm install
|
||
npm run dev # http://localhost:5173, proxies /api to localhost:8080
|
||
```
|
||
|
||
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
|
||
|
||
All settings are optional environment variables (read from `.env` or the real
|
||
environment). See [.env.example](.env.example).
|
||
|
||
| Variable | Default | Purpose |
|
||
|----------|---------|---------|
|
||
| `APP_DEBUG` | `false` | Include exception details in error responses |
|
||
| `DATABASE_PATH` | `storage/database.sqlite` | SQLite file location |
|
||
| `JWT_SECRET` | auto-generated into `storage/secret.key` | Token signing key |
|
||
| `JWT_TTL` | `86400` | Token lifetime in seconds |
|
||
| `APP_URL` | `http://localhost:8080` | Base URL used to build magic links (`http://localhost:5173` for a host `npm run dev`) |
|
||
| `MAIL_TRANSPORT` | `mail` | `mail` (PHP `mail()`), `smtp`, or `log` (append to a file) |
|
||
| `MAIL_FROM` / `MAIL_FROM_NAME` | `no-reply@todo.test` / `Projects` | Envelope sender |
|
||
| `MAIL_LOG_PATH` | `storage/mail.log` | Where `log` transport writes |
|
||
| `MAIL_SMTP_HOST` / `_PORT` / `_USERNAME` / `_PASSWORD` / `_ENCRYPTION` | — / `587` / — / — / `tls` | Used only when `MAIL_TRANSPORT=smtp` |
|
||
|
||
Standalone, SMTP is opt-in and the API otherwise falls back to PHP's `mail()`.
|
||
Under Docker Compose the default is `MAIL_TRANSPORT=smtp` pointed at the bundled
|
||
Mailpit container (`mailpit:1025`, no auth/TLS); open <http://localhost:8025> to
|
||
read what was "sent".
|
||
|
||
## API
|
||
|
||
Base path: `/api`. All request and response bodies are JSON; send
|
||
`Content-Type: application/json`.
|
||
|
||
### `GET /api/health`
|
||
|
||
```json
|
||
{ "status": "ok" }
|
||
```
|
||
|
||
### Auth
|
||
|
||
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
|
||
{ "message": "Check your email for a link to sign in." }
|
||
```
|
||
|
||
#### `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": true,
|
||
"email_verified_at": "2026-09-03T12:00:00Z",
|
||
"pending_email": null,
|
||
"created_at": "2026-09-03T12:00:00Z"
|
||
},
|
||
"token": "<jwt>",
|
||
"expires_at": "2026-09-04T12:00:00+00:00"
|
||
}
|
||
```
|
||
|
||
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.
|
||
|
||
#### `GET /api/me`
|
||
|
||
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/email/change`
|
||
|
||
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
|
||
{
|
||
"message": "Confirmation email sent to the new address.",
|
||
"pending_email": "new@example.com",
|
||
"retry_after": 60
|
||
}
|
||
```
|
||
|
||
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
|
||
|
||
All routes below require `Authorization: Bearer <jwt>`. A project belongs to one
|
||
owner (the creator); another user's project — or a missing one — always responds
|
||
`404`.
|
||
|
||
| Method | Path | Purpose |
|
||
|--------|------|---------|
|
||
| `GET` | `/api/projects` | the caller's projects, sorted A→Z by title |
|
||
| `POST` | `/api/projects` | create a project |
|
||
| `GET` | `/api/projects/{id}` | one project |
|
||
| `PATCH` | `/api/projects/{id}` | update `title` and/or `description` |
|
||
| `DELETE` | `/api/projects/{id}` | delete the project and its cards (`204`) |
|
||
|
||
`GET /api/projects` is always ordered alphabetically (case-insensitive) by
|
||
title; there is no other sort option. A user may own at most **100 projects** —
|
||
creating one beyond that responds `409`.
|
||
|
||
Create/update body: `title` (required on create, 1–255 chars), `description`
|
||
(optional, ≤ 2000 chars, defaults to `""`). `PATCH` needs at least one field.
|
||
|
||
Project representation:
|
||
|
||
```json
|
||
{
|
||
"project": {
|
||
"id": 1,
|
||
"title": "Website relaunch",
|
||
"description": "Q3",
|
||
"owner_id": 1,
|
||
"card_count": 3,
|
||
"completed_count": 1,
|
||
"created_at": "2026-09-03T12:00:00Z",
|
||
"updated_at": "2026-09-03T12:00:00Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
`GET /api/projects` returns `{ "projects": [ … ] }`.
|
||
|
||
Creating a project also seeds it with three **statuses** — "To do", "Doing",
|
||
"Done" (see [Statuses](#statuses)).
|
||
|
||
### Cards
|
||
|
||
Scoped to a project; the parent project's ownership is checked first
|
||
(`404` otherwise).
|
||
|
||
| Method | Path | Purpose |
|
||
|--------|------|---------|
|
||
| `GET` | `/api/projects/{id}/cards` | every card, grouped by column (inbox first) then `position` |
|
||
| `POST` | `/api/projects/{id}/cards` | add a card |
|
||
| `PUT` | `/api/projects/{id}/cards/order` | set the order/contents of one status column |
|
||
| `GET` | `/api/projects/{id}/cards/{cardId}` | one card |
|
||
| `PATCH` | `/api/projects/{id}/cards/{cardId}` | update `text`, `complete`, and/or `status_id` |
|
||
| `DELETE` | `/api/projects/{id}/cards/{cardId}` | delete the card (`204`) |
|
||
|
||
Create body: `text` (required, 1–1000 chars), `complete` (optional bool,
|
||
default `false`). `PATCH` needs at least one field.
|
||
|
||
**Ordering.** `position` is a dense `0..n-1` rank *within a column* — the cards
|
||
that share a `(project_id, status_id)`. The inbox (`status_id IS NULL`) is its
|
||
own column. New cards go to the end of the inbox. There is no project-wide order.
|
||
|
||
A new card has **no** status (`status_id: null`) — it sits in the project
|
||
"inbox" until the user gives it one. Two ways to move it:
|
||
|
||
- `PATCH …/cards/{cardId}` with `status_id` (a status id in this project, or
|
||
`null` for the inbox) — appends the card to the end of the destination column
|
||
and re-packs the one it left. `422` for an unknown or foreign status.
|
||
- `PUT …/cards/order` with `{ "status_id": <id|null>, "card_ids": [3, 1, 2] }` —
|
||
makes those cards the exact contents of that column, in that order (positions
|
||
rewritten to `0..n-1`). Any card dragged in from another column is re-parented
|
||
and its old column re-packed, all in one transaction. `card_ids` must be
|
||
distinct cards of this project and must include every card already in the
|
||
target column (`422` otherwise). Returns `{ "cards": [ … ] }` for the whole
|
||
project. This is what the kanban board calls on every drop.
|
||
|
||
A status row that is deleted clears itself from its cards rather than deleting
|
||
them.
|
||
|
||
Card representation:
|
||
|
||
```json
|
||
{
|
||
"card": {
|
||
"id": 10,
|
||
"project_id": 1,
|
||
"text": "Design homepage",
|
||
"complete": false,
|
||
"position": 0,
|
||
"status_id": null,
|
||
"status": null,
|
||
"created_at": "2026-09-03T12:00:00Z",
|
||
"updated_at": "2026-09-03T12:00:00Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
`status` is the embedded `{ id, name }` of the linked status, or `null` when the
|
||
card has none. `GET …/cards` returns `{ "cards": [ … ] }`.
|
||
|
||
### Statuses
|
||
|
||
Every project has an ordered set of card statuses, created with the project:
|
||
"To do", "Doing", "Done". They are project-specific — each project owns its own
|
||
rows. There is no create/update/delete for the statuses themselves yet; a card
|
||
is moved between them (or to the inbox) via `PATCH …/cards/{cardId}`.
|
||
|
||
| Method | Path | Purpose |
|
||
|--------|------|---------|
|
||
| `GET` | `/api/projects/{id}/statuses` | the project's statuses, ordered by `position` |
|
||
|
||
```json
|
||
{
|
||
"statuses": [
|
||
{ "id": 1, "project_id": 1, "name": "To do", "position": 0 },
|
||
{ "id": 2, "project_id": 1, "name": "Doing", "position": 1 },
|
||
{ "id": 3, "project_id": 1, "name": "Done", "position": 2 }
|
||
]
|
||
}
|
||
```
|
||
|
||
Requires `Authorization: Bearer <jwt>`; a project that is missing or not owned by
|
||
the caller responds `404`.
|
||
|
||
### Error shape
|
||
|
||
Every error response looks like:
|
||
|
||
```json
|
||
{ "error": { "message": "The submitted data was invalid.", "details": { "email": ["Email must be a valid address."] } } }
|
||
```
|
||
|
||
`details` is present only when relevant (e.g. validation).
|
||
|
||
## 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/magic-link \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"email":"ada@example.com"}'
|
||
|
||
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 "{\"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"}' \
|
||
| tr -d ' \n' | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||
|
||
curl -s $BASE/api/projects/$PROJECT/statuses -H "Authorization: Bearer $TOKEN"
|
||
|
||
curl -s -X POST $BASE/api/projects/$PROJECT/cards \
|
||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||
-d '{"text":"Design homepage"}'
|
||
|
||
curl -s $BASE/api/projects/$PROJECT/cards -H "Authorization: Bearer $TOKEN"
|
||
```
|
||
|
||
## Tests
|
||
|
||
```bash
|
||
composer install # installs phpunit (require-dev)
|
||
vendor/bin/phpunit
|
||
```
|
||
|
||
## Layout
|
||
|
||
```
|
||
public/index.php Front controller
|
||
src/bootstrap.php App wiring and route definitions
|
||
src/Support/Config.php Environment-driven configuration
|
||
src/Support/Database.php PDO/SQLite connection
|
||
src/Auth/JwtService.php Issue/verify JWTs
|
||
src/Auth/AuthMiddleware.php Bearer-token authentication
|
||
src/Auth/SessionPayload.php Shared user + session JSON shape
|
||
src/Mail/ Mailer interface, SMTP/mail()/log transports, EmailVerifier
|
||
src/Http/JsonErrorHandler.php Uniform JSON error envelope
|
||
src/Http/Controllers/ Request handlers (Auth, EmailVerification, Project, Card, CardStatus)
|
||
src/Repository/ Database access (User, EmailVerification, Project, Card, CardStatus)
|
||
src/Support/Validator.php Request-body validation helper
|
||
migrations/*.sql Schema, applied by bin/migrate.php
|
||
Dockerfile Multi-stage: Node frontend build + PHP 8.3/Apache runtime
|
||
docker-compose.yml Local stack: app (SPA + API) + Mailpit
|
||
docker/ Apache vhost + container entrypoint
|
||
web/ Vue 3 + TypeScript + Vite PWA frontend (dev on the host)
|
||
```
|
||
|
||
## Provenance
|
||
|
||
This project was generated with [Claude Code](https://claude.com/claude-code).
|