my own indieAuth provider! indiko.dunkirk.sh/docs
indieauth oauth2-server
6
fork

Configure Feed

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

feat: add actions to users page

+172 -1
+50 -1
src/client/admin.ts
··· 97 97 : initials; 98 98 99 99 return ` 100 - <div class="user-card"> 100 + <div class="user-card" data-user-id="${user.id}"> 101 101 <div class="user-avatar">${avatarContent}</div> 102 102 <div class="user-info"> 103 103 <div class="user-name">${user.username}</div> ··· 111 111 <span class="user-badge badge-status ${user.status}">${user.status}</span> 112 112 <span class="user-badge badge-role">${user.role}</span> 113 113 </div> 114 + <div class="user-actions"> 115 + ${user.status !== 'suspended' ? `<button class="btn btn-disable" data-action="disable" data-user-id="${user.id}">disable</button>` : ''} 116 + <button class="btn btn-delete" data-action="delete" data-user-id="${user.id}">delete</button> 117 + </div> 114 118 </div> 115 119 `; 116 120 }).join(''); 121 + 122 + // Add event listeners for action buttons 123 + document.querySelectorAll('.btn[data-action]').forEach(btn => { 124 + btn.addEventListener('click', handleUserAction); 125 + }); 117 126 } catch (error) { 118 127 console.error('Failed to load users:', error); 119 128 usersList.innerHTML = '<div class="error">Failed to load users</div>'; 129 + } 130 + } 131 + 132 + async function handleUserAction(e: Event) { 133 + const btn = e.target as HTMLButtonElement; 134 + const action = btn.dataset.action; 135 + const userId = btn.dataset.userId; 136 + 137 + if (!userId || !action) return; 138 + 139 + const confirmMessage = action === 'delete' 140 + ? 'Are you sure you want to delete this user? This cannot be undone.' 141 + : 'Are you sure you want to disable this user? They will be logged out and unable to sign in.'; 142 + 143 + if (!confirm(confirmMessage)) return; 144 + 145 + try { 146 + const endpoint = action === 'delete' 147 + ? `/api/admin/users/${userId}/delete` 148 + : `/api/admin/users/${userId}/disable`; 149 + 150 + const method = action === 'delete' ? 'DELETE' : 'POST'; 151 + 152 + const response = await fetch(endpoint, { 153 + method, 154 + headers: { 155 + 'Authorization': `Bearer ${token}`, 156 + }, 157 + }); 158 + 159 + if (!response.ok) { 160 + const error = await response.json(); 161 + throw new Error(error.error || 'Failed to perform action'); 162 + } 163 + 164 + // Reload users list 165 + loadUsers(); 166 + } catch (error) { 167 + console.error(`Failed to ${action} user:`, error); 168 + alert(`Failed to ${action} user: ${error instanceof Error ? error.message : 'Unknown error'}`); 120 169 } 121 170 } 122 171
+1
src/html/admin-clients.html
··· 605 605 </div> 606 606 <div class="header-nav"> 607 607 <a href="/admin">users</a> 608 + <a href="/admin/invites">invites</a> 608 609 <a href="/admin/clients" class="active">apps</a> 609 610 </div> 610 611 </header>
+37
src/html/admin.html
··· 260 260 border-color: #9e9e9e; 261 261 } 262 262 263 + .user-actions { 264 + display: flex; 265 + gap: 0.5rem; 266 + flex-wrap: wrap; 267 + } 268 + 269 + .btn { 270 + padding: 0.5rem 1rem; 271 + font-size: 0.75rem; 272 + font-weight: 700; 273 + text-transform: uppercase; 274 + letter-spacing: 0.05rem; 275 + border: 1px solid; 276 + background: transparent; 277 + cursor: pointer; 278 + transition: all 0.2s; 279 + font-family: "Space Grotesk", sans-serif; 280 + } 281 + 282 + .btn-disable { 283 + color: var(--lavender); 284 + border-color: #e57373; 285 + } 286 + 287 + .btn-disable:hover { 288 + background: rgba(244, 67, 54, 0.2); 289 + } 290 + 291 + .btn-delete { 292 + color: var(--lavender); 293 + border-color: var(--rosewood); 294 + } 295 + 296 + .btn-delete:hover { 297 + background: rgba(160, 70, 104, 0.2); 298 + } 299 + 263 300 .loading { 264 301 text-align: center; 265 302 padding: 2rem;
+18
src/index.ts
··· 25 25 listAllApps, 26 26 getAppDetails, 27 27 revokeAppForUser, 28 + disableUser, 29 + deleteUser, 28 30 } from "./routes/api"; 29 31 import { 30 32 authorizeGet, ··· 104 106 "/api/invites/:id": (req: Request) => { 105 107 if (req.method === "PATCH") return updateInvite(req); 106 108 if (req.method === "DELETE") return deleteInvite(req); 109 + return new Response("Method not allowed", { status: 405 }); 110 + }, 111 + "/api/admin/users/:id/disable": (req: Request) => { 112 + if (req.method === "POST") { 113 + const url = new URL(req.url); 114 + const userId = url.pathname.split("/")[4]; 115 + return disableUser(req, userId); 116 + } 117 + return new Response("Method not allowed", { status: 405 }); 118 + }, 119 + "/api/admin/users/:id/delete": (req: Request) => { 120 + if (req.method === "DELETE") { 121 + const url = new URL(req.url); 122 + const userId = url.pathname.split("/")[4]; 123 + return deleteUser(req, userId); 124 + } 107 125 return new Response("Method not allowed", { status: 405 }); 108 126 }, 109 127 // IndieAuth/OAuth 2.0 endpoints
+66
src/routes/api.ts
··· 379 379 380 380 return Response.json({ success: true }); 381 381 } 382 + 383 + export function disableUser(req: Request, userId: string): Response { 384 + const user = getSessionUser(req); 385 + if (user instanceof Response) { 386 + return user; 387 + } 388 + 389 + if (!user.is_admin) { 390 + return Response.json({ error: "Admin access required" }, { status: 403 }); 391 + } 392 + 393 + const targetUserId = Number.parseInt(userId, 10); 394 + if (Number.isNaN(targetUserId)) { 395 + return Response.json({ error: "Invalid user ID" }, { status: 400 }); 396 + } 397 + 398 + const targetUser = db 399 + .query("SELECT id, username FROM users WHERE id = ?") 400 + .get(targetUserId) as { id: number; username: string } | undefined; 401 + 402 + if (!targetUser) { 403 + return Response.json({ error: "User not found" }, { status: 404 }); 404 + } 405 + 406 + db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run(targetUserId); 407 + 408 + db.query("DELETE FROM sessions WHERE user_id = ?").run(targetUserId); 409 + 410 + return Response.json({ success: true }); 411 + } 412 + 413 + export function deleteUser(req: Request, userId: string): Response { 414 + const user = getSessionUser(req); 415 + if (user instanceof Response) { 416 + return user; 417 + } 418 + 419 + if (!user.is_admin) { 420 + return Response.json({ error: "Admin access required" }, { status: 403 }); 421 + } 422 + 423 + const targetUserId = Number.parseInt(userId, 10); 424 + if (Number.isNaN(targetUserId)) { 425 + return Response.json({ error: "Invalid user ID" }, { status: 400 }); 426 + } 427 + 428 + if (targetUserId === user.userId) { 429 + return Response.json({ error: "Cannot delete your own account" }, { status: 400 }); 430 + } 431 + 432 + const targetUser = db 433 + .query("SELECT id FROM users WHERE id = ?") 434 + .get(targetUserId) as { id: number } | undefined; 435 + 436 + if (!targetUser) { 437 + return Response.json({ error: "User not found" }, { status: 404 }); 438 + } 439 + 440 + db.query("DELETE FROM sessions WHERE user_id = ?").run(targetUserId); 441 + db.query("DELETE FROM credentials WHERE user_id = ?").run(targetUserId); 442 + db.query("DELETE FROM permissions WHERE user_id = ?").run(targetUserId); 443 + db.query("DELETE FROM authcodes WHERE user_id = ?").run(targetUserId); 444 + db.query("DELETE FROM users WHERE id = ?").run(targetUserId); 445 + 446 + return Response.json({ success: true }); 447 + }