Skip to content

Commit f6d24ca

Browse files
willwearingclaude
andauthored
feat: add email/password CLI login, API keys settings UI, and course import --replace (#106)
- Add auth/login endpoint for non-interactive CLI authentication - Add API keys management UI to settings page - Add --replace and --archive-missing flags to CLI course import - Increase JSON body limit to 2MB for large course YAML imports - Add comprehensive agent pipeline e2e test - Add PostHog use case selling course content Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 635538d commit f6d24ca

11 files changed

Lines changed: 3457 additions & 5 deletions

File tree

apps/web/e2e/agent-pipeline-e2e.spec.ts

Lines changed: 923 additions & 0 deletions
Large diffs are not rendered by default.

apps/web/src/app/(app)/settings/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createSupabaseServerClient } from "@/lib/supabase/server";
33
import { resolvePageBrand } from "@/lib/brand/resolve";
44
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
55
import { BillingSettings } from "@/components/app/billing-settings";
6+
import { ApiKeysSettings } from "@/components/app/api-keys-settings";
67

78
export default async function SettingsPage() {
89
const supabase = await createSupabaseServerClient();
@@ -36,6 +37,8 @@ export default async function SettingsPage() {
3637

3738
<BillingSettings orgId={brand.orgSlug} />
3839

40+
<ApiKeysSettings orgId={brand.orgSlug} />
41+
3942
<Card>
4043
<CardHeader>
4144
<CardTitle>Preferences</CardTitle>
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
"use client";
2+
3+
import { useEffect, useState, useCallback } from "react";
4+
import { Button } from "@/components/ui/button";
5+
import {
6+
Card,
7+
CardContent,
8+
CardDescription,
9+
CardHeader,
10+
CardTitle,
11+
} from "@/components/ui/card";
12+
import {
13+
Dialog,
14+
DialogContent,
15+
DialogDescription,
16+
DialogFooter,
17+
DialogHeader,
18+
DialogTitle,
19+
DialogTrigger,
20+
} from "@/components/ui/dialog";
21+
import { Input } from "@/components/ui/input";
22+
import { apiClientFetch } from "@/lib/api-client";
23+
import { useAuthToken } from "@/lib/hooks/use-auth-token";
24+
import { PlusIcon, CopyIcon, CheckIcon, Trash2Icon, KeyIcon } from "lucide-react";
25+
26+
interface ApiKeyMeta {
27+
id: string;
28+
name: string;
29+
keyPrefix: string;
30+
lastUsedAt: string | null;
31+
expiresAt: string | null;
32+
createdAt: string;
33+
}
34+
35+
export function ApiKeysSettings({ orgId }: { orgId: string }) {
36+
const token = useAuthToken();
37+
const [keys, setKeys] = useState<ApiKeyMeta[]>([]);
38+
const [loading, setLoading] = useState(true);
39+
const [error, setError] = useState<string | null>(null);
40+
41+
// Create dialog state
42+
const [createOpen, setCreateOpen] = useState(false);
43+
const [newKeyName, setNewKeyName] = useState("");
44+
const [creating, setCreating] = useState(false);
45+
46+
// Newly created key (shown once)
47+
const [createdKey, setCreatedKey] = useState<string | null>(null);
48+
const [copied, setCopied] = useState(false);
49+
50+
// Revoke confirmation
51+
const [revokeTarget, setRevokeTarget] = useState<ApiKeyMeta | null>(null);
52+
const [revoking, setRevoking] = useState(false);
53+
54+
const fetchKeys = useCallback(async () => {
55+
if (!token) return;
56+
try {
57+
const data = await apiClientFetch<ApiKeyMeta[]>(
58+
`/orgs/${orgId}/api-keys`,
59+
token,
60+
);
61+
setKeys(data);
62+
setError(null);
63+
} catch {
64+
setError("Failed to load API keys");
65+
} finally {
66+
setLoading(false);
67+
}
68+
}, [orgId, token]);
69+
70+
useEffect(() => {
71+
fetchKeys();
72+
}, [fetchKeys]);
73+
74+
const handleCreate = async () => {
75+
if (!token || !newKeyName.trim()) return;
76+
setCreating(true);
77+
try {
78+
const { key } = await apiClientFetch<{ key: string; id: string }>(
79+
`/orgs/${orgId}/api-keys`,
80+
token,
81+
{ method: "POST", body: JSON.stringify({ name: newKeyName.trim() }) },
82+
);
83+
setCreatedKey(key);
84+
setNewKeyName("");
85+
await fetchKeys();
86+
} catch {
87+
setError("Failed to create API key");
88+
} finally {
89+
setCreating(false);
90+
}
91+
};
92+
93+
const handleCopy = async () => {
94+
if (!createdKey) return;
95+
await navigator.clipboard.writeText(createdKey);
96+
setCopied(true);
97+
setTimeout(() => setCopied(false), 2000);
98+
};
99+
100+
const handleRevoke = async () => {
101+
if (!token || !revokeTarget) return;
102+
setRevoking(true);
103+
try {
104+
await apiClientFetch(
105+
`/orgs/${orgId}/api-keys/${revokeTarget.id}`,
106+
token,
107+
{ method: "DELETE" },
108+
);
109+
setRevokeTarget(null);
110+
await fetchKeys();
111+
} catch {
112+
setError("Failed to revoke API key");
113+
} finally {
114+
setRevoking(false);
115+
}
116+
};
117+
118+
const handleCreateDialogClose = () => {
119+
setCreateOpen(false);
120+
setCreatedKey(null);
121+
setNewKeyName("");
122+
setCopied(false);
123+
};
124+
125+
const formatKeyDisplay = (prefix: string) => {
126+
const last4 = prefix.slice(-4);
127+
return `gsk_...${last4}`;
128+
};
129+
130+
if (loading) {
131+
return (
132+
<Card>
133+
<CardHeader>
134+
<CardTitle>API Keys</CardTitle>
135+
<CardDescription>Loading API keys...</CardDescription>
136+
</CardHeader>
137+
</Card>
138+
);
139+
}
140+
141+
return (
142+
<>
143+
<Card>
144+
<CardHeader>
145+
<div className="flex items-center justify-between">
146+
<div>
147+
<CardTitle>API Keys</CardTitle>
148+
<CardDescription>
149+
Manage API keys for programmatic access
150+
</CardDescription>
151+
</div>
152+
<Dialog
153+
open={createOpen}
154+
onOpenChange={(open) => {
155+
if (!open) handleCreateDialogClose();
156+
else setCreateOpen(true);
157+
}}
158+
>
159+
<DialogTrigger render={<Button size="sm" />}>
160+
<PlusIcon data-icon="inline-start" />
161+
Create Key
162+
</DialogTrigger>
163+
<DialogContent>
164+
{createdKey ? (
165+
<>
166+
<DialogHeader>
167+
<DialogTitle>API Key Created</DialogTitle>
168+
<DialogDescription>
169+
Save this key now. You will not be able to see it again.
170+
</DialogDescription>
171+
</DialogHeader>
172+
<div className="space-y-3">
173+
<div className="flex items-center gap-2">
174+
<code className="flex-1 rounded-lg border bg-muted/50 px-3 py-2 text-xs font-mono break-all">
175+
{createdKey}
176+
</code>
177+
<Button
178+
variant="outline"
179+
size="icon"
180+
onClick={handleCopy}
181+
>
182+
{copied ? (
183+
<CheckIcon className="size-4 text-green-600" />
184+
) : (
185+
<CopyIcon className="size-4" />
186+
)}
187+
</Button>
188+
</div>
189+
<p className="text-xs text-destructive font-medium">
190+
This is the only time this key will be shown.
191+
</p>
192+
</div>
193+
<DialogFooter>
194+
<Button onClick={handleCreateDialogClose} size="sm">
195+
Done
196+
</Button>
197+
</DialogFooter>
198+
</>
199+
) : (
200+
<>
201+
<DialogHeader>
202+
<DialogTitle>Create API Key</DialogTitle>
203+
<DialogDescription>
204+
Give your key a name to identify it later.
205+
</DialogDescription>
206+
</DialogHeader>
207+
<div className="space-y-2">
208+
<Input
209+
placeholder="e.g. CI/CD Pipeline"
210+
value={newKeyName}
211+
onChange={(e) => setNewKeyName(e.target.value)}
212+
onKeyDown={(e) => {
213+
if (e.key === "Enter" && newKeyName.trim()) {
214+
handleCreate();
215+
}
216+
}}
217+
maxLength={100}
218+
autoFocus
219+
/>
220+
</div>
221+
<DialogFooter>
222+
<Button
223+
onClick={handleCreate}
224+
disabled={!newKeyName.trim() || creating}
225+
size="sm"
226+
>
227+
{creating ? "Creating..." : "Create"}
228+
</Button>
229+
</DialogFooter>
230+
</>
231+
)}
232+
</DialogContent>
233+
</Dialog>
234+
</div>
235+
</CardHeader>
236+
<CardContent>
237+
{error && (
238+
<p className="text-sm text-destructive mb-4">{error}</p>
239+
)}
240+
{keys.length === 0 ? (
241+
<div className="flex flex-col items-center justify-center py-8 text-center">
242+
<KeyIcon className="size-8 text-muted-foreground/50 mb-3" />
243+
<p className="text-sm text-muted-foreground">No API keys yet</p>
244+
<p className="text-xs text-muted-foreground mt-1">
245+
Create a key to access the API programmatically.
246+
</p>
247+
</div>
248+
) : (
249+
<div className="space-y-3">
250+
{keys.map((k) => (
251+
<div
252+
key={k.id}
253+
className="flex items-center justify-between rounded-lg border p-3"
254+
>
255+
<div className="space-y-1 min-w-0">
256+
<p className="text-sm font-medium text-foreground truncate">
257+
{k.name}
258+
</p>
259+
<div className="flex items-center gap-3 text-xs text-muted-foreground">
260+
<code className="font-mono">
261+
{formatKeyDisplay(k.keyPrefix)}
262+
</code>
263+
<span>
264+
Created{" "}
265+
{new Date(k.createdAt).toLocaleDateString()}
266+
</span>
267+
{k.lastUsedAt && (
268+
<span>
269+
Last used{" "}
270+
{new Date(k.lastUsedAt).toLocaleDateString()}
271+
</span>
272+
)}
273+
</div>
274+
</div>
275+
<Button
276+
variant="destructive"
277+
size="icon-sm"
278+
onClick={() => setRevokeTarget(k)}
279+
>
280+
<Trash2Icon className="size-3.5" />
281+
</Button>
282+
</div>
283+
))}
284+
</div>
285+
)}
286+
</CardContent>
287+
</Card>
288+
289+
{/* Revoke confirmation dialog */}
290+
<Dialog
291+
open={!!revokeTarget}
292+
onOpenChange={(open) => {
293+
if (!open) setRevokeTarget(null);
294+
}}
295+
>
296+
<DialogContent>
297+
<DialogHeader>
298+
<DialogTitle>Revoke API Key</DialogTitle>
299+
<DialogDescription>
300+
Are you sure you want to revoke{" "}
301+
<span className="font-medium text-foreground">
302+
{revokeTarget?.name}
303+
</span>
304+
? Any integrations using this key will stop working immediately.
305+
</DialogDescription>
306+
</DialogHeader>
307+
<DialogFooter>
308+
<Button
309+
variant="outline"
310+
size="sm"
311+
onClick={() => setRevokeTarget(null)}
312+
>
313+
Cancel
314+
</Button>
315+
<Button
316+
variant="destructive"
317+
size="sm"
318+
onClick={handleRevoke}
319+
disabled={revoking}
320+
>
321+
{revoking ? "Revoking..." : "Revoke Key"}
322+
</Button>
323+
</DialogFooter>
324+
</DialogContent>
325+
</Dialog>
326+
</>
327+
);
328+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {
2+
Body,
3+
Controller,
4+
HttpCode,
5+
HttpStatus,
6+
Logger,
7+
Post,
8+
UseGuards,
9+
} from '@nestjs/common';
10+
import { ThrottlerGuard, Throttle } from '@nestjs/throttler';
11+
import { IsEmail, IsString, MinLength } from 'class-validator';
12+
import { AuthLoginService } from './auth-login.service';
13+
14+
export class LoginDto {
15+
@IsEmail()
16+
email!: string;
17+
18+
@IsString()
19+
@MinLength(8)
20+
password!: string;
21+
}
22+
23+
const isDev = process.env.NODE_ENV !== 'production';
24+
const LOGIN_LIMIT = isDev ? 200 : 20;
25+
const LOGIN_TTL = isDev ? 60_000 : 60_000;
26+
27+
@Controller('auth')
28+
export class AuthLoginController {
29+
private readonly logger = new Logger(AuthLoginController.name);
30+
31+
constructor(private readonly loginService: AuthLoginService) {}
32+
33+
@Post('login')
34+
@HttpCode(HttpStatus.OK)
35+
@UseGuards(ThrottlerGuard)
36+
@Throttle({ default: { limit: LOGIN_LIMIT, ttl: LOGIN_TTL } })
37+
async login(@Body() body: LoginDto) {
38+
return this.loginService.login(body.email, body.password);
39+
}
40+
}

0 commit comments

Comments
 (0)