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: allow using custom clients

+1840 -664
indiko.db

This is a binary file and will not be displayed.

-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();
+529
src/client/admin-clients.ts
··· 1 + const token = localStorage.getItem('indiko_session'); 2 + const footer = document.getElementById('footer') as HTMLElement; 3 + const clientsList = document.getElementById('clientsList') as HTMLElement; 4 + const createClientBtn = document.getElementById('createClientBtn') as HTMLButtonElement; 5 + const clientModal = document.getElementById('clientModal') as HTMLElement; 6 + const modalClose = document.getElementById('modalClose') as HTMLButtonElement; 7 + const cancelBtn = document.getElementById('cancelBtn') as HTMLButtonElement; 8 + const clientForm = document.getElementById('clientForm') as HTMLFormElement; 9 + const modalTitle = document.getElementById('modalTitle') as HTMLElement; 10 + const addRedirectUriBtn = document.getElementById('addRedirectUriBtn') as HTMLButtonElement; 11 + const redirectUrisList = document.getElementById('redirectUrisList') as HTMLElement; 12 + const toast = document.getElementById('toast') as HTMLElement; 13 + 14 + function showToast(message: string, type: 'success' | 'error' = 'success') { 15 + toast.textContent = message; 16 + toast.className = `toast ${type} show`; 17 + 18 + setTimeout(() => { 19 + toast.classList.remove('show'); 20 + }, 3000); 21 + } 22 + 23 + async function checkAuth() { 24 + if (!token) { 25 + window.location.href = '/login'; 26 + return; 27 + } 28 + 29 + try { 30 + const response = await fetch('/api/hello', { 31 + headers: { 32 + 'Authorization': `Bearer ${token}`, 33 + }, 34 + }); 35 + 36 + if (response.status === 401) { 37 + localStorage.removeItem('indiko_session'); 38 + window.location.href = '/login'; 39 + return; 40 + } 41 + 42 + const data = await response.json(); 43 + 44 + footer.innerHTML = `admin • signed in as <strong><a href="/u/${data.username}">${data.username}</a></strong> • <a href="/login" id="logoutLink">sign out</a> 45 + <div class="back-link"><a href="/">← back to dashboard</a></div>`; 46 + 47 + document.getElementById('logoutLink')?.addEventListener('click', async (e) => { 48 + e.preventDefault(); 49 + try { 50 + await fetch('/auth/logout', { 51 + method: 'POST', 52 + headers: { 53 + 'Authorization': `Bearer ${token}`, 54 + }, 55 + }); 56 + } catch { 57 + // Ignore logout errors 58 + } 59 + localStorage.removeItem('indiko_session'); 60 + window.location.href = '/login'; 61 + }); 62 + 63 + if (!data.isAdmin) { 64 + window.location.href = '/'; 65 + return; 66 + } 67 + 68 + loadClients(); 69 + } catch (error) { 70 + console.error('Auth check failed:', error); 71 + footer.textContent = 'error loading user info'; 72 + clientsList.innerHTML = '<div class="error">Failed to load clients</div>'; 73 + } 74 + } 75 + 76 + interface Client { 77 + id: number; 78 + clientId: string; 79 + name: string; 80 + logoUrl: string | null; 81 + description: string | null; 82 + redirectUris: string[]; 83 + isPreregistered: boolean; 84 + firstSeen: number; 85 + lastUsed: number; 86 + } 87 + 88 + interface ClientUser { 89 + username: string; 90 + name: string; 91 + scopes: string[]; 92 + role: string | null; 93 + grantedAt: number; 94 + lastUsed: number; 95 + } 96 + 97 + interface AppPermission { 98 + username: string; 99 + name: string; 100 + scopes: string[]; 101 + grantedAt: number; 102 + lastUsed: number; 103 + } 104 + 105 + async function loadClients() { 106 + try { 107 + const response = await fetch('/api/admin/clients', { 108 + headers: { 109 + 'Authorization': `Bearer ${token}`, 110 + }, 111 + }); 112 + 113 + if (!response.ok) { 114 + throw new Error('Failed to load clients'); 115 + } 116 + 117 + const data = await response.json(); 118 + displayClients(data.clients); 119 + } catch (error) { 120 + console.error('Failed to load clients:', error); 121 + clientsList.innerHTML = '<div class="error">Failed to load clients</div>'; 122 + } 123 + } 124 + 125 + function displayClients(clients: Client[]) { 126 + if (clients.length === 0) { 127 + clientsList.innerHTML = '<div class="empty">No OAuth clients registered yet.</div>'; 128 + return; 129 + } 130 + 131 + clientsList.innerHTML = clients.map((client) => { 132 + const lastUsedDate = new Date(client.lastUsed * 1000).toLocaleDateString(); 133 + const firstSeenDate = new Date(client.firstSeen * 1000).toLocaleDateString(); 134 + 135 + return ` 136 + <div class="client-card" data-client-id="${client.clientId}" onclick="toggleClient('${client.clientId}')"> 137 + <div class="client-header"> 138 + <div class="client-logo"> 139 + ${client.logoUrl 140 + ? `<img src="${client.logoUrl}" alt="${client.name}" />` 141 + : `<div class="client-logo-placeholder">🔐</div>` 142 + } 143 + </div> 144 + <div class="client-info"> 145 + <div class="client-name">${client.name}</div> 146 + <div class="client-id">${client.clientId}</div> 147 + ${client.description ? `<div class="client-description">${client.description}</div>` : ''} 148 + <div class="client-badges"> 149 + <span class="badge ${client.isPreregistered ? 'badge-preregistered' : 'badge-auto'}"> 150 + ${client.isPreregistered ? 'pre-registered' : 'auto-registered'} 151 + </span> 152 + <span class="badge badge-auto">first seen ${firstSeenDate}</span> 153 + <span class="badge badge-auto">last used ${lastUsedDate}</span> 154 + </div> 155 + </div> 156 + <div class="client-actions" style="display: flex; gap: 0.5rem; align-items: center;"> 157 + ${client.isPreregistered ? ` 158 + <button class="btn-edit" onclick="event.stopPropagation(); editClient('${client.clientId}')">edit</button> 159 + <button class="btn-delete" onclick="event.stopPropagation(); deleteClient('${client.clientId}')">delete</button> 160 + ` : ''} 161 + <span class="expand-indicator" style="color: var(--old-rose); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05rem;">details ▼</span> 162 + </div> 163 + </div> 164 + <div class="client-details" id="details-${encodeURIComponent(client.clientId)}"> 165 + <div class="loading">loading details...</div> 166 + </div> 167 + </div> 168 + `; 169 + }).join(''); 170 + } 171 + 172 + (window as any).toggleClient = async function(clientId: string) { 173 + const card = document.querySelector(`[data-client-id="${clientId}"]`) as HTMLElement; 174 + if (!card) return; 175 + 176 + const isExpanded = card.classList.contains('expanded'); 177 + 178 + if (isExpanded) { 179 + card.classList.remove('expanded'); 180 + return; 181 + } 182 + 183 + card.classList.add('expanded'); 184 + 185 + const detailsDiv = document.getElementById(`details-${encodeURIComponent(clientId)}`); 186 + if (!detailsDiv) return; 187 + 188 + if (detailsDiv.dataset.loaded === 'true') { 189 + return; 190 + } 191 + 192 + try { 193 + const response = await fetch(`/api/admin/clients/${encodeURIComponent(clientId)}`, { 194 + headers: { 195 + 'Authorization': `Bearer ${token}`, 196 + }, 197 + }); 198 + 199 + if (!response.ok) { 200 + throw new Error('Failed to load client details'); 201 + } 202 + 203 + const data = await response.json(); 204 + 205 + detailsDiv.innerHTML = ` 206 + ${data.client.isPreregistered ? ` 207 + <div class="detail-section"> 208 + <div class="detail-title">client secret</div> 209 + <div class="secret-section"> 210 + <input type="password" value="••••••••••••••••••••••••" readonly style="background: rgba(0, 0, 0, 0.3); border: 1px solid var(--old-rose); color: var(--lavender); padding: 0.5rem; font-family: monospace; width: 100%; margin-bottom: 0.5rem;" id="secret-${encodeURIComponent(clientId)}" /> 211 + <button class="btn-edit" onclick="event.stopPropagation(); regenerateSecret('${clientId}')">regenerate secret</button> 212 + </div> 213 + </div> 214 + ` : ''} 215 + <div class="detail-section"> 216 + <div class="detail-title">redirect uris</div> 217 + <div class="redirect-uris"> 218 + ${data.client.redirectUris.map((uri: string) => `<div class="redirect-uri">${uri}</div>`).join('')} 219 + </div> 220 + </div> 221 + <div class="detail-section"> 222 + <div class="detail-title">authorized users (${data.users.length})</div> 223 + ${data.users.length === 0 224 + ? '<div class="empty">No users have authorized this client yet</div>' 225 + : `<div class="users-list"> 226 + ${data.users.map((user: ClientUser) => { 227 + const grantedDate = new Date(user.grantedAt * 1000).toLocaleDateString(); 228 + const lastUsedDate = new Date(user.lastUsed * 1000).toLocaleDateString(); 229 + 230 + return ` 231 + <div class="user-item"> 232 + <div class="user-info"> 233 + <div class="user-name"><a href="/u/${user.username}" onclick="event.stopPropagation();" style="color: var(--lavender); text-decoration: none;">${user.name}</a> (<a href="/u/${user.username}" onclick="event.stopPropagation();" style="color: var(--old-rose); text-decoration: none;">@${user.username}</a>)</div> 234 + ${data.client.isPreregistered ? ` 235 + <div class="user-role-input"> 236 + <label style="color: var(--old-rose); font-size: 0.75rem;">ROLE:</label> 237 + <input type="text" value="${user.role || ''}" placeholder="none" data-username="${user.username}" data-client-id="${clientId}" /> 238 + <button onclick="event.stopPropagation(); setUserRole('${clientId}', '${user.username}', this.previousElementSibling.value)">update</button> 239 + </div> 240 + ` : ''} 241 + <div class="user-meta"> 242 + Granted ${grantedDate} • Last used ${lastUsedDate} • Scopes: ${user.scopes.join(', ')} 243 + </div> 244 + </div> 245 + <button class="revoke-btn" onclick="event.stopPropagation(); revokeUserPermission('${clientId}', '${user.username}')">revoke</button> 246 + </div> 247 + `; 248 + }).join('')} 249 + </div>` 250 + } 251 + </div> 252 + `; 253 + 254 + detailsDiv.dataset.loaded = 'true'; 255 + } catch (error) { 256 + console.error('Failed to load client details:', error); 257 + detailsDiv.innerHTML = '<div class="error">Failed to load details</div>'; 258 + } 259 + }; 260 + 261 + (window as any).setUserRole = async function(clientId: string, username: string, role: string) { 262 + try { 263 + const response = await fetch(`/api/admin/clients/${encodeURIComponent(clientId)}/users/${encodeURIComponent(username)}/role`, { 264 + method: 'POST', 265 + headers: { 266 + 'Authorization': `Bearer ${token}`, 267 + 'Content-Type': 'application/json', 268 + }, 269 + body: JSON.stringify({ role: role || null }), 270 + }); 271 + 272 + if (!response.ok) { 273 + throw new Error('Failed to set user role'); 274 + } 275 + 276 + showToast('User role updated successfully'); 277 + } catch (error) { 278 + console.error('Failed to set user role:', error); 279 + showToast('Failed to update user role. Please try again.', 'error'); 280 + } 281 + }; 282 + 283 + (window as any).editClient = async function(clientId: string) { 284 + try { 285 + const response = await fetch(`/api/admin/clients/${encodeURIComponent(clientId)}`, { 286 + headers: { 287 + 'Authorization': `Bearer ${token}`, 288 + }, 289 + }); 290 + 291 + if (!response.ok) { 292 + throw new Error('Failed to load client'); 293 + } 294 + 295 + const data = await response.json(); 296 + const client = data.client; 297 + 298 + modalTitle.textContent = 'Edit OAuth Client'; 299 + (document.getElementById('editClientId') as HTMLInputElement).value = clientId; 300 + (document.getElementById('clientId') as HTMLInputElement).value = client.clientId; 301 + (document.getElementById('clientId') as HTMLInputElement).disabled = true; 302 + (document.getElementById('clientName') as HTMLInputElement).value = client.name || ''; 303 + (document.getElementById('logoUrl') as HTMLInputElement).value = client.logoUrl || ''; 304 + (document.getElementById('description') as HTMLTextAreaElement).value = client.description || ''; 305 + 306 + redirectUrisList.innerHTML = client.redirectUris.map((uri: string) => ` 307 + <div class="redirect-uri-item"> 308 + <input type="url" class="form-input redirect-uri-input" value="${uri}" required /> 309 + <button type="button" class="btn-remove" onclick="removeRedirectUri(this)">remove</button> 310 + </div> 311 + `).join(''); 312 + 313 + clientModal.classList.add('active'); 314 + } catch (error) { 315 + console.error('Failed to load client:', error); 316 + showToast('Failed to load client details', 'error'); 317 + } 318 + }; 319 + 320 + (window as any).deleteClient = async function(clientId: string) { 321 + if (!confirm('Are you sure you want to delete this client? This will revoke access for all users and cannot be undone.')) { 322 + return; 323 + } 324 + 325 + try { 326 + const response = await fetch(`/api/admin/clients/${encodeURIComponent(clientId)}`, { 327 + method: 'DELETE', 328 + headers: { 329 + 'Authorization': `Bearer ${token}`, 330 + }, 331 + }); 332 + 333 + if (!response.ok) { 334 + throw new Error('Failed to delete client'); 335 + } 336 + 337 + await loadClients(); 338 + } catch (error) { 339 + console.error('Failed to delete client:', error); 340 + showToast('Failed to delete client. Please try again.', 'error'); 341 + } 342 + }; 343 + 344 + createClientBtn.addEventListener('click', () => { 345 + modalTitle.textContent = 'Create OAuth Client'; 346 + clientForm.reset(); 347 + (document.getElementById('editClientId') as HTMLInputElement).value = ''; 348 + (document.getElementById('clientId') as HTMLInputElement).disabled = false; 349 + redirectUrisList.innerHTML = ` 350 + <div class="redirect-uri-item"> 351 + <input type="url" class="form-input redirect-uri-input" placeholder="https://example.com/auth/callback" required /> 352 + <button type="button" class="btn-remove" onclick="removeRedirectUri(this)">remove</button> 353 + </div> 354 + `; 355 + clientModal.classList.add('active'); 356 + }); 357 + 358 + modalClose.addEventListener('click', () => { 359 + clientModal.classList.remove('active'); 360 + }); 361 + 362 + cancelBtn.addEventListener('click', () => { 363 + clientModal.classList.remove('active'); 364 + }); 365 + 366 + addRedirectUriBtn.addEventListener('click', () => { 367 + const newItem = document.createElement('div'); 368 + newItem.className = 'redirect-uri-item'; 369 + newItem.innerHTML = ` 370 + <input type="url" class="form-input redirect-uri-input" placeholder="https://example.com/auth/callback" required /> 371 + <button type="button" class="btn-remove" onclick="removeRedirectUri(this)">remove</button> 372 + `; 373 + redirectUrisList.appendChild(newItem); 374 + }); 375 + 376 + (window as any).removeRedirectUri = function(btn: HTMLButtonElement) { 377 + const items = redirectUrisList.querySelectorAll('.redirect-uri-item'); 378 + if (items.length > 1) { 379 + btn.parentElement?.remove(); 380 + } else { 381 + showToast('At least one redirect URI is required', 'error'); 382 + } 383 + }; 384 + 385 + clientForm.addEventListener('submit', async (e) => { 386 + e.preventDefault(); 387 + 388 + const editClientId = (document.getElementById('editClientId') as HTMLInputElement).value; 389 + const clientId = (document.getElementById('clientId') as HTMLInputElement).value; 390 + const name = (document.getElementById('clientName') as HTMLInputElement).value; 391 + const logoUrl = (document.getElementById('logoUrl') as HTMLInputElement).value; 392 + const description = (document.getElementById('description') as HTMLTextAreaElement).value; 393 + 394 + const redirectUriInputs = Array.from(redirectUrisList.querySelectorAll('.redirect-uri-input')) as HTMLInputElement[]; 395 + const redirectUris = redirectUriInputs.map(input => input.value).filter(uri => uri.trim()); 396 + 397 + if (redirectUris.length === 0) { 398 + showToast('At least one redirect URI is required', 'error'); 399 + return; 400 + } 401 + 402 + const isEdit = !!editClientId; 403 + const url = isEdit 404 + ? `/api/admin/clients/${encodeURIComponent(editClientId)}` 405 + : '/api/admin/clients'; 406 + const method = isEdit ? 'PUT' : 'POST'; 407 + 408 + try { 409 + const response = await fetch(url, { 410 + method, 411 + headers: { 412 + 'Authorization': `Bearer ${token}`, 413 + 'Content-Type': 'application/json', 414 + }, 415 + body: JSON.stringify({ 416 + clientId: isEdit ? undefined : clientId, 417 + name, 418 + logoUrl, 419 + description, 420 + redirectUris, 421 + }), 422 + }); 423 + 424 + if (!response.ok) { 425 + const error = await response.json(); 426 + throw new Error(error.error || 'Failed to save client'); 427 + } 428 + 429 + clientModal.classList.remove('active'); 430 + 431 + // If creating a new client, show the secret 432 + if (!isEdit) { 433 + const result = await response.json(); 434 + if (result.client && result.client.clientSecret) { 435 + // Show secret in modal 436 + const secretText = result.client.clientSecret; 437 + navigator.clipboard.writeText(secretText); 438 + showToast(`Client created! Secret copied to clipboard: ${secretText}`); 439 + } 440 + } else { 441 + showToast('Client updated successfully'); 442 + } 443 + 444 + await loadClients(); 445 + } catch (error) { 446 + console.error('Failed to save client:', error); 447 + showToast(`Failed to ${isEdit ? 'update' : 'create'} client: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error'); 448 + } 449 + }); 450 + 451 + (window as any).regenerateSecret = async function(clientId: string) { 452 + if (!confirm('Are you sure you want to regenerate the client secret? This will invalidate the old secret and any apps using it will need to be updated.')) { 453 + return; 454 + } 455 + 456 + try { 457 + const response = await fetch(`/api/admin/clients/${encodeURIComponent(clientId)}/secret`, { 458 + method: 'POST', 459 + headers: { 460 + 'Authorization': `Bearer ${token}`, 461 + }, 462 + }); 463 + 464 + if (!response.ok) { 465 + throw new Error('Failed to regenerate secret'); 466 + } 467 + 468 + const data = await response.json(); 469 + 470 + // Show the new secret in an alert (could also show in a modal) 471 + const secretInput = document.getElementById(`secret-${encodeURIComponent(clientId)}`) as HTMLInputElement; 472 + if (secretInput) { 473 + secretInput.type = 'text'; 474 + secretInput.value = data.clientSecret; 475 + secretInput.select(); 476 + 477 + // Copy to clipboard 478 + navigator.clipboard.writeText(data.clientSecret); 479 + 480 + showToast(`New secret generated and copied: ${data.clientSecret}`); 481 + 482 + // Reset to password field after a delay 483 + setTimeout(() => { 484 + secretInput.type = 'password'; 485 + secretInput.value = '••••••••••••••••••••••••'; 486 + }, 5000); 487 + } 488 + } catch (error) { 489 + console.error('Failed to regenerate secret:', error); 490 + showToast('Failed to regenerate client secret. Please try again.', 'error'); 491 + } 492 + }; 493 + 494 + (window as any).revokeUserPermission = async function(clientId: string, username: string) { 495 + if (!confirm(`Are you sure you want to revoke access for ${username}? They will need to authorize this app again.`)) { 496 + return; 497 + } 498 + 499 + try { 500 + const response = await fetch(`/api/admin/apps/${encodeURIComponent(clientId)}/users/${encodeURIComponent(username)}`, { 501 + method: 'DELETE', 502 + headers: { 503 + 'Authorization': `Bearer ${token}`, 504 + }, 505 + }); 506 + 507 + if (!response.ok) { 508 + throw new Error('Failed to revoke permission'); 509 + } 510 + 511 + // Reload the client details 512 + const detailsDiv = document.getElementById(`details-${encodeURIComponent(clientId)}`); 513 + if (detailsDiv) { 514 + detailsDiv.dataset.loaded = 'false'; 515 + } 516 + 517 + const card = document.querySelector(`[data-client-id="${clientId}"]`) as HTMLElement; 518 + if (card) { 519 + card.classList.remove('expanded'); 520 + } 521 + 522 + await loadClients(); 523 + } catch (error) { 524 + console.error('Failed to revoke permission:', error); 525 + showToast('Failed to revoke permission. Please try again.', 'error'); 526 + } 527 + }; 528 + 529 + checkAuth();
-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>
+655
src/html/admin-clients.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>oauth clients • 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 + .actions { 132 + display: flex; 133 + justify-content: space-between; 134 + align-items: center; 135 + margin-bottom: 1.5rem; 136 + } 137 + 138 + .btn { 139 + padding: 0.75rem 1.5rem; 140 + background: var(--berry-crush); 141 + color: var(--lavender); 142 + border: none; 143 + cursor: pointer; 144 + font-family: inherit; 145 + font-size: 1rem; 146 + font-weight: 500; 147 + transition: background 0.2s; 148 + text-decoration: none; 149 + display: inline-block; 150 + } 151 + 152 + .btn:hover { 153 + background: var(--rosewood); 154 + } 155 + 156 + .btn:disabled { 157 + opacity: 0.5; 158 + cursor: not-allowed; 159 + } 160 + 161 + .clients-list { 162 + display: flex; 163 + flex-direction: column; 164 + gap: 1rem; 165 + } 166 + 167 + .client-card { 168 + background: rgba(188, 141, 160, 0.05); 169 + border: 1px solid var(--old-rose); 170 + padding: 1.5rem; 171 + cursor: pointer; 172 + transition: background 0.2s; 173 + } 174 + 175 + .client-card:hover { 176 + background: rgba(188, 141, 160, 0.1); 177 + } 178 + 179 + .client-card.expanded { 180 + background: rgba(188, 141, 160, 0.1); 181 + } 182 + 183 + .client-header { 184 + display: flex; 185 + gap: 1rem; 186 + align-items: flex-start; 187 + } 188 + 189 + .client-logo { 190 + width: 4rem; 191 + height: 4rem; 192 + border-radius: 0.5rem; 193 + background: rgba(188, 141, 160, 0.2); 194 + display: flex; 195 + align-items: center; 196 + justify-content: center; 197 + flex-shrink: 0; 198 + overflow: hidden; 199 + } 200 + 201 + .client-logo img { 202 + width: 100%; 203 + height: 100%; 204 + object-fit: cover; 205 + } 206 + 207 + .client-logo-placeholder { 208 + font-size: 1.5rem; 209 + color: var(--old-rose); 210 + } 211 + 212 + .client-info { 213 + flex: 1; 214 + } 215 + 216 + .client-name { 217 + font-size: 1.125rem; 218 + font-weight: 600; 219 + color: var(--lavender); 220 + margin-bottom: 0.25rem; 221 + } 222 + 223 + .client-id { 224 + font-size: 0.75rem; 225 + color: var(--old-rose); 226 + font-family: monospace; 227 + margin-bottom: 0.5rem; 228 + } 229 + 230 + .client-description { 231 + font-size: 0.875rem; 232 + color: var(--old-rose); 233 + margin-bottom: 0.5rem; 234 + } 235 + 236 + .client-badges { 237 + display: flex; 238 + gap: 0.5rem; 239 + flex-wrap: wrap; 240 + margin-top: 0.5rem; 241 + } 242 + 243 + .badge { 244 + padding: 0.25rem 0.75rem; 245 + font-size: 0.75rem; 246 + font-weight: 700; 247 + text-transform: uppercase; 248 + letter-spacing: 0.05rem; 249 + } 250 + 251 + .badge-preregistered { 252 + background: var(--berry-crush); 253 + color: var(--lavender); 254 + } 255 + 256 + .badge-auto { 257 + background: rgba(188, 141, 160, 0.2); 258 + color: var(--lavender); 259 + border: 1px solid var(--old-rose); 260 + } 261 + 262 + .client-details { 263 + margin-top: 1.5rem; 264 + padding-top: 1.5rem; 265 + border-top: 1px solid var(--old-rose); 266 + display: none; 267 + } 268 + 269 + .client-card.expanded .client-details { 270 + display: block; 271 + } 272 + 273 + .detail-section { 274 + margin-bottom: 1.5rem; 275 + } 276 + 277 + .detail-title { 278 + font-size: 0.75rem; 279 + color: var(--old-rose); 280 + text-transform: uppercase; 281 + letter-spacing: 0.05rem; 282 + margin-bottom: 0.5rem; 283 + } 284 + 285 + .redirect-uris { 286 + display: flex; 287 + flex-direction: column; 288 + gap: 0.25rem; 289 + } 290 + 291 + .redirect-uri { 292 + font-family: monospace; 293 + font-size: 0.75rem; 294 + color: var(--lavender); 295 + background: rgba(0, 0, 0, 0.2); 296 + padding: 0.5rem; 297 + } 298 + 299 + .users-list { 300 + display: flex; 301 + flex-direction: column; 302 + gap: 0.75rem; 303 + } 304 + 305 + .user-item { 306 + background: rgba(0, 0, 0, 0.2); 307 + padding: 1rem; 308 + display: flex; 309 + justify-content: space-between; 310 + align-items: center; 311 + } 312 + 313 + .user-info { 314 + flex: 1; 315 + } 316 + 317 + .user-name { 318 + font-weight: 600; 319 + color: var(--lavender); 320 + margin-bottom: 0.25rem; 321 + } 322 + 323 + .user-role-input { 324 + display: flex; 325 + gap: 0.5rem; 326 + align-items: center; 327 + margin-top: 0.5rem; 328 + } 329 + 330 + .user-role-input input { 331 + padding: 0.5rem; 332 + background: rgba(0, 0, 0, 0.3); 333 + border: 1px solid var(--old-rose); 334 + color: var(--lavender); 335 + font-family: inherit; 336 + font-size: 0.875rem; 337 + } 338 + 339 + .user-role-input button { 340 + padding: 0.5rem 1rem; 341 + background: var(--berry-crush); 342 + color: var(--lavender); 343 + border: none; 344 + cursor: pointer; 345 + font-family: inherit; 346 + font-size: 0.875rem; 347 + transition: background 0.2s; 348 + } 349 + 350 + .user-role-input button:hover { 351 + background: var(--rosewood); 352 + } 353 + 354 + .user-meta { 355 + font-size: 0.75rem; 356 + color: var(--old-rose); 357 + } 358 + 359 + .client-card.expanded .expand-indicator::after { 360 + content: " ▲" !important; 361 + } 362 + 363 + .client-actions { 364 + display: flex; 365 + gap: 0.5rem; 366 + margin-top: 1rem; 367 + } 368 + 369 + .btn-edit, .btn-delete, .revoke-btn { 370 + padding: 0.5rem 1rem; 371 + font-family: inherit; 372 + font-size: 0.875rem; 373 + font-weight: 600; 374 + cursor: pointer; 375 + transition: all 0.2s; 376 + border: none; 377 + } 378 + 379 + .btn-edit { 380 + background: rgba(188, 141, 160, 0.2); 381 + color: var(--lavender); 382 + } 383 + 384 + .btn-edit:hover { 385 + background: rgba(188, 141, 160, 0.3); 386 + } 387 + 388 + .btn-delete, .revoke-btn { 389 + background: rgba(160, 70, 104, 0.2); 390 + color: var(--lavender); 391 + border: 2px solid var(--rosewood); 392 + } 393 + 394 + .btn-delete:hover, .revoke-btn:hover { 395 + background: rgba(160, 70, 104, 0.3); 396 + } 397 + 398 + .loading, .error, .empty { 399 + text-align: center; 400 + padding: 2rem; 401 + color: var(--old-rose); 402 + } 403 + 404 + .error { 405 + color: var(--rosewood); 406 + } 407 + 408 + .modal { 409 + display: none; 410 + position: fixed; 411 + top: 0; 412 + left: 0; 413 + right: 0; 414 + bottom: 0; 415 + background: rgba(0, 0, 0, 0.8); 416 + z-index: 1000; 417 + align-items: center; 418 + justify-content: center; 419 + } 420 + 421 + .modal.active { 422 + display: flex; 423 + } 424 + 425 + .modal-content { 426 + background: var(--mahogany); 427 + border: 2px solid var(--old-rose); 428 + padding: 2rem; 429 + max-width: 40rem; 430 + width: 90%; 431 + max-height: 90vh; 432 + overflow-y: auto; 433 + } 434 + 435 + .modal-header { 436 + display: flex; 437 + justify-content: space-between; 438 + align-items: center; 439 + margin-bottom: 1.5rem; 440 + } 441 + 442 + .modal-title { 443 + font-size: 1.5rem; 444 + font-weight: 600; 445 + color: var(--lavender); 446 + } 447 + 448 + .modal-close { 449 + background: none; 450 + border: none; 451 + color: var(--old-rose); 452 + font-size: 1.5rem; 453 + cursor: pointer; 454 + padding: 0; 455 + width: 2rem; 456 + height: 2rem; 457 + } 458 + 459 + .modal-close:hover { 460 + color: var(--lavender); 461 + } 462 + 463 + .form-group { 464 + margin-bottom: 1rem; 465 + } 466 + 467 + .form-label { 468 + display: block; 469 + font-size: 0.875rem; 470 + color: var(--old-rose); 471 + margin-bottom: 0.5rem; 472 + text-transform: uppercase; 473 + letter-spacing: 0.05rem; 474 + } 475 + 476 + .form-input { 477 + width: 100%; 478 + padding: 0.75rem; 479 + background: rgba(0, 0, 0, 0.3); 480 + border: 1px solid var(--old-rose); 481 + color: var(--lavender); 482 + font-family: inherit; 483 + font-size: 1rem; 484 + } 485 + 486 + .form-input:focus { 487 + outline: none; 488 + border-color: var(--berry-crush); 489 + } 490 + 491 + .form-textarea { 492 + min-height: 5rem; 493 + resize: vertical; 494 + } 495 + 496 + .redirect-uris-list { 497 + display: flex; 498 + flex-direction: column; 499 + gap: 0.5rem; 500 + margin-top: 0.5rem; 501 + } 502 + 503 + .redirect-uri-item { 504 + display: flex; 505 + gap: 0.5rem; 506 + } 507 + 508 + .redirect-uri-item input { 509 + flex: 1; 510 + } 511 + 512 + .btn-remove { 513 + padding: 0.5rem 1rem; 514 + background: rgba(160, 70, 104, 0.2); 515 + color: var(--lavender); 516 + border: none; 517 + cursor: pointer; 518 + font-family: inherit; 519 + } 520 + 521 + .btn-remove:hover { 522 + background: rgba(160, 70, 104, 0.3); 523 + } 524 + 525 + .btn-add { 526 + margin-top: 0.5rem; 527 + padding: 0.5rem 1rem; 528 + background: rgba(188, 141, 160, 0.2); 529 + color: var(--lavender); 530 + border: none; 531 + cursor: pointer; 532 + font-family: inherit; 533 + } 534 + 535 + .btn-add:hover { 536 + background: rgba(188, 141, 160, 0.3); 537 + } 538 + 539 + .form-actions { 540 + display: flex; 541 + gap: 1rem; 542 + margin-top: 1.5rem; 543 + } 544 + 545 + .form-actions .btn { 546 + flex: 1; 547 + } 548 + 549 + .toast { 550 + position: fixed; 551 + bottom: 2rem; 552 + right: 2rem; 553 + background: var(--mahogany); 554 + border: 2px solid var(--berry-crush); 555 + padding: 1rem 1.5rem; 556 + color: var(--lavender); 557 + font-size: 0.875rem; 558 + font-weight: 500; 559 + z-index: 2000; 560 + opacity: 0; 561 + transform: translateY(1rem); 562 + transition: opacity 0.3s, transform 0.3s; 563 + max-width: 25rem; 564 + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.5); 565 + } 566 + 567 + .toast.show { 568 + opacity: 1; 569 + transform: translateY(0); 570 + } 571 + 572 + .toast.error { 573 + border-color: var(--rosewood); 574 + } 575 + 576 + .toast.success { 577 + border-color: var(--berry-crush); 578 + } 579 + </style> 580 + </head> 581 + 582 + <body> 583 + <header> 584 + <div> 585 + <img src="../../public/logo.svg" alt="indiko" style="height: 2rem;" /> 586 + </div> 587 + <div class="header-nav"> 588 + <a href="/admin">users</a> 589 + <a href="/admin/clients" class="active">apps</a> 590 + </div> 591 + </header> 592 + 593 + <main> 594 + <div class="actions"> 595 + <h2>oauth clients</h2> 596 + <button class="btn" id="createClientBtn">create client</button> 597 + </div> 598 + <div id="clientsList" class="clients-list"> 599 + <div class="loading">loading clients...</div> 600 + </div> 601 + </main> 602 + 603 + <div id="toast" class="toast"></div> 604 + 605 + <footer id="footer"> 606 + loading... 607 + <div class="back-link"><a href="/">← back to dashboard</a></div> 608 + </footer> 609 + 610 + <div id="clientModal" class="modal"> 611 + <div class="modal-content"> 612 + <div class="modal-header"> 613 + <h3 class="modal-title" id="modalTitle">Create OAuth Client</h3> 614 + <button class="modal-close" id="modalClose">&times;</button> 615 + </div> 616 + <form id="clientForm"> 617 + <input type="hidden" id="editClientId" /> 618 + <div class="form-group"> 619 + <label class="form-label" for="clientId">Client ID (URL)</label> 620 + <input type="url" class="form-input" id="clientId" required placeholder="https://example.com" /> 621 + </div> 622 + <div class="form-group"> 623 + <label class="form-label" for="clientName">Name</label> 624 + <input type="text" class="form-input" id="clientName" placeholder="My Application" /> 625 + </div> 626 + <div class="form-group"> 627 + <label class="form-label" for="logoUrl">Logo URL</label> 628 + <input type="url" class="form-input" id="logoUrl" placeholder="https://example.com/logo.png" /> 629 + </div> 630 + <div class="form-group"> 631 + <label class="form-label" for="description">Description</label> 632 + <textarea class="form-input form-textarea" id="description" placeholder="A brief description of your application"></textarea> 633 + </div> 634 + <div class="form-group"> 635 + <label class="form-label">Redirect URIs</label> 636 + <div id="redirectUrisList" class="redirect-uris-list"> 637 + <div class="redirect-uri-item"> 638 + <input type="url" class="form-input redirect-uri-input" placeholder="https://example.com/auth/callback" required /> 639 + <button type="button" class="btn-remove" onclick="removeRedirectUri(this)">remove</button> 640 + </div> 641 + </div> 642 + <button type="button" class="btn-add" id="addRedirectUriBtn">add redirect uri</button> 643 + </div> 644 + <div class="form-actions"> 645 + <button type="button" class="btn" style="background: rgba(188, 141, 160, 0.2);" id="cancelBtn">cancel</button> 646 + <button type="submit" class="btn">save</button> 647 + </div> 648 + </form> 649 + </div> 650 + </div> 651 + 652 + <script type="module" src="../client/admin-clients.ts"></script> 653 + </body> 654 + 655 + </html>
+2 -2
src/html/admin.html
··· 379 379 <img src="../../public/logo.svg" alt="indiko" style="height: 2rem;" /> 380 380 </div> 381 381 <div class="header-nav"> 382 - <a href="/admin" class="active">users</a> 383 - <a href="/admin/apps">apps</a> 382 + <a href="/admin" class="active">users</a> 383 + <a href="/admin/clients">apps</a> 384 384 </div> 385 385 </header> 386 386
+73 -48
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 + import adminClientsHTML from "./html/admin-clients.html"; 6 6 import loginHTML from "./html/login.html"; 7 7 import profileHTML from "./html/profile.html"; 8 8 import oauthTestHTML from "./html/oauth-test.html"; 9 9 import appsHTML from "./html/apps.html"; 10 - import { canRegister, registerOptions, registerVerify, loginOptions, loginVerify } from "./routes/auth"; 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"; 10 + import { 11 + canRegister, 12 + registerOptions, 13 + registerVerify, 14 + loginOptions, 15 + loginVerify, 16 + } from "./routes/auth"; 17 + import { 18 + hello, 19 + listUsers, 20 + getProfile, 21 + updateProfile, 22 + getAuthorizedApps, 23 + revokeApp, 24 + listAllApps, 25 + getAppDetails, 26 + revokeAppForUser, 27 + } from "./routes/api"; 28 + import { 29 + authorizeGet, 30 + authorizePost, 31 + token, 32 + logout, 33 + userProfile, 34 + createInvite, 35 + listInvites, 36 + } from "./routes/indieauth"; 37 + import { 38 + listClients, 39 + createClient, 40 + getClient, 41 + updateClient, 42 + deleteClient, 43 + setUserRole, 44 + regenerateClientSecret, 45 + } from "./routes/clients"; 13 46 14 47 (() => { 15 48 const required = ["ORIGIN", "RP_ID"]; ··· 29 62 routes: { 30 63 "/": indexHTML, 31 64 "/admin": adminHTML, 32 - "/admin/apps": adminAppsHTML, 65 + "/admin/apps": () => Response.redirect("/admin/clients", 302), 66 + "/admin/clients": adminClientsHTML, 33 67 "/login": loginHTML, 34 68 "/profile": profileHTML, 35 69 "/oauth-test": oauthTestHTML, ··· 50 84 if (req.method === "GET") return listAllApps(req); 51 85 return new Response("Method not allowed", { status: 405 }); 52 86 }, 87 + "/api/admin/clients": (req: Request) => { 88 + if (req.method === "GET") return listClients(req); 89 + if (req.method === "POST") return createClient(req); 90 + return new Response("Method not allowed", { status: 405 }); 91 + }, 53 92 "/api/invites/create": (req: Request) => { 54 93 if (req.method === "POST") return createInvite(req); 55 94 return new Response("Method not allowed", { status: 405 }); ··· 78 117 "/auth/register/verify": registerVerify, 79 118 "/auth/login/options": loginOptions, 80 119 "/auth/login/verify": loginVerify, 81 - }, 82 - development: process.env.NODE_ENV === "dev", 83 - fetch(req) { 84 - // Handle dynamic routes like /u/:username 85 - const url = new URL(req.url); 86 - 87 - // /u/:username - user profiles 88 - const userMatch = url.pathname.match(/^\/u\/([^\/]+)$/); 89 - if (userMatch) { 90 - const username = userMatch[1]; 91 - return userProfile(req, username); 92 - } 93 - 94 - // /api/apps/:clientId - revoke app access 95 - const appMatch = url.pathname.match(/^\/api\/apps\/([^\/]+)$/); 96 - if (appMatch) { 97 - if (req.method === "DELETE") { 98 - const clientId = decodeURIComponent(appMatch[1]); 99 - return revokeApp(req, clientId); 100 - } 120 + // Dynamic routes with Bun's :param syntax 121 + "/u/:username": (req) => userProfile(req, req.params.username), 122 + "/api/apps/:clientId": (req) => { 123 + if (req.method === "DELETE") return revokeApp(req, req.params.clientId); 124 + return new Response("Method not allowed", { status: 405 }); 125 + }, 126 + "/api/admin/apps/:clientId": (req) => { 127 + if (req.method === "GET") return getAppDetails(req, req.params.clientId); 128 + return new Response("Method not allowed", { status: 405 }); 129 + }, 130 + "/api/admin/apps/:clientId/users/:username": (req) => { 131 + if (req.method === "DELETE") 132 + return revokeAppForUser(req, req.params.clientId, req.params.username); 101 133 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 - } 134 + }, 135 + "/api/admin/clients/:clientId": (req) => { 136 + if (req.method === "GET") return getClient(req, req.params.clientId); 137 + if (req.method === "PUT") return updateClient(req, req.params.clientId); 138 + if (req.method === "DELETE") return deleteClient(req, req.params.clientId); 111 139 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); 121 - } 140 + }, 141 + "/api/admin/clients/:clientId/users/:username/role": (req) => { 142 + if (req.method === "POST") 143 + return setUserRole(req, req.params.clientId, req.params.username); 122 144 return new Response("Method not allowed", { status: 405 }); 123 - } 124 - 125 - // Let Bun handle static routes 126 - return undefined as never; 145 + }, 146 + "/api/admin/clients/:clientId/secret": (req) => { 147 + if (req.method === "POST") 148 + return regenerateClientSecret(req, req.params.clientId); 149 + return new Response("Method not allowed", { status: 405 }); 150 + }, 127 151 }, 152 + development: process.env.NODE_ENV === "dev", 128 153 }); 129 154 130 155 console.log("[Indiko] running on", env.ORIGIN);
+7
src/migrations/004_add_client_preregistration_and_roles.sql
··· 1 + -- Add columns to apps table for pre-registration metadata 2 + ALTER TABLE apps ADD COLUMN logo_url TEXT; 3 + ALTER TABLE apps ADD COLUMN description TEXT; 4 + ALTER TABLE apps ADD COLUMN is_preregistered INTEGER NOT NULL DEFAULT 0; 5 + ALTER TABLE apps ADD COLUMN client_secret_hash TEXT; 6 + -- Add role column to permissions table for per-user, per-app roles 7 + ALTER TABLE permissions ADD COLUMN role TEXT;
+443
src/routes/clients.ts
··· 1 + import { db } from "../db"; 2 + import crypto from "crypto"; 3 + 4 + function hashSecret(secret: string): string { 5 + return crypto.createHash("sha256").update(secret).digest("hex"); 6 + } 7 + 8 + function generateClientSecret(): string { 9 + return crypto.randomBytes(32).toString("base64url"); 10 + } 11 + 12 + function getSessionUser(req: Request): { username: string; userId: number; is_admin: boolean } | Response { 13 + const authHeader = req.headers.get("Authorization"); 14 + 15 + if (!authHeader || !authHeader.startsWith("Bearer ")) { 16 + return Response.json({ error: "Unauthorized" }, { status: 401 }); 17 + } 18 + 19 + const token = authHeader.substring(7); 20 + 21 + const session = db 22 + .query( 23 + `SELECT s.expires_at, s.user_id, u.username, u.is_admin 24 + FROM sessions s 25 + JOIN users u ON s.user_id = u.id 26 + WHERE s.token = ?`, 27 + ) 28 + .get(token) as 29 + | { expires_at: number; user_id: number; username: string; is_admin: number } 30 + | undefined; 31 + 32 + if (!session) { 33 + return Response.json({ error: "Invalid session" }, { status: 401 }); 34 + } 35 + 36 + const now = Math.floor(Date.now() / 1000); 37 + if (session.expires_at < now) { 38 + return Response.json({ error: "Session expired" }, { status: 401 }); 39 + } 40 + 41 + return { 42 + username: session.username, 43 + userId: session.user_id, 44 + is_admin: session.is_admin === 1, 45 + }; 46 + } 47 + 48 + export function listClients(req: Request): Response { 49 + const user = getSessionUser(req); 50 + if (user instanceof Response) { 51 + return user; 52 + } 53 + 54 + if (!user.is_admin) { 55 + return Response.json({ error: "Admin access required" }, { status: 403 }); 56 + } 57 + 58 + const clients = db 59 + .query( 60 + `SELECT 61 + id, 62 + client_id, 63 + name, 64 + logo_url, 65 + description, 66 + redirect_uris, 67 + is_preregistered, 68 + first_seen, 69 + last_used 70 + FROM apps 71 + ORDER BY is_preregistered DESC, last_used DESC`, 72 + ) 73 + .all() as Array<{ 74 + id: number; 75 + client_id: string; 76 + name: string | null; 77 + logo_url: string | null; 78 + description: string | null; 79 + redirect_uris: string; 80 + is_preregistered: number; 81 + first_seen: number; 82 + last_used: number; 83 + }>; 84 + 85 + return Response.json({ 86 + clients: clients.map((c) => ({ 87 + id: c.id, 88 + clientId: c.client_id, 89 + name: c.name || new URL(c.client_id).hostname, 90 + logoUrl: c.logo_url, 91 + description: c.description, 92 + redirectUris: JSON.parse(c.redirect_uris) as string[], 93 + isPreregistered: c.is_preregistered === 1, 94 + firstSeen: c.first_seen, 95 + lastUsed: c.last_used, 96 + })), 97 + }); 98 + } 99 + 100 + export async function createClient(req: Request): Promise<Response> { 101 + const user = getSessionUser(req); 102 + if (user instanceof Response) { 103 + return user; 104 + } 105 + 106 + if (!user.is_admin) { 107 + return Response.json({ error: "Admin access required" }, { status: 403 }); 108 + } 109 + 110 + try { 111 + const body = await req.json(); 112 + const { clientId, name, logoUrl, description, redirectUris } = body; 113 + 114 + if (!clientId || typeof clientId !== "string") { 115 + return Response.json({ error: "Client ID is required" }, { status: 400 }); 116 + } 117 + 118 + try { 119 + new URL(clientId); 120 + } catch { 121 + return Response.json({ error: "Client ID must be a valid URL" }, { status: 400 }); 122 + } 123 + 124 + if (!redirectUris || !Array.isArray(redirectUris) || redirectUris.length === 0) { 125 + return Response.json({ error: "At least one redirect URI is required" }, { status: 400 }); 126 + } 127 + 128 + for (const uri of redirectUris) { 129 + try { 130 + new URL(uri); 131 + } catch { 132 + return Response.json({ error: `Invalid redirect URI: ${uri}` }, { status: 400 }); 133 + } 134 + } 135 + 136 + const existing = db 137 + .query("SELECT id FROM apps WHERE client_id = ?") 138 + .get(clientId); 139 + 140 + if (existing) { 141 + return Response.json({ error: "Client ID already exists" }, { status: 409 }); 142 + } 143 + 144 + // Generate client secret for pre-registered clients 145 + const clientSecret = generateClientSecret(); 146 + const clientSecretHash = hashSecret(clientSecret); 147 + 148 + const result = db 149 + .query( 150 + `INSERT INTO apps (client_id, name, logo_url, description, redirect_uris, is_preregistered, client_secret_hash, first_seen, last_used) 151 + VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)`, 152 + ) 153 + .run( 154 + clientId, 155 + name || null, 156 + logoUrl || null, 157 + description || null, 158 + JSON.stringify(redirectUris), 159 + clientSecretHash, 160 + Math.floor(Date.now() / 1000), 161 + Math.floor(Date.now() / 1000), 162 + ); 163 + 164 + return Response.json({ 165 + success: true, 166 + client: { 167 + id: result.lastInsertRowid, 168 + clientId, 169 + clientSecret, // Return the plain secret only once on creation 170 + name: name || new URL(clientId).hostname, 171 + logoUrl: logoUrl || null, 172 + description: description || null, 173 + redirectUris, 174 + isPreregistered: true, 175 + }, 176 + }); 177 + } catch (error) { 178 + console.error("Create client error:", error); 179 + return Response.json({ error: "Failed to create client" }, { status: 500 }); 180 + } 181 + } 182 + 183 + export function getClient(req: Request, clientId: string): Response { 184 + const user = getSessionUser(req); 185 + if (user instanceof Response) { 186 + return user; 187 + } 188 + 189 + if (!user.is_admin) { 190 + return Response.json({ error: "Admin access required" }, { status: 403 }); 191 + } 192 + 193 + const client = db 194 + .query( 195 + `SELECT 196 + id, 197 + client_id, 198 + name, 199 + logo_url, 200 + description, 201 + redirect_uris, 202 + is_preregistered, 203 + first_seen, 204 + last_used 205 + FROM apps 206 + WHERE client_id = ?`, 207 + ) 208 + .get(clientId) as 209 + | { 210 + id: number; 211 + client_id: string; 212 + name: string | null; 213 + logo_url: string | null; 214 + description: string | null; 215 + redirect_uris: string; 216 + is_preregistered: number; 217 + first_seen: number; 218 + last_used: number; 219 + } 220 + | undefined; 221 + 222 + if (!client) { 223 + return Response.json({ error: "Client not found" }, { status: 404 }); 224 + } 225 + 226 + const users = db 227 + .query( 228 + `SELECT 229 + u.username, 230 + u.name, 231 + p.scopes, 232 + p.role, 233 + p.granted_at, 234 + p.last_used 235 + FROM permissions p 236 + JOIN users u ON p.user_id = u.id 237 + WHERE p.client_id = ? 238 + ORDER BY p.last_used DESC`, 239 + ) 240 + .all(clientId) as Array<{ 241 + username: string; 242 + name: string; 243 + scopes: string; 244 + role: string | null; 245 + granted_at: number; 246 + last_used: number; 247 + }>; 248 + 249 + return Response.json({ 250 + client: { 251 + id: client.id, 252 + clientId: client.client_id, 253 + name: client.name || new URL(client.client_id).hostname, 254 + logoUrl: client.logo_url, 255 + description: client.description, 256 + redirectUris: JSON.parse(client.redirect_uris) as string[], 257 + isPreregistered: client.is_preregistered === 1, 258 + firstSeen: client.first_seen, 259 + lastUsed: client.last_used, 260 + }, 261 + users: users.map((u) => ({ 262 + username: u.username, 263 + name: u.name, 264 + scopes: JSON.parse(u.scopes) as string[], 265 + role: u.role, 266 + grantedAt: u.granted_at, 267 + lastUsed: u.last_used, 268 + })), 269 + }); 270 + } 271 + 272 + export async function updateClient(req: Request, clientId: string): Promise<Response> { 273 + const user = getSessionUser(req); 274 + if (user instanceof Response) { 275 + return user; 276 + } 277 + 278 + if (!user.is_admin) { 279 + return Response.json({ error: "Admin access required" }, { status: 403 }); 280 + } 281 + 282 + try { 283 + const body = await req.json(); 284 + const { name, logoUrl, description, redirectUris } = body; 285 + 286 + const existing = db 287 + .query("SELECT id, is_preregistered FROM apps WHERE client_id = ?") 288 + .get(clientId) as { id: number; is_preregistered: number } | undefined; 289 + 290 + if (!existing) { 291 + return Response.json({ error: "Client not found" }, { status: 404 }); 292 + } 293 + 294 + if (redirectUris) { 295 + if (!Array.isArray(redirectUris) || redirectUris.length === 0) { 296 + return Response.json({ error: "At least one redirect URI is required" }, { status: 400 }); 297 + } 298 + 299 + for (const uri of redirectUris) { 300 + try { 301 + new URL(uri); 302 + } catch { 303 + return Response.json({ error: `Invalid redirect URI: ${uri}` }, { status: 400 }); 304 + } 305 + } 306 + } 307 + 308 + db.query( 309 + `UPDATE apps 310 + SET name = ?, logo_url = ?, description = ?, redirect_uris = ? 311 + WHERE client_id = ?`, 312 + ).run( 313 + name || null, 314 + logoUrl || null, 315 + description || null, 316 + redirectUris ? JSON.stringify(redirectUris) : null, 317 + clientId, 318 + ); 319 + 320 + return Response.json({ success: true }); 321 + } catch (error) { 322 + console.error("Update client error:", error); 323 + return Response.json({ error: "Failed to update client" }, { status: 500 }); 324 + } 325 + } 326 + 327 + export function deleteClient(req: Request, clientId: string): Response { 328 + const user = getSessionUser(req); 329 + if (user instanceof Response) { 330 + return user; 331 + } 332 + 333 + if (!user.is_admin) { 334 + return Response.json({ error: "Admin access required" }, { status: 403 }); 335 + } 336 + 337 + const existing = db 338 + .query("SELECT id FROM apps WHERE client_id = ?") 339 + .get(clientId); 340 + 341 + if (!existing) { 342 + return Response.json({ error: "Client not found" }, { status: 404 }); 343 + } 344 + 345 + db.query("DELETE FROM apps WHERE client_id = ?").run(clientId); 346 + 347 + return Response.json({ success: true }); 348 + } 349 + 350 + export async function setUserRole( 351 + req: Request, 352 + clientId: string, 353 + username: string, 354 + ): Promise<Response> { 355 + const user = getSessionUser(req); 356 + if (user instanceof Response) { 357 + return user; 358 + } 359 + 360 + if (!user.is_admin) { 361 + return Response.json({ error: "Admin access required" }, { status: 403 }); 362 + } 363 + 364 + try { 365 + const body = await req.json(); 366 + const { role } = body; 367 + 368 + const targetUser = db 369 + .query("SELECT id FROM users WHERE username = ?") 370 + .get(username) as { id: number } | undefined; 371 + 372 + if (!targetUser) { 373 + return Response.json({ error: "User not found" }, { status: 404 }); 374 + } 375 + 376 + const client = db 377 + .query("SELECT id FROM apps WHERE client_id = ?") 378 + .get(clientId); 379 + 380 + if (!client) { 381 + return Response.json({ error: "Client not found" }, { status: 404 }); 382 + } 383 + 384 + const permission = db 385 + .query("SELECT id FROM permissions WHERE user_id = ? AND client_id = ?") 386 + .get(targetUser.id, clientId) as { id: number } | undefined; 387 + 388 + if (!permission) { 389 + return Response.json({ error: "User has not authorized this client" }, { status: 404 }); 390 + } 391 + 392 + db.query("UPDATE permissions SET role = ? WHERE user_id = ? AND client_id = ?").run( 393 + role || null, 394 + targetUser.id, 395 + clientId, 396 + ); 397 + 398 + return Response.json({ success: true }); 399 + } catch (error) { 400 + console.error("Set user role error:", error); 401 + return Response.json({ error: "Failed to set user role" }, { status: 500 }); 402 + } 403 + } 404 + 405 + export function regenerateClientSecret(req: Request, clientId: string): Response { 406 + const user = getSessionUser(req); 407 + if (user instanceof Response) { 408 + return user; 409 + } 410 + 411 + if (!user.is_admin) { 412 + return Response.json({ error: "Admin access required" }, { status: 403 }); 413 + } 414 + 415 + const client = db 416 + .query("SELECT id, is_preregistered FROM apps WHERE client_id = ?") 417 + .get(clientId) as { id: number; is_preregistered: number } | undefined; 418 + 419 + if (!client) { 420 + return Response.json({ error: "Client not found" }, { status: 404 }); 421 + } 422 + 423 + if (client.is_preregistered !== 1) { 424 + return Response.json( 425 + { error: "Cannot regenerate secret for auto-registered clients" }, 426 + { status: 400 }, 427 + ); 428 + } 429 + 430 + // Generate new client secret 431 + const clientSecret = generateClientSecret(); 432 + const clientSecretHash = hashSecret(clientSecret); 433 + 434 + db.query("UPDATE apps SET client_secret_hash = ? WHERE client_id = ?").run( 435 + clientSecretHash, 436 + clientId, 437 + ); 438 + 439 + return Response.json({ 440 + success: true, 441 + clientSecret, // Return the new plain secret 442 + }); 443 + }
+131 -31
src/routes/indieauth.ts
··· 95 95 .get(clientId); 96 96 97 97 if (!existing) { 98 - // New app - auto-register 98 + // New app - auto-register (without pre-registration, no client secret or role) 99 99 db.query( 100 - "INSERT INTO apps (client_id, redirect_uris, last_used) VALUES (?, ?, ?)", 101 - ).run(clientId, JSON.stringify([redirectUri]), Math.floor(Date.now() / 1000)); 100 + "INSERT INTO apps (client_id, redirect_uris, is_preregistered, first_seen, last_used) VALUES (?, ?, 0, ?, ?)", 101 + ).run(clientId, JSON.stringify([redirectUri]), Math.floor(Date.now() / 1000), Math.floor(Date.now() / 1000)); 102 102 } else { 103 103 // Update last_used 104 104 db.query("UPDATE apps SET last_used = ? WHERE client_id = ?").run( ··· 207 207 codeChallenge: string, 208 208 scopes: string[], 209 209 ): Response { 210 - const appName = new URL(clientId).hostname; 210 + // Load app metadata if pre-registered 211 + const appData = db 212 + .query("SELECT name, logo_url, description FROM apps WHERE client_id = ?") 213 + .get(clientId) as { name: string | null; logo_url: string | null; description: string | null } | undefined; 214 + 215 + const appName = appData?.name || new URL(clientId).hostname; 216 + const appLogo = appData?.logo_url; 217 + const appDescription = appData?.description; 211 218 212 219 const html = `<!doctype html> 213 220 <html lang="en"> ··· 245 252 border: 1px solid var(--old-rose); 246 253 padding: 2rem; 247 254 } 255 + .app-header { 256 + display: flex; 257 + gap: 1rem; 258 + align-items: center; 259 + margin-bottom: 1rem; 260 + } 261 + .app-logo { 262 + width: 4rem; 263 + height: 4rem; 264 + border-radius: 0.5rem; 265 + background: rgba(188, 141, 160, 0.2); 266 + display: flex; 267 + align-items: center; 268 + justify-content: center; 269 + flex-shrink: 0; 270 + overflow: hidden; 271 + } 272 + .app-logo img { 273 + width: 100%; 274 + height: 100%; 275 + object-fit: cover; 276 + } 248 277 h1 { 249 278 font-size: 1.5rem; 250 279 font-weight: 700; 251 280 color: var(--lavender); 252 - margin-bottom: 1rem; 281 + margin-bottom: 0.25rem; 253 282 } 254 283 .app-name { 255 284 color: var(--berry-crush); 256 285 font-weight: 700; 257 286 } 287 + .app-description { 288 + font-size: 0.875rem; 289 + color: var(--old-rose); 290 + margin-top: 0.5rem; 291 + } 258 292 .scopes { 259 293 margin: 1.5rem 0; 260 294 padding: 1rem; ··· 343 377 </head> 344 378 <body> 345 379 <div class="consent-box"> 346 - <h1>authorize app</h1> 347 - 348 - <div class="user-info"> 349 - Signing in as <strong>${user.username}</strong> 380 + <div class="app-header"> 381 + ${appLogo ? `<div class="app-logo"><img src="${appLogo}" alt="${appName}" /></div>` : ''} 382 + <div> 383 + <h1>authorize app</h1> 384 + <div class="user-info"> 385 + Signing in as <strong>${user.username}</strong> 386 + </div> 387 + </div> 350 388 </div> 351 389 352 390 <p style="margin-bottom: 1rem;"> 353 391 <span class="app-name">${appName}</span> is requesting access to: 354 392 </p> 393 + 394 + ${appDescription ? `<p class="app-description">${appDescription}</p>` : ''} 355 395 356 396 <div class="scopes"> 357 397 <div class="scope-title">permissions</div> ··· 479 519 } 480 520 481 521 const { 482 - grant_type, 483 - code, 484 - client_id, 485 - redirect_uri, 486 - code_verifier, 487 - } = body; 522 + grant_type, 523 + code, 524 + client_id, 525 + client_secret, 526 + redirect_uri, 527 + code_verifier, 528 + } = body; 488 529 489 530 if (grant_type !== "authorization_code") { 531 + return Response.json( 532 + { 533 + error: "unsupported_grant_type", 534 + error_description: "Only authorization_code grant type is supported", 535 + }, 536 + { status: 400 }, 537 + ); 538 + } 539 + 540 + // Check if client is pre-registered and requires secret 541 + const app = db 542 + .query("SELECT is_preregistered, client_secret_hash FROM apps WHERE client_id = ?") 543 + .get(client_id) as 544 + | { is_preregistered: number; client_secret_hash: string | null } 545 + | undefined; 546 + 547 + // If client is pre-registered, verify client secret 548 + if (app && app.is_preregistered === 1) { 549 + if (!client_secret) { 490 550 return Response.json( 491 551 { 492 - error: "unsupported_grant_type", 493 - error_description: "Only authorization_code grant type is supported", 552 + error: "invalid_client", 553 + error_description: "client_secret is required for pre-registered clients", 554 + }, 555 + { status: 401 }, 556 + ); 557 + } 558 + 559 + if (!app.client_secret_hash) { 560 + return Response.json( 561 + { 562 + error: "server_error", 563 + error_description: "Client secret not configured", 494 564 }, 495 - { status: 400 }, 565 + { status: 500 }, 496 566 ); 497 567 } 498 568 499 - if (!code || !client_id || !redirect_uri || !code_verifier) { 500 - console.error("Token endpoint: missing parameters", { code: !!code, client_id: !!client_id, redirect_uri: !!redirect_uri, code_verifier: !!code_verifier }); 569 + // Verify client secret 570 + const providedSecretHash = crypto 571 + .createHash("sha256") 572 + .update(client_secret) 573 + .digest("hex"); 574 + 575 + if (providedSecretHash !== app.client_secret_hash) { 501 576 return Response.json( 502 577 { 503 - error: "invalid_request", 504 - error_description: "Missing required parameters", 578 + error: "invalid_client", 579 + error_description: "Invalid client_secret", 505 580 }, 506 - { status: 400 }, 581 + { status: 401 }, 507 582 ); 508 583 } 584 + } 585 + 586 + if (!code || !client_id || !redirect_uri) { 587 + console.error("Token endpoint: missing parameters", { code: !!code, client_id: !!client_id, redirect_uri: !!redirect_uri }); 588 + return Response.json( 589 + { 590 + error: "invalid_request", 591 + error_description: "Missing required parameters", 592 + }, 593 + { status: 400 }, 594 + ); 595 + } 596 + 597 + // For auto-registered clients, code_verifier (PKCE) is still required 598 + if ((!app || app.is_preregistered === 0) && !code_verifier) { 599 + return Response.json( 600 + { 601 + error: "invalid_request", 602 + error_description: "code_verifier is required for public clients", 603 + }, 604 + { status: 400 }, 605 + ); 606 + } 509 607 510 608 // Look up authorization code 511 609 const authcode = db ··· 579 677 ); 580 678 } 581 679 582 - // Verify PKCE code_verifier 680 + // Verify PKCE code_verifier (only for public clients) 681 + if ((!app || app.is_preregistered === 0) && code_verifier) { 583 682 if (!verifyPKCE(code_verifier, authcode.code_challenge)) { 584 - return Response.json( 585 - { 586 - error: "invalid_grant", 587 - error_description: "Invalid code_verifier", 588 - }, 589 - { status: 400 }, 590 - ); 683 + return Response.json( 684 + { 685 + error: "invalid_grant", 686 + error_description: "Invalid code_verifier", 687 + }, 688 + { status: 400 }, 689 + ); 591 690 } 691 + } 592 692 593 693 // Mark code as used 594 694 db.query("UPDATE authcodes SET used = 1 WHERE code = ?").run(code);