Settings changes password with the CURRENT password; email code is the
login-screen forgot-password flow only Also fix: the auth middleware now attaches the signed-in identity on open paths too (a signed-in request-code call previously saw no user and demanded a username). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>main
parent
a6ae242709
commit
b77c2cc9f7
|
|
@ -48,10 +48,10 @@ Key production behaviors:
|
||||||
under another name is skipped. A month can never count a file twice.
|
under another name is skipped. A month can never count a file twice.
|
||||||
- **Login** (`AR_AUTH`) — per-user accounts via `manage.py add-user`; journal review/approval
|
- **Login** (`AR_AUTH`) — per-user accounts via `manage.py add-user`; journal review/approval
|
||||||
and FX confirmations record the signed-in user's verified name.
|
and FX confirmations record the signed-in user's verified name.
|
||||||
- **Password self-service** — users change or recover passwords with a 6-digit code emailed
|
- **Password self-service** — Settings changes the password with the current one; a
|
||||||
to their account address (Settings, or "Forgot password?" on the login screen). Sent via
|
forgotten password is recovered from the login screen ("Forgot password?") via a 6-digit
|
||||||
the company Mail API (`AR_MAIL_API_*`), SMTP fallback; `manage.py set-password` remains
|
code emailed to the account address (company Mail API `AR_MAIL_API_*`, SMTP fallback);
|
||||||
the admin override.
|
`manage.py set-password` remains the admin override.
|
||||||
- **Exchange rates** — "Fetch month-end rates" pulls central-bank rates (Frankfurter, free,
|
- **Exchange rates** — "Fetch month-end rates" pulls central-bank rates (Frankfurter, free,
|
||||||
keyless; `AR_FX_PROVIDER`); fetched rates still require human confirmation (Control C5).
|
keyless; `AR_FX_PROVIDER`); fetched rates still require human confirmation (Control C5).
|
||||||
- **Completed closings are locked** read-only; corrections need an explicit Reopen.
|
- **Completed closings are locked** read-only; corrections need an explicit Reopen.
|
||||||
|
|
|
||||||
|
|
@ -173,15 +173,14 @@ def _user_from_request(request: Request) -> AuthUser | None:
|
||||||
async def auth_middleware(request: Request, call_next):
|
async def auth_middleware(request: Request, call_next):
|
||||||
"""Guards every /api/* route except OPEN_PATHS. Registered in api/main.py."""
|
"""Guards every /api/* route except OPEN_PATHS. Registered in api/main.py."""
|
||||||
path = request.url.path.rstrip("/") or "/"
|
path = request.url.path.rstrip("/") or "/"
|
||||||
if path.startswith("/api") and path not in OPEN_PATHS:
|
if path.startswith("/api"):
|
||||||
|
# Identity is attached whenever a valid token is present — including on open
|
||||||
|
# paths, so e.g. a signed-in password-code request knows who is asking.
|
||||||
user = _user_from_request(request)
|
user = _user_from_request(request)
|
||||||
if user is not None:
|
|
||||||
request.state.user = user
|
request.state.user = user
|
||||||
elif auth_required():
|
if user is None and path not in OPEN_PATHS and auth_required():
|
||||||
return JSONResponse({"detail": "Not signed in (or the session expired). "
|
return JSONResponse({"detail": "Not signed in (or the session expired). "
|
||||||
"Sign in to continue."}, status_code=401)
|
"Sign in to continue."}, status_code=401)
|
||||||
else:
|
|
||||||
request.state.user = None
|
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, emailEnabled } = useAuth();
|
const { user } = 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,9 +16,7 @@ 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 && (emailEnabled
|
{user && <ChangePassword username={user.username} />}
|
||||||
? <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">
|
||||||
|
|
@ -63,79 +61,6 @@ 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("");
|
||||||
|
|
@ -156,7 +81,7 @@ function ChangePassword({ username }: { username: string }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title="Change my password"
|
<Section title="Change my password"
|
||||||
subtitle={`Signed in as ${username}. Forgot the current one? An administrator can reset it on the server (manage.py set-password).`}>
|
subtitle={`Signed in as ${username}. Forgot the current one? Sign out and use "Forgot password?" on the login screen — a code is emailed to you.`}>
|
||||||
<form onSubmit={submit} className="p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 items-end">
|
<form onSubmit={submit} className="p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 items-end">
|
||||||
<label className="text-sm">
|
<label className="text-sm">
|
||||||
<span className="block text-xs font-medium text-subink mb-1">Current password</span>
|
<span className="block text-xs font-medium text-subink mb-1">Current password</span>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue