diff --git a/docs/waves/backlog.md b/docs/waves/backlog.md index 4071b05..7b65ec9 100644 --- a/docs/waves/backlog.md +++ b/docs/waves/backlog.md @@ -554,3 +554,21 @@ deliberately deferred. entry rather than a drive-by. - **Suggested wave or follow-up:** next housekeeping pass, with the check widened so it cannot recur. + +### BL-026 — `notify.send_now` is orphaned once D13 removes password reset + +- **Found during:** `T10.3` (D13), stripping the password code paths +- **Where:** `server/notify.py`, `send_now()` +- **What:** `send_now` sends one message immediately, outside the outbox queue. Its + only caller was `forgot_password`, because a reset link must not sit in a queue. + `T10.3` deleted that endpoint, so the function now has no callers anywhere in + `server/` or `tests/` — verified by grep, not assumed. +- **Why not now:** deleting it is a drive-by CLAUDE.md forbids, and it is not + obviously dead weight: an immediate, unqueued send is the right primitive for any + future time-sensitive mail, and the queue is the wrong shape for that. Deciding + between "delete it" and "keep it as the documented immediate-send path" is a + judgement call that deserves its own entry rather than being settled inside an + auth task. +- **Suggested wave or follow-up:** next housekeeping pass. If kept, its docstring + needs rewriting — it currently explains itself in terms of password resets, which + no longer exist. diff --git a/server/alembic/versions/b7e4f1a20c93_drop_user_password_hash.py b/server/alembic/versions/b7e4f1a20c93_drop_user_password_hash.py new file mode 100644 index 0000000..5515670 --- /dev/null +++ b/server/alembic/versions/b7e4f1a20c93_drop_user_password_hash.py @@ -0,0 +1,42 @@ +"""drop users.password_hash — D13 / T10.3 + +Authentication moved to an LDAPS simple bind against the domain (see +server/ldap_auth.py), so the suite no longer holds a credential of any kind. + +THIS MIGRATION DESTROYS DATA AND CANNOT BE UNDONE IN ANY MEANINGFUL SENSE. +`downgrade()` recreates the column, but every hash in it is gone — and even a +restored hash would be useless, because nothing reads the column any more. The +downgrade exists so the revision is well-formed and so an operator can step back +past it, not because stepping back restores the old login. To actually revert to +local passwords you have to revert the application code and reset every password +by hand. + +The column is recreated NULLABLE on downgrade, deliberately. The baseline schema +declared it NOT NULL, but there are no values to put back, so a NOT NULL column +with no server default would refuse to add itself on any table that has rows. + +Revision ID: b7e4f1a20c93 +Revises: a1b8c6d4e2f9 +Create Date: 2026-08-21 +""" +import sqlalchemy as sa +from alembic import op + +revision = 'b7e4f1a20c93' +down_revision = 'a1b8c6d4e2f9' +branch_labels = None +depends_on = None + + +def upgrade(): + # batch_alter_table for SQLite's benefit: it has no DROP COLUMN before 3.35, + # so alembic rebuilds the table. Postgres drops it directly. Local dev runs on + # SQLite and production on Postgres, so this has to work on both. + with op.batch_alter_table('users') as batch: + batch.drop_column('password_hash') + + +def downgrade(): + with op.batch_alter_table('users') as batch: + batch.add_column(sa.Column('password_hash', sa.String(length=200), + nullable=True)) diff --git a/server/app.py b/server/app.py index 4359cc2..f15e3c6 100644 --- a/server/app.py +++ b/server/app.py @@ -616,8 +616,10 @@ class LoginIn(BaseModel): class NewUserIn(BaseModel): + # No password: D13 authenticates against the domain, so an administrator + # pre-creating an account only supplies identity and authorization. The person + # signs in with their Windows password, or is provisioned on first sign-in. username: str - password: str full_name: str = "" email: str = "" role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES @@ -639,24 +641,6 @@ class PreferencesIn(BaseModel): timezone: Optional[str] = None -class ForgotPasswordIn(BaseModel): - username: str = "" # username or email - - -class ResetPasswordIn(BaseModel): - token: str - new_password: str - - -class PasswordChangeIn(BaseModel): - current_password: str - new_password: str - - -class AdminPasswordIn(BaseModel): - new_password: str - - class ActiveIn(BaseModel): is_active: bool @@ -894,115 +878,6 @@ def logout(response: Response): return {"ok": True} -# ── Self-service password reset (needs email switched on) ────────────────────── -RESET_COOLDOWN_SECONDS = int(os.getenv("AUTH_RESET_COOLDOWN_SECONDS", "120")) -# In-process throttle: one reset mail per (account, client) per cooldown. Enough to -# stop someone using the form to spam a colleague's inbox. Per-worker and lost on -# restart — deliberately simple; the token expiry is the real control. -_reset_last: dict[str, float] = {} - - -def _reset_throttled(request: Request, username: str) -> bool: - now = monotonic() - key = f"{(username or '').strip().lower()}|{request.client.host if request.client else ''}" - prev = _reset_last.get(key) - if prev is not None and (now - prev) < RESET_COOLDOWN_SECONDS: - return True - _reset_last[key] = now - if len(_reset_last) > 5000: # bound the dict on a long-lived worker - cutoff = now - RESET_COOLDOWN_SECONDS - for k in [k for k, t in _reset_last.items() if t < cutoff]: - _reset_last.pop(k, None) - return False - - -def reset_body(user: "models.User", link: str, minutes: int) -> str: - # No account detail beyond the username, and no customer data — same rule as - # the assignment mail. The link is the only sensitive thing in here. - who = user.full_name or user.username - return ( - f"Hi {who},\n\n" - f"A password reset was requested for your Work Package Suite account " - f"({user.username}).\n\n" - f"Set a new password:\n{link}\n\n" - f"The link expires in {minutes} minutes and can only be used once. " - f"If you didn't request this, you can ignore this email — your current " - f"password still works.\n" - ) - - -@app.get("/api/auth/reset-available") -def reset_available(db: Session = Depends(get_db)): - """Whether the login page should offer 'Forgot password'. Self-service reset - depends entirely on outbound email, so it's off unless email is enabled AND - SMTP is configured — otherwise the only route is an admin reset.""" - s = notify.get_settings(db) - return {"enabled": bool(s.get("email_enabled")) and notify.smtp_ready(s)} - - -@app.post("/api/auth/forgot-password") -def forgot_password(body: ForgotPasswordIn, request: Request, db: Session = Depends(get_db)): - """Email a reset link. Always returns the same 200 response whether or not the - account exists — this endpoint is unauthenticated, so it must not become a - username/email oracle. Failures are recorded in the audit log instead.""" - s = notify.get_settings(db) - if not (s.get("email_enabled") and notify.smtp_ready(s)): - raise HTTPException( - status_code=503, - detail="Password reset by email isn't available. Ask an administrator to reset it for you.", - ) - if _reset_throttled(request, body.username): - # Same shape as the success response — no oracle, no mail bomb. - return {"ok": True, "message": "If that account exists, a reset link is on its way."} - user = auth.find_user(db, body.username) - if user and user.is_active and user.email: - base = (s.get("app_base_url") or "").rstrip("/") - token = auth.create_reset_token(user) - link = f"{base}/login.html?reset={token}" if base else f"/login.html?reset={token}" - sent = notify.send_now( - db, user.email, - "Work Package Suite — reset your password", - reset_body(user, link, auth.RESET_MINUTES), - ) - log_event(db, user.username, "password_reset_requested", "user", user.id, - summary=user.username, detail={"emailed": bool(sent)}) - db.commit() - else: - # Log the miss for the admin's benefit; the caller can't tell the difference. - log_event(db, "(anonymous)", "password_reset_miss", "user", "", - summary=(body.username or "")[:200], - detail={"reason": "no account, inactive, or no email on file"}) - db.commit() - return {"ok": True, "message": "If that account exists, a reset link is on its way."} - - -@app.post("/api/auth/reset-password") -def reset_password(body: ResetPasswordIn, db: Session = Depends(get_db)): - """Complete a reset using the emailed token. The token carries the user's - token_version, and finishing a reset bumps it — so the link is single-use and - every existing session for that account is signed out.""" - claims = auth.decode_reset_token(body.token or "") - if not claims: - raise HTTPException(status_code=400, detail="This reset link is invalid or has expired. Request a new one.") - user = db.get(models.User, claims.get("sub")) - if not user or not user.is_active: - raise HTTPException(status_code=400, detail="This reset link is no longer valid.") - if (claims.get("ver", 0) or 0) != (user.token_version or 0): - raise HTTPException(status_code=400, detail="This reset link has already been used. Request a new one.") - problem = auth.password_problem(body.new_password, user.username, user.email) - if problem: - raise HTTPException(status_code=400, detail=problem) - user.password_hash = auth.hash_password(body.new_password) - user.token_version = (user.token_version or 0) + 1 # burns the link + all sessions - # A completed reset also clears any login lockout — the person has proven - # control of the mailbox, so there's nothing left to throttle. - user.failed_attempts = 0 - user.locked_until = None - log_event(db, user.username, "password_reset", "user", user.id, summary=user.username) - db.commit() - return {"ok": True} - - @app.get("/api/auth/me") def whoami(user: models.User = Depends(auth.get_current_user)): """Who is logged in. The frontend guard calls this on every page load. @@ -1056,22 +931,6 @@ def set_preferences(body: PreferencesIn, user: models.User = Depends(auth.get_cu return {"user": user.to_dict()} -@app.post("/api/auth/password") -def change_password(body: PasswordChangeIn, request: Request, response: Response, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): - if not auth.verify_password(body.current_password, user.password_hash): - raise HTTPException(status_code=400, detail="Current password is incorrect") - problem = auth.password_problem(body.new_password, user.username, user.email) - if problem: - raise HTTPException(status_code=400, detail=problem) - user.password_hash = auth.hash_password(body.new_password) - user.token_version = (user.token_version or 0) + 1 # invalidate all OTHER existing sessions - db.commit() - db.refresh(user) - # Keep this session logged in by re-issuing a cookie carrying the new version. - auth.set_session_cookie(response, request, auth.create_token(user)) - return {"ok": True} - - # ── User administration ───────────────────────────────────────────────────────── # Two kinds of caller reach these routes: an app admin, who manages every account, # and a Project Super User, who manages the accounts on the projects they administer. @@ -1159,9 +1018,6 @@ def user_scope(user: models.User = Depends(auth.get_current_user), db: Session = @app.post("/api/auth/users") def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)): - problem = auth.password_problem(body.password, body.username, body.email) - if problem: - raise HTTPException(status_code=400, detail=problem) allowed = grantable_roles(actor) if body.role not in allowed: raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}") @@ -1193,7 +1049,6 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag username=body.username.strip(), email=body.email.strip(), full_name=body.full_name.strip(), - password_hash=auth.hash_password(body.password), role=body.role, project_role=body.project_role.strip()[:120], ) @@ -1220,24 +1075,6 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag return directory_entry(db, u, actor) -@app.post("/api/auth/users/{user_id}/password") -def admin_reset_password(user_id: str, body: AdminPasswordIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)): - u = load_target_user(db, user_id) - require_see_user(db, actor, u) - require_manage_user(db, actor, u) - problem = auth.password_problem(body.new_password, u.username, u.email) - if problem: - raise HTTPException(status_code=400, detail=problem) - u.password_hash = auth.hash_password(body.new_password) - u.token_version = (u.token_version or 0) + 1 # revoke the user's existing sessions - # An administrative password reset was the one user-account change that left no - # trace; it is the most impersonation-adjacent thing on this page, so it logs. - log_event(db, actor, "password_reset", "user", u.id, summary=u.username, - detail={"by": "administrator"}) - db.commit() - return {"ok": True} - - @app.post("/api/auth/users/{user_id}/active") def set_user_active(user_id: str, body: ActiveIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)): u = load_target_user(db, user_id) diff --git a/server/auth.py b/server/auth.py index dac4e8d..74aaa46 100644 --- a/server/auth.py +++ b/server/auth.py @@ -1,8 +1,8 @@ """Authentication for the Work Package Suite. -A self-contained username/password login. Passwords are stored only as bcrypt -hashes; a successful login issues a signed JWT that rides in an HttpOnly cookie -(`wp_session`). Because the token is signed and self-validating, there is no +Authentication is an LDAPS simple bind against the domain (D13); this module owns +everything *after* that. A successful sign-in issues a signed JWT that rides in an +HttpOnly cookie (`wp_session`). Because the token is signed and self-validating, there is no server-side session store — every request is checked by verifying the cookie's signature and expiry (see `auth_gate` and `get_current_user`). @@ -12,6 +12,8 @@ Security model: • The cookie is HttpOnly (JS can't read it → XSS can't steal the session), SameSite=Lax (blunts CSRF), and Secure whenever the request arrives over HTTPS (detected via X-Forwarded-Proto behind NGINX). + • Roles are LOCAL. The directory supplies identity; this app decides what that + identity may do, which is why an existing admin keeps admin (D13 criterion 4). • The signing secret comes from AUTH_SECRET_KEY. In production this MUST be set; if it is missing we fall back to a random per-process key (which logs a warning and invalidates every session on restart) so dev still works. @@ -35,10 +37,11 @@ The user-administration SCOPE of a super user is worked out in server/app.py (`managed_project_ids`, `manage_user_problem`), because it depends on project membership rows — this module only decides which roles carry the power at all. -Password reset: a short-lived signed token (see `create_reset_token`) is emailed -to the account's address. It is single-use by construction — it embeds the user's -`token_version`, which is bumped when the password changes, so a used or -superseded link stops validating. +There is no password and no password reset: D13 replaced local credentials with an +LDAPS bind (server/ldap_auth.py). People change their password with the domain, and +the login page points them at Okta. `token_version` survives as the +session-revocation mechanism — a role change or a deactivation must take effect on +sessions that have already been issued. """ import os import secrets @@ -46,7 +49,6 @@ import logging from datetime import datetime, timedelta, timezone from typing import Optional -import bcrypt import jwt from fastapi import Depends, HTTPException, Request, Response, status from sqlalchemy import select, func @@ -61,8 +63,6 @@ COOKIE_NAME = "wp_session" JWT_ALG = "HS256" # How long a login lasts before the user must sign in again. SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12")) -# How long an emailed password-reset link stays valid. -RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60")) # ── permissions roles ───────────────────────────────────────────────────────── ROLE_ADMIN = "admin" @@ -121,29 +121,6 @@ def is_project_admin(user: "models.User") -> bool: # same question used to exist here and silently disagreed with the scoped one, which # locked per-project super users out of the routes they were entitled to. -# Password policy (shared by the API and the CLI). -MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12")) -_COMMON_PASSWORDS = { - "password", "password1", "password123", "passw0rd", "12345678", "123456789", - "1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123", - "iloveyou1", "abc12345", "qwertyuiop", -} - - -def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]: - """Return a human-readable reason the password is unacceptable, or None if OK. - Shared by the API endpoints and the CLI so the policy is enforced everywhere.""" - if len(pw) < MIN_PASSWORD_LEN: - return f"Password must be at least {MIN_PASSWORD_LEN} characters." - low = pw.lower() - if username and low == username.strip().lower(): - return "Password must not be the same as the username." - if email and low == email.strip().lower(): - return "Password must not be the same as the email." - if low in _COMMON_PASSWORDS: - return "That password is too common — choose something less guessable." - return None - # Paths under /api that do NOT require a session (login itself, health, docs). _EXEMPT_PREFIXES = ("/api/auth/",) _EXEMPT_EXACT = { @@ -184,22 +161,6 @@ def _load_secret() -> str: SECRET_KEY = _load_secret() -# ── password hashing ────────────────────────────────────────────────────────── -def hash_password(plain: str) -> str: - # bcrypt operates on at most 72 bytes; longer inputs are truncated by the - # algorithm. Encode explicitly so non-ASCII passwords hash consistently. - return bcrypt.hashpw(plain.encode("utf-8")[:72], bcrypt.gensalt()).decode("ascii") - - -def verify_password(plain: str, hashed: str) -> bool: - if not hashed: - return False - try: - return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("ascii")) - except (ValueError, TypeError): - return False - - # ── tokens ────────────────────────────────────────────────────────────────── def create_token(user: "models.User") -> str: now = datetime.now(timezone.utc) @@ -227,33 +188,6 @@ def decode_token(token: str) -> Optional[dict]: return claims -def create_reset_token(user: "models.User") -> str: - """Short-lived, single-use token for an emailed password-reset link. - - Single-use falls out of `ver`: completing a reset bumps the user's - token_version, so the link (and any older link) no longer validates.""" - now = datetime.now(timezone.utc) - payload = { - "typ": "pwreset", - "sub": user.id, - "ver": user.token_version or 0, - "iat": now, - "exp": now + timedelta(minutes=RESET_MINUTES), - } - return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG) - - -def decode_reset_token(token: str) -> Optional[dict]: - """Claims for a valid, unexpired reset token, else None.""" - try: - claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG]) - except jwt.PyJWTError: - return None - if claims.get("typ") != "pwreset": - return None - return claims - - # ── cookie helpers ──────────────────────────────────────────────────────────── def _is_https(request: Request) -> bool: # Behind NGINX, TLS is terminated at the proxy and forwarded as plain HTTP, diff --git a/server/manage_users.py b/server/manage_users.py index 08280b8..e52e2cb 100644 --- a/server/manage_users.py +++ b/server/manage_users.py @@ -1,24 +1,29 @@ """Command-line user management for the Work Package Suite. -Use this to create the FIRST admin account (the /api/auth/users endpoint needs an -existing admin, so you have to bootstrap one here), and for occasional account -maintenance from a shell on the server. +Use this to bootstrap the FIRST admin (the /api/auth/users endpoint needs an +existing admin, so one has to be made here) and for occasional account maintenance +from a shell on the server. -Run from the PROJECT ROOT (same place you run uvicorn), so the package imports -and .env resolve the same way the API does: +NO PASSWORDS. D13 moved authentication to an LDAPS bind against the domain, so +this tool never sets or resets a credential — it only creates accounts and assigns +roles. People sign in with their Windows password; the login page sends anyone who +has forgotten it to Okta. + +Run from the PROJECT ROOT (same place you run uvicorn), so the package imports and +.env resolve the same way the API does: python -m server.manage_users create-admin alice --name "Alice Smith" python -m server.manage_users create bob --role user --name "Bob Jones" python -m server.manage_users list - python -m server.manage_users reset-password alice python -m server.manage_users disable bob python -m server.manage_users enable bob -If --password is omitted you'll be prompted (input is hidden). Passwords must be -at least 8 characters. +`username` must be the person's sAMAccountName, because that is what the bind and +the account match use. Creating an account is optional: anyone who authenticates +against the domain and is not already here is provisioned on first sign-in, at +project_user with no project access. """ import argparse -import getpass import sys import uuid @@ -30,19 +35,6 @@ def _gen_id() -> str: return f"user_{uuid.uuid4().hex[:12]}" -def _prompt_password(provided: str | None, username: str = "") -> str: - pw = provided - if not pw: - pw = getpass.getpass("New password: ") - confirm = getpass.getpass("Confirm password: ") - if pw != confirm: - sys.exit("Passwords do not match.") - problem = auth.password_problem(pw, username) - if problem: - sys.exit(problem) - return pw - - def cmd_create(args, role: str | None = None) -> None: role = role or args.role # 'user' is the pre-roles spelling of 'project_user' and is still accepted so the @@ -51,7 +43,6 @@ def cmd_create(args, role: str | None = None) -> None: role = auth.ROLE_PROJECT_USER if role not in auth.ROLES: sys.exit(f"role must be one of {', '.join(auth.ROLES)}") - pw = _prompt_password(getattr(args, "password", None), args.username) with SessionLocal() as db: if auth.find_user(db, args.username): sys.exit(f"A user named '{args.username}' already exists.") @@ -60,7 +51,6 @@ def cmd_create(args, role: str | None = None) -> None: username=args.username.strip(), full_name=(args.name or "").strip(), email=(args.email or "").strip(), - password_hash=auth.hash_password(pw), role=role, ) db.add(u) @@ -80,17 +70,6 @@ def cmd_list(args) -> None: f"{('yes' if u.is_active else 'no'):<8}{u.full_name}") -def cmd_reset_password(args) -> None: - pw = _prompt_password(getattr(args, "password", None), args.username) - with SessionLocal() as db: - u = auth.find_user(db, args.username) - if not u: - sys.exit(f"No user named '{args.username}'.") - u.password_hash = auth.hash_password(pw) - db.commit() - print(f"Password reset for {u.username}.") - - def _set_active(username: str, active: bool) -> None: with SessionLocal() as db: u = auth.find_user(db, username) @@ -110,8 +89,7 @@ def main() -> None: def add_create(name, help_): sp = sub.add_parser(name, help=help_) - sp.add_argument("username") - sp.add_argument("--password", help="set non-interactively (otherwise prompted)") + sp.add_argument("username", help="the person's sAMAccountName") sp.add_argument("--name", default="", help="full name") sp.add_argument("--email", default="") return sp @@ -123,10 +101,6 @@ def main() -> None: sub.add_parser("list", help="list all accounts") - rp = sub.add_parser("reset-password", help="reset a user's password") - rp.add_argument("username") - rp.add_argument("--password", help="set non-interactively (otherwise prompted)") - dp = sub.add_parser("disable", help="disable an account (blocks login)") dp.add_argument("username") ep = sub.add_parser("enable", help="re-enable an account") @@ -139,8 +113,6 @@ def main() -> None: cmd_create(args) elif args.cmd == "list": cmd_list(args) - elif args.cmd == "reset-password": - cmd_reset_password(args) elif args.cmd == "disable": _set_active(args.username, False) elif args.cmd == "enable": diff --git a/server/models.py b/server/models.py index f841809..100d6ec 100644 --- a/server/models.py +++ b/server/models.py @@ -142,8 +142,14 @@ class WorkPackage(Base): class User(Base): - """A login account. Passwords are never stored in the clear — only a bcrypt - hash (see server/auth.py). `username` is what people sign in with. + """A login account. NO PASSWORD IS STORED — D13 moved authentication to an + LDAPS bind against the domain (see server/ldap_auth.py), and the + `password_hash` column was dropped. `username` is the sAMAccountName people + sign in with; an account is created on first successful sign-in if it does not + already exist. + + `is_active` is LOCAL and overrides the directory: clearing it revokes access to + this app without touching the domain account. Two independent notions of "role", deliberately separate: • role the PERMISSIONS role — what the account may do in the app. @@ -159,7 +165,6 @@ class User(Base): username: Mapped[str] = mapped_column(String(120), unique=True, index=True) email: Mapped[str] = mapped_column(String(200), default="") full_name: Mapped[str] = mapped_column(String(200), default="") - password_hash: Mapped[str] = mapped_column(String(200), default="") role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role # Job function on the project — free text, offered from a suggested list. project_role: Mapped[str] = mapped_column(String(120), default="") @@ -182,12 +187,14 @@ class User(Base): # Online-guessing throttle (see login()): consecutive failures + a lockout window. failed_attempts: Mapped[int] = mapped_column(Integer, default=0) locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) - # Bumped to invalidate all existing sessions for this user (e.g. on a password - # change). The value is embedded in the JWT and re-checked on every request. + # Bumped to invalidate all existing sessions for this user. Password changes no + # longer exist (D13), but a role change or a deactivation still has to take + # effect on live sessions. The value is embedded in the JWT and re-checked on + # every request. token_version: Mapped[int] = mapped_column(Integer, default=0) def to_dict(self) -> dict: - """Public view of a user — NEVER includes the password hash.""" + """Public view of a user.""" return { "id": self.id, "username": self.username, "email": self.email, "full_name": self.full_name, "role": self.role, diff --git a/server/notify.py b/server/notify.py index 7774390..8baadab 100644 --- a/server/notify.py +++ b/server/notify.py @@ -49,7 +49,8 @@ DEFAULTS = { # Settings the app needs before anyone is signed in, or that carry no secrets and # are safe for any authenticated user to read (feature flags + localization -# defaults + whether self-service password reset can work at all). +# defaults). Self-service password reset is gone with D13 — the login page links to +# Okta instead, so there is nothing left for the client to feature-detect. PUBLIC_KEYS = ("bim_enabled", "default_locale", "default_timezone") @@ -83,13 +84,9 @@ def public_settings(db: Session) -> dict: def app_flags(db: Session) -> dict: - """Feature flags for any signed-in user (no secrets, no SMTP detail). - `password_reset_enabled` tells the login page whether a self-service reset can - actually deliver mail — there's no point offering the link otherwise.""" + """Feature flags for any signed-in user (no secrets, no SMTP detail).""" s = get_settings(db) - out = {k: s.get(k) for k in PUBLIC_KEYS} - out["password_reset_enabled"] = bool(s.get("email_enabled")) and smtp_ready(s) - return out + return {k: s.get(k) for k in PUBLIC_KEYS} def smtp_ready(s: dict) -> bool: