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
94 changes: 94 additions & 0 deletions src/app/(app)/account/account-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import { changePassword, startLogin } from "@/lib/auth/client-prelude";
import { HIBP_ENABLED, pwnedPasswordCount } from "@/lib/auth/pwned";
import * as deviceUnlock from "@/lib/idb/device-unlock";
import { useKeyBundle } from "@/stores/key-bundle";
import {
buildAccountExport,
downloadAccountExport,
type RawAccountExport,
} from "@/lib/export/account-export";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
Expand Down Expand Up @@ -62,6 +67,7 @@ export function AccountClient({
<SessionsSection />
<ApiTokensSection />
<DataSection />
<BulkBoardExportSection />
<DangerSection />
</div>
);
Expand Down Expand Up @@ -344,6 +350,94 @@ function DataSection() {
);
}

/**
* DSGVO Art. 20 — decrypted, portable export of every board in every
* workspace. The sibling section above ships what the *server* holds, which is
* ciphertext; this one is the readable counterpart, and it can only exist on
* the client because the server has no keys.
*/
function BulkBoardExportSection() {
const userKeyPair = useKeyBundle((s) => s.userKeyPair);
const hydrated = useKeyBundle((s) => s.hydrated);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState<{ done: number; total: number } | null>(null);

const locked = hydrated && !userKeyPair;

async function exportAllBoards() {
if (!userKeyPair) return;
const ok = window.confirm(
"This downloads a plaintext JSON of every board you can read: cards, " +
"comments, checklists and custom-field values across all workspaces. " +
"Keep the file safe, its contents are no longer end-to-end encrypted.\n\nContinue?",
);
if (!ok) return;
setBusy(true);
setError(null);
setProgress(null);
try {
const res = await fetch("/api/me/export");
if (!res.ok) {
setError("Could not fetch your data.");
return;
}
const raw = (await res.json()) as RawAccountExport;
const snapshot = await buildAccountExport({
raw,
userKeyPair,
exportedAt: new Date().toISOString(),
onProgress: (done, total) => setProgress({ done, total }),
});
downloadAccountExport(snapshot);
if (snapshot.skipped.length > 0) {
setError(
`${snapshot.skipped.length} board(s) could not be decrypted and are listed under "skipped" in the file.`,
);
}
} catch {
setError("Could not build the export.");
} finally {
setBusy(false);
setProgress(null);
}
}

return (
<Card title="Export all boards (decrypted)">
<p className="text-sm text-[color:var(--color-muted-foreground)]">
Download every board from every workspace as one readable JSON file. Decryption happens in
this browser, so the server never sees the plaintext.
</p>
{locked ? (
<p className="text-sm text-[color:var(--color-muted-foreground)]">
Your vault is locked. Open a board once to unlock it, then come back.
</p>
) : (
<div>
<Button
type="button"
variant="outline"
onClick={exportAllBoards}
disabled={busy || !userKeyPair}
>
{busy
? progress
? `Decrypting ${progress.done}/${progress.total} boards…`
: "Preparing…"
: "Download all boards (JSON)"}
</Button>
</div>
)}
{error ? (
<p role="alert" className="text-sm text-[color:var(--color-danger)]">
{error}
</p>
) : null}
</Card>
);
}

/** DSGVO Art. 17 — crypto-shred + scheduled hard delete (#46). */
function DangerSection() {
const router = useRouter();
Expand Down
141 changes: 92 additions & 49 deletions src/app/(app)/workspaces/[wsId]/boards/[boardId]/board-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { useKeyBundle } from "@/stores/key-bundle";
import { useBoardLive } from "@/hooks/use-board-live";
import { useBoardPresence } from "@/hooks/use-board-presence";
import { loadBoardSnapshot, saveBoardSnapshot } from "@/lib/idb/board-cache";
import { queuedFetch } from "@/lib/offline/sync";
import { queueOp, queuedFetch } from "@/lib/offline/sync";
import {
downloadCsvExport,
downloadExport,
Expand Down Expand Up @@ -836,96 +836,139 @@ export function BoardClient({
const t = templates.find((x) => x.id === templateId);
if (!t) return;

// Every id in the chain is minted up front (ADR 0023). That is what makes
// the whole template applicable offline: previously each POST had to wait
// for the previous response to learn the id it needed, so nothing could be
// queued. With the ids known in advance the ops are independent, and
// flushOutbox replays them oldest-first, so a child never lands before its
// parent. Every create endpoint in the chain upserts idempotently, so a
// partial replay is safe to repeat.
const newCardId = crypto.randomUUID();

const cardMeta: CardMeta = { title: t.body.title };
if (t.body.description) cardMeta.description = t.body.description;
const env = encryptToEnvelope({
key: boardKey,
payload: cardMeta,
bindTo: "card:new",
});
const res = await fetch(`/api/columns/${columnId}/cards`, {
const { queued, res } = await queuedFetch(currentUserId, {
boardId,
label: `Create card from template "${t.body.title}"`,
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enc_content: env.serialised }),
url: `/api/columns/${columnId}/cards`,
body: { id: newCardId, enc_content: env.serialised },
});
if (!res.ok) return;
const createdCard = (await res.json()) as ApiCard;
if (!queued && !(res && res.ok)) return;

// Append optimistically with zero checklist items; we'll patch the
// progress after all the items have been posted.
const createdCard = !queued && res ? ((await res.json()) as ApiCard) : null;

// Once any link of the chain is queued, every later one must be queued
// too, even if the browser still reports itself online. Otherwise a
// transient failure on the card POST would queue it while the checklist
// POST fired directly and 404'd, because the card does not exist server
// side yet. queuedFetch's per-card ordering guard cannot cover this: the
// card create posts to /api/columns/<id>/cards, which carries no card id
// in its path, so it shares no ordering key with its children.
let chainQueued = queued;
async function chainSend(op: {
label: string;
method: "POST" | "PATCH";
url: string;
body: unknown;
}): Promise<void> {
if (chainQueued) {
await queueOp(currentUserId, { boardId, ...op });
return;
}
const r = await queuedFetch(currentUserId, { boardId, ...op });
if (r.queued) chainQueued = true;
}

// Count from the template itself rather than from successful responses:
// offline there are no responses, and the counts have to match what the
// queued ops will create.
let total = 0;
let done = 0;
for (const cl of t.body.checklists ?? []) {
for (const it of cl.items ?? []) {
total += 1;
if (it.done) done += 1;
}
}

// Append optimistically. Offline we synthesize a tail position the same
// way createCard does, so ordering stays stable until the real row lands.
const tailPos = cards
.filter((c) => c.columnId === columnId)
.reduce((m, c) => Math.max(m, c.position), 0);
setCards((prev) => [
...prev,
{
id: createdCard.id,
columnId: createdCard.columnId,
id: createdCard?.id ?? newCardId,
columnId: createdCard?.columnId ?? columnId,
title: t.body.title,
description: t.body.description,
position: createdCard.position,
dueAt: createdCard.dueAt,
startAt: createdCard.startAt,
archived: createdCard.archived,
labelIds: createdCard.labelIds,
assigneeIds: createdCard.assigneeIds,
milestoneId: createdCard.milestoneId,
parentId: createdCard.parentId,
checklistProgress: { total: 0, done: 0 },
position: createdCard?.position ?? tailPos + 1000,
dueAt: createdCard?.dueAt ?? null,
startAt: createdCard?.startAt ?? null,
archived: createdCard?.archived ?? false,
labelIds: createdCard?.labelIds ?? [],
assigneeIds: createdCard?.assigneeIds ?? [],
milestoneId: createdCard?.milestoneId ?? null,
parentId: createdCard?.parentId ?? null,
checklistProgress: { total, done },
},
]);

let total = 0;
let done = 0;
for (const cl of t.body.checklists ?? []) {
const checklistId = crypto.randomUUID();
const titleEnv = encryptToEnvelope({
key: boardKey,
payload: { title: cl.title },
bindTo: "checklist:new",
});
const clRes = await fetch(`/api/cards/${createdCard.id}/checklists`, {
await chainSend({
label: `Add checklist "${cl.title}"`,
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enc_title: titleEnv.serialised }),
url: `/api/cards/${newCardId}/checklists`,
body: { id: checklistId, enc_title: titleEnv.serialised },
});
if (!clRes.ok) continue;
const createdCl = (await clRes.json()) as { id: string };

for (const it of cl.items ?? []) {
const itemId = crypto.randomUUID();
const itEnv = encryptToEnvelope({
key: boardKey,
payload: { content: it.content },
bindTo: "checklist-item:new",
});
const itemRes = await fetch(`/api/checklists/${createdCl.id}/items`, {
await chainSend({
label: "Add checklist item",
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enc_content: itEnv.serialised }),
url: `/api/checklists/${checklistId}/items`,
body: { id: itemId, enc_content: itEnv.serialised },
});
if (!itemRes.ok) continue;
total += 1;
if (it.done) {
// Persist initial `done` state for items that were ticked
// in the template.
const item = (await itemRes.json()) as { id: string };
done += 1;
await fetch(`/api/checklist-items/${item.id}`, {
// Persist the initial `done` state for items ticked in the template.
await chainSend({
label: "Tick checklist item",
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ done: true }),
url: `/api/checklist-items/${itemId}`,
body: { done: true },
});
}
}
}
if (total > 0) {
setCards((prev) =>
prev.map((c) =>
c.id === createdCard.id ? { ...c, checklistProgress: { total, done } } : c,
),
);

bumpActivity();
// Automation runs on online clients only; skip the trigger emit offline.
if (!queued) {
emitAutomationTrigger({
type: "card_created",
cardId: newCardId,
columnId,
});
}
emitAutomationTrigger({
type: "card_created",
cardId: createdCard.id,
columnId: createdCard.columnId,
});
}

async function deleteTemplate(templateId: string): Promise<void> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ export function KanbanView({
onCreateFromTemplate(col.id, t.id);
setTemplatePickerFor(null);
}}
aria-label={`Create card from template ${t.body.title}`}
className="flex-1 truncate rounded px-2 py-1 text-left text-xs hover:bg-[color:var(--color-card)]"
title={t.body.description ?? ""}
>
Expand Down
15 changes: 15 additions & 0 deletions src/app/api/cards/[id]/checklists/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@ interface RouteContext {
}

const createSchema = z.object({
/**
* Optional client-generated checklist id for offline create (ADR 0023).
* Needed because creating a card from a template is a POST chain in which
* each step needs the previous step's id: minting the ids up front makes
* the whole chain queueable. A replay of the same id returns the existing
* checklist (re-authorized against its current card) instead of a
* duplicate. Same charset/length lockdown as the card route.
*/
id: z
.string()
.min(20)
.max(64)
.regex(/^[A-Za-z0-9_-]+$/)
.optional(),
enc_title: z.string().min(8).max(8192),
/**
* Optional idempotency token for the automation `append_checklist`
Expand Down Expand Up @@ -50,6 +64,7 @@ export async function POST(req: NextRequest, ctx: RouteContext) {
const c = await Checklists.create({
cardId: id,
userId,
...(body.id ? { id: body.id } : {}),
enc_title: body.enc_title,
});
return NextResponse.json(c, { status: 201 });
Expand Down
13 changes: 13 additions & 0 deletions src/app/api/checklists/[id]/items/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ interface RouteContext {
}

const createSchema = z.object({
/**
* Optional client-generated item id for offline create (ADR 0023) — see the
* checklist route for the rationale. A replay of the same id returns the
* existing item (re-authorized against its current board) rather than
* appending a duplicate.
*/
id: z
.string()
.min(20)
.max(64)
.regex(/^[A-Za-z0-9_-]+$/)
.optional(),
enc_content: z.string().min(8).max(8192),
});

Expand All @@ -24,6 +36,7 @@ export async function POST(req: NextRequest, ctx: RouteContext) {
const item = await Checklists.addItem({
checklistId: id,
userId,
...(body.id ? { id: body.id } : {}),
enc_content: body.enc_content,
});
return NextResponse.json(item, { status: 201 });
Expand Down
Loading
Loading