eny.space Landingpage
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add user display to dashboard #5

open opened by samsour.de targeting develop from feature/user-overview
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:vmqt4a4pf5jxvtalzjz2zsqk/sh.tangled.repo.pull/3ml4dzno5at22
+331 -3
Diff #0
+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
··· 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
sign up or login to add to the discussion
samsour.de submitted #0
3 commits
expand
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
  • app/dashboard/user-dashboard-client.tsx:48
expand 0 comments