Skip to content

Commit ccc6165

Browse files
committed
feat: API keys page
1 parent f7b829e commit ccc6165

File tree

5 files changed

+975
-846
lines changed

5 files changed

+975
-846
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"use client";
2+
3+
import {
4+
CalendarIcon,
5+
CaretRightIcon,
6+
KeyIcon,
7+
LockIcon,
8+
WarningIcon,
9+
} from "@phosphor-icons/react";
10+
import dayjs from "dayjs";
11+
import { memo } from "react";
12+
import { Badge } from "@/components/ui/badge";
13+
14+
interface ApiKeyRowItem {
15+
id: string;
16+
name: string;
17+
prefix: string;
18+
start: string;
19+
enabled: boolean;
20+
revokedAt: Date | null;
21+
expiresAt: string | null;
22+
createdAt: Date;
23+
}
24+
25+
interface ApiKeyRowProps {
26+
apiKey: ApiKeyRowItem;
27+
onSelect: (id: string) => void;
28+
}
29+
30+
export const ApiKeyRow = memo(function ApiKeyRowComponent({
31+
apiKey,
32+
onSelect,
33+
}: ApiKeyRowProps) {
34+
const isActive = apiKey.enabled && !apiKey.revokedAt;
35+
const isExpired =
36+
apiKey.expiresAt && dayjs(apiKey.expiresAt).isBefore(dayjs());
37+
38+
return (
39+
<button
40+
className="group grid w-full cursor-pointer grid-cols-[auto_1fr_auto_auto] items-center gap-4 px-5 py-4 text-left transition-colors hover:bg-muted/50"
41+
onClick={() => onSelect(apiKey.id)}
42+
type="button"
43+
>
44+
{/* Icon */}
45+
<div className="flex h-10 w-10 items-center justify-center rounded border bg-background transition-colors group-hover:border-primary/30 group-hover:bg-primary/5">
46+
<KeyIcon
47+
className="text-muted-foreground transition-colors group-hover:text-primary"
48+
size={18}
49+
weight="duotone"
50+
/>
51+
</div>
52+
53+
{/* Info */}
54+
<div className="min-w-0">
55+
<div className="flex items-center gap-2">
56+
<span className="truncate font-medium">{apiKey.name}</span>
57+
{!isActive && (
58+
<Badge className="bg-destructive/10 text-destructive" variant="secondary">
59+
<LockIcon className="mr-1" size={10} weight="fill" />
60+
{apiKey.revokedAt ? "Revoked" : "Disabled"}
61+
</Badge>
62+
)}
63+
{isExpired && (
64+
<Badge className="bg-warning/10 text-warning" variant="secondary">
65+
<WarningIcon className="mr-1" size={10} weight="fill" />
66+
Expired
67+
</Badge>
68+
)}
69+
</div>
70+
<div className="flex items-center gap-3 text-muted-foreground text-sm">
71+
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
72+
{apiKey.prefix}_{apiKey.start}
73+
</code>
74+
<span className="flex items-center gap-1 text-xs">
75+
<CalendarIcon size={12} />
76+
{dayjs(apiKey.createdAt).format("MMM D, YYYY")}
77+
</span>
78+
</div>
79+
</div>
80+
81+
{/* Status */}
82+
{isActive ? (
83+
<Badge
84+
className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
85+
variant="secondary"
86+
>
87+
<div className="mr-1.5 h-1.5 w-1.5 rounded-full bg-green-500" />
88+
Active
89+
</Badge>
90+
) : (
91+
<Badge
92+
className="bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400"
93+
variant="secondary"
94+
>
95+
<div className="mr-1.5 h-1.5 w-1.5 rounded-full bg-gray-400" />
96+
Inactive
97+
</Badge>
98+
)}
99+
100+
{/* Arrow */}
101+
<CaretRightIcon
102+
className="text-muted-foreground/40 transition-all group-hover:translate-x-0.5 group-hover:text-primary"
103+
size={16}
104+
weight="bold"
105+
/>
106+
</button>
107+
);
108+
});
Lines changed: 160 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,184 @@
11
"use client";
22

3+
import {
4+
ArrowClockwiseIcon,
5+
BookOpenIcon,
6+
KeyIcon,
7+
PlusIcon,
8+
ShieldCheckIcon,
9+
} from "@phosphor-icons/react";
10+
import { useQuery } from "@tanstack/react-query";
311
import { useState } from "react";
412
import { ApiKeyCreateDialog } from "@/components/organizations/api-key-create-dialog";
513
import { ApiKeyDetailDialog } from "@/components/organizations/api-key-detail-dialog";
6-
import { ApiKeyList } from "@/components/organizations/api-key-list";
7-
14+
import { Button } from "@/components/ui/button";
15+
import { Skeleton } from "@/components/ui/skeleton";
816
import type { Organization } from "@/hooks/use-organizations";
17+
import { orpc } from "@/lib/orpc";
18+
import { ApiKeyRow } from "./api-key-row";
919

1020
interface ApiKeySettingsProps {
1121
organization: Organization;
1222
}
1323

24+
function SkeletonRow() {
25+
return (
26+
<div className="grid grid-cols-[auto_1fr_auto_auto] items-center gap-4 px-5 py-4">
27+
<Skeleton className="h-10 w-10 rounded" />
28+
<div className="space-y-2">
29+
<Skeleton className="h-4 w-32" />
30+
<Skeleton className="h-3 w-48" />
31+
</div>
32+
<Skeleton className="h-6 w-16 rounded-full" />
33+
<Skeleton className="h-4 w-4" />
34+
</div>
35+
);
36+
}
37+
38+
function ApiKeysSkeleton() {
39+
return (
40+
<div className="h-full lg:grid lg:grid-cols-[1fr_18rem]">
41+
<div className="divide-y border-b lg:border-b-0 lg:border-r">
42+
<SkeletonRow />
43+
<SkeletonRow />
44+
<SkeletonRow />
45+
</div>
46+
<div className="space-y-4 bg-muted/30 p-5">
47+
<Skeleton className="h-10 w-full" />
48+
<Skeleton className="h-18 w-full rounded" />
49+
<Skeleton className="h-10 w-full" />
50+
</div>
51+
</div>
52+
);
53+
}
54+
55+
function EmptyState({ onCreateNew }: { onCreateNew: () => void }) {
56+
return (
57+
<div className="flex h-full flex-col items-center justify-center p-8 text-center">
58+
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
59+
<KeyIcon className="text-primary" size={28} weight="duotone" />
60+
</div>
61+
<h3 className="mb-1 font-semibold text-lg">No API keys yet</h3>
62+
<p className="mb-6 max-w-sm text-muted-foreground text-sm">
63+
Create your first API key to start integrating with our platform
64+
</p>
65+
<Button onClick={onCreateNew}>
66+
<PlusIcon className="mr-2" size={16} />
67+
Create API Key
68+
</Button>
69+
</div>
70+
);
71+
}
72+
73+
function ErrorState({ onRetry }: { onRetry: () => void }) {
74+
return (
75+
<div className="flex h-full flex-col items-center justify-center p-8 text-center">
76+
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
77+
<KeyIcon className="text-destructive" size={28} weight="duotone" />
78+
</div>
79+
<h3 className="mb-1 font-semibold text-lg">Failed to load</h3>
80+
<p className="mb-6 max-w-sm text-muted-foreground text-sm">
81+
Something went wrong while loading your API keys
82+
</p>
83+
<Button onClick={onRetry} variant="outline">
84+
<ArrowClockwiseIcon className="mr-2" size={16} />
85+
Try again
86+
</Button>
87+
</div>
88+
);
89+
}
90+
1491
export function ApiKeySettings({ organization }: ApiKeySettingsProps) {
15-
// API Key dialog state
16-
const [showCreateApiKeyDialog, setShowCreateApiKeyDialog] = useState(false);
17-
const [showApiKeyDetailDialog, setShowApiKeyDetailDialog] = useState(false);
18-
const [apiKeyId, setApiKeyId] = useState<string | null>(null);
92+
const [showCreateDialog, setShowCreateDialog] = useState(false);
93+
const [showDetailDialog, setShowDetailDialog] = useState(false);
94+
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
95+
96+
const { data, isLoading, isError, refetch } = useQuery({
97+
...orpc.apikeys.list.queryOptions({ input: { organizationId: organization.id } }),
98+
refetchOnMount: true,
99+
refetchOnReconnect: true,
100+
staleTime: 0,
101+
});
19102

20-
// API Key handlers
21-
const handleCreateApiKey = () => {
22-
setShowCreateApiKeyDialog(true);
23-
};
103+
const items = data ?? [];
104+
const activeCount = items.filter((k) => k.enabled && !k.revokedAt).length;
24105

25-
const handleSelectApiKey = (id: string) => {
26-
setApiKeyId(id);
27-
setShowApiKeyDetailDialog(true);
28-
};
106+
if (isLoading) return <ApiKeysSkeleton />;
107+
if (isError) return <ErrorState onRetry={refetch} />;
108+
if (items.length === 0) return <EmptyState onCreateNew={() => setShowCreateDialog(true)} />;
29109

30110
return (
31-
<div className="h-full p-6">
32-
<ApiKeyList
33-
onCreateNew={handleCreateApiKey}
34-
onSelect={handleSelectApiKey}
35-
organizationId={organization.id}
36-
/>
111+
<>
112+
<div className="h-full lg:grid lg:grid-cols-[1fr_18rem]">
113+
{/* Keys List */}
114+
<div className="flex flex-col border-b lg:border-b-0 lg:border-r">
115+
<div className="flex-1 divide-y overflow-y-auto">
116+
{items.map((apiKey) => (
117+
<ApiKeyRow
118+
apiKey={apiKey}
119+
key={apiKey.id}
120+
onSelect={(id) => {
121+
setSelectedKeyId(id);
122+
setShowDetailDialog(true);
123+
}}
124+
/>
125+
))}
126+
</div>
127+
</div>
128+
129+
{/* Sidebar */}
130+
<aside className="flex flex-col gap-4 bg-muted/30 p-5">
131+
{/* Create Button */}
132+
<Button className="w-full" onClick={() => setShowCreateDialog(true)}>
133+
<PlusIcon className="mr-2" size={16} />
134+
Create New Key
135+
</Button>
136+
137+
{/* Stats Card */}
138+
<div className="flex items-center gap-3 rounded border bg-background p-4">
139+
<div className="flex h-10 w-10 items-center justify-center rounded bg-primary/10">
140+
<ShieldCheckIcon className="text-primary" size={20} weight="duotone" />
141+
</div>
142+
<div>
143+
<p className="font-semibold tabular-nums">
144+
{activeCount} <span className="font-normal text-muted-foreground">/ {items.length}</span>
145+
</p>
146+
<p className="text-muted-foreground text-sm">Active keys</p>
147+
</div>
148+
</div>
149+
150+
{/* Actions */}
151+
<Button asChild className="w-full justify-start" variant="outline">
152+
<a
153+
href="https://www.databuddy.cc/docs/api-keys"
154+
rel="noopener noreferrer"
155+
target="_blank"
156+
>
157+
<BookOpenIcon className="mr-2" size={16} />
158+
Documentation
159+
</a>
160+
</Button>
161+
162+
{/* Tips */}
163+
<div className="mt-auto rounded border border-dashed bg-background/50 p-4">
164+
<p className="mb-2 font-medium text-sm">Security reminder</p>
165+
<p className="text-muted-foreground text-xs leading-relaxed">
166+
Keep your API keys secure. Never share them publicly or commit them to version control.
167+
</p>
168+
</div>
169+
</aside>
170+
</div>
37171

38-
{/* API Key Dialogs */}
39172
<ApiKeyCreateDialog
40-
onOpenChange={setShowCreateApiKeyDialog}
41-
open={showCreateApiKeyDialog}
173+
onOpenChange={setShowCreateDialog}
174+
open={showCreateDialog}
42175
organizationId={organization.id}
43176
/>
44177
<ApiKeyDetailDialog
45-
keyId={apiKeyId}
46-
onOpenChange={setShowApiKeyDetailDialog}
47-
open={showApiKeyDetailDialog}
178+
keyId={selectedKeyId}
179+
onOpenChangeAction={setShowDetailDialog}
180+
open={showDetailDialog}
48181
/>
49-
</div>
182+
</>
50183
);
51184
}

apps/dashboard/app/(main)/organizations/settings/api-keys/page.tsx

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,50 @@
11
"use client";
22

33
import { Suspense } from "react";
4+
import { Skeleton } from "@/components/ui/skeleton";
45
import { useOrganizations } from "@/hooks/use-organizations";
56
import { ApiKeySettings } from "./api-key-settings";
67

7-
const ComponentSkeleton = () => (
8-
<div className="h-full p-6">
9-
<div className="space-y-4">
10-
{Array.from({ length: 4 }).map((_, i) => (
11-
<div
12-
className="flex items-center justify-between rounded-lg border bg-card p-4"
13-
key={i.toString()}
14-
>
15-
<div className="flex items-center gap-3">
16-
<div className="h-8 w-8 animate-pulse rounded bg-muted" />
17-
<div className="space-y-2">
18-
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
19-
<div className="h-3 w-24 animate-pulse rounded bg-muted" />
20-
</div>
21-
</div>
22-
<div className="flex items-center gap-2">
23-
<div className="h-6 w-16 animate-pulse rounded-full bg-muted" />
24-
<div className="h-8 w-8 animate-pulse rounded bg-muted" />
25-
</div>
26-
</div>
27-
))}
8+
function SkeletonRow() {
9+
return (
10+
<div className="grid grid-cols-[auto_1fr_auto_auto] items-center gap-4 px-5 py-4">
11+
<Skeleton className="h-10 w-10 rounded" />
12+
<div className="space-y-2">
13+
<Skeleton className="h-4 w-32" />
14+
<Skeleton className="h-3 w-48" />
15+
</div>
16+
<Skeleton className="h-6 w-16 rounded-full" />
17+
<Skeleton className="h-4 w-4" />
18+
</div>
19+
);
20+
}
21+
22+
function PageSkeleton() {
23+
return (
24+
<div className="h-full lg:grid lg:grid-cols-[1fr_18rem]">
25+
<div className="divide-y border-b lg:border-b-0 lg:border-r">
26+
<SkeletonRow />
27+
<SkeletonRow />
28+
<SkeletonRow />
29+
</div>
30+
<div className="space-y-4 bg-muted/30 p-5">
31+
<Skeleton className="h-10 w-full" />
32+
<Skeleton className="h-18 w-full rounded" />
33+
<Skeleton className="h-10 w-full" />
34+
</div>
2835
</div>
29-
</div>
30-
);
36+
);
37+
}
3138

3239
export default function ApiKeysSettingsPage() {
3340
const { activeOrganization } = useOrganizations();
3441

3542
if (!activeOrganization) {
36-
return null;
43+
return <PageSkeleton />;
3744
}
3845

3946
return (
40-
<Suspense fallback={<ComponentSkeleton />}>
47+
<Suspense fallback={<PageSkeleton />}>
4148
<ApiKeySettings organization={activeOrganization} />
4249
</Suspense>
4350
);

0 commit comments

Comments
 (0)