Release 0.7.0: email/OTC sign-in (Gitea OAuth retained as fallback)

Replaces the Gitea OAuth gesture as the primary human-auth path
(roadmap item #5, SPEC §6.2). Users sign in by entering their email,
receiving a six-digit code via the existing SMTP layer, and entering
the code on a two-step /login surface. The Gitea OAuth callback
remains functional during migration — the new UI links to it as a
fallback for users with active OAuth sessions or older invite paths
— and is scheduled for removal in a future release once OTC adoption
is universal. Existing users are linked by email on first OTC sign-
in (gitea_id preserved); new users are provisioned with NULL
gitea_id and rely on email as the identity key. The migration
introduces backend/migrations/012_otc.sql (otc_codes table + users
schema rebuild for nullable gitea_id and a partial unique index on
email), two new endpoints (POST /auth/otc/request, POST /auth/otc/verify),
bcrypt as a new backend dependency for code hashing, and 11 new
tests in test_otc_vertical.py covering the happy path, expired and
consumed and wrong codes, the per-email rate limit, the allowlist
gate, the OAuth-era link path, fresh provisioning, and prior-code
invalidation on re-request. No new secrets are required — the
existing SECRET_KEY signs sessions and bcrypt's per-row salt covers
the code hashes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-28 00:55:29 -07:00
parent 21743a08b1
commit f8e797ab09
18 changed files with 1483 additions and 18 deletions
+83
View File
@@ -352,6 +352,89 @@
}
.landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; }
/* --- v0.7.0: email + one-time-code sign-in (§6.2) --- */
.otc-login {
flex: 1;
display: flex; align-items: center; justify-content: center;
padding: 40px 24px;
}
.otc-login-inner {
max-width: 360px;
width: 100%;
display: flex; flex-direction: column;
gap: 14px;
}
.otc-login h1 {
font-size: 22px;
font-weight: 600;
margin: 0 0 4px;
}
.otc-login .otc-hint {
color: #555;
font-size: 14px;
line-height: 1.5;
margin: 0;
}
.otc-login input {
width: 100%;
padding: 10px 12px;
font-size: 15px;
border: 1px solid #ddd;
border-radius: 6px;
box-sizing: border-box;
}
.otc-login input:focus {
outline: none;
border-color: #1a1a1a;
}
.otc-login button[type="submit"] {
background: #1a1a1a; color: #fff;
border: none; border-radius: 6px;
padding: 10px 18px;
font-size: 14px; font-weight: 600;
cursor: pointer;
}
.otc-login button[type="submit"]:hover:not(:disabled) { background: #333; }
.otc-login button[type="submit"]:disabled { opacity: 0.5; cursor: not-allowed; }
.otc-login form {
display: flex; flex-direction: column;
gap: 10px;
}
.otc-actions {
display: flex; align-items: center; gap: 12px;
}
.otc-login .btn-link-quiet {
background: none; border: none;
color: #666; font-size: 13px;
cursor: pointer; padding: 0;
}
.otc-login .btn-link-quiet:hover { color: #1a1a1a; text-decoration: underline; }
.otc-shortcut-hint {
color: #888; font-size: 12px; margin: 4px 0 0;
}
.otc-shortcut-hint kbd {
background: #f0f0ee; border: 1px solid #ddd; border-radius: 3px;
padding: 1px 5px; font-size: 11px; font-family: inherit;
}
.otc-status {
color: #555; font-size: 13px;
background: #f7f6f0;
border-left: 3px solid #cfc8a8;
padding: 8px 12px;
margin: 4px 0 0;
}
.otc-fallback {
font-size: 12px; color: #777;
margin: 16px 0 0;
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
}
.otc-fallback a, .otc-fallback .otc-fallback-link {
color: #666; text-decoration: none;
}
.otc-fallback a:hover { color: #1a1a1a; text-decoration: underline; }
.otc-fallback-sep { color: #ccc; }
/* --- Beta-pending page (post-OAuth-rejection) --- */
.beta-pending {