c2c988d92883e8753a78c83e32e285a82ccc7089
5
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c2c988d928 |
Remove the redundant back-to-dashboard link on the profile view
The app bar's brand/logo already goes to the dashboard, same as the other app pages. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
a3462af005 |
Fix the broken input focus outline, unify input styling, tokenize scales
The root cause of 'some inputs show a focus outline, most don't': the
global input:focus/textarea:focus/select:focus rule (outline:none,
border-color: accent) has specificity (0,0,1,1). Every per-component
rule shaped '.wrapper input { border: 1px solid var(--border) }' (.form
input, .field-row input, .kanban__new input, .sidebar__select select,
.modal__field select) ties it exactly, and -- being unconditional --
silently won that tie by simply appearing later in the file, so the
input's border stayed var(--border) forever regardless of focus. Only
two controls escaped: .project-card__name-input (a class applied
directly to the element, (0,0,1,0), too low to ever win the tie) and
.card-row__text (has its own .card-row__text:focus, (0,0,2,0),
genuinely higher). Confirmed with a live computed-style test against
the real stylesheet before and after.
Fix: one canonical .field class (plus .field--compact for inline 'add'
rows and the sidebar, .field--autosize for the dashboard's JS-grown
textarea), applied directly to the <input>/<textarea>/<select> itself
in every view -- structurally immune to the same tie, since a bare
class can never out-specificity input:focus. This also collapses five
near-duplicate rules (each with its own padding/radius/font-size) into
one definition, and fixes .kanban__new input's stray
background: var(--surface) (every other field uses var(--bg); this one
nearly matched its own var(--surface) sidebar panel).
Also: .btn-danger hardcoded #b3261e/#fff instead of the already-
existing var(--error)/var(--accent-text) tokens, so danger buttons
didn't adapt in dark mode like every other error-coloured element;
now they do. Added .field:disabled styling (opacity/cursor), matching
buttons -- latent until now since no input bound :disabled yet.
Second pass, promoting repeated-but-consistent raw values to tokens:
- border-radius: 6/7/8/10/12/999px -- the stray 7px (.sidebar__link)
folded into the 6px tier -- become --radius-sm/md/lg/xl/pill.
- disabled/ghost opacity: 0.6 was used for every disabled button
except .status-row__delete's 0.4 (now unified) and drag-ghost states'
0.5 (semantically different, kept separate) -- --opacity-disabled/
--opacity-ghost.
Font-size and spacing values also repeat but don't reduce to a clean
scale without arbitrary judgement calls either way -- left alone.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
||
|
|
afdd53ec4c |
Add passkeys (WebAuthn): register from the profile, log in without email
New library dependency: lbuchs/webauthn (^2.2, MIT, zero transitive deps
beyond PHP+OpenSSL+Mbstring, both already required). 'none' attestation --
this only confirms "the same device that registered", not hardware
provenance, the standard trust model for a public site's own passkey login.
Backend
- migrations/010: `passkeys` (one row per registered credential: owner,
credential_id, public_key, sign_count, label) and `webauthn_challenges`
(short-lived, single-use, bridging each ceremony's "options" and "verify"
calls -- user_id set for a registration, null for a login since who's
signing in isn't known until the credential comes back).
- Config: WEBAUTHN_RP_ID (defaults to APP_URL's host) and WEBAUTHN_RP_NAME.
- PasskeyRepository, WebAuthnChallengeRepository, PasskeyController:
GET/POST /api/passkeys, POST /api/passkeys/options, DELETE
/api/passkeys/{id} (all auth), plus the public POST /api/auth/passkey/
options and /verify for login. Registration always asks for a
discoverable, user-verified credential -- what makes login usernameless:
the browser offers whatever passkeys it has for the site, no email first.
- SessionPayload now also exposes `has_passkey` on every user object
(PasskeyRepository::countForUser() > 0), reused by both the profile page
and the dismissible notice.
- PasskeyTest: auth guards, options response shape, challenge single-use/
expiry/purpose/cross-user rules, malformed-input handling, list/remove
CRUD (seeded rows) -- everything short of a real signature, which isn't
practical from PHPUnit. 73 tests pass.
Frontend
- lib/webauthn.ts: base64url <-> ArrayBuffer conversion and the two
ceremonies (registerPasskey, loginWithPasskey), matching the API's wire
format exactly.
- ProfileView: a Passkeys section -- list with Remove buttons, an "Add a
passkey" form (label pre-filled from a UA guess).
- LoginView: a "Log in with a passkey" button above the email form, shown
only when the browser supports WebAuthn.
- PasskeyNotice.vue: dismissible banner across the top of the page
(`user.has_passkey === false`); dismissal is a week-long localStorage
timestamp.
Verified against the rebuilt container using a Chrome DevTools Protocol
*virtual authenticator* (real ECDSA signing, no human interaction) end to
end: notice shown -> register a passkey -> notice gone (same page and after
navigating) -> log out -> "Log in with a passkey" with no email typed ->
correct account, notice still gone -> remove the passkey -> notice back ->
dismiss -> stays hidden for ~7 days across pages. Along the way, caught and
fixed a real bug: AuthenticatorData::getCredentialId() returns a raw binary
string, not a ByteBuffer like most of this library's other binary fields --
bin2hex() it directly rather than calling ->getHex().
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
||
|
|
c82bdbbf0e |
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>
|
||
|
|
f9b65cc4a7 |
Add stage 7: email verification magic links and a profile page
Backend - New Mail namespace: a Mailer interface with SMTP (phpmailer), PHP mail() (the default fallback), and log-to-file transports, selected by MAIL_TRANSPORT. EmailVerifier issues a hashed, 15-minute magic-link token and sends the link (APP_URL/verify-email?token=...). - Migration 005: email_verifications table + users.verification_email_sent_at. - Registration now emails a verification link (best effort — a send failure doesn't fail registration). - POST /api/auth/verify-email consumes a token and returns a session, so opening the link verifies the address (or applies a pending email change) and logs the user in. Single-use; distinct 400s for invalid/used/expired. - POST /api/email/verification resends; POST /api/email/change requests a deferred change (current password required; link goes to the new address; users.email only updates when that link is opened). Both throttled to once per 60s, returning 429 + retry_after. - GET /api/me and every session payload now include pending_email. Shared SessionPayload builds the user/session JSON for all entry points. Frontend - /verify-email view: posts the token, adopts the returned session, redirects. - /profile view: shows address + status, a resend button with a live cooldown (driven by retry_after / 429), and a change-email form (new address + current password) that surfaces the pending change. - Header shows a "verify email" badge linking to the profile. Tests: 9 new (EmailVerificationTest) covering the link lifecycle, throttle, and deferred change; AuthTest folded into ApiTestCase, which now routes mail to a per-test log. Suite: 32 passing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |