Skip to content

E01-S13 — My profile / Settings (screen 2a, /settings)

Issue: #215 · Epic: #201 · Backlog: .ai/specs/backlog-przebudowa.md (E01-S13) · Depends on: S01, S03, S12, S18 (all merged) · Risk: medium (storage bucket + private-profile write path)

📝 TLDR

/settings today is the S18 seed: a display-name form plus a read-only table that shows the username, email, account dates and a recipe count. Proposed: replace it with screen 2a, one form in one column: a profile picture (an uploaded photo, or the first letter of the name on one of 6 colors), “Nazwa przy przepisach” (public display name), “Imię” (private, used only for the vocative greeting), a live aria-live preview, one “Zapisz zmiany” button that turns into ”✓ Zapisano”, the email (read-only) and “Wyloguj”. No gender and no username anywhere on the screen.

📝 Problem Statement

  • The rebuild needs one place where a signed-in user controls how they appear: the picture and name on their recipes (S07 byline, S14 public profile) and the name the app greets them with (S04 home, S12 vocative).
  • Today there is no picture at all. Every author is text only, and S14 (/u/{username}) needs an 88 px picture.
  • The greeting has no source of truth. /witaj inflects the first word of display_name, and the S04 home still passes null (plain “Dzień dobry!”). A public display name like “Kuchnia Ani” makes a bad vocative. A real first name belongs in private data, not in the anon-readable profiles.
  • The current page shows the username. The backlog decided that settings must not (the username is immutable, so showing it invites a “change it” request that cannot be honored). Gender is chosen once at first login (CLAUDE.md, S12) and is not editable, so it is not shown either.

📝 Proposed Solution

  1. Avatar (public). Two new nullable columns on public.profiles (already anon-readable, which is right: the picture is shown to guests next to recipes and on S14):
    • avatar_path text: object path in a new public Storage bucket avatars, {user_id}/{epoch_ms}.jpg. A new path per upload busts the CDN cache without query strings.
    • avatar_color smallint (0–5): the index into a fixed palette of 6. null means “derived”: a stable hash of the user id picks the color, so existing users get a varied default with no backfill.
  2. Photo pipeline. The browser decodes the chosen file, center-crops it to a square and re-encodes it as a 512×512 JPEG (quality 0.85, about 40–120 kB) on a <canvas>. That handles 5–12 MB phone photos, strips EXIF (GPS) on the normal path (best effort: the server checks format and size only) and stays far below the 1 MB default Server Action body limit. The resized blob goes in the form’s FormData. The server action re-validates it (≤ 1 MB, JPEG magic bytes; JPEG only) and uploads it with the user’s own session. Storage RLS limits writes to the {auth.uid()}/ folder.
  3. First name (private). public.profiles_private (S12, owner-read only, no write policies) gets first_name text (≤ 40 chars). The only write path is a new SECURITY DEFINER RPC public.set_first_name(p_first_name text), the same pattern as set_gender(). It upserts the row and never touches gender. A blank value clears it.
  4. One save. saveProfileSettings(prev, formData) replaces updateDisplayName. It validates everything first, then: set_first_name → (explicit removal only) delete the current photo object, failing the save if that delete errors → upload the new photo (if any) → update profiles (display_name, avatar_path, avatar_color) → best-effort delete of the replaced photo object. If the profile update fails, the freshly uploaded object is removed, so no orphan is left. The two tables are written by two calls, not one transaction: a failure after set_first_name reports an error while the first name is already saved, and a retry is idempotent (accepted trade-off: no data is lost or exposed, and a single RPC would duplicate the display-name validation in SQL).
  5. Greeting source. A pure helper greetingName(firstName, displayName) returns firstName when set and otherwise the S12 behavior (first word of display_name). The S04 home (MemberHome) goes through it (/witaj runs before anyone can set a first name, so it keeps the display name), feeding the S12 greeting() / vocative rule. That way “Imię” changes what the user sees on the home page, which is the reason the field exists.
  6. Screen 2a. It replaces the page body (details under UI/UX). The username, account dates and recipe-count rows go away.

Alternatives considered

  • First name on profiles. profiles is anon-readable (using (true)), so the first name would leak to every guest. Rejected; this is exactly the split S18/S12 set up.
  • Upload directly from the browser to Storage, then save the path. That needs a second trust check (is the path the user’s own, does the object exist) and leaves orphans when the user abandons the form. A single server action keeps one trust point. The client-side resize keeps the payload small enough for it.
  • Server-side resize (sharp). It is not available on Cloudflare Workers (the S-04 lesson: no native binaries). Rejected; the canvas resize is universal in the target browsers.
  • Separate save buttons per field. The backlog AC asks for one “Zapisz zmiany”. A single button is also simpler for older users.

📝 Architecture

/settings (server component, auth required)
├─ reads profiles (display_name, avatar_path, avatar_color)
│ profiles_private (first_name) ← owner-read RLS
│ auth user (email)
└─ <SettingsForm> (client)
├─ <AvatarPicker> file → canvas 512² JPEG → hidden File field; colors radiogroup
├─ name / first-name inputs (controlled)
├─ <ProfilePreview aria-live="polite"> avatar + name byline · greeting
└─ submit → saveProfileSettings (server action)
├─ validate (display-name, first-name, color, file)
├─ rpc('set_first_name', { p_first_name }) ← SECURITY DEFINER
├─ removeAvatar: storage.remove([currentPath]) (must succeed)
├─ storage.from('avatars').upload(`${uid}/${Date.now()}.jpg`) ← storage RLS: own folder
├─ profiles.update({ display_name, avatar_path, avatar_color }) ← owner-update RLS
└─ replaced photo: storage.remove([previousPath]) (best effort)
<form action={signOut}> "Wyloguj" (existing src/app/(actions)/sign-out.ts)

New pure modules (unit-tested): src/lib/avatar.ts (palette, avatarColorIndex(userId, stored), avatarInitial(name), avatarUrl(path), validateAvatarUpload(file)), src/lib/first-name.ts (validateFirstName, greetingName). A shared <Avatar> component (src/app/components/ui/avatar.tsx) renders the photo or the letter. S14 and later the recipe byline reuse it.

📝 Data Model

Migration supabase/migrations/20261017181300_profile_avatar_first_name.sql (idempotent; not applied — owner-gated):

alter table public.profiles
add column if not exists avatar_path text,
add column if not exists avatar_color smallint;
-- check (avatar_color between 0 and 5); check (avatar_path ~ '^[0-9a-f-]{36}/[0-9]+\.jpg$')
-- avatar_path must live in the owner's own folder:
-- check (avatar_path is null or split_part(avatar_path, '/', 1) = id::text)
alter table public.profiles_private
add column if not exists first_name text;
-- check (first_name is null or char_length(first_name) between 1 and 40)
create or replace function public.set_first_name(p_first_name text) returns text
language plpgsql security definer set search_path = '' ...
-- 'ok' | 'invalid'; raises when unauthenticated; trims, blank → null;
-- insert ... on conflict (id) do update set first_name = excluded.first_name
revoke all on function public.set_first_name(text) from public, anon;
grant execute on function public.set_first_name(text) to authenticated;
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('avatars', 'avatars', true, 1048576, array['image/jpeg'])
on conflict (id) do nothing;
-- storage.objects policies (bucket_id = 'avatars'):
-- authenticated select/insert/update/delete only where
-- (storage.foldername(name))[1] = (select auth.uid())::text
-- NO anon/public SELECT policy: a public bucket already serves every object
-- by URL without RLS, and a public SELECT policy would let anyone LIST the
-- bucket (every user's folder, incl. replaced photos left behind by a failed
-- best-effort delete). The owner SELECT is what storage remove() needs.
  • The profiles_owner_update policy already restricts updates to the user’s own row. The folder check constraint stops a user from pointing avatar_path at someone else’s object.
  • enforce_gender_immutable (S12) fires on every profiles_private update but only rejects a gender change, so set_first_name passes. A row created by set_first_name has gender = null, which still means “not chosen yet” for /witaj (firstLoginTarget reads the value, not row existence). Verified in the rehearsal.
  • Rollback is written in the migration header: drop the policies, bucket objects, function and columns.

📝 API Contracts

saveProfileSettings(prev: SettingsFormState, formData: FormData): Promise<SettingsFormState> (server action, src/app/(authenticated)/settings/actions.ts):

FieldRule
displayNamevalidateDisplayName (S18: 2–60 chars, whitespace collapsed)
firstNameoptional; trimmed, ≤ 40, letters / space / - / ' only; blank → clear
avatarColorinteger 0–5, or absent (keep)
avataroptional File: JPEG by magic bytes, ≤ 1 MB, non-empty
removeAvatar"1" → set avatar_path = null, delete object

Returns { status: 'idle' | 'success' | 'error', message?, field? }. field names the input to focus and mark aria-invalid. Unauthenticated → error “Zaloguj się, aby zapisać zmiany.” No new route handlers.

📝 UI/UX

Mockup: .ai/specs/assets/e01-s13-settings/mockup.html (look reference; the screen follows the written AC). Order, single column, PageContainer narrow:

  1. H1 “Ustawienia”, 30 px bold text-ink.
  2. Obrazek. A 96 px circle (<Avatar>): the photo, or the uppercase first letter of the name in white, 44 px bold, on the selected color. Buttons under it, each 48 px with a visible text label: “Dodaj zdjęcie” / “Zmień zdjęcie” (an outline button wrapping a visually hidden <input type="file" accept="image/*">) and “Usuń zdjęcie” (shown only when there is a photo). Without a photo, a “Kolor tła” radiogroup follows: 6 swatches of 48×48 px, each showing the letter, with the color name as a caption (“Pomarańczowy”, “Niebieski”, “Zielony”, “Fioletowy”, “Różowy”, “Morski”) and arrow-key navigation (native radios). The selected swatch gets a 3 px ink ring plus a ✓, so the state is not shown by color alone.
  3. “Nazwa przy przepisach”: a 56 px input, border-2 border-field. Help (16 px ink-soft): “Tak podpiszemy przepisy, które dodasz. Widzą ją wszyscy.”
  4. “Imię”: a 56 px input, optional, autocomplete="given-name". Help: “Tylko do powitania na stronie głównej. Nikt inny go nie widzi.”
  5. Podgląd (role="status", aria-live="polite", surface #f9fafb, 2 px card border). It updates as the user types or picks:
    • “Tak zobaczą Cię inni:” <Avatar 48> + {nazwa}
    • “Powitanie:” “Dzień dobry, {wołacz}!” (or “Dzień dobry!” when the S12 rule has no safe vocative).
  6. “Zapisz zmiany”: a primary button, 56 px, full width on mobile. While pending: “Zapisywanie…” (disabled). After success it reads ”✓ Zapisano” until the next edit. A role="status" line repeats “Zapisano.” for screen readers. Errors: a role="alert" line in text-danger, plus aria-invalid and focus on the offending field.
  7. “Adres email”: a read-only <dl> row (break-all), plain text, not an input, with no “change” affordance.
  8. “Wyloguj”: an outline button, 48 px, in its own <form action={signOut}> below a divider. It is outside the settings form, so it can never submit profile changes.

Nothing on the screen shows gender or the username. When S14 flips PROFILE_PAGES_LIVE, a link “Zobacz swój profil publiczny” (underlined, from profilePath() in src/lib/routes.ts) appears under the preview. Until then profilePath() returns null and nothing renders. The username sits only in the href, never as visible text.

WCAG 2.2 AA: every input has a visible <label>, help is tied in via aria-describedby, focus is the global 3 px #1d4ed8 outline, all targets are ≥ 48 px, body text is 19 px and help 16 px #374151, there is no horizontal scroll at 320 px, and all 6 colors clear ≥ 4.5:1 with white (a unit test asserts it).

📝 Edge Cases & Failure Scenarios

CaseBehavior
HEIC / undecodable file (createImageBitmap fails)No upload. Alert: “Nie udało się odczytać zdjęcia. Wybierz zdjęcie JPG lub PNG.”
File > 1 MB reaches the server (resize bypassed)Rejected with an error, nothing stored
Non-image bytes with an image MIME typeRejected by the magic-byte check
Upload ok, profile update failsThe uploaded object is deleted; error shown; old photo kept
“Usuń zdjęcie”: delete ok, profile update failsError shown; avatar_path still names the deleted object (a broken picture until the retry). A retry deletes nothing (already gone, not an error) and clears the path
Replaced photo: old-object delete failsIgnored; the save still succeeds. The old object is no longer referenced, its name carries a millisecond timestamp and the bucket cannot be listed, so it is not discoverable
“Usuń zdjęcie”: object delete failsThe save fails with “Nie udało się usunąć zdjęcia. Spróbuj ponownie.” on the picture and avatar_path is left unchanged, so the screen never claims a photo is gone while its public URL still serves it
Migration not applied yetNot a supported state for saves: every save calls set_first_name, which does not exist yet, so every save (display name included) returns “Nie udało się zapisać…”. Reads degrade (letter avatar, empty first name, home greeting from the display name), so pages still render. Hence the deploy order below.
set_first_name returns invalidField error on “Imię”
User removes the photo and savesavatar_path = null; the letter on the chosen color comes back
Two tabs save at onceLast write wins (as today); each upload has a unique path, so no object is clobbered
Guest opens /settingsMiddleware/requireUser redirect to /login (unchanged)

📝 Risks & Impact Review

  • Privacy: first name and email stay owner-only (profiles_private, auth). The avatar and display name are public by design and say so in the help text. The canvas re-encode strips EXIF/GPS from photos.
  • Storage abuse: 1 MB bucket cap, MIME allow-list, writes limited to the user’s own folder, one avatar referenced per user (old objects deleted).
  • S14 coordination: S14 renders <Avatar> and reads profiles.avatar_path/avatar_color. If S14 merges first without the component, it shows a letter fallback. Coordination goes only through src/lib/routes.ts (profilePath) and the new src/lib/avatar.ts: S14 must reuse avatarColorIndex(id, avatar_color) and <Avatar>, not a second letter-avatar helper, or the same person gets a different default color on /settings and /u/{username}.
  • Deploy order: migration first, then the code. Apply 20261017181300_profile_avatar_first_name.sql (after S12’s 20261017130000, which creates profiles_private) before the implementation PR is merged, because merging to master deploys. The migration is additive (nullable columns, constraints that hold for the existing null rows, a new function, bucket and owner-folder policies), so the currently live code is unaffected by it.

📋 Phasing

Single implementation PR. Steps:

  1. Migration + rolled-back rehearsal (owner-gated, not applied).
  2. Pure libs avatar.ts, first-name.ts with unit tests (palette contrast, hash stability, validation, greetingName).
  3. <Avatar> primitive.
  4. Server action saveProfileSettings with unit tests (mocked Supabase: ordering, rollback of the upload, removal, validation errors).
  5. Settings page + SettingsForm / AvatarPicker / ProfilePreview; remove the username/date/count rows and the old display-name-form.tsx.
  6. Greeting wiring: MemberHome uses greetingName().
  7. Local gate + UI check on the dev server (mobile 375 px and desktop).

📋 Implementation Plan

  • supabase/migrations/20261017181300_profile_avatar_first_name.sql
  • src/lib/avatar.ts (+ test), src/lib/first-name.ts (+ test)
  • src/app/components/ui/avatar.tsx
  • src/app/(authenticated)/settings/{page.tsx, actions.ts (+ test), settings-form.tsx, avatar-picker.tsx, profile-preview.tsx}; delete display-name-form.tsx
  • src/app/page.tsx (MemberHome greeting)
  • src/lib/database.types.ts if the project keeps generated types (add the new columns/RPC by hand)

Resolved assumptions (autonomous defaults)

#QuestionDefault takenWhy
A1Show gender read-only on settings? (session brief allowed it)No. It is not shown at all.Backlog AC: “nie zawiera płci ani username”. It is not editable either way (S12).
A2Show access status / redeem an access code on settings?No.Not in the 2a AC; the S11 gate (3c) lives on the home and add flows. A later story can add it.
A3Where does “Imię” live?profiles_private.first_name, written via set_first_name()profiles is anon-readable; the first name is private.
A4Does “Imię” drive the greeting?Yes: greetingName(firstName, displayName) for the homeThe field has no other purpose; S12’s vocative rule is reused unchanged.
A5The 6 colorsorange-700 #c2410c, blue-700 #1d4ed8, green-700 #15803d, purple-700 #7e22ce, pink-700 #be185d, teal-700 #0f766eAll ≥ 4.5:1 with a white letter; the orange matches the action token.
A6Default color for existing usersDerived from a hash of the user id (avatar_color = null)Varied defaults, no backfill.
A7Photo size/formatClient-side 512×512 JPEG center crop; server cap 1 MBWorkers can’t run sharp; fits the Server Action body limit; strips EXIF.
A8Remove username, account dates and recipe count rowsYesAC lists exactly what the screen holds; the username is explicitly excluded.
A9Link to the public profileOnly via profilePath() once S14 sets it live; hidden until thenCoordination through routes.ts only; no visible username.