Skip to content
Open
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
5 changes: 3 additions & 2 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ Manage shared web env var additions and rotations with `pnpm web:env set <VARIAB
- `PYLON_HOST` - Optional Pylon API host override for local user-deletion cleanup; defaults to `https://api.usepylon.com`. [SERVER]
- `PYLON_FINAL_EMAIL_AUTHOR_USER_ID` - Optional Pylon staff user id used to match an already-posted deletion reply. [SERVER]
- `CUSTOMERIO_TRACK_BASE` - Optional Customer.io Track API base override for local user-deletion cleanup; defaults to `https://track.customer.io`. [SERVER]
- `SUBSTACK_PUBLICATION_URL` - Substack publication origin used by user-deletion subscriber cleanup; no default. Required for the Substack deletion step. [SERVER]
- `SUBSTACK_PUBLICATION_URL` - Substack publication origin used by user-deletion subscriber cleanup; defaults to `https://blog.kilo.ai`. Must be `blog.kilo.ai` or a `*.substack.com` host. The Substack admin search URL is hardcoded to `https://kilocode.substack.com/publish/subscribers`, not this publication. [SERVER]
- `CSA_APP_BASE_URL` - CSA origin used by the Cloud deletion worker to call `POST /api/internal/cloud/users/gdpr-scrub`. Example: the production CSA app URL. [SERVER]
- `SENTRY_ORG` - Sentry organization slug for source map uploads; used in `apps/web/next.config.mjs`. `[SECRET]`
- `SENTRY_PROJECT` - Sentry project slug for source map uploads; used in `apps/web/next.config.mjs`. `[SECRET]`
- `SENTRY_AUTH_TOKEN` - Sentry auth token for source map uploads; used in `apps/web/next.config.mjs`. `[SECRET]`
Expand Down Expand Up @@ -77,7 +78,7 @@ Manage shared web env var additions and rotations with `pnpm web:env set <VARIAB
- `STYTCH_PROJECT_SECRET` - Stytch project secret. `[SECRET]`
- `STYTCH_PUBLIC_TOKEN` - Stytch legacy public token alias used in some test fixtures. [PUBLIC]
- `INTERNAL_API_SECRET` - Shared secret for internal API calls between services; used in `apps/web/src/lib/kiloclaw/cli-runs.test.ts`, `kiloclaw-router.test.ts`, dev seed scripts, and other service routers. `[SECRET]`
- `SUPPORT_API_SECRET` - Shared bearer token for Customer Support Automation (CSA) internal API calls; used in `apps/web/src/app/api/internal/support/`. Leak can look up any email and enqueue deletion for non-admin, non-bot, non-live-subscription customers; access disable is deferred to worker preflight and pending requests can be cancelled. Keep production values off preview deployments; rotate Cloud and CSA together. `[SECRET]`
- `SUPPORT_API_SECRET` - Shared bearer token for Customer Support Automation (CSA) internal API calls. Cloud uses it to authorize CSA → Cloud `apps/web/src/app/api/internal/support/` and Cloud → CSA `POST /api/internal/cloud/users/gdpr-scrub`. A CSA compromise can also call Cloud deletion and Cloud can scrub CSA-local PII. Leak can look up any email and enqueue deletion for non-admin, non-bot, non-live-subscription customers; access disable is deferred to worker preflight and pending requests can be cancelled. Keep production values off preview deployments; rotate Cloud and CSA together. `[SECRET]`
- `CALLBACK_TOKEN_SECRET` - Secret for signing callback tokens. Required for local development. `[SECRET]`
- `INTERNAL_SECRET` - Alias/fallback for `INTERNAL_API_SECRET`; used in KiloClaw E2E scripts (`services/kiloclaw/e2e/`). `[SECRET]`

Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/app/admin/api/users/gdpr-removal/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ describe('/admin/api/users/gdpr-removal', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetUserFromAuth.mockResolvedValue({
user: { id: ADMIN_ID },
user: { id: ADMIN_ID, google_user_email: 'admin@example.com' },
authFailedResponse: null,
} as never);
mockedFindUserById.mockResolvedValue({
Expand Down Expand Up @@ -181,8 +181,9 @@ describe('/admin/api/users/gdpr-removal', () => {

expect(response.status).toBe(202);
expect(mockedEnqueueUserDeletionTargets).toHaveBeenCalledWith({
actor: { kiloUserId: ADMIN_ID },
actor: { kiloUserId: ADMIN_ID, email: 'admin@example.com' },
targets: [{ email: USER_EMAIL, trustedUserId: USER_ID }],
catalogVersion: 2,
});
await expect(response.json()).resolves.toEqual({
requestId: REQUEST_ID,
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/admin/api/users/gdpr-removal/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<GdprRemov
const [result] = await enqueueUserDeletionTargets({
actor: { kiloUserId: admin.id, email: admin.google_user_email },
targets: [{ email: user.google_user_email, trustedUserId: user.id }],
catalogVersion: 2,
});

if (!result) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -478,12 +478,12 @@ function cardDescription(request: DeletionRequest | null, busy: Busy | null): st
return 'Checking whether a deletion request already exists.';
}
if (request?.status === 'completed') {
return 'Deletion finished for the Cloud account and CSA-scoped providers.';
return 'Deletion finished for the Cloud account, PostHog, Substack, Pylon, and CSA support data.';
}
if (request) {
return 'Deletion is queued. Remaining work continues on cron. Use the deletion queue for recovery.';
}
return 'This action is irreversible and will permanently delete all data associated with this user. Note: This will NOT delete all data - additional manual steps are required as outlined in our handbook.';
return 'This action is irreversible and will permanently delete Cloud, PostHog, Substack, Pylon, and CSA support data for this user. Stripe, Stytch, Sentry, and other handbook systems still need manual steps.';
}

function ViewGdprHandbookLink() {
Expand Down
29 changes: 14 additions & 15 deletions apps/web/src/app/admin/deletion-queue/DeletionQueueContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { inferRouterOutputs } from '@trpc/server';
import { AlertCircle, RefreshCw, Search, X } from 'lucide-react';
// Temporarily unused while Add requests / Substack credential are hidden.
// import { KeyRound, Plus } from 'lucide-react';
import { AlertCircle, KeyRound, Plus, RefreshCw, Search, X } from 'lucide-react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { toast } from 'sonner';

Expand Down Expand Up @@ -58,6 +56,7 @@ import {
} from './deletion-queue-format';

const COLUMN_COUNT = 7;
const SHOW_CLOUD_INTAKE = true;

type RouterOutputs = inferRouterOutputs<RootRouter>;
type QueueSummary = RouterOutputs['admin']['userDeletionQueue']['summary'];
Expand Down Expand Up @@ -132,16 +131,16 @@ export function DeletionQueueContent() {
>
<RefreshCw className={listQuery.isFetching ? 'animate-spin' : undefined} /> Refresh
</Button>
{/* Temporarily disabled: Substack task is not in the active catalog.
<Button variant="secondary" size="sm" onClick={() => setCredentialOpen(true)}>
<KeyRound /> Substack credential
</Button>
*/}
{/* Temporarily disabled: start deletion via CSA API or the user profile page.
<Button size="sm" onClick={() => setAddOpen(true)}>
<Plus /> Add requests
</Button>
*/}
{SHOW_CLOUD_INTAKE ? (
<>
<Button variant="secondary" size="sm" onClick={() => setCredentialOpen(true)}>
<KeyRound /> Substack credential
</Button>
<Button size="sm" onClick={() => setAddOpen(true)}>
<Plus /> Add requests
</Button>
</>
) : null}
</div>
</div>

Expand Down Expand Up @@ -533,8 +532,8 @@ function AddRequestsDialog({
<p className="font-medium">Accepted ({preview.accepted.length})</p>
<ul className="space-y-1 text-xs">
{preview.accepted.map(entry => (
<li key={entry.email} className="font-mono">
{entry.email}
<li key={`${entry.email}:${entry.pylonTicket ?? ''}`} className="font-mono">
{entry.email || entry.pylonTicket}
{entry.pylonTicket ? ` · ${entry.pylonTicket}` : ''}
{entry.warnings.length > 0 ? (
<span className="text-status-warning block font-sans">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,12 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { UserDeletionStepKey } from '@kilocode/db/schema-types';
import { deletionAttentionHint } from '@/lib/user/deletion-queue/deletion-hints';
import {
deletionAttentionHint,
deletionManualSearchHref,
} from '@/lib/user/deletion-queue/deletion-hints';
import {
deletionPreflightProgress,
deletionStepDescription,
deletionStepLabel,
deletionStepProgressLabel,
Expand All @@ -45,6 +49,7 @@ import {
formatTimestamp,
humanizeToken,
statusBadgeClass,
type DeletionProgressKind,
} from '../deletion-queue-format';

type RouterOutputs = inferRouterOutputs<RootRouter>;
Expand Down Expand Up @@ -216,7 +221,7 @@ export function DeletionQueueDetailContent({
<Card>
<CardHeader>
<CardTitle>Steps</CardTitle>
<CardDescription>Cleanup, then Cloud user, then Pylon.</CardDescription>
<CardDescription>Preflight, then cleanup, then Cloud user, then Pylon.</CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<Table>
Expand All @@ -236,6 +241,12 @@ export function DeletionQueueDetailContent({
const stuck =
task.status === 'needs_attention' || task.status === 'manual_action_required';
const hint = stuck ? deletionAttentionHint(task.lastErrorCode) : null;
const search = stuck
? deletionManualSearchHref({
stepKey: task.stepKey,
email: request.email,
})
: null;
return (
<TableRow key={task.stepKey}>
<TableCell className="text-xs">{deletionStepLabel(task.stepKey)}</TableCell>
Expand All @@ -251,6 +262,16 @@ export function DeletionQueueDetailContent({
{hint.title}. {hint.action}
</p>
) : null}
{search ? (
<a
href={search.href}
target="_blank"
rel="noreferrer"
className="text-link hover:text-link-hover mt-1 inline-block underline"
>
{search.label}
</a>
) : null}
</TableCell>
<TableCell className="text-sm tabular-nums">
{task.windowAttemptCount}/{task.lifetimeAttemptCount}
Expand Down Expand Up @@ -368,6 +389,7 @@ function CompactDeletionDetail({
onInvalidate: () => Promise<void>;
}) {
const request = detail.request;
const preflightKind = deletionPreflightProgress(request);
const ticket = request.pylonTicket ? `#${request.pylonTicket.replace(/^#/, '')}` : null;
const stuckTask = detail.tasks.find(
task => task.status === 'needs_attention' || task.status === 'manual_action_required'
Expand Down Expand Up @@ -450,24 +472,27 @@ function CompactDeletionDetail({
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
Progress
</p>
<ProgressGroup label="Preflight" hint="Before cleanup starts">
<ProgressStepTile
label="Preflight"
description={
preflightKind === 'stuck' && request.preflightAttentionCode
? request.preflightAttentionCode
: 'Confirm identity, subscriptions, and delete-ready'
}
kind={preflightKind}
/>
</ProgressGroup>
{PROGRESS_GROUPS.map((group, groupIndex) => {
const tasks = group.stepKeys.flatMap(stepKey => {
const task = detail.tasks.find(item => item.stepKey === stepKey);
return task ? [task] : [];
});
if (tasks.length === 0) return null;
const unlocked = isProgressGroupUnlocked(detail.tasks, groupIndex);
const unlocked =
preflightKind === 'finished' && isProgressGroupUnlocked(detail.tasks, groupIndex);
return (
<div key={group.label} className="flex flex-col gap-2">
{groupIndex > 0 ? (
<p className="text-muted-foreground text-center text-[11px] tracking-wide uppercase">
then
</p>
) : null}
<div>
<p className="text-foreground text-xs font-medium">{group.label}</p>
<p className="text-muted-foreground text-[11px]">{group.hint}</p>
</div>
<ProgressGroup key={group.label} label={group.label} hint={group.hint} showThen>
<div
className={
group.stepKeys.length > 1
Expand All @@ -476,14 +501,14 @@ function CompactDeletionDetail({
}
>
{tasks.map(task => (
<ProgressStepTile
<CatalogProgressTile
key={task.stepKey}
task={task}
current={unlocked && isOpenTask(task.status)}
/>
))}
</div>
</div>
</ProgressGroup>
);
})}
</div>
Expand Down Expand Up @@ -584,10 +609,20 @@ const PROGRESS_GROUPS = [
stepKeys: [UserDeletionStepKey.PylonReply],
},
{
label: 'Pylon delete',
label: 'Pylon finalize',
hint: 'After the reply is posted',
stepKeys: [UserDeletionStepKey.PylonFinalize],
},
{
label: 'Pylon delete',
hint: 'After the ticket is tagged and closed',
stepKeys: [UserDeletionStepKey.PylonContact],
},
{
label: 'CSA support DB',
hint: 'After the Pylon contact is deleted',
stepKeys: [UserDeletionStepKey.CsaSupportDb],
},
] as const;

function isFinishedTask(status: string): boolean {
Expand All @@ -611,7 +646,34 @@ function isProgressGroupUnlocked(tasks: Task[], groupIndex: number): boolean {
);
}

function ProgressStepTile({ task, current }: { task: Task; current: boolean }) {
function ProgressGroup({
label,
hint,
showThen = false,
children,
}: {
label: string;
hint: string;
showThen?: boolean;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-2">
{showThen ? (
<p className="text-muted-foreground text-center text-[11px] tracking-wide uppercase">
then
</p>
) : null}
<div>
<p className="text-foreground text-xs font-medium">{label}</p>
<p className="text-muted-foreground text-[11px]">{hint}</p>
</div>
{children}
</div>
);
}

function CatalogProgressTile({ task, current }: { task: Task; current: boolean }) {
const finished = isFinishedTask(task.status);
const stuck = isStuckTask(task.status);
const countLabel = deletionStepProgressLabel(
Expand All @@ -627,24 +689,42 @@ function ProgressStepTile({ task, current }: { task: Task; current: boolean }) {
: finished && countLabel
? countLabel
: deletionStepDescription(task.stepKey);
return (
<ProgressStepTile
label={deletionStepLabel(task.stepKey)}
description={description}
kind={finished ? 'finished' : stuck ? 'stuck' : current ? 'current' : 'idle'}
/>
);
}

function ProgressStepTile({
label,
description,
kind,
}: {
label: string;
description: string;
kind: DeletionProgressKind;
}) {
return (
<div
className={cn(
'flex items-start gap-2.5 rounded-lg border p-2.5 text-xs',
finished
kind === 'finished'
? 'border-status-success-border bg-status-success-surface'
: stuck
: kind === 'stuck'
? 'border-status-warning-border bg-status-warning-surface'
: current
: kind === 'current'
? 'border-status-info-border bg-status-info-surface'
: 'border-border bg-card text-muted-foreground'
)}
>
<span className="shrink-0 font-semibold">
{finished ? <Check className="size-3.5" /> : current ? '▸' : '·'}
{kind === 'finished' ? <Check className="size-3.5" /> : kind === 'current' ? '▸' : '·'}
</span>
<div className="min-w-0">
<p className="text-foreground font-medium">{deletionStepLabel(task.stepKey)}</p>
<p className="text-foreground font-medium">{label}</p>
<p className="text-muted-foreground mt-0.5">{description}</p>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
deletionPreflightProgress,
deletionStepCountLabel,
deletionStepProgressLabel,
formatActivityDetail,
Expand Down Expand Up @@ -113,6 +114,38 @@ describe('formatActivityDetail', () => {
});
});

describe('deletionPreflightProgress', () => {
it('is current while pending without an attention code', () => {
expect(deletionPreflightProgress({ status: 'pending', preflightAttentionCode: null })).toBe(
'current'
);
});

it('is stuck while pending with an attention code', () => {
expect(
deletionPreflightProgress({
status: 'pending',
preflightAttentionCode: 'delete_ready_missing',
})
).toBe('stuck');
});

it('is finished after promotion', () => {
expect(deletionPreflightProgress({ status: 'in_progress', preflightAttentionCode: null })).toBe(
'finished'
);
expect(deletionPreflightProgress({ status: 'completed', preflightAttentionCode: null })).toBe(
'finished'
);
});

it('stays idle when cancelled before promotion', () => {
expect(deletionPreflightProgress({ status: 'cancelled', preflightAttentionCode: null })).toBe(
'idle'
);
});
});

describe('parseDeletionQueueTab', () => {
it('accepts the remaining tabs and falls back unknown values to open', () => {
expect(parseDeletionQueueTab('open')).toBe('open');
Expand Down
Loading
Loading