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
43 changes: 32 additions & 11 deletions apps/web/src/app/(app)/data-exports/DataExportsClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { AlertCircle, Download, Loader2, RefreshCw } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
Expand All @@ -20,6 +21,7 @@ import {
type UserExport,
type UserExportDisplayStatus,
} from './data-export-contract';
import { DownloadCodeDialog, type DownloadCodeChallenge } from './DownloadCodeDialog';

type BadgeVariant = React.ComponentProps<typeof Badge>['variant'];

Expand Down Expand Up @@ -115,16 +117,23 @@ export function DataExportsClient() {
})
);

const downloadMutation = useMutation(
trpc.userExports.createDownload.mutationOptions({
onSuccess: result => {
triggerBrowserDownload(result.downloadUrl);
// Downloading is a two-step step-up: mail a single-use code, then redeem it for
// one signed URL. A held session alone cannot reach the artifact.
const [challenge, setChallenge] = useState<DownloadCodeChallenge | null>(null);
const requestCodeMutation = useMutation(
trpc.userExports.requestDownloadCode.mutationOptions({
onSuccess: (result, variables) => {
setChallenge({
exportId: variables.exportId,
challengeId: result.challengeId,
expiresInMinutes: result.expiresInMinutes,
});
},
onError: error => {
toast.error('Download could not be started', {
toast.error('Download code could not be sent', {
description:
error.data?.code === 'PRECONDITION_FAILED'
? 'Download signing is temporarily unavailable. Try again later.'
error.data?.code === 'TOO_MANY_REQUESTS' || error.data?.code === 'PRECONDITION_FAILED'
? error.message
: 'Try again. If the export has expired, request a new one.',
});
},
Expand All @@ -151,7 +160,8 @@ export function DataExportsClient() {
<CardDescription>
The export includes your App Builder project titles and the prompt prefixes recorded
with your usage history. Large accounts can take a while to generate. We&apos;ll email
you when it&apos;s ready, and downloads expire 24 hours after that.
you when it&apos;s ready, and downloads expire 24 hours after that. Each download needs
a confirmation code we email you.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-start gap-3">
Expand Down Expand Up @@ -236,9 +246,10 @@ export function DataExportsClient() {
key={record.id}
record={record}
isPreparingThisDownload={
downloadMutation.isPending && downloadMutation.variables?.exportId === record.id
requestCodeMutation.isPending &&
requestCodeMutation.variables?.exportId === record.id
}
onDownload={() => downloadMutation.mutate({ exportId: record.id })}
onDownload={() => requestCodeMutation.mutate({ exportId: record.id })}
/>
))}
</ul>
Expand All @@ -263,6 +274,16 @@ export function DataExportsClient() {
)}
</CardContent>
</Card>

<DownloadCodeDialog
challenge={challenge}
isResending={requestCodeMutation.isPending}
onResend={() => {
if (challenge) requestCodeMutation.mutate({ exportId: challenge.exportId });
}}
onClose={() => setChallenge(null)}
onVerified={triggerBrowserDownload}
/>
</div>
);
}
Expand Down Expand Up @@ -335,7 +356,7 @@ function DownloadExportButton({
{isPreparingThisDownload ? (
<>
<Loader2 className="animate-spin" />
Preparing download...
Sending code...
</>
) : (
<>
Expand Down
166 changes: 166 additions & 0 deletions apps/web/src/app/(app)/data-exports/DownloadCodeDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
'use client';

import { useMutation } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useTRPC } from '@/lib/trpc/utils';
import { DOWNLOAD_CODE_LENGTH } from './data-export-contract';

const CODE_INPUT_ID = 'data-export-download-code';
const CODE_HINT_ID = 'data-export-download-code-hint';
const CODE_ERROR_ID = 'data-export-download-code-error';

export type DownloadCodeChallenge = {
exportId: string;
challengeId: string;
expiresInMinutes: number;
};

type DownloadCodeDialogProps = {
challenge: DownloadCodeChallenge | null;
isResending: boolean;
onResend: () => void;
onClose: () => void;
onVerified: (downloadUrl: string) => void;
};

function describeError(code: string | undefined, message: string): string {
switch (code) {
case 'UNAUTHORIZED':
case 'TOO_MANY_REQUESTS':
case 'CONFLICT':
// These carry actionable, user-safe copy from the server.
return message;
case 'PRECONDITION_FAILED':
return 'Download signing is temporarily unavailable. Your code is still valid, so try again in a few minutes.';
case 'NOT_FOUND':
return 'This export is no longer available to download. Request a new export.';
default:
return 'The download could not be started. Try again.';
}
}

export function DownloadCodeDialog({
challenge,
isResending,
onResend,
onClose,
onVerified,
}: DownloadCodeDialogProps) {
const trpc = useTRPC();
const [code, setCode] = useState('');

const createDownload = useMutation(
trpc.userExports.createDownload.mutationOptions({
onSuccess: result => {
onVerified(result.downloadUrl);
onClose();
},
})
);

// A newly issued challenge invalidates whatever was typed against the old one.
useEffect(() => {
setCode('');
createDownload.reset();
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset only when the challenge changes
}, [challenge?.challengeId]);

const errorMessage = createDownload.error
? describeError(createDownload.error.data?.code, createDownload.error.message)
: null;

return (
<Dialog
open={challenge !== null}
onOpenChange={open => {
if (!open) onClose();
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Enter your download code</DialogTitle>
<DialogDescription>
We emailed a {DOWNLOAD_CODE_LENGTH}-digit code to your account address. It authorizes
one download and expires in {challenge?.expiresInMinutes ?? 10} minutes.
</DialogDescription>
</DialogHeader>

<form
className="flex flex-col gap-2"
onSubmit={event => {
event.preventDefault();
if (!challenge || code.length !== DOWNLOAD_CODE_LENGTH) return;
createDownload.mutate({
exportId: challenge.exportId,
challengeId: challenge.challengeId,
code,
});
}}
>
<Label htmlFor={CODE_INPUT_ID}>Download code</Label>
<Input
id={CODE_INPUT_ID}
value={code}
onChange={event =>
setCode(event.target.value.replace(/\D/g, '').slice(0, DOWNLOAD_CODE_LENGTH))
}
inputMode="numeric"
autoComplete="one-time-code"
autoFocus
maxLength={DOWNLOAD_CODE_LENGTH}
aria-describedby={errorMessage ? CODE_ERROR_ID : CODE_HINT_ID}
aria-invalid={errorMessage !== null}
className="font-mono tracking-[0.4em]"
/>
{errorMessage ? (
<p id={CODE_ERROR_ID} role="alert" className="text-destructive text-sm">
{errorMessage}
</p>
) : (
<p id={CODE_HINT_ID} className="text-muted-foreground text-sm">
Do not share this code. It unlocks a copy of your account data.
</p>
)}

<DialogFooter className="mt-4 gap-2">
<Button type="button" variant="outline" onClick={onResend} disabled={isResending}>
{isResending ? (
<>
<Loader2 className="animate-spin" />
Sending code...
</>
) : (
'Send a new code'
)}
</Button>
<Button
type="submit"
disabled={code.length !== DOWNLOAD_CODE_LENGTH || createDownload.isPending}
>
{createDownload.isPending ? (
<>
<Loader2 className="animate-spin" />
Verifying code...
</>
) : (
'Download export'
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
10 changes: 10 additions & 0 deletions apps/web/src/app/(app)/data-exports/data-export-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
* router. The explicit status union keeps UI behavior easy to review.
*/

/**
* Digits in an emailed download code.
*
* The per-code attempt budget resets whenever a new code is issued, so it caps
* the guess *rate*, not the total. The search space is therefore what bounds a
* held session's odds over time, which is why this is wider than the 6 digits a
* sign-in code uses: those are spent in one sitting, this one is not.
*/
export const DOWNLOAD_CODE_LENGTH = 8;

export const USER_EXPORT_STATUSES = [
'queued',
'processing',
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/emails/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ Every template must include this branding footer below the content table:
| `clawComplementaryInferenceEnded.html` | `claw_url`, `year` | — |
| `accountDeletionRequest.html` | `email`, `year` | — |
| `userDataExportReady.html` | `data_exports_url`, `expiry_date`, `year` | — |
| `dataExportDownloadCode.html` | `code`, `email`, `expires_in`, `year` | — |
| `creditsTopUp.html` | `heading`, `intro`, `amount_usd`, `credits_usd`, `purchase_date`, `credits_url`, `receipt_section`, `year`. Org variants render org-specific copy into `intro` before template rendering; when provided, the organization name is interpolated there rather than passed as a separate template variable. | — |
| `kiloClawSubscriptionStarted.html` | `plan_name`, `price_usd`, `billing_period`, `next_billing_date`, `manage_url`, `year` | — |
| `securityFindingNew.html` | `severity`, `repository_name`, `finding_title`, `finding_description`, `finding_details`, `action_url`, `manage_notifications_url`, `year` | — |
Expand Down
Loading
Loading