Password updates via emailed 6-digit code

New flow (active once AR_SMTP_* is configured; hidden otherwise):
- POST /api/auth/request-code emails a code to the account address
  (usernames are emails). HMAC-stored, 10-min expiry, single-use,
  5-attempt lockout, 60s resend throttle, no user enumeration.
- POST /api/auth/reset-password sets the new password with the code —
  works signed-in (Settings) and from the login screen (Forgot
  password?), so users can self-recover without the admin.
- Mailer: stdlib smtplib (STARTTLS/SSL, certifi CA bundle); SMTP
  settings documented in .env templates.
- Settings switches to the code flow when email is on; the
  current-password form remains the fallback.

Note: CRAI_Report was checked as the reference for code-sending — it
has no email/OTP functionality, so this is a fresh implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
main
Talha Ahmed 2026-08-19 19:35:07 +05:00
parent 1dc3a2d493
commit 2aed450f4c
11 changed files with 523 additions and 13 deletions

View File

@ -27,6 +27,15 @@ AR_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:517
# Exchange rates: frankfurter = free, keyless, central-bank rates
AR_FX_PROVIDER=frankfurter
# Email (optional) — enables "email me a code" for password resets. Any SMTP account:
# Office365: smtp.office365.com : 587 Gmail: smtp.gmail.com : 587 (app password)
# Unset -> passwords change via current password / admin reset instead.
#AR_SMTP_HOST=
#AR_SMTP_PORT=587
#AR_SMTP_USER=
#AR_SMTP_PASSWORD=
#AR_SMTP_FROM=
# Generated exports older than this are purged (uploads are NEVER auto-deleted). 0 = keep.
AR_RETENTION_DAYS=90
@ -60,6 +69,13 @@ AR_RETENTION_DAYS=90
# AR_FX_PROVIDER=exchangerate-api # paid fallback ($10/mo) — then set:
# AR_FX_API_KEY=
# Email for password codes (see the LOCAL section for provider examples)
#AR_SMTP_HOST=
#AR_SMTP_PORT=587
#AR_SMTP_USER=
#AR_SMTP_PASSWORD=
#AR_SMTP_FROM=
#AR_RETENTION_DAYS=90
# AR_MAX_UPLOAD_BYTES=2147483648 # 2 GB default

View File

@ -19,6 +19,7 @@ Users are created with `python manage.py add-user` — there is no self-signup e
from __future__ import annotations
import base64
import datetime as dt
import hashlib
import hmac
import json
@ -41,9 +42,10 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"])
# Paths reachable without a token: health probes, login itself, and the "is auth on?"
# check the frontend makes before deciding whether to show the login screen.
OPEN_PATHS = {"/api/health", "/api/auth/login", "/api/auth/status"}
# Paths reachable without a token: health probes, login itself, the "is auth on?" check,
# and the forgot-password code flow (which by definition happens while locked out).
OPEN_PATHS = {"/api/health", "/api/auth/login", "/api/auth/status",
"/api/auth/request-code", "/api/auth/reset-password"}
if SECRET_KEY:
_SECRET = SECRET_KEY.encode()
@ -207,8 +209,9 @@ class LoginIn(BaseModel):
@router.get("/status")
def auth_status() -> dict:
"""Whether the frontend must show a login screen."""
return {"auth_required": auth_required()}
"""Whether the frontend must show a login screen, and whether email codes work."""
from ..config import email_enabled
return {"auth_required": auth_required(), "email_enabled": email_enabled()}
@router.post("/login")
@ -238,6 +241,110 @@ def me(request: Request) -> dict:
"username": user.username, "display_name": user.display_name}
# ------------------------------------------------------------- emailed password codes
# Usernames ARE email addresses, so the code goes to the account's own address. The code
# is stored as an HMAC (never plaintext), lives 10 minutes, works once, and the account
# locks the flow after 5 wrong attempts (request a fresh code to retry).
CODE_TTL_MINUTES = 10
CODE_MAX_ATTEMPTS = 5
_GENERIC_CODE_MSG = ("If that account exists, a code has been emailed to it. "
"It expires in 10 minutes.")
def _hash_code(code: str) -> str:
return hmac.new(_SECRET, f"pwcode:{code}".encode(), hashlib.sha256).hexdigest()
class RequestCodeIn(BaseModel):
username: str = "" # optional when signed in (defaults to the session's account)
@router.post("/request-code")
def request_password_code(body: RequestCodeIn, request: Request,
db: OrmSession = Depends(db_dep)) -> dict:
"""Email a 6-digit password code to the account's address.
The response never reveals whether the username exists same message either way, so
the login screen can't be used to enumerate accounts."""
from ..config import email_enabled
from ..services.mailer import MailerError, send_password_code
if not email_enabled():
raise HTTPException(503, "Email is not set up on this server — ask the "
"administrator to reset your password instead.")
me_user = current_user(request)
username = (me_user.username if me_user else body.username).strip().lower()
if not username:
raise HTTPException(400, "Enter your username (email address).")
user = db.query(models.User).filter(models.User.username == username).first()
if user is None or not user.is_active:
logger.info("password code requested for unknown/inactive account: %s", username)
return {"sent": True, "detail": _GENERIC_CODE_MSG}
# Light resend throttle: one code per minute (a resend invalidates the previous code).
now = dt.datetime.utcnow()
if user.reset_code_expires:
issued_at = user.reset_code_expires - dt.timedelta(minutes=CODE_TTL_MINUTES)
if now - issued_at < dt.timedelta(seconds=60):
raise HTTPException(429, "A code was just sent — check your inbox, or try "
"again in a minute.")
code = f"{secrets.randbelow(1_000_000):06d}"
user.reset_code_hash = _hash_code(code)
user.reset_code_expires = now + dt.timedelta(minutes=CODE_TTL_MINUTES)
user.reset_code_attempts = 0
db.commit()
try:
send_password_code(user.username, code, CODE_TTL_MINUTES)
except MailerError as e:
# Roll the code back — a code nobody received must not stay live.
user.reset_code_hash = ""
user.reset_code_expires = None
db.commit()
raise HTTPException(502, f"{e} Ask the administrator to reset your password.")
return {"sent": True, "detail": _GENERIC_CODE_MSG}
class ResetPasswordIn(BaseModel):
username: str = "" # optional when signed in
code: str
new_password: str
@router.post("/reset-password")
def reset_password_with_code(body: ResetPasswordIn, request: Request,
db: OrmSession = Depends(db_dep)) -> dict:
"""Set a new password using the emailed code (works signed-in and from the login
screen). One generic failure message never confirms which part was wrong."""
me_user = current_user(request)
username = (me_user.username if me_user else body.username).strip().lower()
generic = HTTPException(400, "That code is wrong, expired, or already used — "
"request a fresh one.")
if not username or not body.code.strip():
raise generic
if len(body.new_password) < 8:
raise HTTPException(400, "The new password must be at least 8 characters.")
user = db.query(models.User).filter(models.User.username == username).first()
now = dt.datetime.utcnow()
if (user is None or not user.is_active or not user.reset_code_hash
or not user.reset_code_expires or user.reset_code_expires < now
or user.reset_code_attempts >= CODE_MAX_ATTEMPTS):
raise generic
if not hmac.compare_digest(_hash_code(body.code.strip()), user.reset_code_hash):
user.reset_code_attempts += 1
db.commit()
raise generic
user.password_hash = hash_password(body.new_password)
user.reset_code_hash = "" # single use
user.reset_code_expires = None
user.reset_code_attempts = 0
db.commit()
logger.info("password reset via email code: %s", user.username)
return {"changed": True}
class ChangePasswordIn(BaseModel):
current_password: str
new_password: str

View File

@ -116,6 +116,22 @@ SECRET_KEY = os.environ.get("AR_SECRET_KEY", "")
# Token lifetime (hours).
AUTH_TOKEN_HOURS = int(os.environ.get("AR_AUTH_TOKEN_HOURS", "12"))
# --------------------------------------------------------------------------- email (SMTP)
# Used ONLY for password codes ("email me a code" on the login/Settings screens).
# Unset -> the email-code flow is hidden and passwords change via the current-password
# form (or manage.py set-password by the admin). Any standard SMTP account works
# (Office365: smtp.office365.com:587, Gmail: smtp.gmail.com:587 with an app password).
SMTP_HOST = os.environ.get("AR_SMTP_HOST", "")
SMTP_PORT = int(os.environ.get("AR_SMTP_PORT", "587"))
SMTP_USER = os.environ.get("AR_SMTP_USER", "")
SMTP_PASSWORD = os.environ.get("AR_SMTP_PASSWORD", "")
SMTP_FROM = os.environ.get("AR_SMTP_FROM", SMTP_USER)
SMTP_STARTTLS = os.environ.get("AR_SMTP_STARTTLS", "true").strip().lower() != "false"
def email_enabled() -> bool:
return bool(SMTP_HOST and SMTP_FROM)
# --------------------------------------------------------------------------- FX provider
# frankfurter (default; free, keyless, central-bank rates) | exchangerate-api (paid, needs
# FX_API_KEY). Rates fetched are suggestions: Control C5 still requires a human to confirm

View File

@ -123,6 +123,11 @@ def _migrate() -> None:
`TEXT DEFAULT ''` fails. Declare plain TEXT and let the ORM default apply on insert.
"""
added = {
"users": [
("reset_code_hash", "VARCHAR(255) DEFAULT ''"),
("reset_code_expires", "DATETIME"),
("reset_code_attempts", "INTEGER DEFAULT 0"),
],
"sessions": [
("progress_rows_done", "INTEGER DEFAULT 0"),
("progress_rows_total", "INTEGER DEFAULT 0"),

View File

@ -26,6 +26,11 @@ class User(Base):
password_hash = Column(String(512), nullable=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=_now)
# Emailed password code (usernames are email addresses). Stored as an HMAC, never the
# code itself; single-use, expires, and locks after too many wrong attempts.
reset_code_hash = Column(String(128), default="")
reset_code_expires = Column(DateTime)
reset_code_attempts = Column(Integer, default=0)
class Session(Base):

View File

@ -0,0 +1,66 @@
"""Outbound email — used ONLY for password codes. Stdlib smtplib, no new dependencies.
Configured via AR_SMTP_* (see config.py). When unconfigured, callers get MailerError and
the UI falls back to the current-password / admin-reset flows."""
from __future__ import annotations
import logging
import smtplib
import ssl
from email.message import EmailMessage
from ..config import (SMTP_FROM, SMTP_HOST, SMTP_PASSWORD, SMTP_PORT, SMTP_STARTTLS,
SMTP_USER, email_enabled)
logger = logging.getLogger(__name__)
class MailerError(RuntimeError):
"""Email could not be sent (unconfigured, auth failure, network...)."""
def _ssl_context() -> ssl.SSLContext:
"""certifi CA bundle when available — the Windows OS cert store is unreliable on some
machines (same workaround as the FX service)."""
try:
import certifi
return ssl.create_default_context(cafile=certifi.where())
except ImportError:
return ssl.create_default_context()
def send_email(to: str, subject: str, body: str) -> None:
if not email_enabled():
raise MailerError("Email is not configured on this server (AR_SMTP_* settings).")
msg = EmailMessage()
msg["From"] = SMTP_FROM
msg["To"] = to
msg["Subject"] = subject
msg.set_content(body)
try:
if SMTP_STARTTLS:
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=20) as s:
s.starttls(context=_ssl_context())
if SMTP_USER:
s.login(SMTP_USER, SMTP_PASSWORD)
s.send_message(msg)
else: # implicit TLS (port 465)
with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=20,
context=_ssl_context()) as s:
if SMTP_USER:
s.login(SMTP_USER, SMTP_PASSWORD)
s.send_message(msg)
logger.info("email sent to %s: %s", to, subject)
except (smtplib.SMTPException, OSError) as e:
raise MailerError(f"Could not send the email: {e}") from e
def send_password_code(to: str, code: str, minutes: int) -> None:
send_email(
to,
"Your password code — Amazon A/R Aging",
f"Your verification code is:\n\n {code}\n\n"
f"It expires in {minutes} minutes and works once.\n\n"
f"If you didn't request a password change, ignore this email — "
f"your password has not been changed.",
)

View File

@ -143,6 +143,66 @@ def test_change_password_flow(clean_users):
assert c.get("/api/auth/me", headers=hdr).status_code == 200
def test_email_code_reset_flow(clean_users, monkeypatch):
"""Emailed 6-digit code: request -> reset. Mailer mocked; email config forced on."""
from app.services import mailer
from app.api import auth as auth_module
sent: dict = {}
def fake_send(to, code, minutes):
sent["to"], sent["code"] = to, code
monkeypatch.setattr("app.config.email_enabled", lambda: True)
monkeypatch.setattr(mailer, "send_password_code", fake_send)
_add_user("coder@utopiabrands.com", "Code Person", "first-password-1")
with TestClient(app) as c:
# Unknown account: generic answer, no email, no enumeration.
r = c.post("/api/auth/request-code", json={"username": "ghost@utopiabrands.com"})
assert r.status_code == 200 and "code" not in sent
r = c.post("/api/auth/request-code", json={"username": "coder@utopiabrands.com"})
assert r.status_code == 200
assert sent["to"] == "coder@utopiabrands.com" and len(sent["code"]) == 6
# Immediate resend is throttled.
assert c.post("/api/auth/request-code",
json={"username": "coder@utopiabrands.com"}).status_code == 429
# Wrong code refused; attempts count up.
bad = "000000" if sent["code"] != "000000" else "111111"
assert c.post("/api/auth/reset-password", json={
"username": "coder@utopiabrands.com", "code": bad,
"new_password": "second-password-2"}).status_code == 400
# Right code sets the new password and is single-use.
r = c.post("/api/auth/reset-password", json={
"username": "coder@utopiabrands.com", "code": sent["code"],
"new_password": "second-password-2"})
assert r.status_code == 200 and r.json()["changed"] is True
assert c.post("/api/auth/reset-password", json={
"username": "coder@utopiabrands.com", "code": sent["code"],
"new_password": "third-password-3"}).status_code == 400
assert c.post("/api/auth/login", json={
"username": "coder@utopiabrands.com",
"password": "first-password-1"}).status_code == 401
assert c.post("/api/auth/login", json={
"username": "coder@utopiabrands.com",
"password": "second-password-2"}).status_code == 200
assert auth_module.CODE_MAX_ATTEMPTS >= 3 # sanity: lockout exists
def test_request_code_without_email_configured(clean_users):
_add_user("noemail@utopiabrands.com", "No Email", "some-password-1")
with TestClient(app) as c:
r = c.post("/api/auth/request-code", json={"username": "noemail@utopiabrands.com"})
assert r.status_code == 503
assert "administrator" in r.json()["detail"]
def test_inactive_user_cannot_login(clean_users):
_add_user("gone", "Gone Person", "some-pw-12345")
db = SessionLocal()

View File

@ -489,7 +489,7 @@ const q = (o: Record<string, string | undefined>) =>
export const api = {
health: () => req<{ status: string; version: string }>("/health"),
authStatus: () => req<{ auth_required: boolean }>("/auth/status"),
authStatus: () => req<{ auth_required: boolean; email_enabled: boolean }>("/auth/status"),
login: (username: string, password: string) =>
req<{ token: string; user: AuthUserT }>("/auth/login", {
method: "POST", body: JSON.stringify({ username, password }),
@ -499,6 +499,14 @@ export const api = {
req<{ changed: boolean }>("/auth/change-password", {
method: "POST", body: JSON.stringify({ current_password, new_password }),
}),
requestPasswordCode: (username = "") =>
req<{ sent: boolean; detail: string }>("/auth/request-code", {
method: "POST", body: JSON.stringify({ username }),
}),
resetPassword: (code: string, new_password: string, username = "") =>
req<{ changed: boolean }>("/auth/reset-password", {
method: "POST", body: JSON.stringify({ username, code, new_password }),
}),
listSessions: () => req<SessionT[]>("/sessions"),
createSession: (body: Partial<SessionT> & { allow_duplicate?: boolean }) =>

View File

@ -12,13 +12,15 @@ import { api, AuthUserT, clearToken, getToken, setOnUnauthorized, setToken } fro
interface AuthState {
loading: boolean;
authRequired: boolean;
/** Server can send password codes by email (AR_SMTP_* configured). */
emailEnabled: boolean;
user: AuthUserT | null;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthCtx = createContext<AuthState>({
loading: true, authRequired: false, user: null,
loading: true, authRequired: false, emailEnabled: false, user: null,
login: async () => undefined, logout: () => undefined,
});
@ -27,6 +29,7 @@ export const useAuth = () => useContext(AuthCtx);
export function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true);
const [authRequired, setAuthRequired] = useState(false);
const [emailEnabled, setEmailEnabled] = useState(false);
const [user, setUser] = useState<AuthUserT | null>(null);
useEffect(() => {
@ -42,6 +45,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
const status = await api.authStatus();
setAuthRequired(status.auth_required);
setEmailEnabled(status.email_enabled ?? false);
if (status.auth_required && getToken()) {
try {
const me = await api.me();
@ -63,6 +67,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const value = useMemo<AuthState>(() => ({
loading,
authRequired,
emailEnabled,
user,
login: async (username: string, password: string) => {
const res = await api.login(username, password);
@ -73,7 +78,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
clearToken();
setUser(null);
},
}), [loading, authRequired, user]);
}), [loading, authRequired, emailEnabled, user]);
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
}

View File

@ -1,5 +1,6 @@
import { FormEvent, useState } from "react";
import { BookOpenCheck, Eye, EyeOff, FileSpreadsheet, Globe, Landmark, Lock, LogIn, Scale, ShieldCheck, User } from "lucide-react";
import { api } from "../api/client";
import { Spinner } from "../components/ui";
import { useAuth } from "../auth";
@ -9,12 +10,13 @@ import { useAuth } from "../auth";
* login feels like the first screen of the dashboard, not a bolt-on.
*/
export default function Login() {
const { login } = useAuth();
const { login, emailEnabled } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPw, setShowPw] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [forgot, setForgot] = useState(false);
const submit = async (e: FormEvent) => {
e.preventDefault();
@ -111,6 +113,13 @@ export default function Login() {
</div>
</div>
{forgot ? (
<ForgotPassword
initialUsername={username}
onDone={() => setForgot(false)}
/>
) : (
<>
<h2 className="text-2xl font-semibold text-ink">Welcome back</h2>
<p className="mt-1 text-sm text-subink">Sign in to continue to this month's closing.</p>
@ -168,12 +177,25 @@ export default function Login() {
>
{busy ? <Spinner /> : <LogIn size={16} />} Sign in
</button>
{emailEnabled && (
<div className="text-right">
<button type="button"
className="text-xs font-medium text-primary hover:underline"
onClick={() => { setError(null); setForgot(true); }}>
Forgot password?
</button>
</div>
)}
</form>
<p className="mt-8 text-xs text-muted leading-relaxed">
No account or forgot your password? Ask the administrator accounts are
created and reset on the server, never self-service.
{emailEnabled
? "No account? Ask the administrator — accounts are created on the server."
: "No account or forgot your password? Ask the administrator — accounts are created and reset on the server."}
</p>
</>
)}
</div>
</main>
@ -182,3 +204,128 @@ export default function Login() {
</div>
);
}
/** Forgot-password: email a 6-digit code, then set a new password with it. */
function ForgotPassword({ initialUsername, onDone }: {
initialUsername: string; onDone: () => void;
}) {
const [email, setEmail] = useState(initialUsername);
const [sent, setSent] = useState(false);
const [code, setCode] = useState("");
const [next, setNext] = useState("");
const [repeat, setRepeat] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
const sendCode = async () => {
setError(null); setBusy(true);
try {
const r = await api.requestPasswordCode(email.trim());
setMsg(r.detail);
setSent(true);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
};
const reset = async (e: FormEvent) => {
e.preventDefault();
setError(null); setBusy(true);
try {
await api.resetPassword(code.trim(), next, email.trim());
setDone(true);
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
};
if (done)
return (
<div>
<h2 className="text-2xl font-semibold text-ink">Password updated</h2>
<p className="mt-2 text-sm text-subink">Sign in with your new password.</p>
<button className="btn-primary w-full justify-center py-2.5 mt-6" onClick={onDone}>
<LogIn size={16} /> Back to sign in
</button>
</div>
);
return (
<div>
<h2 className="text-2xl font-semibold text-ink">Reset password</h2>
<p className="mt-1 text-sm text-subink">
We'll email a 6-digit code to your account's address.
</p>
<form onSubmit={reset} className="mt-8 space-y-4">
<div>
<label className="label" htmlFor="fp-user">Username (email)</label>
<div className="flex gap-2">
<div className="relative flex-1">
<User size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-faint pointer-events-none" />
<input id="fp-user" className="input pl-10 py-2.5" autoComplete="username"
autoFocus={!sent} placeholder="you@utopiabrands.com"
value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<button type="button" className="btn-ghost whitespace-nowrap"
disabled={busy || !email.trim()} onClick={sendCode}>
{busy && !sent ? <Spinner /> : null} {sent ? "Resend code" : "Email me a code"}
</button>
</div>
{msg && <p className="mt-1.5 text-xs text-ok">{msg}</p>}
</div>
{sent && (
<>
<div>
<label className="label" htmlFor="fp-code">6-digit code from the email</label>
<input id="fp-code" className="input py-2.5 num tracking-[0.35em] text-center"
inputMode="numeric" maxLength={6} autoFocus placeholder="••••••"
value={code} onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))} />
</div>
<div>
<label className="label" htmlFor="fp-new">New password (min 8)</label>
<input id="fp-new" className="input py-2.5" type="password"
autoComplete="new-password"
value={next} onChange={(e) => setNext(e.target.value)} />
</div>
<div>
<label className="label" htmlFor="fp-rep">Repeat new password</label>
<input id="fp-rep" className="input py-2.5" type="password"
autoComplete="new-password"
value={repeat} onChange={(e) => setRepeat(e.target.value)} />
{repeat.length > 0 && next !== repeat &&
<p className="mt-1.5 text-xs text-bad">Passwords don't match.</p>}
</div>
</>
)}
{error && (
<div className="rounded-xl border border-bad/30 bg-badbg/60 px-3.5 py-2.5 text-sm text-bad">
{error}
</div>
)}
{sent && (
<button className="btn-primary w-full justify-center py-2.5" type="submit"
disabled={busy || code.length !== 6 || next.length < 8 || next !== repeat}>
{busy ? <Spinner /> : null} Set new password
</button>
)}
<div className="text-center">
<button type="button" className="text-xs font-medium text-subink hover:text-ink"
onClick={onDone}>
Back to sign in
</button>
</div>
</form>
</div>
);
}

View File

@ -7,7 +7,7 @@ import { useAuth } from "../auth";
export default function Settings() {
const { data: health } = useQuery({ queryKey: ["health"], queryFn: api.health });
const { user } = useAuth();
const { user, emailEnabled } = useAuth();
return (
<div className="p-6 max-w-3xl mx-auto space-y-6">
@ -16,7 +16,9 @@ export default function Settings() {
<p className="text-sm text-subink">Application defaults and security posture.</p>
</header>
{user && <ChangePassword username={user.username} />}
{user && (emailEnabled
? <ChangePasswordByCode username={user.username} />
: <ChangePassword username={user.username} />)}
<Section title="Processing defaults">
<dl className="divide-y divide-line">
@ -61,6 +63,79 @@ export default function Settings() {
);
}
/** Code-based update: a 6-digit code is emailed to the signed-in account, then the new
* password is set with it shown when the server has email configured. */
function ChangePasswordByCode({ username }: { username: string }) {
const [sent, setSent] = useState(false);
const [code, setCode] = useState("");
const [next, setNext] = useState("");
const [repeat, setRepeat] = useState("");
const send = useMutation({
mutationFn: () => api.requestPasswordCode(),
onSuccess: () => setSent(true),
});
const reset = useMutation({
mutationFn: () => api.resetPassword(code.trim(), next),
onSuccess: () => { setCode(""); setNext(""); setRepeat(""); setSent(false); },
});
const mismatch = repeat.length > 0 && next !== repeat;
const ready = code.length === 6 && next.length >= 8 && next === repeat;
const submit = (e: FormEvent) => {
e.preventDefault();
if (ready) reset.mutate();
};
return (
<Section title="Change my password"
subtitle={`A 6-digit code is emailed to ${username} — enter it with your new password.`}>
<form onSubmit={submit} className="p-4 space-y-3">
<div className="flex items-center gap-3 flex-wrap">
<button type="button" className="btn-ghost" disabled={send.isPending}
onClick={() => send.mutate()}>
{send.isPending ? <Spinner /> : <KeyRound size={15} />}
{sent ? "Resend code" : "Email me a code"}
</button>
{send.isSuccess && <span className="text-xs text-ok">Code sent check your inbox (valid 10 minutes).</span>}
{send.isError && <span className="text-xs text-bad">{(send.error as Error).message}</span>}
</div>
{sent && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 items-end">
<label className="text-sm">
<span className="block text-xs font-medium text-subink mb-1">6-digit code</span>
<input className="input num tracking-[0.3em] text-center" inputMode="numeric"
maxLength={6} autoFocus placeholder="••••••" value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))} />
</label>
<label className="text-sm">
<span className="block text-xs font-medium text-subink mb-1">New password (min 8)</span>
<input className="input" type="password" autoComplete="new-password"
value={next} onChange={(e) => setNext(e.target.value)} />
</label>
<label className="text-sm">
<span className="block text-xs font-medium text-subink mb-1">Repeat new password</span>
<input className="input" type="password" autoComplete="new-password"
value={repeat} onChange={(e) => setRepeat(e.target.value)} />
</label>
<div className="sm:col-span-3 flex items-center gap-3 flex-wrap">
<button className="btn-primary" type="submit" disabled={!ready || reset.isPending}>
{reset.isPending ? <Spinner /> : <KeyRound size={15} />} Update password
</button>
{mismatch && <span className="text-xs text-bad">Passwords don't match.</span>}
{reset.isError && <span className="text-xs text-bad">{(reset.error as Error).message}</span>}
</div>
</div>
)}
{reset.isSuccess && !sent &&
<p className="text-xs text-ok">Password updated use it from your next sign-in.</p>}
</form>
</Section>
);
}
function ChangePassword({ username }: { username: string }) {
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");