Skip to content

Commit c430d9e

Browse files
committed
feat(settings): hub w/ tabs + feature toggles + per-feature config docs
- apps/web/src/config/features.ts — single source of truth for which features are enabled. Flip enabled:false to hide a tab + sidebar link - apps/web/src/components/settings/ — six section components: profile, appearance, billing, security, api-keys, webhooks - /dashboard/settings is now a tabbed hub (vertical tabs on desktop, scrollable horizontal on mobile, hash-routed for deep links) - Old standalone routes redirect to settings#<tab> - Sidebar gates each link by feature flag; affiliate + referrals get their own 'Growth' group; settings replaces the per-feature spam - docs/features/README.md explains toggle workflow + per-feature config file locations
1 parent 6d4e8b6 commit c430d9e

16 files changed

Lines changed: 1790 additions & 1426 deletions

File tree

apps/admin/src/app/[locale]/users/_user-actions-menu.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,7 @@ export function UserActionsMenu({ row, currentUserId }: Props) {
121121
</DropdownMenuLabel>
122122
<DropdownMenuSeparator />
123123
{isAdmin ? (
124-
<DropdownMenuItem
125-
onClick={onDemote}
126-
disabled={pending || isSelf}
127-
>
124+
<DropdownMenuItem onClick={onDemote} disabled={pending || isSelf}>
128125
<ShieldOff className="mr-2 h-4 w-4" />
129126
Demote to user
130127
</DropdownMenuItem>
Lines changed: 3 additions & 275 deletions
Original file line numberDiff line numberDiff line change
@@ -1,278 +1,6 @@
1-
"use client";
2-
3-
import { Badge } from "@starter-saas/ui/components/badge";
4-
import { Button } from "@starter-saas/ui/components/button";
5-
import {
6-
Card,
7-
CardContent,
8-
CardHeader,
9-
CardTitle,
10-
} from "@starter-saas/ui/components/card";
11-
import {
12-
Dialog,
13-
DialogContent,
14-
DialogDescription,
15-
DialogFooter,
16-
DialogHeader,
17-
DialogTitle,
18-
} from "@starter-saas/ui/components/dialog";
19-
import { EmptyState } from "@starter-saas/ui/components/empty-state";
20-
import { Input } from "@starter-saas/ui/components/input";
21-
import { Label } from "@starter-saas/ui/components/label";
22-
import { Skeleton } from "@starter-saas/ui/components/skeleton";
23-
import { Copy, Key, Plus, Trash2 } from "lucide-react";
24-
import { useEffect, useState } from "react";
25-
import { toast } from "sonner";
26-
import { PageHeader } from "@/components/app/page-header";
27-
28-
type Row = {
29-
id: string;
30-
name: string;
31-
prefix: string;
32-
scopes: string[];
33-
lastUsedAt: string | null;
34-
expiresAt: string | null;
35-
revokedAt: string | null;
36-
createdAt: string;
37-
};
38-
39-
type IssuedKey = {
40-
id: string;
41-
prefix: string;
42-
plaintext: string;
43-
};
1+
import { redirect } from "next/navigation";
442

3+
// Consolidated into /dashboard/settings (API keys tab).
454
export default function ApiKeysPage() {
46-
const [rows, setRows] = useState<Row[] | null>(null);
47-
const [newName, setNewName] = useState("");
48-
const [busy, setBusy] = useState(false);
49-
const [issued, setIssued] = useState<IssuedKey | null>(null);
50-
51-
const load = async () => {
52-
try {
53-
const res = await fetch("/api/api-keys", { cache: "no-store" });
54-
if (!res.ok) {
55-
throw new Error(`status ${res.status}`);
56-
}
57-
const data = (await res.json()) as { rows: Row[] };
58-
setRows(data.rows);
59-
} catch (err) {
60-
toast.error("Couldn't load API keys", {
61-
description: err instanceof Error ? err.message : "?",
62-
});
63-
setRows([]);
64-
}
65-
};
66-
67-
useEffect(() => {
68-
void load();
69-
}, []);
70-
71-
const create = async () => {
72-
if (newName.trim().length < 1) {
73-
toast.error("Give the key a name");
74-
return;
75-
}
76-
setBusy(true);
77-
const toastId = toast.loading("Generating key…");
78-
try {
79-
const res = await fetch("/api/api-keys", {
80-
method: "POST",
81-
headers: { "content-type": "application/json" },
82-
body: JSON.stringify({ name: newName.trim() }),
83-
});
84-
if (!res.ok) {
85-
const text = await res.text().catch(() => "");
86-
throw new Error(text || `status ${res.status}`);
87-
}
88-
const data = (await res.json()) as IssuedKey;
89-
setIssued(data);
90-
setNewName("");
91-
toast.success("Key created", { id: toastId });
92-
await load();
93-
} catch (err) {
94-
toast.error("Couldn't create key", {
95-
id: toastId,
96-
description: err instanceof Error ? err.message : "?",
97-
});
98-
} finally {
99-
setBusy(false);
100-
}
101-
};
102-
103-
const revoke = async (row: Row) => {
104-
if (
105-
!confirm(`Revoke ${row.name}? Apps using this key will stop working.`)
106-
) {
107-
return;
108-
}
109-
const toastId = toast.loading("Revoking…");
110-
try {
111-
const res = await fetch(`/api/api-keys/${row.id}`, { method: "DELETE" });
112-
if (!res.ok) {
113-
throw new Error(`status ${res.status}`);
114-
}
115-
toast.success("Revoked", { id: toastId });
116-
await load();
117-
} catch (err) {
118-
toast.error("Couldn't revoke", {
119-
id: toastId,
120-
description: err instanceof Error ? err.message : "?",
121-
});
122-
}
123-
};
124-
125-
return (
126-
<>
127-
<PageHeader
128-
title="API keys"
129-
description="Personal tokens for hitting /api/v1 from scripts, integrations, and dev tools."
130-
/>
131-
132-
<Card>
133-
<CardHeader>
134-
<CardTitle className="text-base">Generate a key</CardTitle>
135-
</CardHeader>
136-
<CardContent>
137-
<form
138-
className="grid gap-3 sm:grid-cols-[1fr_auto]"
139-
onSubmit={(e) => {
140-
e.preventDefault();
141-
void create();
142-
}}
143-
>
144-
<div className="grid gap-1.5">
145-
<Label htmlFor="key-name" className="sr-only">
146-
Key name
147-
</Label>
148-
<Input
149-
id="key-name"
150-
value={newName}
151-
onChange={(e) => setNewName(e.target.value)}
152-
placeholder="laptop-cli, ci-bot, etc."
153-
disabled={busy}
154-
/>
155-
</div>
156-
<Button type="submit" disabled={busy}>
157-
<Plus className="mr-1.5 h-4 w-4" /> Create
158-
</Button>
159-
</form>
160-
</CardContent>
161-
</Card>
162-
163-
<div className="mt-6">
164-
{rows === null ? (
165-
<div className="grid gap-2">
166-
{Array.from({ length: 3 }).map((_, i) => (
167-
<Skeleton key={i} className="h-16 w-full" />
168-
))}
169-
</div>
170-
) : rows.length === 0 ? (
171-
<EmptyState
172-
illustration="grid"
173-
title="No keys yet"
174-
description="Generate your first key above. We show the full token once — you'll need to copy it then."
175-
/>
176-
) : (
177-
<ul className="divide-y rounded-lg border">
178-
{rows.map((row) => (
179-
<li key={row.id} className="flex items-center gap-3 px-4 py-3">
180-
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
181-
<Key className="h-4 w-4 text-muted-foreground" />
182-
</div>
183-
<div className="min-w-0 flex-1">
184-
<p className="font-medium text-sm">{row.name}</p>
185-
<p className="font-mono text-muted-foreground text-xs">
186-
{row.prefix}
187-
</p>
188-
<p className="text-muted-foreground text-xs">
189-
{row.lastUsedAt
190-
? `Last used ${new Date(row.lastUsedAt).toLocaleString()}`
191-
: "Never used"}
192-
{row.expiresAt
193-
? ` · expires ${new Date(row.expiresAt).toLocaleDateString()}`
194-
: ""}
195-
</p>
196-
</div>
197-
{row.revokedAt ? (
198-
<Badge variant="outline" className="capitalize">
199-
Revoked
200-
</Badge>
201-
) : (
202-
<Button
203-
variant="ghost"
204-
size="sm"
205-
onClick={() => revoke(row)}
206-
className="text-destructive hover:text-destructive"
207-
>
208-
<Trash2 className="h-4 w-4" />
209-
<span className="sr-only">Revoke</span>
210-
</Button>
211-
)}
212-
</li>
213-
))}
214-
</ul>
215-
)}
216-
</div>
217-
218-
<IssuedKeyDialog
219-
keyData={issued}
220-
onOpenChange={(open) => {
221-
if (!open) {
222-
setIssued(null);
223-
}
224-
}}
225-
/>
226-
</>
227-
);
228-
}
229-
230-
function IssuedKeyDialog({
231-
keyData,
232-
onOpenChange,
233-
}: {
234-
keyData: IssuedKey | null;
235-
onOpenChange: (open: boolean) => void;
236-
}) {
237-
const open = keyData !== null;
238-
const copy = async () => {
239-
if (!keyData) {
240-
return;
241-
}
242-
try {
243-
await navigator.clipboard.writeText(keyData.plaintext);
244-
toast.success("Copied");
245-
} catch {
246-
toast.error("Clipboard blocked — copy manually");
247-
}
248-
};
249-
return (
250-
<Dialog open={open} onOpenChange={onOpenChange}>
251-
<DialogContent>
252-
<DialogHeader>
253-
<DialogTitle>Copy this key now</DialogTitle>
254-
<DialogDescription>
255-
We won't show it again. Store it somewhere safe — anyone with this
256-
token can act as you on the API.
257-
</DialogDescription>
258-
</DialogHeader>
259-
{keyData ? (
260-
<div className="grid gap-3">
261-
<div className="rounded-md border bg-muted/40 p-3 font-mono text-sm">
262-
{keyData.plaintext}
263-
</div>
264-
<Button type="button" onClick={copy} className="w-full">
265-
<Copy className="mr-1.5 h-4 w-4" />
266-
Copy to clipboard
267-
</Button>
268-
</div>
269-
) : null}
270-
<DialogFooter>
271-
<Button variant="ghost" onClick={() => onOpenChange(false)}>
272-
I've stored it
273-
</Button>
274-
</DialogFooter>
275-
</DialogContent>
276-
</Dialog>
277-
);
5+
redirect("/dashboard/settings#api-keys" as never);
2786
}

0 commit comments

Comments
 (0)