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.
|
||||
- **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.
|
||||
- **Password self-service** — users change or recover passwords with a 6-digit code emailed
|
||||
to their account address (Settings, or "Forgot password?" on the login screen). Sent via
|
||||
the company Mail API (`AR_MAIL_API_*`), SMTP fallback; `manage.py set-password` remains
|
||||
the admin override.
|
||||
- **Password self-service** — Settings changes the password with the current one; a
|
||||
forgotten password is recovered from the login screen ("Forgot password?") via a 6-digit
|
||||
code emailed to the account address (company Mail API `AR_MAIL_API_*`, SMTP fallback);
|
||||
`manage.py set-password` remains the admin override.
|
||||
- **Exchange rates** — "Fetch month-end rates" pulls central-bank rates (Frankfurter, free,
|
||||
keyless; `AR_FX_PROVIDER`); fetched rates still require human confirmation (Control C5).
|
||||
- **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):
|
||||
"""Guards every /api/* route except OPEN_PATHS. Registered in api/main.py."""
|
||||
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)
|
||||
if user is not None:
|
||||
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). "
|
||||
"Sign in to continue."}, status_code=401)
|
||||
else:
|
||||
request.state.user = None
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { useAuth } from "../auth";
|
|||
|
||||
export default function Settings() {
|
||||
const { data: health } = useQuery({ queryKey: ["health"], queryFn: api.health });
|
||||
const { user, emailEnabled } = useAuth();
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<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>
|
||||
</header>
|
||||
|
||||
{user && (emailEnabled
|
||||
? <ChangePasswordByCode username={user.username} />
|
||||
: <ChangePassword username={user.username} />)}
|
||||
{user && <ChangePassword username={user.username} />}
|
||||
|
||||
<Section title="Processing defaults">
|
||||
<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 }) {
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
|
|
@ -156,7 +81,7 @@ function ChangePassword({ username }: { username: string }) {
|
|||
|
||||
return (
|
||||
<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">
|
||||
<label className="text-sm">
|
||||
<span className="block text-xs font-medium text-subink mb-1">Current password</span>
|
||||
|
|
|
|||
Loading…
Reference in New Issue