Skip to content

Commit 1d69a2d

Browse files
committed
feat: add dashboard pages for sandbox management, API keys, and org settings
1 parent 31cdec0 commit 1d69a2d

15 files changed

Lines changed: 1461 additions & 2 deletions

File tree

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
},
1212
"dependencies": {
1313
"@astrojs/react": "^4.4.2",
14+
"@sandchest/contract": "workspace:*",
1415
"astro": "^5.2.0",
1516
"better-auth": "^1.4.18",
1617
"react": "^19.2.4",
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { useState, useEffect } from 'react'
2+
import { authClient } from '../../lib/auth-client'
3+
import { formatShortDate } from '../../lib/format'
4+
5+
interface ApiKey {
6+
id: string
7+
name: string | null
8+
start: string | null
9+
createdAt: Date
10+
}
11+
12+
export default function ApiKeyManager() {
13+
const [keys, setKeys] = useState<ApiKey[]>([])
14+
const [loading, setLoading] = useState(true)
15+
const [error, setError] = useState('')
16+
const [creating, setCreating] = useState(false)
17+
const [newKeyName, setNewKeyName] = useState('')
18+
const [showCreate, setShowCreate] = useState(false)
19+
const [newKeyValue, setNewKeyValue] = useState<string | null>(null)
20+
const [copied, setCopied] = useState(false)
21+
const [revoking, setRevoking] = useState<Set<string>>(new Set())
22+
23+
useEffect(() => {
24+
loadKeys()
25+
}, [])
26+
27+
async function loadKeys() {
28+
setError('')
29+
try {
30+
const { data, error: authError } = await authClient.apiKey.list()
31+
if (authError) {
32+
setError(authError.message ?? 'Failed to load API keys')
33+
return
34+
}
35+
setKeys(data ?? [])
36+
} catch {
37+
setError('Failed to load API keys')
38+
} finally {
39+
setLoading(false)
40+
}
41+
}
42+
43+
async function handleCreate(e: React.FormEvent<HTMLFormElement>) {
44+
e.preventDefault()
45+
setError('')
46+
setCreating(true)
47+
48+
try {
49+
const { data, error: authError } = await authClient.apiKey.create({
50+
name: newKeyName.trim() || undefined,
51+
})
52+
53+
if (authError) {
54+
setError(authError.message ?? 'Failed to create API key')
55+
setCreating(false)
56+
return
57+
}
58+
59+
if (data?.key) {
60+
setNewKeyValue(data.key)
61+
}
62+
63+
setNewKeyName('')
64+
await loadKeys()
65+
} catch {
66+
setError('Failed to create API key')
67+
} finally {
68+
setCreating(false)
69+
}
70+
}
71+
72+
async function handleRevoke(keyId: string) {
73+
setRevoking((prev) => new Set(prev).add(keyId))
74+
try {
75+
const { error: authError } = await authClient.apiKey.delete({ keyId })
76+
if (authError) {
77+
setError(authError.message ?? 'Failed to revoke API key')
78+
return
79+
}
80+
setKeys((prev) => prev.filter((k) => k.id !== keyId))
81+
} catch {
82+
setError('Failed to revoke API key')
83+
} finally {
84+
setRevoking((prev) => {
85+
const next = new Set(prev)
86+
next.delete(keyId)
87+
return next
88+
})
89+
}
90+
}
91+
92+
async function handleCopy() {
93+
if (!newKeyValue) return
94+
await navigator.clipboard.writeText(newKeyValue)
95+
setCopied(true)
96+
setTimeout(() => setCopied(false), 2000)
97+
}
98+
99+
return (
100+
<div>
101+
<div className="dash-page-header">
102+
<h1 className="dash-page-title">API Keys</h1>
103+
<button
104+
className="dash-primary-btn"
105+
onClick={() => {
106+
setShowCreate(!showCreate)
107+
setNewKeyValue(null)
108+
}}
109+
>
110+
{showCreate ? 'Cancel' : 'Create key'}
111+
</button>
112+
</div>
113+
114+
{error && <p className="dash-error">{error}</p>}
115+
116+
{newKeyValue && (
117+
<div className="dash-key-reveal">
118+
<p className="dash-key-reveal-label">
119+
Your new API key (copy it now — it won't be shown again):
120+
</p>
121+
<div className="dash-key-reveal-row">
122+
<code className="dash-key-reveal-value">{newKeyValue}</code>
123+
<button className="dash-action-btn" onClick={handleCopy}>
124+
{copied ? 'Copied' : 'Copy'}
125+
</button>
126+
</div>
127+
<button
128+
className="dash-link-btn"
129+
onClick={() => {
130+
setNewKeyValue(null)
131+
setShowCreate(false)
132+
}}
133+
>
134+
Done
135+
</button>
136+
</div>
137+
)}
138+
139+
{showCreate && !newKeyValue && (
140+
<form onSubmit={handleCreate} className="dash-create-form">
141+
<label htmlFor="key-name" className="dash-label">
142+
Name (optional)
143+
</label>
144+
<input
145+
id="key-name"
146+
type="text"
147+
placeholder="e.g. production, staging"
148+
value={newKeyName}
149+
onChange={(e) => setNewKeyName(e.target.value)}
150+
className="dash-input"
151+
disabled={creating}
152+
autoFocus
153+
/>
154+
<button type="submit" className="dash-primary-btn" disabled={creating}>
155+
{creating ? 'Creating...' : 'Create'}
156+
</button>
157+
</form>
158+
)}
159+
160+
{loading ? (
161+
<div className="dash-empty">Loading API keys...</div>
162+
) : keys.length === 0 ? (
163+
<div className="dash-empty">
164+
No API keys yet. Create one to authenticate SDK and CLI requests.
165+
</div>
166+
) : (
167+
<div className="dash-table-wrap">
168+
<table className="dash-table">
169+
<thead>
170+
<tr>
171+
<th>Name</th>
172+
<th>Key</th>
173+
<th>Created</th>
174+
<th></th>
175+
</tr>
176+
</thead>
177+
<tbody>
178+
{keys.map((key) => (
179+
<tr key={key.id}>
180+
<td className="dash-text-strong">
181+
{key.name || <span className="dash-text-weak">unnamed</span>}
182+
</td>
183+
<td>
184+
<code className="dash-key-prefix">{key.start ?? '???'}...</code>
185+
</td>
186+
<td className="dash-text-weak">{formatShortDate(key.createdAt)}</td>
187+
<td>
188+
<button
189+
className="dash-action-btn danger"
190+
onClick={() => handleRevoke(key.id)}
191+
disabled={revoking.has(key.id)}
192+
>
193+
{revoking.has(key.id) ? 'Revoking...' : 'Revoke'}
194+
</button>
195+
</td>
196+
</tr>
197+
))}
198+
</tbody>
199+
</table>
200+
</div>
201+
)}
202+
</div>
203+
)
204+
}

0 commit comments

Comments
 (0)