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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ one.
converted file also gets a report of what the conversion could not carry. Records you already have
are matched rather than duplicated, and a dive you deleted returns under its own identity. The
archive additionally restores the dive-computer files and c-card scans, which the bare document
names by digest but does not carry.
names by digest but does not carry. The check-in details a logbook carries, and an archive's
portrait, are shown beside yours in the preview, and only what you choose there is saved.
- **Passwordless sign-in** — email magic links or Google; no passwords stored, ever.
- **Dark mode & responsive** — works on the boat, in the dive shop, and on your desk.

Expand Down
24 changes: 14 additions & 10 deletions src/app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,14 +241,16 @@ export default async function PrivacyPage() {
</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.
Both pictures are kept as the file you picked &mdash; or, for the
portrait, the one a full archive carries, if you take it in the
import preview &mdash; 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
Expand Down Expand Up @@ -389,8 +391,10 @@ export default async function PrivacyPage() {
</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
portrait, or inside the portrait carried by a full archive you
import, which travel with the upload and are removed by this
server before the photo is stored &mdash; an archive&rsquo;s
only if you take it in the import preview
</li>
</ul>
<p className="text-foreground mb-4">
Expand Down
6 changes: 4 additions & 2 deletions src/components/settings/data-export-card.render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,10 @@ describe("DataExportCard", () => {
expect(warning).toMatch(
/card scans and the portrait are personal documents/,
);
// In the archive, and - until an import can restore it - not put back by one.
expect(warning).toMatch(/portrait is in it too, though an import does not/);
// In the archive, and offered back by an import rather than restored silently.
expect(warning).toMatch(
/portrait is in it too, and importing the archive offers it back/,
);
});

it.each([
Expand Down
2 changes: 1 addition & 1 deletion src/components/settings/data-export-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ const EXPORT_ROWS: ExportRow[] = [
icon: FileArchive,
title: "Full archive",
description:
"Everything, as a zip: the DiveJSON, the UDDF, the full CSV set, every dive-computer file you uploaded — each under the recording it belongs to — and every certification card image you uploaded. This is the one to keep as a backup — it is also the only download that can put those files back, since the import card below can restore bytes it actually carries. Your portrait is in it too, though an import does not put that back yet. The card scans and the portrait are personal documents, so treat the file as one.",
"Everything, as a zip: the DiveJSON, the UDDF, the full CSV set, every dive-computer file you uploaded — each under the recording it belongs to — and every certification card image you uploaded. This is the one to keep as a backup — it is also the only download that can put those files back, since the import card below can restore bytes it actually carries. Your portrait is in it too, and importing the archive offers it back beside yours. The card scans and the portrait are personal documents, so treat the file as one.",
},
];

Expand Down
120 changes: 119 additions & 1 deletion src/components/settings/data-import-card.render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,18 @@ const mocks = vi.hoisted(() => ({
}));

vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ refreshUser: mocks.refreshUser }),
useAuth: () => ({ user: { name: "Alex" }, refreshUser: mocks.refreshUser }),
}));

// The account's portrait is fetched through this, the endpoint being owner-only.
// Answering with a URL is what puts its `<img>` on the page.
vi.mock("@/hooks/useAuthedBlobUrl", () => ({
useAuthedBlobUrl: (fetchBlob: unknown) => ({
url: fetchBlob ? "blob:portrait" : null,
isLoading: false,
hasError: false,
error: null,
}),
}));

vi.mock("@/lib/api/logbook-import", async (importOriginal) => ({
Expand Down Expand Up @@ -63,6 +74,7 @@ function preview(overrides: Partial<ImportPreview> = {}): ImportPreview {
archive: false,
token: "tok-1",
check_in_details: [],
portrait: null,
...overrides,
};
}
Expand Down Expand Up @@ -548,3 +560,109 @@ describe("the check-in details in an import preview", () => {
expect(mocks.refreshUser).not.toHaveBeenCalled();
});
});

describe("the archive's portrait in an import preview", () => {
const sha = "b".repeat(64);
const proposed = "data:image/webp;base64,UklGRg==";

const applyButton = () =>
screen.getByRole("button", { name: /import this logbook/i });

async function previewWith(overrides: Partial<ImportPreview>) {
mocks.apply.mockResolvedValue(report());
mocks.preview.mockResolvedValue(preview({ archive: true, ...overrides }));
render(<DataImportCard />);
await choose(new File(["zip"], "backup.zip", { type: "application/zip" }));
await screen.findByText(/nothing has been written yet/i);
}

it("shows the account's beside the archive's, the archive's chosen", async () => {
await previewWith({ portrait: { account_sha256: sha, proposed } });

const row = screen.getByRole("group", { name: "Portrait" });
expect(within(row).getByAltText("Portrait of Alex")).toHaveAttribute(
"src",
"blob:portrait",
);
expect(within(row).getByAltText("Portrait from this file")).toHaveAttribute(
"src",
proposed,
);
expect(
within(row).getByText("This file's replaces yours when you import."),
).toBeVisible();

await userEvent.click(applyButton());
await waitFor(() => expect(mocks.apply).toHaveBeenCalledTimes(1));
expect(mocks.apply.mock.calls[0][3]).toEqual({
choice: "take",
account_sha256: sha,
});
});

it("sends keep, with the digest the preview showed, after Keep mine", async () => {
await previewWith({ portrait: { account_sha256: sha, proposed } });

const row = screen.getByRole("group", { name: "Portrait" });
await userEvent.click(
within(row).getByRole("button", { name: "Keep mine" }),
);
expect(
within(row).getByText("Keeping yours; nothing from this file is saved."),
).toBeVisible();

await userEvent.click(applyButton());
await waitFor(() => expect(mocks.apply).toHaveBeenCalledTimes(1));
// The digest goes on keep too: the API requires the key either way.
expect(mocks.apply.mock.calls[0][3]).toEqual({
choice: "keep",
account_sha256: sha,
});
});

it("sends a null digest for an account with no portrait", async () => {
await previewWith({ portrait: { account_sha256: null, proposed } });

const row = screen.getByRole("group", { name: "Portrait" });
expect(within(row).getByRole("img", { name: "No portrait" })).toBeVisible();
await userEvent.click(
within(row).getByRole("button", { name: "Leave unset" }),
);

await userEvent.click(applyButton());
await waitFor(() => expect(mocks.apply).toHaveBeenCalledTimes(1));
expect(mocks.apply.mock.calls[0][3]).toEqual({
choice: "keep",
account_sha256: null,
});
});

it("shows the section with the portrait alone for an archive carrying no fact", async () => {
await previewWith({ portrait: { account_sha256: sha, proposed } });

expect(screen.getByText("Check-in details")).toBeVisible();
expect(screen.getAllByRole("group")).toHaveLength(1);
expect(screen.getByRole("group", { name: "Portrait" })).toBeVisible();
});

it("shows no row, and sends no choice, when nothing is offered", async () => {
await previewWith({
check_in_details: [{ detail: "phone", account: null, proposed: "+44 2" }],
});

expect(screen.getByText("Check-in details")).toBeVisible();
expect(screen.queryByRole("group", { name: "Portrait" })).toBeNull();
await userEvent.click(applyButton());
await waitFor(() => expect(mocks.apply).toHaveBeenCalledTimes(1));
expect(mocks.apply.mock.calls[0][3]).toBeUndefined();
});

it("says in the dropzone that the portrait and the check-in details are offered", () => {
render(<DataImportCard />);
expect(
screen.getByText(
/check-in details a logbook carries, and an archive.s portrait, are shown beside yours/,
),
).toBeVisible();
});
});
30 changes: 17 additions & 13 deletions src/components/settings/data-import-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { useToast } from "@/components/ui/use-toast";
import {
ImportCheckInDetails,
useImportCheckIn,
type ImportCheckInChoices,
} from "@/components/settings/import-check-in-details";
import { useAuth } from "@/contexts/AuthContext";
import { getApiErrorMessage } from "@/lib/api/error";
Expand All @@ -25,7 +26,6 @@ import {
LOGBOOK_IMPORT_ACCEPT,
MAX_IMPORT_ARCHIVE_SIZE,
MAX_IMPORT_DOCUMENT_SIZE,
type ImportCheckInSubmission,
type ImportPreview,
type ImportReport,
} from "@/lib/api/logbook-import";
Expand Down Expand Up @@ -290,14 +290,14 @@ function PendingImport({
file: File;
preview: ImportPreview;
isApplying: boolean;
onApply: (checkIn: ImportCheckInSubmission | undefined) => void;
onApply: (choices: ImportCheckInChoices) => void;
onCancel: () => void;
}) {
const checkIn = useImportCheckIn(preview.check_in_details);
const checkIn = useImportCheckIn(preview.check_in_details, preview.portrait);

const handleApply = async () => {
const submission = await checkIn.collect();
if (submission !== null) onApply(submission);
const choices = await checkIn.collect();
if (choices !== null) onApply(choices);
};

return (
Expand Down Expand Up @@ -421,19 +421,21 @@ export function DataImportCard() {
}
};

const handleApply = async (checkIn: ImportCheckInSubmission | undefined) => {
const handleApply = async ({ details, portrait }: ImportCheckInChoices) => {
if (!pending) return;

try {
setIsApplying(true);
const applied = await logbookImportAPI.apply(
pending.file,
pending.preview.token,
checkIn,
details,
portrait,
);
// Only when a fact changed: the check-in card on this page seeds from the
// signed-in user, and saving it from a stale copy would send the imported
// facts back as nulls. Not otherwise, since a refresh resets every mounted
// Only when a fact or the portrait changed: the check-in card on this page
// seeds from the signed-in user, and saving it from a stale copy would send
// the imported facts back as nulls, and the portrait slot and "Adjust" read
// their digests from it. Not otherwise, since a refresh resets every mounted
// form seeded from that user.
if (checkInWasWritten(applied)) await refreshUser();
setResult(applied);
Expand Down Expand Up @@ -488,9 +490,11 @@ export function DataImportCard() {
and you are told what the conversion could not carry. A .zip is
either a full OpenDiving archive — which restores your
dive-computer files and certification scans as well — or a folder
of dive-computer files, read as one logbook. Records already in
your logbook are matched rather than duplicated, and a dive you
deleted comes back under its own identity.
of dive-computer files, read as one logbook. The check-in details
a logbook carries, and an archive&rsquo;s portrait, are shown
beside yours first, and you choose which are saved. Records
already in your logbook are matched rather than duplicated, and a
dive you deleted comes back under its own identity.
</p>
</div>
<div>
Expand Down
Loading
Loading