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 admin ui

+1084 -18
+240
src/client/admin-apps.ts
··· 1 + const token = localStorage.getItem('indiko_session'); 2 + const footer = document.getElementById('footer') as HTMLElement; 3 + const appsList = document.getElementById('appsList') as HTMLElement; 4 + 5 + async function checkAuth() { 6 + if (!token) { 7 + window.location.href = '/login'; 8 + return; 9 + } 10 + 11 + try { 12 + const response = await fetch('/api/hello', { 13 + headers: { 14 + 'Authorization': `Bearer ${token}`, 15 + }, 16 + }); 17 + 18 + if (response.status === 401) { 19 + localStorage.removeItem('indiko_session'); 20 + window.location.href = '/login'; 21 + return; 22 + } 23 + 24 + const data = await response.json(); 25 + 26 + footer.innerHTML = `admin • signed in as <strong><a href="/u/${data.username}">${data.username}</a></strong> • <a href="/login" id="logoutLink">sign out</a> 27 + <div class="back-link"><a href="/">← back to dashboard</a></div>`; 28 + 29 + document.getElementById('logoutLink')?.addEventListener('click', async (e) => { 30 + e.preventDefault(); 31 + try { 32 + await fetch('/auth/logout', { 33 + method: 'POST', 34 + headers: { 35 + 'Authorization': `Bearer ${token}`, 36 + }, 37 + }); 38 + } catch { 39 + // Ignore logout errors 40 + } 41 + localStorage.removeItem('indiko_session'); 42 + window.location.href = '/login'; 43 + }); 44 + 45 + if (!data.isAdmin) { 46 + window.location.href = '/'; 47 + return; 48 + } 49 + 50 + loadApps(); 51 + } catch (error) { 52 + console.error('Auth check failed:', error); 53 + footer.textContent = 'error loading user info'; 54 + appsList.innerHTML = '<div class="error">Failed to load apps</div>'; 55 + } 56 + } 57 + 58 + interface App { 59 + clientId: string; 60 + name: string; 61 + firstSeen: number; 62 + lastUsed: number; 63 + userCount: number; 64 + } 65 + 66 + interface AppPermission { 67 + username: string; 68 + name: string; 69 + scopes: string[]; 70 + grantedAt: number; 71 + lastUsed: number; 72 + } 73 + 74 + interface AppDetails { 75 + app: { 76 + clientId: string; 77 + name: string; 78 + firstSeen: number; 79 + lastUsed: number; 80 + }; 81 + permissions: AppPermission[]; 82 + } 83 + 84 + async function loadApps() { 85 + try { 86 + const response = await fetch('/api/admin/apps', { 87 + headers: { 88 + 'Authorization': `Bearer ${token}`, 89 + }, 90 + }); 91 + 92 + if (!response.ok) { 93 + throw new Error('Failed to load apps'); 94 + } 95 + 96 + const data = await response.json(); 97 + displayApps(data.apps); 98 + } catch (error) { 99 + console.error('Failed to load apps:', error); 100 + appsList.innerHTML = '<div class="error">Failed to load apps</div>'; 101 + } 102 + } 103 + 104 + function displayApps(apps: App[]) { 105 + if (apps.length === 0) { 106 + appsList.innerHTML = '<div class="empty">No apps registered yet. Apps will appear here after users grant them access.</div>'; 107 + return; 108 + } 109 + 110 + appsList.innerHTML = apps.map((app) => { 111 + const lastUsedDate = new Date(app.lastUsed * 1000).toLocaleDateString(); 112 + const firstSeenDate = new Date(app.firstSeen * 1000).toLocaleDateString(); 113 + 114 + return ` 115 + <div class="app-card" data-client-id="${app.clientId}" onclick="toggleApp('${app.clientId}')"> 116 + <div class="app-header"> 117 + <div class="app-info"> 118 + <div class="app-name">${app.name}</div> 119 + <div class="app-meta"> 120 + <span>First seen ${firstSeenDate}</span> 121 + <span>Last used ${lastUsedDate}</span> 122 + </div> 123 + </div> 124 + <div class="app-stats"> 125 + <span class="stat-badge">${app.userCount} user${app.userCount !== 1 ? 's' : ''}</span> 126 + <span class="expand-indicator">details</span> 127 + </div> 128 + </div> 129 + <div class="app-details" id="details-${encodeURIComponent(app.clientId)}"> 130 + <div class="loading">loading permissions...</div> 131 + </div> 132 + </div> 133 + `; 134 + }).join(''); 135 + } 136 + 137 + (window as any).toggleApp = async function(clientId: string) { 138 + const card = document.querySelector(`[data-client-id="${clientId}"]`) as HTMLElement; 139 + if (!card) return; 140 + 141 + const isExpanded = card.classList.contains('expanded'); 142 + 143 + if (isExpanded) { 144 + card.classList.remove('expanded'); 145 + return; 146 + } 147 + 148 + card.classList.add('expanded'); 149 + 150 + const detailsDiv = document.getElementById(`details-${encodeURIComponent(clientId)}`); 151 + if (!detailsDiv) return; 152 + 153 + if (detailsDiv.dataset.loaded === 'true') { 154 + return; 155 + } 156 + 157 + try { 158 + const response = await fetch(`/api/admin/apps/${encodeURIComponent(clientId)}`, { 159 + headers: { 160 + 'Authorization': `Bearer ${token}`, 161 + }, 162 + }); 163 + 164 + if (!response.ok) { 165 + throw new Error('Failed to load app details'); 166 + } 167 + 168 + const data: AppDetails = await response.json(); 169 + 170 + if (data.permissions.length === 0) { 171 + detailsDiv.innerHTML = '<div class="empty">No users have granted access to this app</div>'; 172 + } else { 173 + detailsDiv.innerHTML = ` 174 + <div class="permissions-list"> 175 + ${data.permissions.map((perm) => { 176 + const grantedDate = new Date(perm.grantedAt * 1000).toLocaleDateString(); 177 + const lastUsedDate = new Date(perm.lastUsed * 1000).toLocaleDateString(); 178 + 179 + return ` 180 + <div class="permission-item"> 181 + <div class="permission-user"> 182 + <div class="permission-username">${perm.name} (@${perm.username})</div> 183 + <div class="permission-scopes"> 184 + ${perm.scopes.map(scope => `<span class="scope-badge">${scope}</span>`).join('')} 185 + </div> 186 + <div class="permission-meta"> 187 + <span>Granted ${grantedDate}</span> 188 + <span>Last used ${lastUsedDate}</span> 189 + </div> 190 + </div> 191 + <button class="revoke-btn" onclick="event.stopPropagation(); revokePermission('${clientId}', '${perm.username}')">revoke</button> 192 + </div> 193 + `; 194 + }).join('')} 195 + </div> 196 + `; 197 + } 198 + 199 + detailsDiv.dataset.loaded = 'true'; 200 + } catch (error) { 201 + console.error('Failed to load app details:', error); 202 + detailsDiv.innerHTML = '<div class="error">Failed to load permissions</div>'; 203 + } 204 + }; 205 + 206 + (window as any).revokePermission = async function(clientId: string, username: string) { 207 + if (!confirm(`Are you sure you want to revoke access for ${username}? They will need to authorize this app again.`)) { 208 + return; 209 + } 210 + 211 + try { 212 + const response = await fetch(`/api/admin/apps/${encodeURIComponent(clientId)}/users/${encodeURIComponent(username)}`, { 213 + method: 'DELETE', 214 + headers: { 215 + 'Authorization': `Bearer ${token}`, 216 + }, 217 + }); 218 + 219 + if (!response.ok) { 220 + throw new Error('Failed to revoke permission'); 221 + } 222 + 223 + const detailsDiv = document.getElementById(`details-${encodeURIComponent(clientId)}`); 224 + if (detailsDiv) { 225 + detailsDiv.dataset.loaded = 'false'; 226 + } 227 + 228 + const card = document.querySelector(`[data-client-id="${clientId}"]`) as HTMLElement; 229 + if (card) { 230 + card.classList.remove('expanded'); 231 + } 232 + 233 + await loadApps(); 234 + } catch (error) { 235 + console.error('Failed to revoke permission:', error); 236 + alert('Failed to revoke permission. Please try again.'); 237 + } 238 + }; 239 + 240 + checkAuth();
+85 -1
src/client/admin.ts
··· 1 1 const token = localStorage.getItem('indiko_session'); 2 2 const footer = document.getElementById('footer') as HTMLElement; 3 3 const usersList = document.getElementById('usersList') as HTMLElement; 4 + const invitesList = document.getElementById('invitesList') as HTMLElement; 5 + const createInviteBtn = document.getElementById('createInviteBtn') as HTMLButtonElement; 4 6 5 7 // Check auth and display user 6 8 async function checkAuth() { ··· 46 48 47 49 // Check if admin 48 50 if (!data.isAdmin) { 49 - usersList.innerHTML = '<div class="error">Admin access required</div>'; 51 + window.location.href = '/'; 50 52 return; 51 53 } 52 54 53 55 // Load users if admin 54 56 loadUsers(); 57 + loadInvites(); 55 58 } catch (error) { 56 59 console.error('Auth check failed:', error); 57 60 footer.textContent = 'error loading user info'; ··· 59 62 } 60 63 } 61 64 65 + async function createInvite() { 66 + createInviteBtn.disabled = true; 67 + createInviteBtn.textContent = 'creating...'; 68 + 69 + try { 70 + const response = await fetch('/api/invites/create', { 71 + method: 'POST', 72 + headers: { 73 + 'Authorization': `Bearer ${token}`, 74 + }, 75 + }); 76 + 77 + if (!response.ok) { 78 + throw new Error('Failed to create invite'); 79 + } 80 + 81 + await loadInvites(); 82 + } catch (error) { 83 + console.error('Failed to create invite:', error); 84 + alert('Failed to create invite'); 85 + } finally { 86 + createInviteBtn.disabled = false; 87 + createInviteBtn.textContent = 'create invite link'; 88 + } 89 + } 90 + 91 + async function loadInvites() { 92 + try { 93 + const response = await fetch('/api/invites', { 94 + headers: { 95 + 'Authorization': `Bearer ${token}`, 96 + }, 97 + }); 98 + 99 + if (!response.ok) { 100 + throw new Error('Failed to load invites'); 101 + } 102 + 103 + const data = await response.json(); 104 + 105 + if (data.invites.length === 0) { 106 + invitesList.innerHTML = '<div class="loading">No invites created yet</div>'; 107 + return; 108 + } 109 + 110 + invitesList.innerHTML = data.invites.map((invite: { 111 + id: number; 112 + code: string; 113 + used: boolean; 114 + createdAt: number; 115 + usedAt: number | null; 116 + createdBy: string; 117 + usedBy: string | null; 118 + inviteUrl: string; 119 + }) => { 120 + const createdDate = new Date(invite.createdAt * 1000).toLocaleDateString(); 121 + const status = invite.used 122 + ? `Used by ${invite.usedBy} on ${new Date(invite.usedAt! * 1000).toLocaleDateString()}` 123 + : 'Unused'; 124 + 125 + return ` 126 + <div class="invite-item"> 127 + <div> 128 + <div class="invite-code">${invite.code}</div> 129 + <div class="invite-meta">Created by ${invite.createdBy} on ${createdDate} • ${status}</div> 130 + <div class="invite-url">${invite.inviteUrl}</div> 131 + </div> 132 + <div class="invite-actions-btns"> 133 + <button class="copy-btn" onclick="navigator.clipboard.writeText('${invite.inviteUrl}')">copy link</button> 134 + </div> 135 + </div> 136 + `; 137 + }).join(''); 138 + } catch (error) { 139 + console.error('Failed to load invites:', error); 140 + invitesList.innerHTML = '<div class="error">Failed to load invites</div>'; 141 + } 142 + } 143 + 62 144 async function loadUsers() { 63 145 try { 64 146 const response = await fetch('/api/users', { ··· 121 203 } 122 204 123 205 checkAuth(); 206 + 207 + createInviteBtn.addEventListener('click', createInvite);
+23 -3
src/client/login.ts
··· 7 7 // Check if registration is allowed on page load 8 8 async function checkRegistrationAllowed() { 9 9 try { 10 + // Check for invite code in URL 11 + const urlParams = new URLSearchParams(window.location.search); 12 + const inviteCode = urlParams.get('invite'); 13 + 14 + if (inviteCode) { 15 + // Show registration form with invite 16 + const subtitleElement = document.querySelector('.subtitle'); 17 + if (subtitleElement) { 18 + subtitleElement.textContent = 'create your account'; 19 + } 20 + (document.getElementById('registerUsername') as HTMLInputElement).placeholder = 'choose username'; 21 + (document.getElementById('registerBtn') as HTMLButtonElement).textContent = 'create account'; 22 + loginForm.style.display = 'none'; 23 + registerForm.style.display = 'block'; 24 + return; 25 + } 26 + 10 27 const response = await fetch('/auth/can-register'); 11 28 const {canRegister} = await response.json(); 12 29 ··· 109 126 registerBtn.disabled = true; 110 127 registerBtn.textContent = 'preparing...'; 111 128 129 + // Get invite code from URL if present 130 + const urlParams = new URLSearchParams(window.location.search); 131 + const inviteCode = urlParams.get('invite'); 132 + 112 133 // Get registration options 113 134 const optionsRes = await fetch('/auth/register/options', { 114 135 method: 'POST', 115 136 headers: {'Content-Type': 'application/json'}, 116 - body: JSON.stringify({username}) 137 + body: JSON.stringify({username, inviteCode}) 117 138 }); 118 139 119 140 if (!optionsRes.ok) { ··· 134 155 const verifyRes = await fetch('/auth/register/verify', { 135 156 method: 'POST', 136 157 headers: {'Content-Type': 'application/json'}, 137 - body: JSON.stringify({username, response: regResponse, challenge: options.challenge}) 158 + body: JSON.stringify({username, response: regResponse, challenge: options.challenge, inviteCode}) 138 159 }); 139 160 140 161 if (!verifyRes.ok) { ··· 148 169 showMessage('Registration successful!', 'success'); 149 170 150 171 // Check for return URL parameter 151 - const urlParams = new URLSearchParams(window.location.search); 152 172 const returnUrl = urlParams.get('return') || '/'; 153 173 154 174 const redirectTimer = setTimeout(() => {
+343
src/html/admin-apps.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <title>apps • admin • indiko</title> 8 + <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" /> 9 + <link rel="preconnect" href="https://fonts.googleapis.com"> 10 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 11 + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> 12 + <style> 13 + :root { 14 + --mahogany: #26242b; 15 + --lavender: #d9d0de; 16 + --old-rose: #bc8da0; 17 + --rosewood: #a04668; 18 + --berry-crush: #ab4967; 19 + } 20 + 21 + * { 22 + margin: 0; 23 + padding: 0; 24 + box-sizing: border-box; 25 + } 26 + 27 + body { 28 + font-family: "Space Grotesk", sans-serif; 29 + background: var(--mahogany); 30 + color: var(--lavender); 31 + min-height: 100vh; 32 + display: flex; 33 + flex-direction: column; 34 + align-items: center; 35 + padding: 2.5rem 1.25rem; 36 + } 37 + 38 + header { 39 + width: 100%; 40 + max-width: 56.25rem; 41 + align-self: flex-start; 42 + margin-left: auto; 43 + margin-right: auto; 44 + margin-bottom: 2rem; 45 + display: flex; 46 + justify-content: space-between; 47 + align-items: flex-start; 48 + } 49 + 50 + .header-nav { 51 + display: flex; 52 + gap: 1rem; 53 + margin-top: 0.5rem; 54 + } 55 + 56 + .header-nav a { 57 + color: var(--old-rose); 58 + text-decoration: none; 59 + font-size: 0.875rem; 60 + font-weight: 500; 61 + padding: 0.5rem 1rem; 62 + border: 1px solid var(--old-rose); 63 + transition: all 0.2s; 64 + } 65 + 66 + .header-nav a:hover { 67 + background: rgba(188, 141, 160, 0.1); 68 + color: var(--berry-crush); 69 + border-color: var(--berry-crush); 70 + } 71 + 72 + .header-nav a.active { 73 + background: var(--berry-crush); 74 + color: var(--lavender); 75 + border-color: var(--berry-crush); 76 + } 77 + 78 + h1 { 79 + font-size: 2rem; 80 + font-weight: 700; 81 + background: linear-gradient(135deg, var(--old-rose), var(--berry-crush), var(--rosewood)); 82 + -webkit-background-clip: text; 83 + -webkit-text-fill-color: transparent; 84 + background-clip: text; 85 + letter-spacing: -0.125rem; 86 + } 87 + 88 + main { 89 + flex: 1; 90 + width: 100%; 91 + max-width: 56.25rem; 92 + padding: 2rem 1.25rem; 93 + } 94 + 95 + h2 { 96 + font-size: 1.5rem; 97 + font-weight: 600; 98 + color: var(--lavender); 99 + margin-bottom: 1.5rem; 100 + letter-spacing: -0.05rem; 101 + } 102 + 103 + footer { 104 + width: 100%; 105 + max-width: 56.25rem; 106 + padding: 1rem; 107 + text-align: center; 108 + color: var(--old-rose); 109 + font-size: 0.875rem; 110 + font-weight: 300; 111 + letter-spacing: 0.05rem; 112 + } 113 + 114 + footer a { 115 + color: var(--berry-crush); 116 + text-decoration: none; 117 + transition: color 0.2s; 118 + } 119 + 120 + footer a:hover { 121 + color: var(--rosewood); 122 + text-decoration: underline; 123 + } 124 + 125 + .back-link { 126 + margin-top: 0.5rem; 127 + font-size: 0.875rem; 128 + color: var(--old-rose); 129 + } 130 + 131 + .apps-list { 132 + display: flex; 133 + flex-direction: column; 134 + gap: 1rem; 135 + } 136 + 137 + .app-card { 138 + background: rgba(188, 141, 160, 0.05); 139 + border: 1px solid var(--old-rose); 140 + padding: 1.5rem; 141 + cursor: pointer; 142 + transition: background 0.2s; 143 + } 144 + 145 + .app-card:hover { 146 + background: rgba(188, 141, 160, 0.1); 147 + } 148 + 149 + .app-card.expanded { 150 + background: rgba(188, 141, 160, 0.1); 151 + } 152 + 153 + .app-header { 154 + display: flex; 155 + justify-content: space-between; 156 + align-items: flex-start; 157 + } 158 + 159 + .app-info { 160 + flex: 1; 161 + } 162 + 163 + .app-name { 164 + font-size: 1.125rem; 165 + font-weight: 600; 166 + color: var(--lavender); 167 + margin-bottom: 0.5rem; 168 + } 169 + 170 + .app-meta { 171 + font-size: 0.875rem; 172 + color: var(--old-rose); 173 + display: flex; 174 + flex-wrap: wrap; 175 + gap: 1rem; 176 + } 177 + 178 + .app-stats { 179 + display: flex; 180 + gap: 1rem; 181 + align-items: center; 182 + } 183 + 184 + .stat-badge { 185 + padding: 0.25rem 0.75rem; 186 + font-size: 0.75rem; 187 + font-weight: 700; 188 + text-transform: uppercase; 189 + letter-spacing: 0.05rem; 190 + background: rgba(188, 141, 160, 0.2); 191 + color: var(--lavender); 192 + border: 1px solid var(--old-rose); 193 + } 194 + 195 + .app-details { 196 + margin-top: 1.5rem; 197 + padding-top: 1.5rem; 198 + border-top: 1px solid var(--old-rose); 199 + display: none; 200 + } 201 + 202 + .app-card.expanded .app-details { 203 + display: block; 204 + } 205 + 206 + .permissions-list { 207 + display: flex; 208 + flex-direction: column; 209 + gap: 0.75rem; 210 + } 211 + 212 + .permission-item { 213 + background: rgba(0, 0, 0, 0.2); 214 + padding: 1rem; 215 + display: flex; 216 + justify-content: space-between; 217 + align-items: center; 218 + } 219 + 220 + .permission-user { 221 + flex: 1; 222 + } 223 + 224 + .permission-username { 225 + font-weight: 600; 226 + color: var(--lavender); 227 + margin-bottom: 0.25rem; 228 + } 229 + 230 + .permission-meta { 231 + font-size: 0.75rem; 232 + color: var(--old-rose); 233 + display: flex; 234 + gap: 1rem; 235 + } 236 + 237 + .permission-scopes { 238 + display: flex; 239 + flex-wrap: wrap; 240 + gap: 0.5rem; 241 + margin: 0.5rem 0; 242 + } 243 + 244 + .scope-badge { 245 + padding: 0.25rem 0.5rem; 246 + font-size: 0.625rem; 247 + font-weight: 600; 248 + text-transform: uppercase; 249 + letter-spacing: 0.05rem; 250 + background: rgba(188, 141, 160, 0.3); 251 + color: var(--lavender); 252 + border: 1px solid var(--old-rose); 253 + } 254 + 255 + .revoke-btn { 256 + padding: 0.5rem 1rem; 257 + background: transparent; 258 + color: var(--rosewood); 259 + border: 2px solid var(--rosewood); 260 + font-family: "Space Grotesk", sans-serif; 261 + font-size: 0.875rem; 262 + font-weight: 600; 263 + text-transform: uppercase; 264 + letter-spacing: 0.05rem; 265 + cursor: pointer; 266 + transition: all 0.2s; 267 + } 268 + 269 + .revoke-btn:hover:not(:disabled) { 270 + background: var(--rosewood); 271 + color: var(--lavender); 272 + } 273 + 274 + .revoke-btn:disabled { 275 + opacity: 0.5; 276 + cursor: not-allowed; 277 + } 278 + 279 + .loading { 280 + text-align: center; 281 + padding: 2rem; 282 + color: var(--old-rose); 283 + font-size: 1rem; 284 + } 285 + 286 + .error { 287 + text-align: center; 288 + padding: 2rem; 289 + color: var(--rosewood); 290 + font-size: 1rem; 291 + } 292 + 293 + .empty { 294 + text-align: center; 295 + padding: 2rem; 296 + color: var(--old-rose); 297 + font-size: 1rem; 298 + } 299 + 300 + .expand-indicator { 301 + color: var(--old-rose); 302 + font-size: 0.75rem; 303 + text-transform: uppercase; 304 + letter-spacing: 0.05rem; 305 + } 306 + 307 + .app-card.expanded .expand-indicator::after { 308 + content: " ▲"; 309 + } 310 + 311 + .app-card:not(.expanded) .expand-indicator::after { 312 + content: " ▼"; 313 + } 314 + </style> 315 + </head> 316 + 317 + <body> 318 + <header> 319 + <div> 320 + <img src="../../public/logo.svg" alt="indiko" style="height: 2rem;" /> 321 + </div> 322 + <div class="header-nav"> 323 + <a href="/admin">users</a> 324 + <a href="/admin/apps" class="active">apps</a> 325 + </div> 326 + </header> 327 + 328 + <main> 329 + <h2>apps</h2> 330 + <div id="appsList" class="apps-list"> 331 + <div class="loading">loading apps...</div> 332 + </div> 333 + </main> 334 + 335 + <footer id="footer"> 336 + loading... 337 + <div class="back-link"><a href="/">← back to dashboard</a></div> 338 + </footer> 339 + 340 + <script type="module" src="../client/admin-apps.ts"></script> 341 + </body> 342 + 343 + </html>
+120 -4
src/html/admin.html
··· 87 87 88 88 main { 89 89 flex: 1; 90 - display: flex; 91 - justify-content: center; 90 + display: grid; 91 + grid-template-columns: 2fr 1fr; 92 + gap: 2rem; 93 + align-items: start; 92 94 width: 100%; 93 - padding-top: 2rem; 95 + max-width: 80rem; 96 + padding: 2rem 1.25rem; 94 97 } 95 98 96 99 footer { ··· 123 126 124 127 .users-section { 125 128 width: 100%; 126 - max-width: 56.25rem; 127 129 } 128 130 129 131 .users-section h2 { ··· 264 266 color: var(--rosewood); 265 267 font-size: 1rem; 266 268 } 269 + 270 + .invites-section { 271 + width: 100%; 272 + } 273 + 274 + .invites-section h2 { 275 + font-size: 1.5rem; 276 + color: var(--lavender); 277 + margin-bottom: 1rem; 278 + } 279 + 280 + .invite-actions { 281 + display: flex; 282 + gap: 1rem; 283 + margin-bottom: 1.5rem; 284 + } 285 + 286 + .invite-btn { 287 + padding: 0.75rem 1.5rem; 288 + background: var(--berry-crush); 289 + color: var(--lavender); 290 + border: none; 291 + cursor: pointer; 292 + font-family: inherit; 293 + font-size: 1rem; 294 + font-weight: 500; 295 + transition: background 0.2s; 296 + } 297 + 298 + .invite-btn:hover { 299 + background: var(--rosewood); 300 + } 301 + 302 + .invite-btn:disabled { 303 + opacity: 0.5; 304 + cursor: not-allowed; 305 + } 306 + 307 + .invite-list { 308 + display: flex; 309 + flex-direction: column; 310 + gap: 0.75rem; 311 + } 312 + 313 + .invite-item { 314 + background: rgba(188, 141, 160, 0.05); 315 + border: 1px solid var(--old-rose); 316 + padding: 1rem; 317 + display: grid; 318 + grid-template-columns: 1fr auto; 319 + gap: 1rem; 320 + align-items: center; 321 + } 322 + 323 + .invite-code { 324 + font-family: monospace; 325 + font-size: 0.875rem; 326 + color: var(--lavender); 327 + word-break: break-all; 328 + } 329 + 330 + .invite-meta { 331 + font-size: 0.75rem; 332 + color: var(--old-rose); 333 + margin-top: 0.25rem; 334 + } 335 + 336 + .invite-actions-btns { 337 + display: flex; 338 + gap: 0.5rem; 339 + } 340 + 341 + .copy-btn, .delete-btn { 342 + padding: 0.5rem 1rem; 343 + background: rgba(188, 141, 160, 0.2); 344 + color: var(--lavender); 345 + border: 1px solid var(--old-rose); 346 + cursor: pointer; 347 + font-family: inherit; 348 + font-size: 0.875rem; 349 + transition: background 0.2s; 350 + } 351 + 352 + .copy-btn:hover { 353 + background: rgba(188, 141, 160, 0.3); 354 + } 355 + 356 + .delete-btn { 357 + background: rgba(160, 70, 104, 0.2); 358 + border-color: var(--rosewood); 359 + } 360 + 361 + .delete-btn:hover { 362 + background: rgba(160, 70, 104, 0.3); 363 + } 364 + 365 + .invite-url { 366 + background: rgba(0, 0, 0, 0.2); 367 + padding: 0.5rem; 368 + margin-top: 0.5rem; 369 + font-family: monospace; 370 + font-size: 0.75rem; 371 + word-break: break-all; 372 + } 267 373 </style> 268 374 </head> 269 375 ··· 283 389 <h2>users</h2> 284 390 <div id="usersList" class="users-list"> 285 391 <div class="loading">loading users...</div> 392 + </div> 393 + </div> 394 + 395 + <div class="invites-section"> 396 + <h2>invites</h2> 397 + <div class="invite-actions"> 398 + <button class="invite-btn" id="createInviteBtn">create invite link</button> 399 + </div> 400 + <div id="invitesList" class="invite-list"> 401 + <div class="loading">loading invites...</div> 286 402 </div> 287 403 </div> 288 404 </main>
+33 -2
src/index.ts
··· 2 2 import { db } from "./db"; 3 3 import indexHTML from "./html/index.html"; 4 4 import adminHTML from "./html/admin.html"; 5 + import adminAppsHTML from "./html/admin-apps.html"; 5 6 import loginHTML from "./html/login.html"; 6 7 import profileHTML from "./html/profile.html"; 7 8 import oauthTestHTML from "./html/oauth-test.html"; 8 9 import appsHTML from "./html/apps.html"; 9 10 import { canRegister, registerOptions, registerVerify, loginOptions, loginVerify } from "./routes/auth"; 10 - import { hello, listUsers, getProfile, updateProfile, getAuthorizedApps, revokeApp } from "./routes/api"; 11 - import { authorizeGet, authorizePost, token, logout, userProfile, createInvite } from "./routes/indieauth"; 11 + import { hello, listUsers, getProfile, updateProfile, getAuthorizedApps, revokeApp, listAllApps, getAppDetails, revokeAppForUser } from "./routes/api"; 12 + import { authorizeGet, authorizePost, token, logout, userProfile, createInvite, listInvites } from "./routes/indieauth"; 12 13 13 14 (() => { 14 15 const required = ["ORIGIN", "RP_ID"]; ··· 28 29 routes: { 29 30 "/": indexHTML, 30 31 "/admin": adminHTML, 32 + "/admin/apps": adminAppsHTML, 31 33 "/login": loginHTML, 32 34 "/profile": profileHTML, 33 35 "/oauth-test": oauthTestHTML, ··· 44 46 if (req.method === "GET") return getAuthorizedApps(req); 45 47 return new Response("Method not allowed", { status: 405 }); 46 48 }, 49 + "/api/admin/apps": (req: Request) => { 50 + if (req.method === "GET") return listAllApps(req); 51 + return new Response("Method not allowed", { status: 405 }); 52 + }, 47 53 "/api/invites/create": (req: Request) => { 48 54 if (req.method === "POST") return createInvite(req); 55 + return new Response("Method not allowed", { status: 405 }); 56 + }, 57 + "/api/invites": (req: Request) => { 58 + if (req.method === "GET") return listInvites(req); 49 59 return new Response("Method not allowed", { status: 405 }); 50 60 }, 51 61 // IndieAuth/OAuth 2.0 endpoints ··· 87 97 if (req.method === "DELETE") { 88 98 const clientId = decodeURIComponent(appMatch[1]); 89 99 return revokeApp(req, clientId); 100 + } 101 + return new Response("Method not allowed", { status: 405 }); 102 + } 103 + 104 + // /api/admin/apps/:clientId - get app details (admin) 105 + const adminAppMatch = url.pathname.match(/^\/api\/admin\/apps\/([^\/]+)$/); 106 + if (adminAppMatch) { 107 + if (req.method === "GET") { 108 + const clientId = decodeURIComponent(adminAppMatch[1]); 109 + return getAppDetails(req, clientId); 110 + } 111 + return new Response("Method not allowed", { status: 405 }); 112 + } 113 + 114 + // /api/admin/apps/:clientId/users/:username - revoke app access for user (admin) 115 + const adminAppUserMatch = url.pathname.match(/^\/api\/admin\/apps\/([^\/]+)\/users\/([^\/]+)$/); 116 + if (adminAppUserMatch) { 117 + if (req.method === "DELETE") { 118 + const clientId = decodeURIComponent(adminAppUserMatch[1]); 119 + const username = decodeURIComponent(adminAppUserMatch[2]); 120 + return revokeAppForUser(req, clientId, username); 90 121 } 91 122 return new Response("Method not allowed", { status: 405 }); 92 123 }
+146
src/routes/api.ts
··· 233 233 234 234 return Response.json({ success: true }); 235 235 } 236 + 237 + export function listAllApps(req: Request): Response { 238 + const user = getSessionUser(req); 239 + if (user instanceof Response) { 240 + return user; 241 + } 242 + 243 + if (!user.is_admin) { 244 + return Response.json({ error: "Admin access required" }, { status: 403 }); 245 + } 246 + 247 + const apps = db 248 + .query( 249 + `SELECT 250 + a.client_id, 251 + a.name, 252 + a.first_seen, 253 + a.last_used, 254 + COUNT(DISTINCT p.user_id) as user_count 255 + FROM apps a 256 + LEFT JOIN permissions p ON a.client_id = p.client_id 257 + GROUP BY a.client_id 258 + ORDER BY a.last_used DESC`, 259 + ) 260 + .all() as Array<{ 261 + client_id: string; 262 + name: string | null; 263 + first_seen: number; 264 + last_used: number; 265 + user_count: number; 266 + }>; 267 + 268 + return Response.json({ 269 + apps: apps.map((app) => ({ 270 + clientId: app.client_id, 271 + name: app.name || new URL(app.client_id).hostname, 272 + firstSeen: app.first_seen, 273 + lastUsed: app.last_used, 274 + userCount: app.user_count, 275 + })), 276 + }); 277 + } 278 + 279 + export function getAppDetails(req: Request, clientId: string): Response { 280 + const user = getSessionUser(req); 281 + if (user instanceof Response) { 282 + return user; 283 + } 284 + 285 + if (!user.is_admin) { 286 + return Response.json({ error: "Admin access required" }, { status: 403 }); 287 + } 288 + 289 + const app = db 290 + .query( 291 + `SELECT client_id, name, first_seen, last_used 292 + FROM apps 293 + WHERE client_id = ?`, 294 + ) 295 + .get(clientId) as 296 + | { 297 + client_id: string; 298 + name: string | null; 299 + first_seen: number; 300 + last_used: number; 301 + } 302 + | undefined; 303 + 304 + if (!app) { 305 + return Response.json({ error: "App not found" }, { status: 404 }); 306 + } 307 + 308 + const permissions = db 309 + .query( 310 + `SELECT 311 + u.username, 312 + u.name, 313 + p.scopes, 314 + p.granted_at, 315 + p.last_used 316 + FROM permissions p 317 + JOIN users u ON p.user_id = u.id 318 + WHERE p.client_id = ? 319 + ORDER BY p.last_used DESC`, 320 + ) 321 + .all(clientId) as Array<{ 322 + username: string; 323 + name: string; 324 + scopes: string; 325 + granted_at: number; 326 + last_used: number; 327 + }>; 328 + 329 + return Response.json({ 330 + app: { 331 + clientId: app.client_id, 332 + name: app.name || new URL(app.client_id).hostname, 333 + firstSeen: app.first_seen, 334 + lastUsed: app.last_used, 335 + }, 336 + permissions: permissions.map((p) => ({ 337 + username: p.username, 338 + name: p.name, 339 + scopes: JSON.parse(p.scopes) as string[], 340 + grantedAt: p.granted_at, 341 + lastUsed: p.last_used, 342 + })), 343 + }); 344 + } 345 + 346 + export function revokeAppForUser( 347 + req: Request, 348 + clientId: string, 349 + username: string, 350 + ): Response { 351 + const user = getSessionUser(req); 352 + if (user instanceof Response) { 353 + return user; 354 + } 355 + 356 + if (!user.is_admin) { 357 + return Response.json({ error: "Admin access required" }, { status: 403 }); 358 + } 359 + 360 + const targetUser = db 361 + .query("SELECT id FROM users WHERE username = ?") 362 + .get(username) as { id: number } | undefined; 363 + 364 + if (!targetUser) { 365 + return Response.json({ error: "User not found" }, { status: 404 }); 366 + } 367 + 368 + const result = db 369 + .query("DELETE FROM permissions WHERE user_id = ? AND client_id = ?") 370 + .run(targetUser.id, clientId); 371 + 372 + if (result.changes === 0) { 373 + return Response.json({ error: "Permission not found" }, { status: 404 }); 374 + } 375 + 376 + db.query( 377 + "DELETE FROM authcodes WHERE user_id = ? AND client_id = ? AND used = 0", 378 + ).run(targetUser.id, clientId); 379 + 380 + return Response.json({ success: true }); 381 + }
+51 -8
src/routes/auth.ts
··· 28 28 export async function registerOptions(req: Request): Promise<Response> { 29 29 try { 30 30 const body = await req.json(); 31 - const { username } = body; 31 + const { username, inviteCode } = body; 32 32 33 33 if (!username || typeof username !== "string") { 34 34 return Response.json({ error: "Username required" }, { status: 400 }); ··· 53 53 54 54 const isBootstrap = userCount.count === 0; 55 55 56 + // If not bootstrap, require valid invite code 56 57 if (!isBootstrap) { 57 - return Response.json({ error: "Registration closed" }, { status: 403 }); 58 + if (!inviteCode) { 59 + return Response.json({ error: "Invite code required" }, { status: 403 }); 60 + } 61 + 62 + // Validate invite code 63 + const invite = db 64 + .query("SELECT id, used FROM invites WHERE code = ?") 65 + .get(inviteCode) as { id: number; used: number } | undefined; 66 + 67 + if (!invite) { 68 + return Response.json({ error: "Invalid invite code" }, { status: 403 }); 69 + } 70 + 71 + if (invite.used === 1) { 72 + return Response.json({ error: "Invite code already used" }, { status: 403 }); 73 + } 58 74 } 59 75 60 76 // Generate WebAuthn registration options ··· 88 104 export async function registerVerify(req: Request): Promise<Response> { 89 105 try { 90 106 const body = await req.json(); 91 - const { username, response, challenge: expectedChallenge } = body as { 107 + const { username, response, challenge: expectedChallenge, inviteCode } = body as { 92 108 username: string; 93 109 response: RegistrationResponseJSON; 94 110 challenge?: string; 111 + inviteCode?: string; 95 112 }; 96 113 97 114 if (!username || !response) { ··· 138 155 139 156 const isBootstrap = userCount.count === 0; 140 157 158 + // If not bootstrap, validate invite code 159 + let inviteId: number | undefined; 141 160 if (!isBootstrap) { 142 - return Response.json({ error: "Registration closed" }, { status: 403 }); 161 + if (!inviteCode) { 162 + return Response.json({ error: "Invite code required" }, { status: 403 }); 163 + } 164 + 165 + const invite = db 166 + .query("SELECT id, used FROM invites WHERE code = ?") 167 + .get(inviteCode) as { id: number; used: number } | undefined; 168 + 169 + if (!invite) { 170 + return Response.json({ error: "Invalid invite code" }, { status: 403 }); 171 + } 172 + 173 + if (invite.used === 1) { 174 + return Response.json({ error: "Invite code already used" }, { status: 403 }); 175 + } 176 + 177 + inviteId = invite.id; 143 178 } 144 179 145 180 // Verify WebAuthn response ··· 168 203 169 204 const { credential } = verification.registrationInfo; 170 205 171 - // Create user (bootstrap is always admin) 206 + // Create user (bootstrap is always admin, invited users are regular users) 172 207 const insertUser = db.query( 173 - "INSERT INTO users (username, name, is_admin, role) VALUES (?, ?, 1, 'admin') RETURNING id", 208 + "INSERT INTO users (username, name, is_admin, role) VALUES (?, ?, ?, ?) RETURNING id", 174 209 ); 175 - const user = insertUser.get(username, username) as { 210 + const user = insertUser.get(username, username, isBootstrap ? 1 : 0, isBootstrap ? 'admin' : 'user') as { 176 211 id: number; 177 212 }; 178 213 ··· 187 222 credential.counter, 188 223 ); 189 224 225 + // Mark invite as used if applicable 226 + if (inviteId) { 227 + const usedAt = Math.floor(Date.now() / 1000); 228 + db.query( 229 + "UPDATE invites SET used = 1, used_by = ?, used_at = ? WHERE id = ?", 230 + ).run(user.id, usedAt, inviteId); 231 + } 232 + 190 233 // Delete challenge 191 234 db.query("DELETE FROM challenges WHERE challenge = ?").run( 192 235 challenge.challenge, ··· 203 246 { 204 247 token, 205 248 username, 206 - isAdmin: true, 249 + isAdmin: isBootstrap, 207 250 }, 208 251 { 209 252 headers: {
+43
src/routes/indieauth.ts
··· 880 880 inviteUrl: `${process.env.ORIGIN}/login?invite=${inviteCode}`, 881 881 }); 882 882 } 883 + 884 + // GET /api/invites - List all invites (admin only) 885 + export function listInvites(req: Request): Response { 886 + const user = getSessionUser(req); 887 + if (user instanceof Response) { 888 + return user; 889 + } 890 + 891 + if (!user.isAdmin) { 892 + return Response.json({ error: "Admin access required" }, { status: 403 }); 893 + } 894 + 895 + const invites = db.query(` 896 + SELECT i.id, i.code, i.used, i.created_at, i.used_at, 897 + creator.username as created_by_username, 898 + usedby.username as used_by_username 899 + FROM invites i 900 + LEFT JOIN users creator ON i.created_by = creator.id 901 + LEFT JOIN users usedby ON i.used_by = usedby.id 902 + ORDER BY i.created_at DESC 903 + `).all() as Array<{ 904 + id: number; 905 + code: string; 906 + used: number; 907 + created_at: number; 908 + used_at: number | null; 909 + created_by_username: string; 910 + used_by_username: string | null; 911 + }>; 912 + 913 + return Response.json({ 914 + invites: invites.map((inv) => ({ 915 + id: inv.id, 916 + code: inv.code, 917 + used: inv.used === 1, 918 + createdAt: inv.created_at, 919 + usedAt: inv.used_at, 920 + createdBy: inv.created_by_username, 921 + usedBy: inv.used_by_username, 922 + inviteUrl: `${process.env.ORIGIN}/login?invite=${inv.code}`, 923 + })), 924 + }); 925 + }