Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 44 additions & 45 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,12 +384,12 @@ Figma's fallback `<path>` beside the `<foreignObject>` paints solid black over t

## Changing your account email is a request/confirm flow, not a plain field edit

`USER_FIELDS` in `lib/validations/user-fields.ts` has no `email`, matching the API's `UserUpdate`;
`ProfileCard` touches only name/username. Email lives in `components/settings/EmailChangeCard.tsx`:
enter a new address, submit via `authAPI.requestEmailChange(newEmail)`, get the same generic "check
your new email" message even for a taken address, and the change applies only once the emailed link
is confirmed. `POST /user/email-change/request` always acts on the caller's own account, so there is
no address to name but the new one.
`USER_FIELDS` in `lib/validations/user-fields.ts` has no `email`, matching the API's `UserUpdate`.
Email lives in `components/settings/EmailChangeCard.tsx`: enter a new address, submit via
`authAPI.requestEmailChange(newEmail)`, get the same generic "check your new email" message even for
a taken address, and the change applies only once the emailed link is confirmed.
`POST /user/email-change/request` always acts on the caller's own account, so there is no address to
name but the new one.

The field is always visible with one full-width "Send confirmation link" button — no edit toggle, no
cancel — matching the Profile Information card beside it. Both cards use `flex flex-col h-full` /
Expand Down Expand Up @@ -865,20 +865,20 @@ would bury the rows that need attention, the same reasoning as `worstServiceStat
for untracked gear. The window is 90 days, not gear's 30: renewing a rescue or first-aid card means
booking a course with an instructor, not dropping a regulator at a shop.

## Card images ride on the certification form's own save
## Card images and both pictures ride on their form's own save

The API takes card images on `PUT /certification/{uuid}/file/{side}`, not as multipart on create, so
a card being created has no uuid to upload against until `createCertification` resolves. That is an
ordering constraint, not a reason for a second dialog: `CertificationCardFiles` collects
add/replace/delete per side into `CertificationCardEdits`, and `applyCertificationCardEdits` sends
them after the details save — serially, in `CERTIFICATION_SIDES` order, `PUT` alone for a replace
since it overwrites. Cancel therefore leaves the stored cards untouched, which the upload-on-pick
dialog could not.
Each is held as a pending edit and sent after the form's fields save, so Cancel leaves every stored
image untouched. For cards the order is forced: `PUT /certification/{uuid}/file/{side}` needs a uuid
a new card has only once `createCertification` resolves. `CertificationCardFiles` collects
add/replace/delete per side and `applyCertificationCardEdits` sends them serially, in
`CERTIFICATION_SIDES` order. The avatar (Profile Information) and the portrait (Check-in details,
About you) each hold one `PictureEdit` — replace, adjust, remove, or the portrait's copy — which
`applyPictureEdit` sends after `PATCH /user`. Rejected: a picture card saving on pick beside forms
that wait for Save.

A failed image does not fail the save: the details are written, and refilling the form to retry one
picture costs more than the picture. One toast per side, and the certification is re-read whenever
anything was sent — the embedded `files` the list and the check-in sheet draw from is stale either
way, and only the API knows which sides landed.
A failed image does not fail the save: the fields are written, and refilling the form to retry one
picture costs more than the picture. One toast names the side or the picture. A certification is
re-read whenever anything was sent, since only the API knows which sides landed.

## Every c-card is drawn in one shape, and cropped to it on the way in

Expand Down Expand Up @@ -4021,24 +4021,23 @@ variable a released artifact must document. Initials (`getUserInitials`) are the
`flag()` stays for `WEB_HSTS` and `WEB_NOINDEX`; its doc comment and `runtime-config.test.ts`
demonstrate the unrecognized-value warning on `WEB_NOINDEX`.

## Avatars: The digest is the whole client contract
## Pictures: The digest is the whole client contract

`UserRead` carries `avatar_sha256`, one nullable string answering three questions: whether there is
a picture, which version, and what to append as `?v=`. There is deliberately no URL. The bytes are
owner-only behind an `Authorization` header and the access token lives in memory
(`lib/api/client.ts`), so an `<img src>` at the API could never load them; `UserAvatar` fetches
through the API client via `hooks/useAuthedBlobUrl.ts` and renders from an object URL. Its props are
`{ name, avatarSha, size, className }` — no `email`. Radix's `AvatarFallback` renders until
`AvatarImage` has loaded, so in-flight, failed and no-picture are one state drawn as initials, with
no probe and no broken-image glyph. Staleness is handled by the URL: `?v={sha}` changes with the
picture, the old entry ages out of the five-minute `max-age`, and after upload or remove the card
calls `refreshUser()`, which re-reads `avatar_sha256` for every mounted `UserAvatar` in the same
paint.
`UserRead` carries `avatar_sha256` and `portrait_sha256`, each one nullable string answering three
questions: whether there is a picture, which version, and what to append as `?v=`. Each
`*_original_sha256` does the same for the original, and is what offers "Adjust". There is
deliberately no URL: the bytes are owner-only and the access token lives in memory
(`lib/api/client.ts`), so an `<img src>` could never load them. `UserAvatar` and `PortraitImage`
fetch through `hooks/useAuthedBlobUrl.ts` and render from an object URL. Radix's `AvatarFallback`
renders until `AvatarImage` has loaded, so in-flight, failed and no-picture are one state drawn as
initials, with no broken-image glyph. Staleness is handled by the URL: `?v={sha}` changes with the
picture, and after a save the form calls `refreshUser()`, which repaints every mounted picture in
the same paint.

## The crop dialog's three traps

Never JPEG: `canvas.toBlob("image/jpeg")` composites transparency onto black. The avatar exports PNG
because `PUT /user/avatar` re-encodes anyway; a card exports WebP because its endpoint does not.
Never JPEG: `canvas.toBlob("image/jpeg")` composites transparency onto black. A card exports WebP
because its endpoint stores what it is given.

`react-easy-crop` injects its own `<style>` by default, which the nonce-based production CSP drops
(the dev CSP allows `'unsafe-inline'`). `disableAutomaticStylesInjection` plus
Expand All @@ -4053,25 +4052,25 @@ which reports the box after ancestor transforms, and nothing re-measures once `D
`data-[state=open]:zoom-in-100 data-[state=closed]:zoom-out-100` on this one `DialogContent`; an
explicit `cropSize` still derives media size from the scaled rect.

## Avatars: The `accept` list is load-bearing, not decoration
## Pictures: The `accept` list is load-bearing, not decoration

`accept="image/jpeg,image/png,image/webp,image/gif"` is what makes iPhone photos work. Since WebKit
bug 267277, iOS Safari transcodes a HEIC pick to JPEG only when the `accept` list restricts image
types and excludes HEIC. `accept="image/*"` hands over raw HEIC, which no browser decodes into a
canvas; adding `image/heic` is worse, since Safari 17+ then delivers the original and has a
documented bug converting picked PNGs to HEIC. The constant lives in `lib/api/auth.ts` with the
reasoning attached so nobody simplifies it inline. A Files-app pick bypasses `accept` entirely; the
API sniffs bytes regardless, so this is not a security surface.
`accept="image/jpeg,image/png"` is what makes iPhone photos work. Since WebKit bug 267277, iOS
Safari transcodes a HEIC pick to JPEG only when the `accept` list restricts image types and excludes
HEIC. `accept="image/*"` hands over raw HEIC, which the API keeps no original of; adding
`image/heic` is worse, since Safari 17+ then delivers the original and has a documented bug
converting picked PNGs to HEIC. The constant lives in `lib/picture.ts` with the reasoning attached
so nobody simplifies it inline. A Files-app pick bypasses `accept` entirely; `readPictureType` and
the API sniff bytes regardless, so this is not a security surface.

## The caller decodes the file before the cropper ever sees it

`react-easy-crop` has no failure callback: `CropperProps` carry `onMediaLoaded` and `onCropComplete`
and nothing for the other outcome, so a source that never decodes — a Files-app HEIC walking past
`accept` — leaves the dialog with an empty frame, `croppedAreaPixels` never arriving and Save
disabled forever. `AvatarCard.handlePick` and `CertificationCardFiles.handlePick` therefore decode
the object URL themselves and mount `ImageCropDialog` only on success, toasting otherwise;
`mediaProps={{ onError }}` would catch it a frame later with a half-open dialog to unwind. The
second decode inside `cropToBlob` hits the browser cache.
disabled forever. `PictureField` and `CertificationCardFiles` therefore decode the object URL
themselves and mount `ImageCropDialog` only on success, toasting otherwise;
`mediaProps={{ onError }}` would catch it a frame later with a half-open dialog to unwind. A card's
second decode, inside `cropToBlob`, hits the browser cache.

Failures raised in the browser are `ImageCropError` (`lib/image-crop.ts`), because
`getApiErrorMessage` reads an axios response's `detail` and returns its `fallback` for everything
Expand Down Expand Up @@ -4904,7 +4903,7 @@ apart.
The pins `reads the gear list once`, `reads the certification list once` and `reads the stats once`
must use `mockImplementation`; with `mockResolvedValue` they pass despite the bug. The dashboard's
waits a beat, since effects run on a task and `findByText` returns before the second pass.
`avatar-card` and `units-card` do not loop but use identity-stable mocks too, so any copied
`user-fields-form` and `units-card` do not loop but use identity-stable mocks too, so any copied
neighbour is right.

A shared `src/test/` helper is rejected: `vi.mock` factories hoist above imports, so it is reachable
Expand Down
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,18 @@ one.
snapshot of it**.
- **Certifications** — keep photos of your c-cards on hand at the dive shop without digging out the
plastic.
- **Check-in** — the details a dive shop asks for at the desk, kept once: date of birth, phone, an
emergency contact and your dive insurance. One page gathers them with your c-cards, dive count and
last dive, and prints — your browser's save-as-PDF is the copy for a desk with no signal.
- **Check-in** — the details a dive shop asks for at the desk, kept once: a portrait cropped like a
passport photo, date of birth, phone, an emergency contact and your dive insurance. One page
gathers them with your c-cards, dive count and last dive, and prints — your browser's save-as-PDF
is the copy for a desk with no signal.
- **Courses** — the training itself, with the agency, instructor and shop: link the dives you did on
it and the cards it issued, so a course is one record instead of a shape you have to remember.
- **Full export** — one click to take _everything_ out in open formats: a **DiveJSON** document
holding the whole logbook, a **UDDF** one other programs import, a **CSV** for a spreadsheet, or a
complete **archive** carrying all three alongside every dive-computer file you uploaded and every
c-card image you stored. [DiveJSON](https://divejson.org) is the open dive-log interchange format
this project maintains, and this app is its reference implementation. A data-ownership log without
an exit door is a contradiction.
complete **archive** carrying all three alongside every dive-computer file you uploaded, every
c-card image you stored, and your profile picture and portrait. [DiveJSON](https://divejson.org)
is the open dive-log interchange format this project maintains, and this app is its reference
implementation. A data-ownership log without an exit door is a contradiction.
- **Logbook import** — and a door that only opens outwards is half a promise, so a whole logbook
reads straight back in: the **DiveJSON** document and the **archive**, and — through the
[DiveJSON converter](https://github.com/divejson/divejson-py) — **UDDF**, a **Subsurface** `.ssrf`
Expand Down
32 changes: 24 additions & 8 deletions src/app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ export default async function PrivacyPage() {
In Cloudflare R2, created under Cloudflare&rsquo;s EU
jurisdiction, which is its setting for keeping objects within the
European Union. That covers the dive-computer files kept with a
dive, certification card images, profile pictures, and the species
photographs §4.6 says this copy downloads.
dive, certification card images, profile pictures and portraits,
and the species photographs §4.6 says this copy downloads.
</OperatorAnswer>
<OperatorAnswer question="How email is sent — §4.3, §6.3">
Through Resend, with delivery routed through its EU region.
Expand Down Expand Up @@ -210,11 +210,12 @@ export default async function PrivacyPage() {
</li>
<li>
<strong>Check-in Details:</strong> What a dive shop asks for at
the desk, all of it optional: your date of birth and phone
number; an emergency contact, meaning somebody else&rsquo;s
name, phone number and relationship to you, which you enter; and
your dive insurance &mdash; the provider, the policy number and
when it expires
the desk, all of it optional: a portrait, meaning a photo of
your face cropped like a passport photo; your date of birth and
phone number; an emergency contact, meaning somebody
else&rsquo;s name, phone number and relationship to you, which
you enter; and your dive insurance &mdash; the provider, the
policy number and when it expires
</li>
<li>
<strong>Dive Logs:</strong> Dive location, depth, duration,
Expand All @@ -239,6 +240,16 @@ export default async function PrivacyPage() {
you give to reply to
</li>
</ul>
<p className="text-foreground mb-4">
Both pictures are kept as the file you picked, so that you can
change how either is cropped later, with the location and camera
details a phone photo carries removed by this server before the
file is stored; what the app shows is drawn from that file at the
crop you chose. Your portrait is shown to you where you edit it
and on your check-in page, and never where your profile picture
appears. The full archive export carries both files, and both are
deleted with your account.
</p>
<p className="text-foreground mb-4">
One thing arrives without you typing it. If you create your
account by signing in with Google, your Google profile picture is
Expand Down Expand Up @@ -357,7 +368,7 @@ export default async function PrivacyPage() {
2.3 Location Information
</h3>
<p className="text-foreground mb-4">
Location reaches this server three ways, and all three start with
Location reaches this server four ways, and all four start with
you:
</p>
<ul className="list-disc list-inside text-foreground mb-4 space-y-2">
Expand All @@ -376,6 +387,11 @@ export default async function PrivacyPage() {
dive site is, and where a trip went, and those are written as
the file gives them
</li>
<li>
GPS positions inside a photo you pick as your profile picture or
portrait, which travel with the upload and are removed by this
server before the photo is stored
</li>
</ul>
<p className="text-foreground mb-4">
The app never asks your browser where you are. That is not a
Expand Down
3 changes: 0 additions & 3 deletions src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Loader2 } from "lucide-react";

import { useAuth } from "@/contexts/AuthContext";
import { useAuthGuard } from "@/hooks/useAuthGuard";
import { AvatarCard } from "@/components/settings/avatar-card";
import { CheckInDetailsCard } from "@/components/settings/check-in-details-card";
import { DataExportCard } from "@/components/settings/data-export-card";
import { DataImportCard } from "@/components/settings/data-import-card";
Expand Down Expand Up @@ -49,8 +48,6 @@ export default function SettingsPage() {
<div className="grid lg:grid-cols-2 gap-8">
<ProfileCard />

<AvatarCard />

<EmailChangeCard currentEmail={user.email} />

<CheckInDetailsCard />
Expand Down
6 changes: 3 additions & 3 deletions src/components/auth/profile-completion-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ export function ProfileCompletionForm() {
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 items-center text-center">
{/* Initials, always: there is no account yet to fetch a picture from, and
no upload step here on purpose - see the avatar card in Settings. A
Google sign-up arrives at the dashboard with their Google picture
already imported by the API. */}
no upload step here on purpose - the profile picture is added with the
profile in Settings. A Google sign-up arrives at the dashboard with their
Google picture already imported by the API. */}
<UserAvatar
name={onboarding.name ?? onboarding.email}
size={64}
Expand Down
Loading
Loading