+331
-3
Diff
round #0
+147
app/api/pds/atproto/accounts/route.ts
+147
app/api/pds/atproto/accounts/route.ts
···
1
+
import { NextResponse } from "next/server";
2
+
3
+
import {
4
+
getPdsBaseUrlFromService,
5
+
getPdsServiceForCurrentUser,
6
+
} from "../helpers";
7
+
8
+
function toBasicAuth(user: string, pass: string) {
9
+
return `Basic ${Buffer.from(`${user}:${pass}`).toString("base64")}`;
10
+
}
11
+
12
+
export async function GET() {
13
+
try {
14
+
const { service } = await getPdsServiceForCurrentUser();
15
+
16
+
const adminPassword = service?.encrypted_config?.adminPassword as
17
+
| string
18
+
| undefined;
19
+
if (!adminPassword) {
20
+
return NextResponse.json(
21
+
{ message: "Missing PDS admin credentials" },
22
+
{ status: 500 }
23
+
);
24
+
}
25
+
26
+
const pdsBaseUrl = getPdsBaseUrlFromService(service);
27
+
const authHeader = toBasicAuth("admin", String(adminPassword).trim());
28
+
29
+
// Step 1: list all repos (public endpoint, gives us DIDs)
30
+
const listUrl = new URL(`${pdsBaseUrl}/xrpc/com.atproto.sync.listRepos`);
31
+
listUrl.searchParams.set("limit", "100");
32
+
33
+
const listRes = await fetch(listUrl.toString(), {
34
+
cache: "no-store",
35
+
headers: { Accept: "application/json" },
36
+
});
37
+
38
+
if (!listRes.ok) {
39
+
const body = await listRes.json().catch(() => ({}));
40
+
return NextResponse.json(
41
+
{
42
+
message: "Failed to list repos",
43
+
status: listRes.status,
44
+
upstream: body,
45
+
},
46
+
{ status: 502 }
47
+
);
48
+
}
49
+
50
+
const { repos = [] } = await listRes.json();
51
+
const dids: string[] = repos.map((r: { did: string }) => r.did);
52
+
53
+
if (dids.length === 0) {
54
+
return NextResponse.json({ accounts: [] });
55
+
}
56
+
57
+
// Step 2: get account details for all DIDs (admin Basic auth accepted here)
58
+
const infoUrl = new URL(
59
+
`${pdsBaseUrl}/xrpc/com.atproto.admin.getAccountInfos`
60
+
);
61
+
dids.forEach((did) => infoUrl.searchParams.append("dids", did));
62
+
63
+
const infoRes = await fetch(infoUrl.toString(), {
64
+
cache: "no-store",
65
+
headers: {
66
+
Accept: "application/json",
67
+
Authorization: authHeader,
68
+
},
69
+
});
70
+
71
+
if (!infoRes.ok) {
72
+
const body = await infoRes.json().catch(() => ({}));
73
+
return NextResponse.json(
74
+
{
75
+
message: "Failed to fetch account details",
76
+
status: infoRes.status,
77
+
upstream: body,
78
+
},
79
+
{ status: 502 }
80
+
);
81
+
}
82
+
83
+
const { infos = [] } = await infoRes.json();
84
+
return NextResponse.json({ accounts: infos });
85
+
} catch (error) {
86
+
const message = error instanceof Error ? error.message : "Unknown error";
87
+
const status = (error as any)?.status ?? 500;
88
+
return NextResponse.json({ message }, { status });
89
+
}
90
+
}
91
+
92
+
export async function DELETE(req: Request) {
93
+
try {
94
+
const { did } = (await req.json()) as { did?: string };
95
+
if (!did) {
96
+
return NextResponse.json(
97
+
{ message: "Missing required field: did" },
98
+
{ status: 400 }
99
+
);
100
+
}
101
+
102
+
const { service } = await getPdsServiceForCurrentUser();
103
+
const adminPassword = service?.encrypted_config?.adminPassword as
104
+
| string
105
+
| undefined;
106
+
if (!adminPassword) {
107
+
return NextResponse.json(
108
+
{ message: "Missing PDS admin credentials" },
109
+
{ status: 500 }
110
+
);
111
+
}
112
+
113
+
const pdsBaseUrl = getPdsBaseUrlFromService(service);
114
+
const authHeader = toBasicAuth("admin", String(adminPassword).trim());
115
+
116
+
const res = await fetch(
117
+
`${pdsBaseUrl}/xrpc/com.atproto.admin.deleteAccount`,
118
+
{
119
+
method: "POST",
120
+
headers: {
121
+
"Content-Type": "application/json",
122
+
Authorization: authHeader,
123
+
},
124
+
body: JSON.stringify({ did }),
125
+
}
126
+
);
127
+
128
+
if (!res.ok) {
129
+
const body = await res.json().catch(() => ({}));
130
+
return NextResponse.json(
131
+
{
132
+
message: "Failed to delete account",
133
+
status: res.status,
134
+
upstream: body,
135
+
},
136
+
{ status: 502 }
137
+
);
138
+
}
139
+
140
+
return NextResponse.json({ success: true });
141
+
} catch (error) {
142
+
const message = error instanceof Error ? error.message : "Unknown error";
143
+
const status = (error as any)?.status ?? 500;
144
+
return NextResponse.json({ message }, { status });
145
+
}
146
+
}
147
+
+184
-3
app/dashboard/user-dashboard-client.tsx
+184
-3
app/dashboard/user-dashboard-client.tsx
···
48
48
}
49
49
50
50
return (
51
-
<div className="grid gap-6 md:grid-cols-2">
52
-
<CreateUserSection pdsBareHost={pdsBareHost} />
53
-
<InviteSection />
51
+
<div className="space-y-6">
52
+
<div className="grid gap-6 md:grid-cols-2">
53
+
<CreateUserSection pdsBareHost={pdsBareHost} />
54
+
<InviteSection />
55
+
</div>
56
+
<UsersSection />
54
57
</div>
55
58
);
56
59
}
···
187
190
</section>
188
191
);
189
192
}
193
+
194
+
type PdsAccount = {
195
+
did: string;
196
+
handle: string;
197
+
email?: string;
198
+
indexedAt?: string;
199
+
};
200
+
201
+
function DeleteDialog({
202
+
handle,
203
+
onConfirm,
204
+
onCancel,
205
+
busy,
206
+
}: {
207
+
handle: string;
208
+
onConfirm: () => void;
209
+
onCancel: () => void;
210
+
busy: boolean;
211
+
}) {
212
+
const [input, setInput] = useState("");
213
+
const matches = input === handle;
214
+
215
+
return (
216
+
<div className="mt-2 space-y-3 rounded-md border border-rose-500/30 bg-rose-950/20 p-3 text-sm">
217
+
<div className="space-y-1">
218
+
<Paragraph className="font-medium text-rose-300">Delete {handle}?</Paragraph>
219
+
<Paragraph className="text-xs text-white/50">
220
+
All data is permanently and immediately deleted. This cannot be undone.
221
+
</Paragraph>
222
+
</div>
223
+
<div className="space-y-1">
224
+
<Paragraph className="text-xs text-white/50">Type the handle to confirm:</Paragraph>
225
+
<Input
226
+
value={input}
227
+
onChange={(e) => setInput(e.target.value)}
228
+
placeholder={handle}
229
+
className="h-7 text-xs"
230
+
autoFocus
231
+
/>
232
+
</div>
233
+
<div className="flex gap-2">
234
+
<Button
235
+
onClick={onConfirm}
236
+
disabled={!matches || busy}
237
+
className="h-7 rounded-full px-3 text-xs bg-rose-600 hover:bg-rose-500 border-0 flex-1"
238
+
>
239
+
{busy ? "Deletingβ¦" : "Delete permanently"}
240
+
</Button>
241
+
<Button
242
+
onClick={onCancel}
243
+
disabled={busy}
244
+
className="h-7 rounded-full px-3 text-xs bg-transparent border border-white/20 hover:bg-white/5"
245
+
>
246
+
Cancel
247
+
</Button>
248
+
</div>
249
+
</div>
250
+
);
251
+
}
252
+
253
+
function AccountRow({ account, onRefresh }: { account: PdsAccount; onRefresh: () => void }) {
254
+
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
255
+
const [busy, setBusy] = useState(false);
256
+
const [rowError, setRowError] = useState<string | null>(null);
257
+
258
+
const deleteAccount = async () => {
259
+
setBusy(true);
260
+
setRowError(null);
261
+
try {
262
+
const res = await fetch("/api/pds/atproto/accounts", {
263
+
method: "DELETE",
264
+
headers: { "Content-Type": "application/json" },
265
+
body: JSON.stringify({ did: account.did }),
266
+
});
267
+
const payload = await res.json().catch(() => ({}));
268
+
if (!res.ok) throw new Error(payload?.message || "Failed to delete account");
269
+
onRefresh();
270
+
} catch (e) {
271
+
setRowError(e instanceof Error ? e.message : String(e));
272
+
setShowDeleteDialog(false);
273
+
} finally {
274
+
setBusy(false);
275
+
}
276
+
};
277
+
278
+
return (
279
+
<div className="py-3 text-sm space-y-1">
280
+
<div className="flex items-start justify-between gap-4">
281
+
<div className="space-y-0.5 min-w-0">
282
+
<Paragraph className="font-medium text-white truncate">{account.handle}</Paragraph>
283
+
{account.email && (
284
+
<Paragraph className="text-xs text-white/50 truncate">{account.email}</Paragraph>
285
+
)}
286
+
<Paragraph className="font-mono text-xs text-white/30 truncate">{account.did}</Paragraph>
287
+
</div>
288
+
<div className="flex items-center gap-2 shrink-0">
289
+
{account.indexedAt && (
290
+
<Paragraph className="text-xs text-white/40 whitespace-nowrap">
291
+
{new Date(account.indexedAt).toLocaleDateString()}
292
+
</Paragraph>
293
+
)}
294
+
<button
295
+
onClick={() => setShowDeleteDialog(true)}
296
+
disabled={busy || showDeleteDialog}
297
+
className="text-xs text-white/40 hover:text-rose-400 transition-colors disabled:opacity-40"
298
+
>
299
+
Delete
300
+
</button>
301
+
</div>
302
+
</div>
303
+
{showDeleteDialog && (
304
+
<DeleteDialog
305
+
handle={account.handle}
306
+
onConfirm={deleteAccount}
307
+
onCancel={() => setShowDeleteDialog(false)}
308
+
busy={busy}
309
+
/>
310
+
)}
311
+
{rowError && (
312
+
<Paragraph className="text-xs text-rose-300 break-all">{rowError}</Paragraph>
313
+
)}
314
+
</div>
315
+
);
316
+
}
317
+
318
+
function UsersSection() {
319
+
const [accounts, setAccounts] = useState<PdsAccount[]>([]);
320
+
const [loading, setLoading] = useState(true);
321
+
const [error, setError] = useState<string | null>(null);
322
+
323
+
const load = async () => {
324
+
setLoading(true);
325
+
setError(null);
326
+
try {
327
+
const res = await fetch("/api/pds/atproto/accounts");
328
+
const payload = await res.json().catch(() => ({}));
329
+
if (!res.ok) throw new Error(`${payload?.message || "Failed to load accounts"} β ${JSON.stringify(payload?.upstream ?? {})}`);
330
+
setAccounts(payload?.accounts ?? []);
331
+
} catch (e) {
332
+
setError(e instanceof Error ? e.message : String(e));
333
+
} finally {
334
+
setLoading(false);
335
+
}
336
+
};
337
+
338
+
useEffect(() => { load(); }, []);
339
+
340
+
return (
341
+
<section className="space-y-3 rounded-md border border-white/10 bg-white/5 p-4">
342
+
<div className="flex items-center justify-between">
343
+
<Paragraph className="text-sm font-semibold text-white">Users</Paragraph>
344
+
<button
345
+
onClick={load}
346
+
disabled={loading}
347
+
className="text-xs text-white/40 hover:text-white/80 transition-colors disabled:opacity-40"
348
+
>
349
+
{loading ? "Loadingβ¦" : "Refresh"}
350
+
</button>
351
+
</div>
352
+
353
+
{error && (
354
+
<Paragraph className="text-sm text-rose-300">{error}</Paragraph>
355
+
)}
356
+
357
+
{!loading && !error && accounts.length === 0 && (
358
+
<Paragraph className="text-sm text-white/40">No accounts found.</Paragraph>
359
+
)}
360
+
361
+
{accounts.length > 0 && (
362
+
<div className="divide-y divide-white/5">
363
+
{accounts.map((account) => (
364
+
<AccountRow key={account.did} account={account} onRefresh={load} />
365
+
))}
366
+
</div>
367
+
)}
368
+
</section>
369
+
);
370
+
}
History
1 round
0 comments
samsour.de
submitted
#0
3 commits
expand
collapse
feat(dashboard): add user display
feat(dashboard): implemente account deletion
feat(account-deletion): add another confirmation step with user input as the final answer
merge conflicts detected
expand
collapse
expand
collapse
- app/dashboard/user-dashboard-client.tsx:48