Skip to content

Commit ee2748f

Browse files
authored
feat(admin): add KiloClaw instance link on user profile page (#1344)
## Summary - Add a "View KiloClaw" button to the admin user profile page's KiloClaw section that links directly to the user's active KiloClaw instance admin page (`/admin/kiloclaw/[id]`). - Extends the `getKiloClawState` tRPC procedure to also query `kiloclaw_instances` for the user's active instance (where `destroyed_at IS NULL`) and return the `activeInstanceId`. - The link button appears consistently in all card states (subscription present, earlybird-only, no subscription) whenever the user has an active instance. Uses the same cross-link pattern as Gastown's "Inspect" buttons (`Button variant="outline"` with `ExternalLink` icon). ## Verification - [x] `tsgo --noEmit --incremental false` — passes with no errors - [x] `oxlint` — passes with 0 warnings and 0 errors - [x] Manual code review of both changed files for correctness ## Visual Changes The "View KiloClaw" button appears in the KiloClaw card header on the admin user profile page, next to the existing "Edit Trial End" button (when applicable). It is only visible when the user has an active KiloClaw instance. | Before | After | | ------ | ----- | | KiloClaw card header has only "Edit Trial End" button (or no buttons) | KiloClaw card header includes "View KiloClaw" button with external link icon when active instance exists | ## Reviewer Notes - The `activeInstanceId` query filters on `destroyed_at IS NULL` to only return the user's currently active instance. This aligns with the unique index on `kiloclaw_instances` (`UQ_kiloclaw_instances_active`). - The link is added to both the "has subscription" and "no subscription" card states since a user could have an active instance regardless of subscription status. - This mirrors the existing reverse link from KiloClaw instance detail pages back to user profiles (`KiloclawInstanceDetail.tsx:1107`).
2 parents e715789 + cfec2d2 commit ee2748f

4 files changed

Lines changed: 71 additions & 9 deletions

File tree

kiloclaw/worker-configuration.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ declare namespace Cloudflare {
1414
FLY_REGISTRY_APP: "kiloclaw-machines";
1515
FLY_ORG_SLUG: "kilo-679";
1616
FLY_IMAGE_TAG: "latest";
17-
FLY_REGION: "dfw,ewr,lax,sjc,eu";
17+
FLY_REGION: "eu,us";
1818
OPENCLAW_ALLOWED_ORIGINS: "https://claw.kilosessions.ai,https://kilo.ai,https://www.kilo.ai";
1919
REQUIRE_PROXY_TOKEN: "true";
2020
PROACTIVE_REFRESH_THRESHOLD_HOURS: "72";

src/app/admin/components/UserAdmin/UserAdminKiloClaw.tsx

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import { useEffect, useState } from 'react';
44
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
5+
import Link from 'next/link';
6+
import { ExternalLink } from 'lucide-react';
57
import { Badge } from '@/components/ui/badge';
68
import { Button } from '@/components/ui/button';
79
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -139,8 +141,20 @@ export function UserAdminKiloClaw({ userId }: { userId: string }) {
139141
return (
140142
<Card className="lg:col-span-2">
141143
<CardHeader>
142-
<CardTitle>KiloClaw</CardTitle>
143-
<CardDescription>No KiloClaw subscription</CardDescription>
144+
<div className="flex items-start justify-between gap-3">
145+
<div>
146+
<CardTitle>KiloClaw</CardTitle>
147+
<CardDescription>n/a</CardDescription>
148+
</div>
149+
{data?.activeInstanceId && (
150+
<Button variant="outline" size="sm" asChild>
151+
<Link href={`/admin/kiloclaw/${data.activeInstanceId}`}>
152+
<ExternalLink className="mr-1 h-3 w-3" />
153+
View KiloClaw
154+
</Link>
155+
</Button>
156+
)}
157+
</div>
144158
</CardHeader>
145159
<CardContent className="space-y-3">
146160
{data?.earlybird ? (
@@ -185,11 +199,21 @@ export function UserAdminKiloClaw({ userId }: { userId: string }) {
185199
<CardTitle>KiloClaw</CardTitle>
186200
<CardDescription>KiloClaw subscription and trial status</CardDescription>
187201
</div>
188-
{canEditTrialEnd && (
189-
<Button variant="outline" size="sm" onClick={() => setDialogOpen(true)}>
190-
{isTrialReset ? 'Reset Trial' : 'Edit Trial End'}
191-
</Button>
192-
)}
202+
<div className="flex gap-2">
203+
{data.activeInstanceId && (
204+
<Button variant="outline" size="sm" asChild>
205+
<Link href={`/admin/kiloclaw/${data.activeInstanceId}`}>
206+
<ExternalLink className="mr-1 h-3 w-3" />
207+
View KiloClaw
208+
</Link>
209+
</Button>
210+
)}
211+
{canEditTrialEnd && (
212+
<Button variant="outline" size="sm" onClick={() => setDialogOpen(true)}>
213+
{isTrialReset ? 'Reset Trial' : 'Edit Trial End'}
214+
</Button>
215+
)}
216+
</div>
193217
</div>
194218
</CardHeader>
195219
<CardContent className="space-y-6">

src/routers/admin-kiloclaw-user-router.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { insertTestUser } from '@/tests/helpers/user.helper';
55
import {
66
kiloclaw_admin_audit_logs,
77
kiloclaw_earlybird_purchases,
8+
kiloclaw_instances,
89
kiloclaw_subscriptions,
910
} from '@kilocode/db/schema';
1011
import { eq } from 'drizzle-orm';
@@ -53,6 +54,7 @@ describe('admin.users.getKiloClawState', () => {
5354
hasAccess: expectedAccessWithoutEntitlement,
5455
accessReason: null,
5556
earlybird: null,
57+
activeInstanceId: null,
5658
});
5759
});
5860

@@ -133,6 +135,34 @@ describe('admin.users.getKiloClawState', () => {
133135
})
134136
);
135137
});
138+
139+
it('returns activeInstanceId when the user has an active instance', async () => {
140+
const [instance] = await db
141+
.insert(kiloclaw_instances)
142+
.values({
143+
user_id: targetUser.id,
144+
sandbox_id: 'sandbox-test-active',
145+
})
146+
.returning({ id: kiloclaw_instances.id });
147+
148+
const caller = await createCallerForUser(adminUser.id);
149+
const result = await caller.admin.users.getKiloClawState({ userId: targetUser.id });
150+
151+
expect(result.activeInstanceId).toBe(instance.id);
152+
});
153+
154+
it('returns null activeInstanceId when the user only has destroyed instances', async () => {
155+
await db.insert(kiloclaw_instances).values({
156+
user_id: targetUser.id,
157+
sandbox_id: 'sandbox-test-destroyed',
158+
destroyed_at: new Date().toISOString(),
159+
});
160+
161+
const caller = await createCallerForUser(adminUser.id);
162+
const result = await caller.admin.users.getKiloClawState({ userId: targetUser.id });
163+
164+
expect(result.activeInstanceId).toBeNull();
165+
});
136166
});
137167

138168
describe('admin.users.updateKiloClawTrialEndAt', () => {

src/routers/admin-router.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,14 +469,21 @@ export const adminRouter = createTRPCRouter({
469469
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
470470
}
471471

472-
const [subscription, earlybird] = await Promise.all([
472+
const [subscription, earlybird, activeInstance] = await Promise.all([
473473
db.query.kiloclaw_subscriptions.findFirst({
474474
where: eq(kiloclaw_subscriptions.user_id, input.userId),
475475
}),
476476
db.query.kiloclaw_earlybird_purchases.findFirst({
477477
columns: { id: true },
478478
where: eq(kiloclaw_earlybird_purchases.user_id, input.userId),
479479
}),
480+
db.query.kiloclaw_instances.findFirst({
481+
columns: { id: true },
482+
where: and(
483+
eq(kiloclaw_instances.user_id, input.userId),
484+
isNull(kiloclaw_instances.destroyed_at)
485+
),
486+
}),
480487
]);
481488

482489
const now = new Date();
@@ -518,6 +525,7 @@ export const adminRouter = createTRPCRouter({
518525
daysRemaining: earlybirdDaysRemaining,
519526
}
520527
: null,
528+
activeInstanceId: activeInstance?.id ?? null,
521529
};
522530
}),
523531

0 commit comments

Comments
 (0)