Skip to content

Commit 34d2dd6

Browse files
feat(admin): add KiloClaw subscription management and tabbed user detail layout (#2196)
1 parent 5a1efbb commit 34d2dd6

File tree

8 files changed

+1191
-334
lines changed

8 files changed

+1191
-334
lines changed
Lines changed: 109 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
3+
import { Card, CardContent } from '@/components/ui/card';
44
import { PaymentMethodStatusBadge } from '@/components/admin/PaymentMethodStatusBadge';
55
import { UserStatusBadge } from '@/components/admin/UserStatusBadge';
66
import { CopyTextButton } from '@/components/admin/CopyEmailButton';
@@ -10,44 +10,52 @@ import ResetAPIKeyButton from './ResetAPIKeyButton';
1010
import ResetToMagicLinkLoginButton from './ResetToMagicLinkLoginButton';
1111
import { Button } from '@/components/ui/button';
1212
import Link from 'next/link';
13-
import { Webhook } from 'lucide-react';
13+
import { SquareArrowOutUpRight, Webhook } from 'lucide-react';
14+
import { createHash } from 'crypto';
15+
16+
function getGravatarUrl(email: string, size: number = 80): string {
17+
const hash = createHash('md5').update(email.toLowerCase().trim()).digest('hex');
18+
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=mp`;
19+
}
1420

1521
type UserAdminAccountInfoProps = UserDetailProps;
1622

1723
export function UserAdminAccountInfo(user: UserAdminAccountInfoProps) {
24+
const gravatarUrl = getGravatarUrl(user.google_user_email);
25+
const stripeUrl = `https://dashboard.stripe.com/${process.env.NODE_ENV === 'development' ? 'test/' : ''}customers/${user.stripe_customer_id}`;
26+
const hibpUrl = `https://haveibeenpwned.com/account/${encodeURIComponent(user.google_user_email)}`;
27+
1828
return (
1929
<Card
20-
className={`flex-1 ${user.blocked_reason || user.is_blacklisted_by_domain ? 'border-red-500 bg-red-950/50' : ''}`}
30+
className={
31+
user.blocked_reason || user.is_blacklisted_by_domain ? 'border-red-500 bg-red-950/50' : ''
32+
}
2133
>
22-
<CardHeader>
23-
<div className="flex flex-wrap items-center gap-4">
24-
<div className="flex min-w-max items-center gap-4">
34+
<CardContent className="pt-5">
35+
{/* Top row: identity + badges/actions */}
36+
<div className="flex flex-wrap items-start justify-between gap-4">
37+
<div className="flex items-center gap-3">
2538
<img
2639
src={user.google_user_image_url}
2740
alt={user.google_user_name}
28-
className="h-16 w-16 rounded-full"
41+
className="h-12 w-12 rounded-full"
2942
onError={e => {
3043
const target = e.target as HTMLImageElement;
3144
target.src = '/default-avatar.svg';
3245
}}
3346
/>
3447
<div>
35-
<CardTitle className="text-2xl">{user.google_user_name}</CardTitle>
36-
<div className="flex items-center gap-2">
37-
<CardDescription className="text-lg">{user.google_user_email}</CardDescription>
48+
<h2 className="text-lg font-semibold leading-tight">{user.google_user_name}</h2>
49+
<div className="flex items-center gap-1">
50+
<span className="text-muted-foreground text-sm">{user.google_user_email}</span>
3851
<CopyTextButton text={user.google_user_email} />
3952
</div>
4053
</div>
4154
</div>
42-
<div className="ml-auto flex min-w-min shrink flex-wrap items-center gap-2">
55+
56+
<div className="flex flex-wrap items-center gap-2">
4357
<UserStatusBadge is_detail={true} user={user} />
4458
<PaymentMethodStatusBadge paymentMethodStatus={user.paymentMethodStatus} />
45-
</div>
46-
</div>
47-
</CardHeader>
48-
<CardContent>
49-
<div className="flex flex-row-reverse flex-wrap justify-between gap-6">
50-
<div className="flex grow basis-auto flex-col items-end space-y-2">
5159
<ResetAPIKeyButton userId={user.id} />
5260
{!user.is_sso_protected_domain && <ResetToMagicLinkLoginButton userId={user.id} />}
5361
<Button variant="outline" size="sm" asChild>
@@ -57,75 +65,95 @@ export function UserAdminAccountInfo(user: UserAdminAccountInfoProps) {
5765
</Button>
5866
<Button variant="outline" size="sm" asChild>
5967
<Link href={`/admin/users/${encodeURIComponent(user.id)}/webhooks`}>
60-
<Webhook className="mr-2 h-4 w-4" />
61-
View webhooks
68+
<Webhook className="mr-1 h-3.5 w-3.5" />
69+
Webhooks
6270
</Link>
6371
</Button>
72+
<a
73+
href={stripeUrl}
74+
target="_blank"
75+
rel="noopener noreferrer"
76+
className="inline-flex items-center gap-1.5 rounded-md bg-purple-950 px-2.5 py-1.5 text-xs font-medium text-purple-200 transition-colors hover:bg-purple-900"
77+
>
78+
Stripe
79+
<SquareArrowOutUpRight size={12} />
80+
</a>
81+
<a
82+
href={hibpUrl}
83+
target="_blank"
84+
rel="noopener noreferrer"
85+
className="inline-flex items-center gap-1.5 rounded-md bg-orange-950 px-2.5 py-1.5 text-xs font-medium text-orange-200 transition-colors hover:bg-orange-900"
86+
title="Check if this email has been exposed in any data breaches"
87+
>
88+
HIBP
89+
<SquareArrowOutUpRight size={12} />
90+
</a>
91+
<img
92+
src={gravatarUrl}
93+
alt={`Gravatar for ${user.google_user_name}`}
94+
className="border-border h-7 w-7 rounded-full border"
95+
title="Gravatar"
96+
/>
6497
</div>
65-
<div className="grow basis-auto space-y-4">
66-
<div>
67-
<h4 className="text-muted-foreground text-sm font-medium">Updated At</h4>
68-
<p>{formatDate(user.updated_at)}</p>
69-
</div>
70-
<div>
71-
<h4 className="text-muted-foreground text-sm font-medium">Hosted Domain</h4>
72-
<p>
73-
{user.hosted_domain || 'N/A'}{' '}
74-
{user.hosted_domain && <CopyTextButton text={user.hosted_domain} />}
75-
</p>
76-
</div>
77-
</div>
78-
<div className="grow basis-auto space-y-4">
79-
<div>
80-
<h4 className="text-muted-foreground text-sm font-medium">User ID</h4>
81-
<p className="font-mono text-sm break-all">
82-
{user.id} <CopyTextButton text={user.id} />
83-
</p>
84-
</div>
85-
<div>
86-
<h4 className="text-muted-foreground text-sm font-medium">Email</h4>
87-
<div className="flex items-center gap-2">
88-
<p className="break-all">{user.google_user_email}</p>
89-
<CopyTextButton text={user.google_user_email} />
90-
</div>
91-
</div>
92-
<div>
93-
<h4 className="text-muted-foreground text-sm font-medium">Created At</h4>
94-
<p>{formatDate(user.created_at)}</p>
95-
</div>
96-
<div>
97-
<h4 className="text-muted-foreground text-sm font-medium">
98-
OpenRouter Upstream Safety Identifier
99-
</h4>
100-
{user.openrouter_upstream_safety_identifier ? (
101-
<div className="flex items-center gap-2">
102-
<p className="font-mono text-sm break-all">
103-
{user.openrouter_upstream_safety_identifier}
104-
</p>
105-
<CopyTextButton text={user.openrouter_upstream_safety_identifier} />
106-
</div>
107-
) : (
108-
<p className="text-muted-foreground">N/A</p>
109-
)}
110-
</div>
111-
<div>
112-
<h4 className="text-muted-foreground text-sm font-medium">
113-
Vercel Downstream Safety Identifier
114-
</h4>
115-
{user.vercel_downstream_safety_identifier ? (
116-
<div className="flex items-center gap-2">
117-
<p className="font-mono text-sm break-all">
118-
{user.vercel_downstream_safety_identifier}
119-
</p>
120-
<CopyTextButton text={user.vercel_downstream_safety_identifier} />
121-
</div>
122-
) : (
123-
<p className="text-muted-foreground">N/A</p>
124-
)}
125-
</div>
126-
</div>
98+
</div>
99+
100+
{/* Metadata grid */}
101+
<div className="mt-4 grid grid-cols-2 gap-x-8 gap-y-3 sm:grid-cols-3 lg:grid-cols-4">
102+
<Field label="User ID" mono>
103+
{user.id} <CopyTextButton text={user.id} />
104+
</Field>
105+
<Field label="Email">
106+
{user.google_user_email} <CopyTextButton text={user.google_user_email} />
107+
</Field>
108+
<Field label="Hosted Domain">
109+
{user.hosted_domain || 'N/A'}
110+
{user.hosted_domain ? <CopyTextButton text={user.hosted_domain} /> : null}
111+
</Field>
112+
<Field label="Created">{formatDate(user.created_at)}</Field>
113+
<Field label="Updated">{formatDate(user.updated_at)}</Field>
114+
<Field label="OpenRouter Upstream Safety ID" mono>
115+
{user.openrouter_upstream_safety_identifier ? (
116+
<>
117+
{user.openrouter_upstream_safety_identifier}
118+
<CopyTextButton text={user.openrouter_upstream_safety_identifier} />
119+
</>
120+
) : (
121+
<span className="text-muted-foreground">N/A</span>
122+
)}
123+
</Field>
124+
<Field label="Vercel Downstream Safety ID" mono>
125+
{user.vercel_downstream_safety_identifier ? (
126+
<>
127+
{user.vercel_downstream_safety_identifier}
128+
<CopyTextButton text={user.vercel_downstream_safety_identifier} />
129+
</>
130+
) : (
131+
<span className="text-muted-foreground">N/A</span>
132+
)}
133+
</Field>
127134
</div>
128135
</CardContent>
129136
</Card>
130137
);
131138
}
139+
140+
function Field({
141+
label,
142+
mono,
143+
children,
144+
}: {
145+
label: string;
146+
mono?: boolean;
147+
children: React.ReactNode;
148+
}) {
149+
return (
150+
<div className="min-w-0">
151+
<h4 className="text-muted-foreground text-xs font-medium">{label}</h4>
152+
<div
153+
className={`flex items-center gap-1 text-sm break-all ${mono ? 'font-mono text-xs' : ''}`}
154+
>
155+
{children}
156+
</div>
157+
</div>
158+
);
159+
}

apps/web/src/app/admin/components/UserAdmin/UserAdminDashboard.tsx

Lines changed: 7 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,16 @@
11
import type { UserDetailProps } from '@/types/admin';
2-
import { UserAdminExternalLinks } from './UserAdminExternalLinks';
3-
import { UserAdminCreditGrant } from './UserAdminCreditGrant';
4-
import { UserAdminCreditTransactions } from './UserAdminCreditTransactions';
5-
import { UserAdminPaymentMethods } from './UserAdminPaymentMethods';
6-
import { UserAdminUsageBilling } from './UserAdminUsageBilling';
7-
import { UserAdminAccountInfo } from './UserAdminAccountInfo';
8-
import { UserAdminNotes } from './UserAdminNotes';
9-
import { UserAdminGdprRemoval } from './UserAdminGdprRemoval';
10-
import { UserAdminReferrals } from './UserAdminReferrals';
11-
import { UserAdminStytchFingerprints } from './UserAdminStytchFingerprints';
12-
import { UserAdminInvoices } from './UserAdminInvoices';
13-
import { promoCreditCategories } from '@/lib/promoCreditCategories';
14-
import { toGuiCreditCategory } from '@/lib/PromoCreditCategoryConfig';
152
import AdminPage from '@/app/admin/components/AdminPage';
163
import {
174
BreadcrumbItem,
185
BreadcrumbLink,
196
BreadcrumbPage,
207
BreadcrumbSeparator,
218
} from '@/components/ui/breadcrumb';
22-
import { UserAdminOrganizations } from '@/app/admin/components/UserAdmin/UserAdminOrganizations';
23-
import { UserAdminKiloPass } from '@/app/admin/components/UserAdmin/UserAdminKiloPass';
24-
import { UserAdminKiloClaw } from '@/app/admin/components/UserAdmin/UserAdminKiloClaw';
25-
import { UserAdminGastown } from '@/app/admin/components/UserAdmin/UserAdminGastown';
9+
import { UserAdminTabbedSections } from '@/app/admin/components/UserAdmin/UserAdminTabbedSections';
10+
import { promoCreditCategories } from '@/lib/promoCreditCategories';
11+
import { toGuiCreditCategory } from '@/lib/PromoCreditCategoryConfig';
12+
13+
const guiCreditCategories = promoCreditCategories.map(toGuiCreditCategory);
2614

2715
export function UserAdminDashboard({ ...user }: UserDetailProps) {
2816
const breadcrumbs = (
@@ -39,31 +27,8 @@ export function UserAdminDashboard({ ...user }: UserDetailProps) {
3927

4028
return (
4129
<AdminPage breadcrumbs={breadcrumbs}>
42-
<div className="flex w-full flex-col gap-y-8">
43-
<div className="flex flex-wrap gap-8">
44-
<UserAdminAccountInfo {...user} />
45-
<UserAdminExternalLinks {...user} />
46-
</div>
47-
48-
<div className="grid grid-cols-1 gap-8 lg:grid-cols-4">
49-
<UserAdminOrganizations organization_memberships={user.organization_memberships} />
50-
<UserAdminNotes {...user} />
51-
<UserAdminGdprRemoval {...user} />
52-
<UserAdminKiloPass userId={user.id} />
53-
<UserAdminKiloClaw userId={user.id} />
54-
<UserAdminUsageBilling {...user} />
55-
<UserAdminCreditGrant
56-
{...user}
57-
promoCreditCategories={promoCreditCategories.map(toGuiCreditCategory)}
58-
/>
59-
<UserAdminStytchFingerprints {...user} />
60-
<UserAdminCreditTransactions {...user} />
61-
<UserAdminPaymentMethods {...user} />
62-
<UserAdminInvoices stripe_customer_id={user.stripe_customer_id} />
63-
<UserAdminReferrals kilo_user_id={user.id} />
64-
</div>
65-
66-
<UserAdminGastown userId={user.id} />
30+
<div className="flex w-full flex-col gap-y-4">
31+
<UserAdminTabbedSections {...user} promoCreditCategories={guiCreditCategories} />
6732
</div>
6833
</AdminPage>
6934
);

apps/web/src/app/admin/components/UserAdmin/UserAdminExternalLinks.tsx

Lines changed: 28 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
1+
import { Card, CardContent } from '@/components/ui/card';
22
import { SquareArrowOutUpRight } from 'lucide-react';
33
import type { UserDetailProps } from '@/types/admin';
44
import { createHash } from 'crypto';
@@ -17,42 +17,33 @@ export function UserAdminExternalLinks({
1717

1818
return (
1919
<Card className="h-fit">
20-
<CardHeader>
21-
<CardTitle>External Links </CardTitle>
22-
</CardHeader>
23-
<CardContent>
24-
<div className="flex flex-col gap-4">
25-
<div className="flex flex-col gap-3">
26-
<a
27-
href={`https://dashboard.stripe.com/${process.env.NODE_ENV === 'development' ? 'test/' : ''}customers/${stripe_customer_id}`}
28-
target="_blank"
29-
className="inline-flex items-center justify-between gap-2 rounded-md bg-purple-950 px-4 py-3 text-sm font-medium text-purple-200 transition-colors hover:bg-purple-900"
30-
>
31-
View in Stripe
32-
<SquareArrowOutUpRight size={16} />
33-
</a>
34-
35-
<a
36-
href={`https://haveibeenpwned.com/account/${encodeURIComponent(google_user_email)}`}
37-
target="_blank"
38-
className="inline-flex items-center justify-between gap-2 rounded-md bg-orange-950 px-4 py-3 text-sm font-medium text-orange-200 transition-colors hover:bg-orange-900"
39-
title="Check if this email has been exposed in any data breaches"
40-
>
41-
<span className="flex items-center gap-2">Check on Have I Been Pwned</span>
42-
<SquareArrowOutUpRight size={16} />
43-
</a>
44-
<div className="bg-background flex items-center gap-3 rounded-md p-3">
45-
<div className="flex-1">
46-
<p className="text-foreground text-sm font-medium">Gravatar</p>
47-
<p className="text-muted-foreground truncate text-xs">{google_user_email}</p>
48-
</div>
49-
<img
50-
src={gravatarUrl}
51-
alt={`Gravatar for ${google_user_name}`}
52-
className="border-border h-16 w-16 rounded-full border-2"
53-
/>
54-
</div>
55-
</div>
20+
<CardContent className="flex flex-wrap items-center gap-3 pt-5">
21+
<a
22+
href={`https://dashboard.stripe.com/${process.env.NODE_ENV === 'development' ? 'test/' : ''}customers/${stripe_customer_id}`}
23+
target="_blank"
24+
rel="noopener noreferrer"
25+
className="inline-flex items-center gap-2 rounded-md bg-purple-950 px-3 py-2 text-sm font-medium text-purple-200 transition-colors hover:bg-purple-900"
26+
>
27+
Stripe
28+
<SquareArrowOutUpRight size={14} />
29+
</a>
30+
<a
31+
href={`https://haveibeenpwned.com/account/${encodeURIComponent(google_user_email)}`}
32+
target="_blank"
33+
rel="noopener noreferrer"
34+
className="inline-flex items-center gap-2 rounded-md bg-orange-950 px-3 py-2 text-sm font-medium text-orange-200 transition-colors hover:bg-orange-900"
35+
title="Check if this email has been exposed in any data breaches"
36+
>
37+
HIBP
38+
<SquareArrowOutUpRight size={14} />
39+
</a>
40+
<div className="flex items-center gap-2">
41+
<img
42+
src={gravatarUrl}
43+
alt={`Gravatar for ${google_user_name}`}
44+
className="border-border h-8 w-8 rounded-full border"
45+
/>
46+
<span className="text-muted-foreground text-xs">Gravatar</span>
5647
</div>
5748
</CardContent>
5849
</Card>

0 commit comments

Comments
 (0)