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
parent
1dc3a2d493
commit
2aed450f4c
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 }) =>
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
Loading…
Reference in New Issue