diff --git a/ar-aging-app/.env.example b/ar-aging-app/.env.example index 1e727b6..cd93d13 100644 --- a/ar-aging-app/.env.example +++ b/ar-aging-app/.env.example @@ -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 diff --git a/ar-aging-app/backend/app/api/auth.py b/ar-aging-app/backend/app/api/auth.py index e6ccce9..9362b27 100644 --- a/ar-aging-app/backend/app/api/auth.py +++ b/ar-aging-app/backend/app/api/auth.py @@ -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 diff --git a/ar-aging-app/backend/app/config.py b/ar-aging-app/backend/app/config.py index 55c0e5c..3a84882 100644 --- a/ar-aging-app/backend/app/config.py +++ b/ar-aging-app/backend/app/config.py @@ -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 diff --git a/ar-aging-app/backend/app/db/database.py b/ar-aging-app/backend/app/db/database.py index 280415d..d76e611 100644 --- a/ar-aging-app/backend/app/db/database.py +++ b/ar-aging-app/backend/app/db/database.py @@ -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"), diff --git a/ar-aging-app/backend/app/db/models.py b/ar-aging-app/backend/app/db/models.py index 352f08f..f9fbbd1 100644 --- a/ar-aging-app/backend/app/db/models.py +++ b/ar-aging-app/backend/app/db/models.py @@ -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): diff --git a/ar-aging-app/backend/app/services/mailer.py b/ar-aging-app/backend/app/services/mailer.py new file mode 100644 index 0000000..2c88649 --- /dev/null +++ b/ar-aging-app/backend/app/services/mailer.py @@ -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.", + ) diff --git a/ar-aging-app/backend/tests/test_auth.py b/ar-aging-app/backend/tests/test_auth.py index 283d576..ed1d76c 100644 --- a/ar-aging-app/backend/tests/test_auth.py +++ b/ar-aging-app/backend/tests/test_auth.py @@ -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() diff --git a/ar-aging-app/frontend/src/api/client.ts b/ar-aging-app/frontend/src/api/client.ts index fdd126f..296e22a 100644 --- a/ar-aging-app/frontend/src/api/client.ts +++ b/ar-aging-app/frontend/src/api/client.ts @@ -489,7 +489,7 @@ const q = (o: Record) => 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("/sessions"), createSession: (body: Partial & { allow_duplicate?: boolean }) => diff --git a/ar-aging-app/frontend/src/auth.tsx b/ar-aging-app/frontend/src/auth.tsx index bf41ff3..c19db22 100644 --- a/ar-aging-app/frontend/src/auth.tsx +++ b/ar-aging-app/frontend/src/auth.tsx @@ -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; logout: () => void; } const AuthCtx = createContext({ - 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(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(() => ({ 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 {children}; } diff --git a/ar-aging-app/frontend/src/pages/Login.tsx b/ar-aging-app/frontend/src/pages/Login.tsx index 3274c3b..84e5550 100644 --- a/ar-aging-app/frontend/src/pages/Login.tsx +++ b/ar-aging-app/frontend/src/pages/Login.tsx @@ -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(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() { + {forgot ? ( + setForgot(false)} + /> + ) : ( + <>

Welcome back

Sign in to continue to this month's closing.

@@ -168,12 +177,25 @@ export default function Login() { > {busy ? : } Sign in + + {emailEnabled && ( +
+ +
+ )}

- 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."}

+ + )} @@ -182,3 +204,128 @@ export default function Login() { ); } + +/** 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(null); + const [error, setError] = useState(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 ( +
+

Password updated

+

Sign in with your new password.

+ +
+ ); + + return ( +
+

Reset password

+

+ We'll email a 6-digit code to your account's address. +

+ +
+
+ +
+
+ + setEmail(e.target.value)} /> +
+ +
+ {msg &&

{msg}

} +
+ + {sent && ( + <> +
+ + setCode(e.target.value.replace(/\D/g, ""))} /> +
+
+ + setNext(e.target.value)} /> +
+
+ + setRepeat(e.target.value)} /> + {repeat.length > 0 && next !== repeat && +

Passwords don't match.

} +
+ + )} + + {error && ( +
+ {error} +
+ )} + + {sent && ( + + )} + +
+ +
+
+
+ ); +} diff --git a/ar-aging-app/frontend/src/pages/Settings.tsx b/ar-aging-app/frontend/src/pages/Settings.tsx index e7defca..ed65a54 100644 --- a/ar-aging-app/frontend/src/pages/Settings.tsx +++ b/ar-aging-app/frontend/src/pages/Settings.tsx @@ -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 (
@@ -16,7 +16,9 @@ export default function Settings() {

Application defaults and security posture.

- {user && } + {user && (emailEnabled + ? + : )}
@@ -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 ( +
+
+
+ + {send.isSuccess && Code sent — check your inbox (valid 10 minutes).} + {send.isError && {(send.error as Error).message}} +
+ + {sent && ( +
+ + + +
+ + {mismatch && Passwords don't match.} + {reset.isError && {(reset.error as Error).message}} +
+
+ )} + {reset.isSuccess && !sent && +

Password updated — use it from your next sign-in.

} +
+
+ ); +} + function ChangePassword({ username }: { username: string }) { const [current, setCurrent] = useState(""); const [next, setNext] = useState("");