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
|
# Exchange rates: frankfurter = free, keyless, central-bank rates
|
||||||
AR_FX_PROVIDER=frankfurter
|
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.
|
# Generated exports older than this are purged (uploads are NEVER auto-deleted). 0 = keep.
|
||||||
AR_RETENTION_DAYS=90
|
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_PROVIDER=exchangerate-api # paid fallback ($10/mo) — then set:
|
||||||
# AR_FX_API_KEY=
|
# 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_RETENTION_DAYS=90
|
||||||
# AR_MAX_UPLOAD_BYTES=2147483648 # 2 GB default
|
# 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import datetime as dt
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
|
|
@ -41,9 +42,10 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
# Paths reachable without a token: health probes, login itself, and the "is auth on?"
|
# Paths reachable without a token: health probes, login itself, the "is auth on?" check,
|
||||||
# check the frontend makes before deciding whether to show the login screen.
|
# and the forgot-password code flow (which by definition happens while locked out).
|
||||||
OPEN_PATHS = {"/api/health", "/api/auth/login", "/api/auth/status"}
|
OPEN_PATHS = {"/api/health", "/api/auth/login", "/api/auth/status",
|
||||||
|
"/api/auth/request-code", "/api/auth/reset-password"}
|
||||||
|
|
||||||
if SECRET_KEY:
|
if SECRET_KEY:
|
||||||
_SECRET = SECRET_KEY.encode()
|
_SECRET = SECRET_KEY.encode()
|
||||||
|
|
@ -207,8 +209,9 @@ class LoginIn(BaseModel):
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
def auth_status() -> dict:
|
def auth_status() -> dict:
|
||||||
"""Whether the frontend must show a login screen."""
|
"""Whether the frontend must show a login screen, and whether email codes work."""
|
||||||
return {"auth_required": auth_required()}
|
from ..config import email_enabled
|
||||||
|
return {"auth_required": auth_required(), "email_enabled": email_enabled()}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
|
|
@ -238,6 +241,110 @@ def me(request: Request) -> dict:
|
||||||
"username": user.username, "display_name": user.display_name}
|
"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):
|
class ChangePasswordIn(BaseModel):
|
||||||
current_password: str
|
current_password: str
|
||||||
new_password: str
|
new_password: str
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,22 @@ SECRET_KEY = os.environ.get("AR_SECRET_KEY", "")
|
||||||
# Token lifetime (hours).
|
# Token lifetime (hours).
|
||||||
AUTH_TOKEN_HOURS = int(os.environ.get("AR_AUTH_TOKEN_HOURS", "12"))
|
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
|
# --------------------------------------------------------------------------- FX provider
|
||||||
# frankfurter (default; free, keyless, central-bank rates) | exchangerate-api (paid, needs
|
# 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
|
# 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.
|
`TEXT DEFAULT ''` fails. Declare plain TEXT and let the ORM default apply on insert.
|
||||||
"""
|
"""
|
||||||
added = {
|
added = {
|
||||||
|
"users": [
|
||||||
|
("reset_code_hash", "VARCHAR(255) DEFAULT ''"),
|
||||||
|
("reset_code_expires", "DATETIME"),
|
||||||
|
("reset_code_attempts", "INTEGER DEFAULT 0"),
|
||||||
|
],
|
||||||
"sessions": [
|
"sessions": [
|
||||||
("progress_rows_done", "INTEGER DEFAULT 0"),
|
("progress_rows_done", "INTEGER DEFAULT 0"),
|
||||||
("progress_rows_total", "INTEGER DEFAULT 0"),
|
("progress_rows_total", "INTEGER DEFAULT 0"),
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ class User(Base):
|
||||||
password_hash = Column(String(512), nullable=False)
|
password_hash = Column(String(512), nullable=False)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
created_at = Column(DateTime, default=_now)
|
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):
|
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
|
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):
|
def test_inactive_user_cannot_login(clean_users):
|
||||||
_add_user("gone", "Gone Person", "some-pw-12345")
|
_add_user("gone", "Gone Person", "some-pw-12345")
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|
|
||||||
|
|
@ -489,7 +489,7 @@ const q = (o: Record<string, string | undefined>) =>
|
||||||
export const api = {
|
export const api = {
|
||||||
health: () => req<{ status: string; version: string }>("/health"),
|
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) =>
|
login: (username: string, password: string) =>
|
||||||
req<{ token: string; user: AuthUserT }>("/auth/login", {
|
req<{ token: string; user: AuthUserT }>("/auth/login", {
|
||||||
method: "POST", body: JSON.stringify({ username, password }),
|
method: "POST", body: JSON.stringify({ username, password }),
|
||||||
|
|
@ -499,6 +499,14 @@ export const api = {
|
||||||
req<{ changed: boolean }>("/auth/change-password", {
|
req<{ changed: boolean }>("/auth/change-password", {
|
||||||
method: "POST", body: JSON.stringify({ current_password, new_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"),
|
listSessions: () => req<SessionT[]>("/sessions"),
|
||||||
createSession: (body: Partial<SessionT> & { allow_duplicate?: boolean }) =>
|
createSession: (body: Partial<SessionT> & { allow_duplicate?: boolean }) =>
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,15 @@ import { api, AuthUserT, clearToken, getToken, setOnUnauthorized, setToken } fro
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
authRequired: boolean;
|
authRequired: boolean;
|
||||||
|
/** Server can send password codes by email (AR_SMTP_* configured). */
|
||||||
|
emailEnabled: boolean;
|
||||||
user: AuthUserT | null;
|
user: AuthUserT | null;
|
||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthCtx = createContext<AuthState>({
|
const AuthCtx = createContext<AuthState>({
|
||||||
loading: true, authRequired: false, user: null,
|
loading: true, authRequired: false, emailEnabled: false, user: null,
|
||||||
login: async () => undefined, logout: () => undefined,
|
login: async () => undefined, logout: () => undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -27,6 +29,7 @@ export const useAuth = () => useContext(AuthCtx);
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [authRequired, setAuthRequired] = useState(false);
|
const [authRequired, setAuthRequired] = useState(false);
|
||||||
|
const [emailEnabled, setEmailEnabled] = useState(false);
|
||||||
const [user, setUser] = useState<AuthUserT | null>(null);
|
const [user, setUser] = useState<AuthUserT | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -42,6 +45,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
try {
|
try {
|
||||||
const status = await api.authStatus();
|
const status = await api.authStatus();
|
||||||
setAuthRequired(status.auth_required);
|
setAuthRequired(status.auth_required);
|
||||||
|
setEmailEnabled(status.email_enabled ?? false);
|
||||||
if (status.auth_required && getToken()) {
|
if (status.auth_required && getToken()) {
|
||||||
try {
|
try {
|
||||||
const me = await api.me();
|
const me = await api.me();
|
||||||
|
|
@ -63,6 +67,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const value = useMemo<AuthState>(() => ({
|
const value = useMemo<AuthState>(() => ({
|
||||||
loading,
|
loading,
|
||||||
authRequired,
|
authRequired,
|
||||||
|
emailEnabled,
|
||||||
user,
|
user,
|
||||||
login: async (username: string, password: string) => {
|
login: async (username: string, password: string) => {
|
||||||
const res = await api.login(username, password);
|
const res = await api.login(username, password);
|
||||||
|
|
@ -73,7 +78,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
clearToken();
|
clearToken();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
},
|
},
|
||||||
}), [loading, authRequired, user]);
|
}), [loading, authRequired, emailEnabled, user]);
|
||||||
|
|
||||||
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import { BookOpenCheck, Eye, EyeOff, FileSpreadsheet, Globe, Landmark, Lock, LogIn, Scale, ShieldCheck, User } from "lucide-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 { Spinner } from "../components/ui";
|
||||||
import { useAuth } from "../auth";
|
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.
|
* login feels like the first screen of the dashboard, not a bolt-on.
|
||||||
*/
|
*/
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { login } = useAuth();
|
const { login, emailEnabled } = useAuth();
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [showPw, setShowPw] = useState(false);
|
const [showPw, setShowPw] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [forgot, setForgot] = useState(false);
|
||||||
|
|
||||||
const submit = async (e: FormEvent) => {
|
const submit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -111,6 +113,13 @@ export default function Login() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{forgot ? (
|
||||||
|
<ForgotPassword
|
||||||
|
initialUsername={username}
|
||||||
|
onDone={() => setForgot(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<h2 className="text-2xl font-semibold text-ink">Welcome back</h2>
|
<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>
|
<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
|
{busy ? <Spinner /> : <LogIn size={16} />} Sign in
|
||||||
</button>
|
</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>
|
</form>
|
||||||
|
|
||||||
<p className="mt-8 text-xs text-muted leading-relaxed">
|
<p className="mt-8 text-xs text-muted leading-relaxed">
|
||||||
No account or forgot your password? Ask the administrator — accounts are
|
{emailEnabled
|
||||||
created and reset on the server, never self-service.
|
? "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>
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
@ -182,3 +204,128 @@ export default function Login() {
|
||||||
</div>
|
</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() {
|
export default function Settings() {
|
||||||
const { data: health } = useQuery({ queryKey: ["health"], queryFn: api.health });
|
const { data: health } = useQuery({ queryKey: ["health"], queryFn: api.health });
|
||||||
const { user } = useAuth();
|
const { user, emailEnabled } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-3xl mx-auto space-y-6">
|
<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>
|
<p className="text-sm text-subink">Application defaults and security posture.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{user && <ChangePassword username={user.username} />}
|
{user && (emailEnabled
|
||||||
|
? <ChangePasswordByCode username={user.username} />
|
||||||
|
: <ChangePassword username={user.username} />)}
|
||||||
|
|
||||||
<Section title="Processing defaults">
|
<Section title="Processing defaults">
|
||||||
<dl className="divide-y divide-line">
|
<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 }) {
|
function ChangePassword({ username }: { username: string }) {
|
||||||
const [current, setCurrent] = useState("");
|
const [current, setCurrent] = useState("");
|
||||||
const [next, setNext] = useState("");
|
const [next, setNext] = useState("");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue