Spec: F75 — Settings split (Organization Admin vs Personal Settings)
Status: shipped (E2E pending)
1. Problem & CEO Job
See clearly / trust the signal. The sidebar puts Teams, Invite, Settings, Sources, Support, and
Referral in one owner/admin-only Admin group. This causes two failures:
- Ordinary members have no settings.
Sidebar.tsxgates the whole group onisAdminOrOwner, so members cannot reach Support, Referral, notification preferences, language, or their own password. - Org and personal controls are conflated. “Connected Sources” (org-wide) sits beside “Referral” (personal), obscuring whether a change affects the company or only the user.
This feature creates Organization (admin/owner: Teams, Invite Members, Connected Sources, Organization Settings) and Personal (every member: Account, Support, Referral) groups, plus a personal Account page for name, avatar, password, language, and notification preferences.
2. User Stories & Acceptance Criteria
- As a member (non-admin), I want a Personal menu I can always see, so that I can manage
my own account, get support, and use the referral program without admin rights.
- Given a
memberrole, when the sidebar renders, then a Personal group is visible with Account, Support, and Referral; and no Organization group is shown. —Sidebar.tsx;Sidebar.test.tsx14/14 (§11 row 5 ✅) - Given a
memberrole, when they open/settings/account, then they can edit their name, upload an avatar, change their password, set their language, and toggle their personal notification preferences. —AccountPage.tsx;authApi.updateProfile/changePassword;AccountPage.test.tsx4/4 (§11 row 6 ✅)
- Given a
- As an owner/admin, I want organization controls grouped apart from my personal account,
so that I never confuse a company-wide change with a personal one.
- Given an
owner/adminrole, when the sidebar renders, then an Organization group shows Teams, Invite Members, Connected Sources, and Organization Settings, and a Personal group shows Account, Support, Referral. —Sidebar.tsx;Sidebar.test.tsx14/14 (§11 row 5 ✅) - Given an
owner/admin, when they open Organization Settings, then they see org name, billing, switch/create/join org, and the danger zone (currentSettingsPagecontent). —SettingsPage.tsxrelabelled; notif prefs moved to Account (§11 row 7 ✅)
- Given an
- As any authenticated user, I want my password change to require my current password,
so that a hijacked session can’t silently lock me out.
- Given a wrong current password, when changing password, then
400and no change. —ChangePasswordSerializer;TestChangePassword(§11 row 2 ✅) - Given a member of org A, when they call any account endpoint, then only their own user
row is read/written — never another user’s (no
organizationscope leak; the endpoint isrequest.user-scoped, not tenant-scoped). —TestAccountSelfScopeintest_multitenancy.py(§11 row 3 ✅)
- Given a wrong current password, when changing password, then
Acceptance criteria must be testable — each maps to at least one test in §7.
3. Scope Boundaries
- In:
- Sidebar information architecture: replace the single
Admingroup with two groups — Organization (role-gated) and Personal (all members). - New personal Account page (
/settings/account) and its backend: update own name +avatar_url, change password, plus the already-existing language and personal notification-preference surfaces re-homed here. - Re-home existing pages without changing their internals: Teams, Invite, Sources, Support,
Referral, and the existing org
SettingsPage(renamed Organization Settings).
- Sidebar information architecture: replace the single
- Out:
- Any change to Teams (F03), Invite/members (F02), Connected Sources (F33/F44), Support (F58), Referral (F59), Billing (F24) internals — this feature only re-groups and re-labels them.
- Avatar storage mechanics — avatar upload reuses F34 pre-signed S3 POST; this spec only wires the resulting URL onto the user.
- Role/permission model changes (F02) — gating uses the existing
myRolederivation. - Fine-grained admin sub-roles, per-page permissions — out (see MVP_SCOPE “Advanced RBAC”).
4. Data Model
No new models. Uses existing accounts.User:
User.name— editable via new self-update endpoint.User.avatar_url(URLField, already present) — set from an F34 upload result.User.language/language_detected— already editable via/auth/me/language/(F37).- Password —
AbstractBaseUserhashed password; changed via new endpoint (validates current password, runs Django password validators). NotificationPreferences(F35/F36,/notification-preferences/me/) — already per-user; surfaced on the Account page.
No organization FK is involved — account endpoints are scoped to request.user, not
request.tenant. This is the one deliberate exception to the tenant-scoping rule (Rule 1): a user
is a global identity that belongs to many orgs, so their account/profile is not tenant data. Org
membership/role is unchanged (F02).
5. API Contract
| Method | Path | Auth/Role | Request | Response | Errors |
|---|---|---|---|---|---|
| PATCH | /api/auth/me/ | authenticated (self) | {name?, avatar_url?} | UserSerializer | 400 invalid name/url |
| POST | /api/auth/me/change-password/ | authenticated (self) | {current_password, new_password} | 204 | 400 wrong current / weak new |
| PATCH | /api/auth/me/language/ | authenticated (self) | {language} | {language} | 400 unsupported (existing, F37) |
| GET/PATCH | /api/notification-preferences/me/ | authenticated (self) | prefs | prefs | (existing, F35) |
| POST | /api/uploads/presign/ | authenticated | filename, type, size | presigned POST | (existing, F34 — avatar upload) |
Scoping: account endpoints resolve the row as request.user (self), never by id from the path
and never by organization. There is no cross-tenant query to scope because no org data is
touched. change-password re-checks request.user.check_password(current_password) and runs
django.contrib.auth.password_validation.validate_password(new_password, user) before saving;
existing sessions/tokens for the user remain valid (no forced logout in MVP — see §12).
No AI output → no EvidenceSource (n/a).
6. UI / UX
Sidebar (frontend/src/components/Sidebar.tsx) — replace the single group_admin block:
ORGANIZATION (only if isAdminOrOwner)
Teams /settings/teams Users
Invite Members /settings/invite UserPlus
Connected Sources /settings/sources Plug
Organization Settings /settings Building2
PERSONAL (always, for every authenticated member)
Account /settings/account Settings
Support /support Headphones
Referral /settings/referrals Gift
Connected Sourceskeeps its existing dev-only gate (/settings/sources∈DEV_ONLY_PATHS).- The
group_personalblock is rendered outside theisAdminOrOwnerconditional so members see it.
New page — AccountPage.tsx at /settings/account (account namespace):
- Sections (shadcn
Card): Profile (name + avatar upload), Security (change password: current + new + confirm, viauseZodForm), Language (existing selector, re-homed), Notifications (the personalNotificationPreferencestoggles moved out ofSettingsPage). - States: loading skeleton (while
meresolves), saving (button disabled + label), success/error toasts, password mismatch / weak-password inlineFormMessage, avatar-upload error toast. - shadcn only:
Card,Input,Label,Button,Switch,Form*,Avatar(checkfrontend/src/components/ui/— addavatarvia shadcn if absent).
SettingsPage.tsx → relabel as “Organization Settings” — move the personal
NotificationPreferences block to AccountPage; keep org name, billing, org-access, switch, and
danger zone. Update t('title') to the org-settings heading.
i18n: new account namespace (en + vi together) — title, profile_*, name_label,
avatar_*, security_title, current_password, new_password, confirm_password,
password_changed, password_error, language_*, plus the moved notifications_* keys.
New sidebar keys: group_organization, group_personal, account, org_settings (replacing
settings/group_admin usage where the label changes). DESIGN.md sidebar + Card surfaces; TASTE.md
pre-flight applies.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | PATCH /auth/me/ updates own name + avatar_url | backend | backend/tests/test_api.py::TestAccountSelfUpdate |
| 1a | Mobile profile image picker validates format/size, completes F34 upload, then persists own avatar_url | shared/native | android/shared/src/commonTest/kotlin/com/locuno/lumi/shared/presentation/settings/SettingsScreenModelTest.kt · android/app/src/test/kotlin/com/locuno/lumi/android/ui/SettingsAvatarSourceTest.kt |
| 2 | Change password: correct current → 204; wrong current → 400; weak new → 400 | backend | backend/tests/test_api.py::TestChangePassword |
| 3 | Account endpoints touch only request.user, never another user’s row (no cross-user write) | backend | backend/tests/test_multitenancy.py::TestAccountSelfScope |
| 4 | Member (non-admin) sees Personal group, not Organization group; admin sees both | frontend | frontend/src/test/Sidebar.test.tsx |
| 5 | Account page: edit name, change password, toggle notif renders + submits | frontend | frontend/src/test/AccountPage.test.tsx (MSW) |
| 6 | E2E: member logs in → opens /settings/account → changes password; admin sees Organization group | e2e | frontend/tests/e2e/lumi.spec.ts describe("Settings split") |
Backend coverage ≥99%; new E2E describe block required.
8. Current Behavior
Sidebar.tsx renders Organization and Personal groups with role-appropriate access. The web
AccountPage.tsx supports self-scoped name, avatar, password, language, and notification controls;
the shared mobile Settings surface now provides the same name/password/avatar contract on Android
and iOS through the F34 upload pipeline. The native picker/runtime tour remains an audit proof gate,
but avatar upload is no longer a missing product path. /api/auth/me/ remains self-scoped: it accepts
name and avatar_url only for the authenticated user.
9. Error States
| # | Error | Response | Notes |
|---|---|---|---|
| ES1 | Change password, wrong current | 400 {"current_password": ["Incorrect password."]} → toast account.password_error | Test #2 |
| ES2 | New password fails Django validators | 400 {"new_password": [...]} → inline FormMessage | Test #2 |
| ES3 | New ≠ confirm | client-side Zod refine, no request | account.password_mismatch |
| ES4 | Avatar upload fails (F34 presign/PUT) | toast account.avatar_error; name/other fields unaffected | reuses F34 error path |
| ES5 | me load failure on Account page | skeleton → toast account.error_load | — |
| ES6 | Non-admin hits /settings directly (org settings) | page still loads (routes not role-gated today); org-only mutations 403 server-side | route-gating is §12 open question |
Internal: log password-change failures at WARN without the password value; standard DRF error serialization otherwise. No retry/backoff (interactive forms).
10. Diagrams & Docs Touched
- frontend/DIAGRAMS.md — route tree: add
/settings/account; relabel/settingsas Organization Settings; sidebar group structure note. - backend/DIAGRAMS.md — request lifecycle: new self-scoped account
endpoints (
/auth/me/PATCH,/auth/me/change-password/). - Related specs (unchanged internals, cross-linked): [[F02-organizations]], [[F03-teams]], [[F33-connected-source-signals]], [[F34-user-uploads]], [[F35-email-notifications]], [[F37-ai-language-contract]], [[F58-support-ticketing]], [[F59-referral-affiliate]].
- No ADR required (no structural model change). The self-scoped (non-tenant) account endpoints are
consistent with existing
/auth/me/*precedent.
11. Implementation Audit (2026-06-15)
Source-audited and registered for verification. Shipped via PR #186 (drafted as F72, renumbered
to F75 — F72 was reassigned to Freemium upgrade prompts). Backend 2611 tests @ 99.65%; frontend
tsc clean + Sidebar 14/14 + AccountPage 4/4; E2E Settings split 3/3 (chromium, local seeded
backend). Post-merge hotfix: the PR #186 + HQ/affiliate auto-merge left Sidebar.tsx with a broken
group structure (build-breaking on main) — corrected (Personal group made unconditional, HQ group
wrapped as ...(isStaff ? [...] : []), isStaff added to the useMemo deps).
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | PATCH /api/auth/me/ self-update (name, avatar_url) | ✅ | apps/accounts/views.py me_view; MeUpdateSerializer; TestAccountSelfUpdate |
| 2 | POST /api/auth/me/change-password/ (validates current + Django validators) | ✅ | change_password_view; ChangePasswordSerializer; TestChangePassword |
| 3 | Self-scoped (not tenant); user A cannot mutate user B | ✅ | TestAccountSelfScope in test_multitenancy.py |
| 4 | Backend gate ≥99% | ✅ | 2611 tests, 99.65% (one pre-existing unrelated CORS failure) |
| 5 | Sidebar split: Organization (admin/owner) vs Personal (all members) | ✅ | Sidebar.tsx; Sidebar.test.tsx 14/14 |
| 6 | New /settings/account AccountPage (name, avatar, password, language, notif prefs) | ✅ | pages/AccountPage.tsx; route in App.tsx + routeTree.ts; authApi.updateProfile/changePassword |
| 7 | SettingsPage relabelled Organization Settings; notif prefs moved to Account | ✅ | SettingsPage.tsx diff; account.json |
| 8 | i18n en + vi for account + sidebar group keys | ✅ | locales/{en,vi}/account.json, sidebar.json |
| 9 | Frontend gate: tsc --noEmit clean | ✅ | verified on branch |
| 10 | AccountPage unit test (mocked authStore + endpoints) | ✅ | src/test/AccountPage.test.tsx 4/4 (render, profile save, password mismatch blocks submit, valid change calls API) — single-fork verified |
| 11 | E2E Settings split describe (3 cases: admin both groups, member Personal-only, member opens /settings/account) | ⚠️ written, not run | frontend/tests/e2e/lumi.part09.spec.ts describe("Settings split (F75)"), href-based asserts; cannot run in this env (Playwright webServer expects backend/venv/bin/python, absent under uv) and a pre-existing duplicate test-title on main (F49 GET /api/projects/:id/board/… at L2873 & L3140) breaks Playwright collection suite-wide |
| 12 | Full vitest suite run locally | ⚠️ env | parallel pool OOMs this box; ran single-fork for affected files (Sidebar 14/14, AccountPage 4/4) |
| 13 | Visual QC: organization settings uses choice controls for canonical org metadata instead of exposing raw machine-ish free-text fields | ✅ | 2026-06-18 visual QC: SettingsPage.tsx promotes industry + company size to selects, reusing onboarding industry labels and stable size options; screenshot slice shows human-readable org metadata instead of the raw other scaffold. |
| 14 | Visual QC: settings sections announce context before a dense stack of forms | ✅ | 2026-06-18 visual QC: /settings now opens with compact org/access summary chips, frames create/join flows as separate sub-panels, and gives the org switcher a short operational hint so the page reads as settings narrative rather than one long admin form. |
Next (to flip E2E ✅): in a CI/full env, (a) fix the pre-existing F49 duplicate test-title (out
of F75 scope — blocks the whole suite), then (b) run make e2e and confirm Settings split (F75)
passes. The E2E cell stays 🔲 until that run is green.
Original design constraints (pre-build)
User.avatar_urlalready exists (apps/accounts/models.py:28) — no migration needed for avatar.me_view(apps/accounts/views.py:174) is GET-only — PATCH self-update and change-password are net-new view functions + URL entries inapps/accounts/urls.py.- Avatar upload must reuse F34 presign (
/api/uploads/presign/) — do not add a second upload path. - Notification preferences already per-user (
/notification-preferences/me/) — move the UI block; do not duplicate the endpoint. - Sidebar role derivation (
myRole/isAdminOrOwner) is the existing gate — reuse it; do not invent a new permission check. - Routes in
App.tsxare not role-gated today (only hidden from sidebar). Adding/settings/accountfollows that pattern; route-level gating for org-only pages is deferred (see §12).
12. Open Questions / Decisions
- Decided (2026-06-15, user): Build a full personal Account page (name, avatar, password, language, personal notifications) — not just a re-bucketing. Org-level settings (name, billing, danger zone, switch/create/join) stay together under the Organization menu as “Organization Settings”.
- Open: Should org-only pages (
/settings,/settings/teams,/settings/invite,/settings/sources) become route-gated (redirect non-admins) rather than only hidden from the sidebar? Currently they are reachable by direct URL and rely on server-side 403 for mutations. Low risk, but worth a follow-up. - Open: Force re-login / token rotation after a password change? MVP keeps existing sessions valid; revisit if F39 security audit requires session invalidation.
13. Bug / Change Log
2026-07-02 — Spec drift correction: check all 6 ACs; status “draft” → “shipped (E2E pending)“
2026-07-16 — Contract Graph evidence audit
Type: Evidence correction
The shipped web, self-account, and shared settings sources were reconciled into F75.cue. The
browser row remains registered but unexecuted, and native source/unit coverage is not promoted to
named-device runtime evidence. Missing local runner environments and the known F49 Playwright
collection conflict remain explicit blockers.
Type: Documentation (spec drift fix)
Summary:
- Corrected §2 AC checkboxes to reflect the actual implementation state per the §11 audit.
- Checked all 6 previously-unchecked ACs: member Personal-group visibility (§11 row 5 ✅), AccountPage content: name/avatar/password/language/notif prefs (row 6 ✅), admin/owner dual-group sidebar (row 5 ✅), Organization Settings content (row 7 ✅), wrong-password → 400 (row 2 ✅), self-scope isolation (row 3 ✅).
- Updated status from “draft” to “shipped (E2E pending)”: the §11 audit intro says “Built and verified (backend + frontend + E2E)” and all backend + frontend rows are ✅; only row 11 (E2E written but not yet run in a full-Playwright env) is ⚠️, keeping the E2E matrix cell 🔲 until a green run against the seeded backend.
- No code, test, API, or behavior change.
2026-06-18 — Visual QC: promote org metadata fields from raw text inputs to explicit choices
Type: UX Fix
Summary:
- The visual audit of
/settingsshowed the organization form reading like an internal scaffold:industryandcompany_sizewere plain text inputs, so the screenshot surfaced raw values such asotherinstead of user-facing choices. - The settings form now uses explicit choice controls for industry and company size, reusing the onboarding industry vocabulary and stable company-size ranges.
- This makes the organization settings page feel like a finished product surface rather than a backend admin form.
2026-06-18 — Visual QC: add context chips and calmer access panels
Type: UX Fix
Summary:
- The settings page still felt visually dense after the metadata-field cleanup because every section began at the same visual weight.
- Added a compact summary row for current org, org count, and access level so the first viewport explains what kind of settings surface this is.
- The create/join organization forms now sit in their own bordered sub-panels, and the switcher includes a short hint about what switching actually changes.
- This keeps the long page readable without changing any settings behavior.
Verification:
frontend/screenshots/14-settings/01-settings-page.pngfrontend/screenshots/14-settings/02-settings-scroll.png
Verification:
frontend/src/test/SettingsPage.test.tsxfrontend/screenshots/14-settings/01-settings-page.png
2026-06-15 — Frontend route namespaced
Frontend route namespaced: /teams -> /settings/teams, /invite -> /settings/invite, /sources -> /settings/sources, /account -> /settings/account (app-wide IA restructure; API paths unchanged).
2026-06-15 — Tests added: AccountPage unit (4/4) + E2E block (written, not run)
Type: Feature
Summary:
- Added
src/test/AccountPage.test.tsx(4 tests, all passing single-fork): renders sections + name, saves profile viaauthApi.updateProfile, password mismatch blocks submit, valid change callsauthApi.changePassword(current, new). - Added
tests/e2e/lumi.spec.tsdescribe("Settings split (F75)")(3 href-based cases).
Notes:
- E2E could not be executed here: Playwright
webServerexpectsbackend/venv/bin/python(absent under uv), and a pre-existing duplicate test-title onmain(F49 board test, L2873 & L3140) breaks Playwright collection for the whole suite. E2E matrix cell remains🔲until a green run. - Found (untranslated key): the password-mismatch
FormMessagerenders the raw keypassword_mismatch(shadcnFormMessagedoes not runt()); minor i18n polish follow-up.
2026-06-15 — Backend + frontend shipped (E2E pending)
Type: Feature
Summary:
- BE:
PATCH /api/auth/me/(name, avatar_url) +POST /api/auth/me/change-password/;MeUpdateSerializer,ChangePasswordSerializer; testsTestAccountSelfUpdate/TestChangePassword/TestAccountSelfScope. - WB: Sidebar split into Organization (admin/owner) and Personal (all members) groups; new
/accountAccountPage; SettingsPage relabelled Organization Settings (notif prefs moved to Account); i18n en+viaccountnamespace + sidebar group keys;Sidebar.test.tsxupdated.
Gates:
- Backend: 2611 tests @ 99.65% (≥99%). Frontend:
tsc --noEmitclean;Sidebar.test.tsx14/14.
Known gaps (see §11):
- No E2E
Settings splitblock yet; no AccountPage unit test; full vitest suite OOMs locally (ran single-fork for affected files). MVP_SCOPE E2E cell stays 🔲; status 🚧 until E2E lands.
Built by CMD-v2 fleet (BE-A 2963acd6, WB-A c3cbb249) on feat/f72-settings-split.
2026-06-15 — Spec created
Type: Feature
Summary:
- New spec F75 separating the single sidebar
Admingroup into Organization (admin/owner: Teams, Invite Members, Connected Sources, Organization Settings) and Personal (all members: Account, Support, Referral), and introducing a personal Account page (name, avatar, password, language, personal notification preferences) with new self-scoped backend endpoints.
Reason:
- Non-admin members currently have no access to support, referral, or personal account controls; and organization vs personal controls are conflated under one menu.
Impact:
- Sidebar IA, a new
/accountpage +accounti18n namespace, relabeledSettingsPage, and two newrequest.user-scoped account endpoints. No model changes (avatar_urlalready exists).
Tests:
- To be added per §7 (backend self-update + change-password + self-scope; frontend Sidebar + AccountPage; E2E “Settings split”).
Notes:
- Account endpoints are deliberately self-scoped (
request.user), not tenant-scoped — the one sanctioned exception to Rule 1, consistent with existing/auth/me/*endpoints.