Repository navigation
feat(settings): Profile settings — display name, bio, avatar, theme, message length, password (G34) #46
Description
Summary
You cannot edit your own display name or bio from the macOS app at all. UpdateUserRequest already carries displayName, bio and theme (UserDTO.swift:169–171) and nothing calls them. Several other documented Profile settings are equally absent.
The documented pane (/help/settings ▸ Profile settings)
| Setting | Documented | macOS today |
|---|---|---|
| Display Name | "How you appear to others (defaults to your username if not set)" | ❌ |
| Bio | "Short description shown on your public profile" | ❌ |
| Avatar | "upload a file or set from a URL" | ❌ |
| Theme | Light / dark / system | ❌ (deliberately deferred — values were unconfirmed) |
| Max message length | "Character limit per message (default 666)" | ❌ |
Plus, from Security: Change password and Forgot password.
Live facts (probed 2026-09-07)
GET /api/user on the test account returns "theme":"light" and "maxMessageLength":666, so both are real, populated fields.
POST /api/user/avatar/from-url exists in UserEndpoint with no UserServicing method behind it.
POST /api/auth/forgot-password is already in Kit (AuthEndpoint.swift:19–26) and is not wired to any UI.
POST /api/auth/forgot-password and POST /api/auth/reset-password (plus an admin-only one). The web's "Change password: enter your current password and a new one" has no documented public endpoint. Probe before building — either find the route the web form posts to, or implement the reset-by-email flow and say so plainly in the pane. Do not guess a path.
Two things to get right
- Theme.
UserSettingsdeliberately omits it because the valid value set was unconfirmed. The web offers exactly three (System / Light / Dark) and the live account holds"light". ProbePATCH /api/user/updatewith each of the three before widening the typed surface, and keep an.unknown(String)case so a future value cannot fail the decode. - Max message length is not the server ceiling.
GET /api/limits(shipped as G14) is the platform maximum;maxMessageLengthon the account is a user-chosen cap underneath it. The composer must honour the lower of the two, and the pane must say which is which. Getting this backwards would let a user set a limit the server rejects, or silently raise their own ceiling past the platform's.
maxMessageLength is on UserDTO but absent from UpdateUserRequest — that is a Kit change, not just a UI one.
Division of labor
Kit
- Add
maxMessageLengthtoUpdateUserRequest. - Confirm
avatarupload:POST /api/user/avatar/from-urlis present; check whether a multipart file-upload route also exists and add it if so. - Probe and add the change-password route, or record its absence in the issue and fall back to reset-by-email.
Domain
- Extend
UserSettings(or a siblingProfileSettings) withdisplayName,bio,theme,maxMessageLength; keep the existing change-gated PATCH discipline so untouched fields are never clobbered. -
UserServicingmethods for avatar-from-URL and avatar-file-upload. - A
ContentLimits+ account-cap reconciliation helper so the composer has one number to ask for. - Password reset / change service method.
App
- A Profile pane in Settings: display name, bio, avatar (file picker and from-URL field, matching the web), theme picker, max-message-length stepper.
- Reuse the existing change-gated Save pattern from
PreferencesView— do not introduce a second save idiom. - Security section: change password (or reset-by-email) and forgot-password.
- Theme: applying it must actually drive the app's appearance, not just persist a string. Decide whether the account theme overrides the system appearance or seeds it, and document the choice.
Tests
- happy — each field round-trips through PATCH
- invalid — empty display name falls back to the username; over-long bio rejected client-side
- upstream-failure — PATCH fails → prior values restored, error surfaced, nothing half-saved
- boundary —
maxMessageLengthat, below, and above theGET /api/limitsceiling; composer honours the lower of the two
Acceptance criteria
- A user can set their display name, bio, and avatar without leaving the app.
- The composer's character budget matches whatever the account cap says.
- No password UI ships pointed at an unverified route.
- Full E2E gate green.
Notes
work-consolidation.md tracks this as G34. Size M.
Implementation plan (added 2026-09-15) — all four probes done
Every "probe before building" in this issue was resolved live on 2026-09-15.
1. Theme — the three work, and so does anything else
PATCH /api/user/update {"theme":"system"} → 200, stored "system"
{"theme":"dark"} → 200, stored "dark"
{"theme":"light"} → 200, stored "light"
{"theme":"auto"} → 200, stored "auto"
{"theme":"nonsense"} → 200, stored "nonsense"
The field is unvalidated. So .unknown(String) is not defensive padding — it is the documented behaviour, and the picker must offer the account's own value when it is unrecognised. Otherwise the Picker has a selection matching no tag (SwiftUI renders a blank control) and the first edit to any other field silently rewrites the theme. Pinned by a test that asserts the PATCH body omits an untouched unknown theme.
2. There is no change-password route
The live spec carries only /api/auth/forgot-password, /api/auth/reset-password, and an admin-only /api/admin/users/{userId}/password. The web's "enter your current password and a new one" form has no public endpoint.
So the pane ships reset-by-email and says so plainly. No password UI points at an unverified route, which is this issue's own acceptance criterion.
3. maxMessageLength — and the ceiling trap, confirmed reachable
GET /api/limits → message.maxContentLength: 5000 ← the platform
PATCH {"maxMessageLength":500} → 200
PATCH {"maxMessageLength":99999} → 400 "must be a positive integer between 1 and 10000"
PATCH {"maxMessageLength":0} → 400 same
The account range reaches 10000 while the platform stops at 5000 — so a user really can set a cap above the ceiling. This issue named the trap and the numbers make it reachable rather than theoretical.
ContentLimits.effectiveMessageLength(accountCap:)is the single place that decides, returning the lower of the two.ComposerViewModel.refreshLimits()used the platform ceiling alone, ignoring a cap the user had deliberately set. Fixed here.CurrentUsercarriesmaxMessageLength, for the same reason it carriesdefaultPubliclyVisible: it is on theGET /api/userpayload already, so the composer gets it session-cached with no extra round-trip.- The setter clamps to
1...10000, so no caller can earn the 400. - The pane shows both numbers and warns when the account cap is the one not in force.
4. Avatar — both halves exist
POST /api/user/avatar/upload and POST /api/user/avatar/from-url. The pane offers a file picker and a URL field, matching the web. uploadAvatar already had a service method; setAvatarFromURL did not.
The avatar is written by its own route, outside the change-gated body — so both the working copy and the saved snapshot are updated, or Save would light up claiming an unsaved change that does not exist.
Division of labour as built
Kit — UpdateUserRequest += maxMessageLength (it was on UserDTO and absent from the request, so the value was readable and unwritable).
Domain — ProfileSettings + AppTheme + ProfileLocation; ContentLimits.effectiveMessageLength(accountCap:); CurrentUser.maxMessageLength; UserServicing.profileSettings / updateProfileSettings(_:changedFrom:) / setAvatarFromURL / requestPasswordReset.
ProfileSettings is kept separate from UserSettings on purpose: same account, same PATCH route, different question — this is "who am I", that is "how does the app behave".
App — ProfileSettingsView + view model, registered as a Settings tab. Reuses PreferencesView's change-gated Save idiom rather than introducing a second save shape, as this issue asked.
Profile location — read-only, and #57 is why
Folded in from #57, which was re-scoped after its own probe: a location can be set through the API and cleared through nothing (#91). The pane therefore shows a published location — closing the "invisible" half of #57 — and offers no setter, because shipping set-without-clear would make macOS a way to publish an approximate home location that the user can never take back.
On the theme not driving appearance
This issue asked whether the account theme should override or seed the system appearance. Neither, for now: the value is stored on the account and applies on the web, and the Mac app follows the system appearance. Driving NSApp.appearance from a server preference is a behaviour change worth its own decision, and the pane says plainly what the setting does today rather than implying more.
Activity
Adron commentedon Sep 16, 2026
Implemented in PR #92 (G34). Work continues on the PR.
All four probes resolved:
PATCHstored"nonsense"happily. So.unknown(String)is the documented behaviour, not defensive padding, and the picker must offer the account's own value.forgot-password,reset-passwordand an admin-only one. Ships reset-by-email and says so.The read-only half of #57 is folded in here.