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

feat: make indiko compatible with the latest version of indieauth

dunkirk.sh 28e1e8e9 234a091c

verified
+1894 -1321
+403 -270
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; 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( 5 + "createClientBtn", 6 + ) as HTMLButtonElement; 7 + const clientModal = document.getElementById("clientModal") as HTMLElement; 8 + const modalClose = document.getElementById("modalClose") as HTMLButtonElement; 9 + const cancelBtn = document.getElementById("cancelBtn") as HTMLButtonElement; 10 + const clientForm = document.getElementById("clientForm") as HTMLFormElement; 11 + const modalTitle = document.getElementById("modalTitle") as HTMLElement; 12 + const addRedirectUriBtn = document.getElementById( 13 + "addRedirectUriBtn", 14 + ) as HTMLButtonElement; 15 + const redirectUrisList = document.getElementById( 16 + "redirectUrisList", 17 + ) as HTMLElement; 18 + const toast = document.getElementById("toast") as HTMLElement; 13 19 14 - function showToast(message: string, type: 'success' | 'error' = 'success') { 20 + function showToast(message: string, type: "success" | "error" = "success") { 15 21 toast.textContent = message; 16 22 toast.className = `toast ${type} show`; 17 - 23 + 18 24 setTimeout(() => { 19 - toast.classList.remove('show'); 25 + toast.classList.remove("show"); 20 26 }, 3000); 21 27 } 22 28 23 29 async function checkAuth() { 24 30 if (!token) { 25 - window.location.href = '/login'; 31 + window.location.href = "/login"; 26 32 return; 27 33 } 28 34 29 35 try { 30 - const response = await fetch('/api/hello', { 36 + const response = await fetch("/api/hello", { 31 37 headers: { 32 - 'Authorization': `Bearer ${token}`, 38 + Authorization: `Bearer ${token}`, 33 39 }, 34 40 }); 35 41 36 42 if (response.status === 401 || response.status === 403) { 37 - localStorage.removeItem('indiko_session'); 38 - window.location.href = '/login'; 43 + localStorage.removeItem("indiko_session"); 44 + window.location.href = "/login"; 39 45 return; 40 46 } 41 47 ··· 44 50 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 51 <div class="back-link"><a href="/">← back to dashboard</a></div>`; 46 52 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 - }); 53 + document 54 + .getElementById("logoutLink") 55 + ?.addEventListener("click", async (e) => { 56 + e.preventDefault(); 57 + try { 58 + await fetch("/auth/logout", { 59 + method: "POST", 60 + headers: { 61 + Authorization: `Bearer ${token}`, 62 + }, 63 + }); 64 + } catch { 65 + // Ignore logout errors 66 + } 67 + localStorage.removeItem("indiko_session"); 68 + window.location.href = "/login"; 69 + }); 62 70 63 71 if (!data.isAdmin) { 64 - window.location.href = '/'; 72 + window.location.href = "/"; 65 73 return; 66 74 } 67 75 68 76 loadClients(); 69 77 } catch (error) { 70 - console.error('Auth check failed:', error); 71 - footer.textContent = 'error loading user info'; 78 + console.error("Auth check failed:", error); 79 + footer.textContent = "error loading user info"; 72 80 clientsList.innerHTML = '<div class="error">Failed to load clients</div>'; 73 81 } 74 82 } ··· 106 114 107 115 async function loadClients() { 108 116 try { 109 - const response = await fetch('/api/admin/clients', { 117 + const response = await fetch("/api/admin/clients", { 110 118 headers: { 111 - 'Authorization': `Bearer ${token}`, 119 + Authorization: `Bearer ${token}`, 112 120 }, 113 121 }); 114 122 115 123 if (!response.ok) { 116 - throw new Error('Failed to load clients'); 124 + throw new Error("Failed to load clients"); 117 125 } 118 126 119 127 const data = await response.json(); 120 128 displayClients(data.clients); 121 129 } catch (error) { 122 - console.error('Failed to load clients:', error); 130 + console.error("Failed to load clients:", error); 123 131 clientsList.innerHTML = '<div class="error">Failed to load clients</div>'; 124 132 } 125 133 } 126 134 127 135 function displayClients(clients: Client[]) { 128 136 if (clients.length === 0) { 129 - clientsList.innerHTML = '<div class="empty">No OAuth clients registered yet.</div>'; 137 + clientsList.innerHTML = 138 + '<div class="empty">No OAuth clients registered yet.</div>'; 130 139 return; 131 140 } 132 141 133 - clientsList.innerHTML = clients.map((client) => { 134 - const lastUsedDate = new Date(client.lastUsed * 1000).toLocaleDateString(); 135 - const firstSeenDate = new Date(client.firstSeen * 1000).toLocaleDateString(); 136 - 137 - return ` 142 + clientsList.innerHTML = clients 143 + .map((client) => { 144 + const lastUsedDate = new Date( 145 + client.lastUsed * 1000, 146 + ).toLocaleDateString(); 147 + const firstSeenDate = new Date( 148 + client.firstSeen * 1000, 149 + ).toLocaleDateString(); 150 + 151 + return ` 138 152 <div class="client-card" data-client-id="${client.clientId}"> 139 153 <div class="client-header" onclick="toggleClient('${client.clientId}')"> 140 154 <div class="client-logo"> 141 - ${client.logoUrl 142 - ? `<img src="${client.logoUrl}" alt="${client.name}" />` 143 - : `<div class="client-logo-placeholder">🔐</div>` 155 + ${ 156 + client.logoUrl 157 + ? `<img src="${client.logoUrl}" alt="${client.name}" />` 158 + : `<div class="client-logo-placeholder">🔐</div>` 144 159 } 145 160 </div> 146 161 <div class="client-info"> 147 162 <div class="client-name">${client.name}</div> 148 163 <div class="client-id">${client.clientId}</div> 149 - ${client.description ? `<div class="client-description">${client.description}</div>` : ''} 164 + ${client.description ? `<div class="client-description">${client.description}</div>` : ""} 150 165 <div class="client-badges"> 151 - <span class="badge ${client.isPreregistered ? 'badge-preregistered' : 'badge-auto'}"> 152 - ${client.isPreregistered ? 'pre-registered' : 'auto-registered'} 166 + <span class="badge ${client.isPreregistered ? "badge-preregistered" : "badge-auto"}"> 167 + ${client.isPreregistered ? "pre-registered" : "auto-registered"} 153 168 </span> 154 169 <span class="badge badge-auto">first seen ${firstSeenDate}</span> 155 170 <span class="badge badge-auto">last used ${lastUsedDate}</span> 156 171 </div> 157 172 </div> 158 173 <div class="client-actions" style="display: flex; gap: 0.5rem; align-items: center;"> 159 - ${client.isPreregistered ? ` 174 + ${ 175 + client.isPreregistered 176 + ? ` 160 177 <button class="btn-edit" onclick="event.stopPropagation(); editClient('${client.clientId}')">edit</button> 161 178 <button class="btn-delete" onclick="event.stopPropagation(); deleteClient('${client.clientId}', event)">delete</button> 162 - ` : ''} 179 + ` 180 + : "" 181 + } 163 182 <span class="expand-indicator">details <span class="arrow">▼</span></span> 164 183 </div> 165 184 </div> ··· 168 187 </div> 169 188 </div> 170 189 `; 171 - }).join(''); 190 + }) 191 + .join(""); 172 192 } 173 193 174 - (window as any).toggleClient = async function(clientId: string) { 175 - const card = document.querySelector(`[data-client-id="${clientId}"]`) as HTMLElement; 194 + (window as any).toggleClient = async (clientId: string) => { 195 + const card = document.querySelector( 196 + `[data-client-id="${clientId}"]`, 197 + ) as HTMLElement; 176 198 if (!card) return; 177 199 178 - const isExpanded = card.classList.contains('expanded'); 179 - const arrow = card.querySelector('.arrow') as HTMLElement; 180 - 200 + const isExpanded = card.classList.contains("expanded"); 201 + const arrow = card.querySelector(".arrow") as HTMLElement; 202 + 181 203 if (isExpanded) { 182 - card.classList.remove('expanded'); 183 - if (arrow) arrow.textContent = '▼'; 204 + card.classList.remove("expanded"); 205 + if (arrow) arrow.textContent = "▼"; 184 206 return; 185 207 } 186 208 187 - card.classList.add('expanded'); 188 - if (arrow) arrow.textContent = '▲'; 189 - 190 - const detailsDiv = document.getElementById(`details-${encodeURIComponent(clientId)}`); 209 + card.classList.add("expanded"); 210 + if (arrow) arrow.textContent = "▲"; 211 + 212 + const detailsDiv = document.getElementById( 213 + `details-${encodeURIComponent(clientId)}`, 214 + ); 191 215 if (!detailsDiv) return; 192 216 193 - if (detailsDiv.dataset.loaded === 'true') { 217 + if (detailsDiv.dataset.loaded === "true") { 194 218 return; 195 219 } 196 220 197 221 try { 198 - const response = await fetch(`/api/admin/clients/${encodeURIComponent(clientId)}`, { 199 - headers: { 200 - 'Authorization': `Bearer ${token}`, 222 + const response = await fetch( 223 + `/api/admin/clients/${encodeURIComponent(clientId)}`, 224 + { 225 + headers: { 226 + Authorization: `Bearer ${token}`, 227 + }, 201 228 }, 202 - }); 229 + ); 203 230 204 231 if (!response.ok) { 205 - throw new Error('Failed to load client details'); 232 + throw new Error("Failed to load client details"); 206 233 } 207 234 208 235 const data = await response.json(); 209 - 236 + 210 237 detailsDiv.innerHTML = ` 211 - ${data.client.isPreregistered ? ` 238 + ${ 239 + data.client.isPreregistered 240 + ? ` 212 241 <div class="detail-section"> 213 242 <div class="detail-title">client secret</div> 214 243 <div class="secret-section"> ··· 216 245 <button class="btn-edit" onclick="event.stopPropagation(); regenerateSecret('${clientId}', event)">regenerate secret</button> 217 246 </div> 218 247 </div> 219 - ` : ''} 248 + ` 249 + : "" 250 + } 220 251 <div class="detail-section"> 221 252 <div class="detail-title">redirect uris</div> 222 253 <div class="redirect-uris"> 223 - ${data.client.redirectUris.map((uri: string) => `<div class="redirect-uri">${uri}</div>`).join('')} 254 + ${data.client.redirectUris.map((uri: string) => `<div class="redirect-uri">${uri}</div>`).join("")} 224 255 </div> 225 256 </div> 226 257 <div class="detail-section"> 227 258 <div class="detail-title">authorized users (${data.users.length})</div> 228 - ${data.users.length === 0 229 - ? '<div class="empty">No users have authorized this client yet</div>' 230 - : `<div class="users-list"> 231 - ${data.users.map((user: ClientUser) => { 232 - const grantedDate = new Date(user.grantedAt * 1000).toLocaleDateString(); 233 - const lastUsedDate = new Date(user.lastUsed * 1000).toLocaleDateString(); 234 - 235 - return ` 259 + ${ 260 + data.users.length === 0 261 + ? '<div class="empty">No users have authorized this client yet</div>' 262 + : `<div class="users-list"> 263 + ${data.users 264 + .map((user: ClientUser) => { 265 + const grantedDate = new Date( 266 + user.grantedAt * 1000, 267 + ).toLocaleDateString(); 268 + const lastUsedDate = new Date( 269 + user.lastUsed * 1000, 270 + ).toLocaleDateString(); 271 + 272 + return ` 236 273 <div class="user-item"> 237 274 <div class="user-info"> 238 275 <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> 239 - ${data.client.isPreregistered && data.client.availableRoles !== null ? ` 276 + ${ 277 + data.client.isPreregistered && 278 + data.client.availableRoles !== null 279 + ? ` 240 280 <div class="user-role-input"> 241 - <label style="color: var(--old-rose); font-size: 0.75rem;">ROLE${data.client.availableRoles.length > 0 ? '' : ' (OPTIONAL)'}:</label> 242 - ${data.client.availableRoles.length > 0 243 - ? `<select data-username="${user.username}" data-client-id="${clientId}" style="padding: 0.5rem; background: rgba(0, 0, 0, 0.3); border: 1px solid var(--old-rose); color: var(--lavender); font-family: inherit; font-size: 0.875rem;"> 281 + <label style="color: var(--old-rose); font-size: 0.75rem;">ROLE${data.client.availableRoles.length > 0 ? "" : " (OPTIONAL)"}:</label> 282 + ${ 283 + data.client.availableRoles.length > 0 284 + ? `<select data-username="${user.username}" data-client-id="${clientId}" style="padding: 0.5rem; background: rgba(0, 0, 0, 0.3); border: 1px solid var(--old-rose); color: var(--lavender); font-family: inherit; font-size: 0.875rem;"> 244 285 <option value="">No role</option> 245 - ${data.client.availableRoles.map((role: string) => ` 246 - <option value="${role}" ${user.role === role ? 'selected' : ''}>${role}</option> 247 - `).join('')} 286 + ${data.client.availableRoles 287 + .map( 288 + (role: string) => ` 289 + <option value="${role}" ${user.role === role ? "selected" : ""}>${role}</option> 290 + `, 291 + ) 292 + .join("")} 248 293 </select>` 249 - : `<input type="text" value="${user.role || ''}" placeholder="e.g. admin, editor, viewer" data-username="${user.username}" data-client-id="${clientId}" />` 294 + : `<input type="text" value="${user.role || ""}" placeholder="e.g. admin, editor, viewer" data-username="${user.username}" data-client-id="${clientId}" />` 250 295 } 251 296 <button onclick="event.stopPropagation(); setUserRole('${clientId}', '${user.username}', this.previousElementSibling.value)">update</button> 252 297 </div> 253 - ` : ''} 298 + ` 299 + : "" 300 + } 254 301 <div class="user-meta"> 255 - Granted ${grantedDate} • Last used ${lastUsedDate} • Scopes: ${user.scopes.join(', ')} 302 + Granted ${grantedDate} • Last used ${lastUsedDate} • Scopes: ${user.scopes.join(", ")} 256 303 </div> 257 304 </div> 258 305 <button class="revoke-btn" onclick="event.stopPropagation(); revokeUserPermission('${clientId}', '${user.username}', event)">revoke</button> 259 306 </div> 260 307 `; 261 - }).join('')} 308 + }) 309 + .join("")} 262 310 </div>` 263 311 } 264 312 </div> 265 313 `; 266 - 267 - detailsDiv.dataset.loaded = 'true'; 314 + 315 + detailsDiv.dataset.loaded = "true"; 268 316 } catch (error) { 269 - console.error('Failed to load client details:', error); 317 + console.error("Failed to load client details:", error); 270 318 detailsDiv.innerHTML = '<div class="error">Failed to load details</div>'; 271 319 } 272 320 }; 273 321 274 - (window as any).setUserRole = async function(clientId: string, username: string, role: string) { 322 + (window as any).setUserRole = async ( 323 + clientId: string, 324 + username: string, 325 + role: string, 326 + ) => { 275 327 try { 276 - const response = await fetch(`/api/admin/clients/${encodeURIComponent(clientId)}/users/${encodeURIComponent(username)}/role`, { 277 - method: 'POST', 278 - headers: { 279 - 'Authorization': `Bearer ${token}`, 280 - 'Content-Type': 'application/json', 328 + const response = await fetch( 329 + `/api/admin/clients/${encodeURIComponent(clientId)}/users/${encodeURIComponent(username)}/role`, 330 + { 331 + method: "POST", 332 + headers: { 333 + Authorization: `Bearer ${token}`, 334 + "Content-Type": "application/json", 335 + }, 336 + body: JSON.stringify({ role: role || null }), 281 337 }, 282 - body: JSON.stringify({ role: role || null }), 283 - }); 338 + ); 284 339 285 340 if (!response.ok) { 286 - throw new Error('Failed to set user role'); 341 + throw new Error("Failed to set user role"); 287 342 } 288 343 289 - showToast('User role updated successfully'); 344 + showToast("User role updated successfully"); 290 345 } catch (error) { 291 - console.error('Failed to set user role:', error); 292 - showToast('Failed to update user role. Please try again.', 'error'); 346 + console.error("Failed to set user role:", error); 347 + showToast("Failed to update user role. Please try again.", "error"); 293 348 } 294 349 }; 295 350 296 - (window as any).editClient = async function(clientId: string) { 351 + (window as any).editClient = async (clientId: string) => { 297 352 try { 298 - const response = await fetch(`/api/admin/clients/${encodeURIComponent(clientId)}`, { 299 - headers: { 300 - 'Authorization': `Bearer ${token}`, 353 + const response = await fetch( 354 + `/api/admin/clients/${encodeURIComponent(clientId)}`, 355 + { 356 + headers: { 357 + Authorization: `Bearer ${token}`, 358 + }, 301 359 }, 302 - }); 360 + ); 303 361 304 362 if (!response.ok) { 305 - throw new Error('Failed to load client'); 363 + throw new Error("Failed to load client"); 306 364 } 307 365 308 366 const data = await response.json(); 309 367 const client = data.client; 310 368 311 - modalTitle.textContent = 'Edit OAuth Client'; 312 - (document.getElementById('editClientId') as HTMLInputElement).value = clientId; 313 - (document.getElementById('clientName') as HTMLInputElement).value = client.name || ''; 314 - (document.getElementById('logoUrl') as HTMLInputElement).value = client.logoUrl || ''; 315 - (document.getElementById('description') as HTMLTextAreaElement).value = client.description || ''; 316 - (document.getElementById('availableRoles') as HTMLTextAreaElement).value = client.availableRoles ? client.availableRoles.join('\n') : ''; 317 - (document.getElementById('defaultRole') as HTMLInputElement).value = client.defaultRole || ''; 369 + modalTitle.textContent = "Edit OAuth Client"; 370 + (document.getElementById("editClientId") as HTMLInputElement).value = 371 + clientId; 372 + (document.getElementById("clientName") as HTMLInputElement).value = 373 + client.name || ""; 374 + (document.getElementById("logoUrl") as HTMLInputElement).value = 375 + client.logoUrl || ""; 376 + (document.getElementById("description") as HTMLTextAreaElement).value = 377 + client.description || ""; 378 + (document.getElementById("availableRoles") as HTMLTextAreaElement).value = 379 + client.availableRoles ? client.availableRoles.join("\n") : ""; 380 + (document.getElementById("defaultRole") as HTMLInputElement).value = 381 + client.defaultRole || ""; 318 382 319 - redirectUrisList.innerHTML = client.redirectUris.map((uri: string) => ` 383 + redirectUrisList.innerHTML = client.redirectUris 384 + .map( 385 + (uri: string) => ` 320 386 <div class="redirect-uri-item"> 321 387 <input type="url" class="form-input redirect-uri-input" value="${uri}" required /> 322 388 <button type="button" class="btn-remove" onclick="removeRedirectUri(this)">remove</button> 323 389 </div> 324 - `).join(''); 390 + `, 391 + ) 392 + .join(""); 325 393 326 - clientModal.classList.add('active'); 394 + clientModal.classList.add("active"); 327 395 } catch (error) { 328 - console.error('Failed to load client:', error); 329 - showToast('Failed to load client details', 'error'); 396 + console.error("Failed to load client:", error); 397 + showToast("Failed to load client details", "error"); 330 398 } 331 399 }; 332 400 333 - (window as any).deleteClient = async function(clientId: string, event?: Event) { 401 + (window as any).deleteClient = async (clientId: string, event?: Event) => { 334 402 const btn = event?.target as HTMLButtonElement | undefined; 335 - 403 + 336 404 // Double-click confirmation pattern 337 - if (btn?.dataset.confirmState === 'pending') { 405 + if (btn?.dataset.confirmState === "pending") { 338 406 // Second click - execute delete 339 407 delete btn.dataset.confirmState; 340 408 btn.disabled = true; 341 - btn.textContent = 'deleting...'; 342 - 409 + btn.textContent = "deleting..."; 410 + 343 411 try { 344 - const response = await fetch(`/api/admin/clients/${encodeURIComponent(clientId)}`, { 345 - method: 'DELETE', 346 - headers: { 347 - 'Authorization': `Bearer ${token}`, 412 + const response = await fetch( 413 + `/api/admin/clients/${encodeURIComponent(clientId)}`, 414 + { 415 + method: "DELETE", 416 + headers: { 417 + Authorization: `Bearer ${token}`, 418 + }, 348 419 }, 349 - }); 420 + ); 350 421 351 422 if (!response.ok) { 352 - throw new Error('Failed to delete client'); 423 + throw new Error("Failed to delete client"); 353 424 } 354 425 355 426 await loadClients(); 356 427 } catch (error) { 357 - console.error('Failed to delete client:', error); 358 - showToast('Failed to delete client. Please try again.', 'error'); 428 + console.error("Failed to delete client:", error); 429 + showToast("Failed to delete client. Please try again.", "error"); 359 430 btn.disabled = false; 360 - btn.textContent = 'delete'; 431 + btn.textContent = "delete"; 361 432 } 362 433 } else { 363 434 // First click - set pending state 364 435 if (btn) { 365 436 const originalText = btn.textContent; 366 - btn.dataset.confirmState = 'pending'; 367 - btn.textContent = 'you sure?'; 368 - 437 + btn.dataset.confirmState = "pending"; 438 + btn.textContent = "you sure?"; 439 + 369 440 // Reset after 3 seconds if not confirmed 370 441 setTimeout(() => { 371 - if (btn.dataset.confirmState === 'pending') { 442 + if (btn.dataset.confirmState === "pending") { 372 443 delete btn.dataset.confirmState; 373 444 btn.textContent = originalText; 374 445 } ··· 377 448 } 378 449 }; 379 450 380 - createClientBtn.addEventListener('click', () => { 381 - modalTitle.textContent = 'Create OAuth Client'; 451 + createClientBtn.addEventListener("click", () => { 452 + modalTitle.textContent = "Create OAuth Client"; 382 453 clientForm.reset(); 383 - (document.getElementById('editClientId') as HTMLInputElement).value = ''; 454 + (document.getElementById("editClientId") as HTMLInputElement).value = ""; 384 455 redirectUrisList.innerHTML = ` 385 456 <div class="redirect-uri-item"> 386 457 <input type="url" class="form-input redirect-uri-input" placeholder="https://example.com/auth/callback" required /> 387 458 <button type="button" class="btn-remove" onclick="removeRedirectUri(this)">remove</button> 388 459 </div> 389 460 `; 390 - clientModal.classList.add('active'); 461 + clientModal.classList.add("active"); 391 462 }); 392 463 393 - modalClose.addEventListener('click', () => { 394 - clientModal.classList.remove('active'); 464 + modalClose.addEventListener("click", () => { 465 + clientModal.classList.remove("active"); 395 466 }); 396 467 397 - cancelBtn.addEventListener('click', () => { 398 - clientModal.classList.remove('active'); 468 + cancelBtn.addEventListener("click", () => { 469 + clientModal.classList.remove("active"); 399 470 }); 400 471 401 - addRedirectUriBtn.addEventListener('click', () => { 402 - const newItem = document.createElement('div'); 403 - newItem.className = 'redirect-uri-item'; 472 + addRedirectUriBtn.addEventListener("click", () => { 473 + const newItem = document.createElement("div"); 474 + newItem.className = "redirect-uri-item"; 404 475 newItem.innerHTML = ` 405 476 <input type="url" class="form-input redirect-uri-input" placeholder="https://example.com/auth/callback" required /> 406 477 <button type="button" class="btn-remove" onclick="removeRedirectUri(this)">remove</button> ··· 408 479 redirectUrisList.appendChild(newItem); 409 480 }); 410 481 411 - (window as any).removeRedirectUri = function(btn: HTMLButtonElement) { 412 - const items = redirectUrisList.querySelectorAll('.redirect-uri-item'); 482 + (window as any).removeRedirectUri = (btn: HTMLButtonElement) => { 483 + const items = redirectUrisList.querySelectorAll(".redirect-uri-item"); 413 484 if (items.length > 1) { 414 485 btn.parentElement?.remove(); 415 486 } else { 416 - showToast('At least one redirect URI is required', 'error'); 487 + showToast("At least one redirect URI is required", "error"); 417 488 } 418 489 }; 419 490 420 - clientForm.addEventListener('submit', async (e) => { 491 + clientForm.addEventListener("submit", async (e) => { 421 492 e.preventDefault(); 422 493 423 - const editClientId = (document.getElementById('editClientId') as HTMLInputElement).value; 424 - const name = (document.getElementById('clientName') as HTMLInputElement).value; 425 - const logoUrl = (document.getElementById('logoUrl') as HTMLInputElement).value; 426 - const description = (document.getElementById('description') as HTMLTextAreaElement).value; 427 - const availableRolesText = (document.getElementById('availableRoles') as HTMLTextAreaElement).value; 428 - const defaultRole = (document.getElementById('defaultRole') as HTMLInputElement).value; 429 - 430 - const redirectUriInputs = Array.from(redirectUrisList.querySelectorAll('.redirect-uri-input')) as HTMLInputElement[]; 431 - const redirectUris = redirectUriInputs.map(input => input.value).filter(uri => uri.trim()); 494 + const editClientId = ( 495 + document.getElementById("editClientId") as HTMLInputElement 496 + ).value; 497 + const name = (document.getElementById("clientName") as HTMLInputElement) 498 + .value; 499 + const logoUrl = (document.getElementById("logoUrl") as HTMLInputElement) 500 + .value; 501 + const description = ( 502 + document.getElementById("description") as HTMLTextAreaElement 503 + ).value; 504 + const availableRolesText = ( 505 + document.getElementById("availableRoles") as HTMLTextAreaElement 506 + ).value; 507 + const defaultRole = ( 508 + document.getElementById("defaultRole") as HTMLInputElement 509 + ).value; 510 + 511 + const redirectUriInputs = Array.from( 512 + redirectUrisList.querySelectorAll(".redirect-uri-input"), 513 + ) as HTMLInputElement[]; 514 + const redirectUris = redirectUriInputs 515 + .map((input) => input.value) 516 + .filter((uri) => uri.trim()); 432 517 433 518 // Parse available roles from textarea (one per line) 434 519 const availableRoles = availableRolesText 435 - .split('\n') 436 - .map(r => r.trim()) 437 - .filter(r => r); 520 + .split("\n") 521 + .map((r) => r.trim()) 522 + .filter((r) => r); 438 523 439 524 // Validate default role is in available roles 440 - if (defaultRole && availableRoles.length > 0 && !availableRoles.includes(defaultRole)) { 441 - showToast('Default role must be one of the available roles', 'error'); 525 + if ( 526 + defaultRole && 527 + availableRoles.length > 0 && 528 + !availableRoles.includes(defaultRole) 529 + ) { 530 + showToast("Default role must be one of the available roles", "error"); 442 531 return; 443 532 } 444 533 445 534 if (redirectUris.length === 0) { 446 - showToast('At least one redirect URI is required', 'error'); 535 + showToast("At least one redirect URI is required", "error"); 447 536 return; 448 537 } 449 538 450 539 const isEdit = !!editClientId; 451 - const url = isEdit 540 + const url = isEdit 452 541 ? `/api/admin/clients/${encodeURIComponent(editClientId)}` 453 - : '/api/admin/clients'; 454 - const method = isEdit ? 'PUT' : 'POST'; 542 + : "/api/admin/clients"; 543 + const method = isEdit ? "PUT" : "POST"; 455 544 456 545 try { 457 546 const response = await fetch(url, { 458 547 method, 459 548 headers: { 460 - 'Authorization': `Bearer ${token}`, 461 - 'Content-Type': 'application/json', 549 + Authorization: `Bearer ${token}`, 550 + "Content-Type": "application/json", 462 551 }, 463 552 body: JSON.stringify({ 464 553 name, ··· 472 561 473 562 if (!response.ok) { 474 563 const error = await response.json(); 475 - throw new Error(error.error || 'Failed to save client'); 564 + throw new Error(error.error || "Failed to save client"); 476 565 } 477 566 478 - clientModal.classList.remove('active'); 479 - 567 + clientModal.classList.remove("active"); 568 + 480 569 // If creating a new client, show the credentials in modal 481 570 if (!isEdit) { 482 571 const result = await response.json(); 483 - if (result.client && result.client.clientId && result.client.clientSecret) { 484 - const secretModal = document.getElementById('secretModal') as HTMLElement; 485 - const generatedClientId = document.getElementById('generatedClientId') as HTMLElement; 486 - const generatedSecret = document.getElementById('generatedSecret') as HTMLElement; 487 - 572 + if ( 573 + result.client && 574 + result.client.clientId && 575 + result.client.clientSecret 576 + ) { 577 + const secretModal = document.getElementById( 578 + "secretModal", 579 + ) as HTMLElement; 580 + const generatedClientId = document.getElementById( 581 + "generatedClientId", 582 + ) as HTMLElement; 583 + const generatedSecret = document.getElementById( 584 + "generatedSecret", 585 + ) as HTMLElement; 586 + 488 587 if (generatedClientId && generatedSecret && secretModal) { 489 588 generatedClientId.textContent = result.client.clientId; 490 589 generatedSecret.textContent = result.client.clientSecret; 491 - secretModal.classList.add('active'); 590 + secretModal.classList.add("active"); 492 591 } 493 592 } 494 593 } else { 495 - showToast('Client updated successfully'); 594 + showToast("Client updated successfully"); 496 595 } 497 - 596 + 498 597 await loadClients(); 499 598 } catch (error) { 500 - console.error('Failed to save client:', error); 501 - showToast(`Failed to ${isEdit ? 'update' : 'create'} client: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error'); 599 + console.error("Failed to save client:", error); 600 + showToast( 601 + `Failed to ${isEdit ? "update" : "create"} client: ${error instanceof Error ? error.message : "Unknown error"}`, 602 + "error", 603 + ); 502 604 } 503 605 }); 504 606 505 - (window as any).regenerateSecret = async function(clientId: string, event?: Event) { 607 + (window as any).regenerateSecret = async (clientId: string, event?: Event) => { 506 608 const btn = event?.target as HTMLButtonElement | undefined; 507 - 609 + 508 610 // Double-click confirmation pattern (same as delete) 509 - if (btn?.dataset.confirmState === 'pending') { 611 + if (btn?.dataset.confirmState === "pending") { 510 612 // Second click - execute regenerate 511 613 delete btn.dataset.confirmState; 512 614 btn.disabled = true; 513 - btn.textContent = 'regenerating...'; 514 - 615 + btn.textContent = "regenerating..."; 616 + 515 617 try { 516 - const response = await fetch(`/api/admin/clients/${encodeURIComponent(clientId)}/secret`, { 517 - method: 'POST', 518 - headers: { 519 - 'Authorization': `Bearer ${token}`, 618 + const response = await fetch( 619 + `/api/admin/clients/${encodeURIComponent(clientId)}/secret`, 620 + { 621 + method: "POST", 622 + headers: { 623 + Authorization: `Bearer ${token}`, 624 + }, 520 625 }, 521 - }); 626 + ); 522 627 523 628 if (!response.ok) { 524 - throw new Error('Failed to regenerate secret'); 629 + throw new Error("Failed to regenerate secret"); 525 630 } 526 631 527 632 const data = await response.json(); 528 - 633 + 529 634 // Show the secret in modal 530 - const secretModal = document.getElementById('secretModal') as HTMLElement; 531 - const generatedClientId = document.getElementById('generatedClientId') as HTMLElement; 532 - const generatedSecret = document.getElementById('generatedSecret') as HTMLElement; 533 - 635 + const secretModal = document.getElementById("secretModal") as HTMLElement; 636 + const generatedClientId = document.getElementById( 637 + "generatedClientId", 638 + ) as HTMLElement; 639 + const generatedSecret = document.getElementById( 640 + "generatedSecret", 641 + ) as HTMLElement; 642 + 534 643 if (generatedClientId && generatedSecret && secretModal) { 535 644 generatedClientId.textContent = clientId; 536 645 generatedSecret.textContent = data.clientSecret; 537 - secretModal.classList.add('active'); 646 + secretModal.classList.add("active"); 538 647 } 539 - 648 + 540 649 btn.disabled = false; 541 - btn.textContent = 'regenerate secret'; 650 + btn.textContent = "regenerate secret"; 542 651 } catch (error) { 543 - console.error('Failed to regenerate secret:', error); 544 - showToast('Failed to regenerate client secret. Please try again.', 'error'); 652 + console.error("Failed to regenerate secret:", error); 653 + showToast( 654 + "Failed to regenerate client secret. Please try again.", 655 + "error", 656 + ); 545 657 btn.disabled = false; 546 - btn.textContent = 'regenerate secret'; 658 + btn.textContent = "regenerate secret"; 547 659 } 548 660 } else { 549 661 // First click - set pending state 550 662 if (btn) { 551 663 const originalText = btn.textContent; 552 - btn.dataset.confirmState = 'pending'; 553 - btn.textContent = 'you sure?'; 554 - 664 + btn.dataset.confirmState = "pending"; 665 + btn.textContent = "you sure?"; 666 + 555 667 // Reset after 3 seconds if not confirmed 556 668 setTimeout(() => { 557 - if (btn.dataset.confirmState === 'pending') { 669 + if (btn.dataset.confirmState === "pending") { 558 670 delete btn.dataset.confirmState; 559 671 btn.textContent = originalText; 560 672 } ··· 563 675 } 564 676 }; 565 677 566 - (window as any).revokeUserPermission = async function(clientId: string, username: string, event?: Event) { 678 + (window as any).revokeUserPermission = async ( 679 + clientId: string, 680 + username: string, 681 + event?: Event, 682 + ) => { 567 683 const btn = event?.target as HTMLButtonElement | undefined; 568 - 684 + 569 685 // Double-click confirmation pattern 570 - if (btn?.dataset.confirmState === 'pending') { 686 + if (btn?.dataset.confirmState === "pending") { 571 687 // Second click - execute revoke 572 688 delete btn.dataset.confirmState; 573 689 btn.disabled = true; 574 - btn.textContent = 'revoking...'; 575 - 690 + btn.textContent = "revoking..."; 691 + 576 692 try { 577 - const response = await fetch(`/api/admin/apps/${encodeURIComponent(clientId)}/users/${encodeURIComponent(username)}`, { 578 - method: 'DELETE', 579 - headers: { 580 - 'Authorization': `Bearer ${token}`, 693 + const response = await fetch( 694 + `/api/admin/apps/${encodeURIComponent(clientId)}/users/${encodeURIComponent(username)}`, 695 + { 696 + method: "DELETE", 697 + headers: { 698 + Authorization: `Bearer ${token}`, 699 + }, 581 700 }, 582 - }); 701 + ); 583 702 584 703 if (!response.ok) { 585 - throw new Error('Failed to revoke permission'); 704 + throw new Error("Failed to revoke permission"); 586 705 } 587 706 588 707 // Reload the client details 589 - const detailsDiv = document.getElementById(`details-${encodeURIComponent(clientId)}`); 708 + const detailsDiv = document.getElementById( 709 + `details-${encodeURIComponent(clientId)}`, 710 + ); 590 711 if (detailsDiv) { 591 - detailsDiv.dataset.loaded = 'false'; 712 + detailsDiv.dataset.loaded = "false"; 592 713 } 593 714 594 - const card = document.querySelector(`[data-client-id="${clientId}"]`) as HTMLElement; 715 + const card = document.querySelector( 716 + `[data-client-id="${clientId}"]`, 717 + ) as HTMLElement; 595 718 if (card) { 596 - card.classList.remove('expanded'); 719 + card.classList.remove("expanded"); 597 720 } 598 721 599 722 await loadClients(); 600 723 } catch (error) { 601 - console.error('Failed to revoke permission:', error); 602 - showToast('Failed to revoke permission. Please try again.', 'error'); 724 + console.error("Failed to revoke permission:", error); 725 + showToast("Failed to revoke permission. Please try again.", "error"); 603 726 btn.disabled = false; 604 - btn.textContent = 'revoke'; 727 + btn.textContent = "revoke"; 605 728 } 606 729 } else { 607 730 // First click - set pending state 608 731 if (btn) { 609 732 const originalText = btn.textContent; 610 - btn.dataset.confirmState = 'pending'; 611 - btn.textContent = 'you sure?'; 612 - 733 + btn.dataset.confirmState = "pending"; 734 + btn.textContent = "you sure?"; 735 + 613 736 // Reset after 3 seconds if not confirmed 614 737 setTimeout(() => { 615 - if (btn.dataset.confirmState === 'pending') { 738 + if (btn.dataset.confirmState === "pending") { 616 739 delete btn.dataset.confirmState; 617 740 btn.textContent = originalText; 618 741 } ··· 622 745 }; 623 746 624 747 // Secret modal handlers 625 - const secretModal = document.getElementById('secretModal') as HTMLElement; 626 - const secretModalClose = document.getElementById('secretModalClose') as HTMLButtonElement; 627 - const copyClientIdBtn = document.getElementById('copyClientIdBtn') as HTMLButtonElement; 628 - const copySecretBtn = document.getElementById('copySecretBtn') as HTMLButtonElement; 748 + const secretModal = document.getElementById("secretModal") as HTMLElement; 749 + const secretModalClose = document.getElementById( 750 + "secretModalClose", 751 + ) as HTMLButtonElement; 752 + const copyClientIdBtn = document.getElementById( 753 + "copyClientIdBtn", 754 + ) as HTMLButtonElement; 755 + const copySecretBtn = document.getElementById( 756 + "copySecretBtn", 757 + ) as HTMLButtonElement; 629 758 630 - secretModalClose?.addEventListener('click', () => { 631 - secretModal?.classList.remove('active'); 759 + secretModalClose?.addEventListener("click", () => { 760 + secretModal?.classList.remove("active"); 632 761 }); 633 762 634 - copyClientIdBtn?.addEventListener('click', async () => { 635 - const generatedClientId = document.getElementById('generatedClientId') as HTMLElement; 763 + copyClientIdBtn?.addEventListener("click", async () => { 764 + const generatedClientId = document.getElementById( 765 + "generatedClientId", 766 + ) as HTMLElement; 636 767 if (generatedClientId) { 637 768 try { 638 - await navigator.clipboard.writeText(generatedClientId.textContent || ''); 769 + await navigator.clipboard.writeText(generatedClientId.textContent || ""); 639 770 const originalText = copyClientIdBtn.textContent; 640 - copyClientIdBtn.textContent = 'copied! ✓'; 771 + copyClientIdBtn.textContent = "copied! ✓"; 641 772 setTimeout(() => { 642 773 copyClientIdBtn.textContent = originalText; 643 774 }, 2000); 644 775 } catch (error) { 645 - console.error('Failed to copy:', error); 646 - showToast('Failed to copy to clipboard', 'error'); 776 + console.error("Failed to copy:", error); 777 + showToast("Failed to copy to clipboard", "error"); 647 778 } 648 779 } 649 780 }); 650 781 651 - copySecretBtn?.addEventListener('click', async () => { 652 - const generatedSecret = document.getElementById('generatedSecret') as HTMLElement; 782 + copySecretBtn?.addEventListener("click", async () => { 783 + const generatedSecret = document.getElementById( 784 + "generatedSecret", 785 + ) as HTMLElement; 653 786 if (generatedSecret) { 654 787 try { 655 - await navigator.clipboard.writeText(generatedSecret.textContent || ''); 788 + await navigator.clipboard.writeText(generatedSecret.textContent || ""); 656 789 const originalText = copySecretBtn.textContent; 657 - copySecretBtn.textContent = 'copied! ✓'; 790 + copySecretBtn.textContent = "copied! ✓"; 658 791 setTimeout(() => { 659 792 copySecretBtn.textContent = originalText; 660 793 }, 2000); 661 794 } catch (error) { 662 - console.error('Failed to copy:', error); 663 - showToast('Failed to copy to clipboard', 'error'); 795 + console.error("Failed to copy:", error); 796 + showToast("Failed to copy to clipboard", "error"); 664 797 } 665 798 } 666 799 }); 667 800 668 801 // Close modals on escape key 669 - document.addEventListener('keydown', (e) => { 670 - if (e.key === 'Escape') { 671 - clientModal?.classList.remove('active'); 672 - secretModal?.classList.remove('active'); 802 + document.addEventListener("keydown", (e) => { 803 + if (e.key === "Escape") { 804 + clientModal?.classList.remove("active"); 805 + secretModal?.classList.remove("active"); 673 806 } 674 807 }); 675 808 676 809 // Close modals on outside click 677 - clientModal?.addEventListener('click', (e) => { 810 + clientModal?.addEventListener("click", (e) => { 678 811 if (e.target === clientModal) { 679 - clientModal.classList.remove('active'); 812 + clientModal.classList.remove("active"); 680 813 } 681 814 }); 682 815 683 - secretModal?.addEventListener('click', (e) => { 816 + secretModal?.addEventListener("click", (e) => { 684 817 if (e.target === secretModal) { 685 - secretModal.classList.remove('active'); 818 + secretModal.classList.remove("active"); 686 819 } 687 820 }); 688 821
+287 -206
src/client/admin-invites.ts
··· 1 - const token = localStorage.getItem('indiko_session'); 2 - const footer = document.getElementById('footer') as HTMLElement; 3 - const invitesList = document.getElementById('invitesList') as HTMLElement; 4 - const createInviteBtn = document.getElementById('createInviteBtn') as HTMLButtonElement; 1 + const token = localStorage.getItem("indiko_session"); 2 + const footer = document.getElementById("footer") as HTMLElement; 3 + const invitesList = document.getElementById("invitesList") as HTMLElement; 4 + const createInviteBtn = document.getElementById( 5 + "createInviteBtn", 6 + ) as HTMLButtonElement; 5 7 6 8 // Check auth and display user 7 9 async function checkAuth() { 8 10 if (!token) { 9 - window.location.href = '/login'; 11 + window.location.href = "/login"; 10 12 return; 11 13 } 12 14 13 15 try { 14 - const response = await fetch('/api/hello', { 16 + const response = await fetch("/api/hello", { 15 17 headers: { 16 - 'Authorization': `Bearer ${token}`, 18 + Authorization: `Bearer ${token}`, 17 19 }, 18 20 }); 19 21 20 22 if (response.status === 401 || response.status === 403) { 21 - localStorage.removeItem('indiko_session'); 22 - window.location.href = '/login'; 23 + localStorage.removeItem("indiko_session"); 24 + window.location.href = "/login"; 23 25 return; 24 26 } 25 27 ··· 29 31 <div class="back-link"><a href="/">← back to dashboard</a></div>`; 30 32 31 33 // Handle logout 32 - document.getElementById('logoutLink')?.addEventListener('click', async (e) => { 33 - e.preventDefault(); 34 - try { 35 - await fetch('/auth/logout', { 36 - method: 'POST', 37 - headers: { 38 - 'Authorization': `Bearer ${token}`, 39 - }, 40 - }); 41 - } catch { 42 - // Ignore logout errors 43 - } 44 - localStorage.removeItem('indiko_session'); 45 - window.location.href = '/login'; 46 - }); 34 + document 35 + .getElementById("logoutLink") 36 + ?.addEventListener("click", async (e) => { 37 + e.preventDefault(); 38 + try { 39 + await fetch("/auth/logout", { 40 + method: "POST", 41 + headers: { 42 + Authorization: `Bearer ${token}`, 43 + }, 44 + }); 45 + } catch { 46 + // Ignore logout errors 47 + } 48 + localStorage.removeItem("indiko_session"); 49 + window.location.href = "/login"; 50 + }); 47 51 48 52 // Check if admin 49 53 if (!data.isAdmin) { 50 - window.location.href = '/'; 54 + window.location.href = "/"; 51 55 return; 52 56 } 53 57 54 58 // Load invites 55 59 loadInvites(); 56 60 } catch (error) { 57 - console.error('Auth check failed:', error); 58 - footer.textContent = 'error loading user info'; 61 + console.error("Auth check failed:", error); 62 + footer.textContent = "error loading user info"; 59 63 usersList.innerHTML = '<div class="error">Failed to load users</div>'; 60 64 } 61 65 } 62 66 63 67 async function createInvite() { 64 68 // Show the create invite modal 65 - const modal = document.getElementById('createInviteModal'); 69 + const modal = document.getElementById("createInviteModal"); 66 70 if (modal) { 67 - modal.style.display = 'flex'; 71 + modal.style.display = "flex"; 68 72 // Load apps for role assignment 69 73 await loadAppsForInvite(); 70 74 } ··· 72 76 73 77 async function loadAppsForInvite() { 74 78 try { 75 - const response = await fetch('/api/admin/clients', { 79 + const response = await fetch("/api/admin/clients", { 76 80 headers: { 77 - 'Authorization': `Bearer ${token}`, 81 + Authorization: `Bearer ${token}`, 78 82 }, 79 83 }); 80 84 81 85 if (!response.ok) { 82 - throw new Error('Failed to load apps'); 86 + throw new Error("Failed to load apps"); 83 87 } 84 88 85 89 const data = await response.json(); 86 - const appRolesContainer = document.getElementById('appRolesContainer'); 87 - 90 + const appRolesContainer = document.getElementById("appRolesContainer"); 91 + 88 92 if (!appRolesContainer) return; 89 - 93 + 90 94 if (data.clients.length === 0) { 91 - appRolesContainer.innerHTML = '<p style="color: var(--old-rose); font-size: 0.875rem;">No pre-registered apps available</p>'; 95 + appRolesContainer.innerHTML = 96 + '<p style="color: var(--old-rose); font-size: 0.875rem;">No pre-registered apps available</p>'; 92 97 return; 93 98 } 94 99 95 100 appRolesContainer.innerHTML = data.clients 96 101 .filter((app: { isPreregistered: boolean }) => app.isPreregistered) 97 - .map((app: { id: number; clientId: string; name: string; roles: string[] }) => { 98 - const roleOptions = app.roles.length > 0 99 - ? app.roles.map(role => `<option value="${role}">${role}</option>`).join('') 100 - : '<option value="" disabled>No roles defined yet</option>'; 101 - 102 - const displayName = app.name || app.clientId; 103 - 104 - return ` 102 + .map( 103 + (app: { 104 + id: number; 105 + clientId: string; 106 + name: string; 107 + roles: string[]; 108 + }) => { 109 + const roleOptions = 110 + app.roles.length > 0 111 + ? app.roles 112 + .map((role) => `<option value="${role}">${role}</option>`) 113 + .join("") 114 + : '<option value="" disabled>No roles defined yet</option>'; 115 + 116 + const displayName = app.name || app.clientId; 117 + 118 + return ` 105 119 <div class="app-role-item"> 106 120 <label> 107 121 <input type="checkbox" name="appRole" value="${app.id}" data-client-id="${app.clientId}"> ··· 112 126 ${roleOptions} 113 127 </select> 114 128 </div> 115 - `}).join(''); 129 + `; 130 + }, 131 + ) 132 + .join(""); 116 133 117 134 // Enable/disable role select when checkbox changes 118 - const checkboxes = appRolesContainer.querySelectorAll('input[name="appRole"]'); 135 + const checkboxes = appRolesContainer.querySelectorAll( 136 + 'input[name="appRole"]', 137 + ); 119 138 checkboxes.forEach((checkbox) => { 120 - checkbox.addEventListener('change', (e) => { 139 + checkbox.addEventListener("change", (e) => { 121 140 const target = e.target as HTMLInputElement; 122 141 const appId = target.value; 123 - const roleSelect = appRolesContainer.querySelector(`select.role-select[data-app-id="${appId}"]`) as HTMLSelectElement; 124 - 142 + const roleSelect = appRolesContainer.querySelector( 143 + `select.role-select[data-app-id="${appId}"]`, 144 + ) as HTMLSelectElement; 145 + 125 146 if (roleSelect) { 126 147 roleSelect.disabled = !target.checked; 127 148 if (!target.checked) { 128 - roleSelect.value = ''; 149 + roleSelect.value = ""; 129 150 } 130 151 } 131 152 }); 132 153 }); 133 154 } catch (error) { 134 - console.error('Failed to load apps:', error); 155 + console.error("Failed to load apps:", error); 135 156 } 136 157 } 137 158 138 159 async function submitCreateInvite() { 139 - const maxUsesInput = document.getElementById('maxUses') as HTMLInputElement; 140 - const expiresAtInput = document.getElementById('expiresAt') as HTMLInputElement; 141 - const noteInput = document.getElementById('inviteNote') as HTMLTextAreaElement; 142 - const messageInput = document.getElementById('inviteMessage') as HTMLTextAreaElement; 143 - const submitBtn = document.getElementById('submitInviteBtn') as HTMLButtonElement; 160 + const maxUsesInput = document.getElementById("maxUses") as HTMLInputElement; 161 + const expiresAtInput = document.getElementById( 162 + "expiresAt", 163 + ) as HTMLInputElement; 164 + const noteInput = document.getElementById( 165 + "inviteNote", 166 + ) as HTMLTextAreaElement; 167 + const messageInput = document.getElementById( 168 + "inviteMessage", 169 + ) as HTMLTextAreaElement; 170 + const submitBtn = document.getElementById( 171 + "submitInviteBtn", 172 + ) as HTMLButtonElement; 144 173 145 174 const maxUses = maxUsesInput.value ? parseInt(maxUsesInput.value, 10) : 1; 146 - const expiresAt = expiresAtInput.value ? Math.floor(new Date(expiresAtInput.value).getTime() / 1000) : null; 175 + const expiresAt = expiresAtInput.value 176 + ? Math.floor(new Date(expiresAtInput.value).getTime() / 1000) 177 + : null; 147 178 const note = noteInput.value.trim() || null; 148 179 const message = messageInput.value.trim() || null; 149 180 150 181 // Collect app roles 151 - const appRolesContainer = document.getElementById('appRolesContainer'); 182 + const appRolesContainer = document.getElementById("appRolesContainer"); 152 183 const appRoles: Array<{ appId: number; role: string }> = []; 153 - 184 + 154 185 if (appRolesContainer) { 155 - const checkedBoxes = appRolesContainer.querySelectorAll('input[name="appRole"]:checked'); 186 + const checkedBoxes = appRolesContainer.querySelectorAll( 187 + 'input[name="appRole"]:checked', 188 + ); 156 189 checkedBoxes.forEach((checkbox) => { 157 190 const appId = parseInt((checkbox as HTMLInputElement).value, 10); 158 - const roleSelect = appRolesContainer.querySelector(`select.role-select[data-app-id="${appId}"]`) as HTMLSelectElement; 159 - 160 - let role = ''; 191 + const roleSelect = appRolesContainer.querySelector( 192 + `select.role-select[data-app-id="${appId}"]`, 193 + ) as HTMLSelectElement; 194 + 195 + let role = ""; 161 196 if (roleSelect && roleSelect.value) { 162 197 role = roleSelect.value; 163 198 } 164 - 199 + 165 200 if (role) { 166 201 appRoles.push({ 167 202 appId, ··· 172 207 } 173 208 174 209 submitBtn.disabled = true; 175 - submitBtn.textContent = 'creating...'; 210 + submitBtn.textContent = "creating..."; 176 211 177 212 try { 178 - const response = await fetch('/api/invites/create', { 179 - method: 'POST', 213 + const response = await fetch("/api/invites/create", { 214 + method: "POST", 180 215 headers: { 181 - 'Authorization': `Bearer ${token}`, 182 - 'Content-Type': 'application/json', 216 + Authorization: `Bearer ${token}`, 217 + "Content-Type": "application/json", 183 218 }, 184 219 body: JSON.stringify({ 185 220 maxUses, ··· 191 226 }); 192 227 193 228 if (!response.ok) { 194 - throw new Error('Failed to create invite'); 229 + throw new Error("Failed to create invite"); 195 230 } 196 231 197 232 await loadInvites(); 198 233 closeCreateInviteModal(); 199 234 } catch (error) { 200 - console.error('Failed to create invite:', error); 201 - alert('Failed to create invite'); 235 + console.error("Failed to create invite:", error); 236 + alert("Failed to create invite"); 202 237 } finally { 203 238 submitBtn.disabled = false; 204 - submitBtn.textContent = 'create invite'; 239 + submitBtn.textContent = "create invite"; 205 240 } 206 241 } 207 242 208 243 function closeCreateInviteModal() { 209 - const modal = document.getElementById('createInviteModal'); 244 + const modal = document.getElementById("createInviteModal"); 210 245 if (modal) { 211 - modal.style.display = 'none'; 246 + modal.style.display = "none"; 212 247 // Reset form 213 - (document.getElementById('maxUses') as HTMLInputElement).value = '1'; 214 - (document.getElementById('expiresAt') as HTMLInputElement).value = ''; 215 - (document.getElementById('inviteNote') as HTMLTextAreaElement).value = ''; 216 - (document.getElementById('inviteMessage') as HTMLTextAreaElement).value = ''; 217 - const appRolesContainer = document.getElementById('appRolesContainer'); 248 + (document.getElementById("maxUses") as HTMLInputElement).value = "1"; 249 + (document.getElementById("expiresAt") as HTMLInputElement).value = ""; 250 + (document.getElementById("inviteNote") as HTMLTextAreaElement).value = ""; 251 + (document.getElementById("inviteMessage") as HTMLTextAreaElement).value = 252 + ""; 253 + const appRolesContainer = document.getElementById("appRolesContainer"); 218 254 if (appRolesContainer) { 219 - appRolesContainer.querySelectorAll('input[type="checkbox"]').forEach((input) => { 220 - (input as HTMLInputElement).checked = false; 221 - }); 222 - appRolesContainer.querySelectorAll('select').forEach((select) => { 223 - (select as HTMLSelectElement).value = ''; 255 + appRolesContainer 256 + .querySelectorAll('input[type="checkbox"]') 257 + .forEach((input) => { 258 + (input as HTMLInputElement).checked = false; 259 + }); 260 + appRolesContainer.querySelectorAll("select").forEach((select) => { 261 + (select as HTMLSelectElement).value = ""; 224 262 (select as HTMLSelectElement).disabled = true; 225 263 }); 226 264 } ··· 233 271 234 272 async function loadInvites() { 235 273 try { 236 - const response = await fetch('/api/invites', { 274 + const response = await fetch("/api/invites", { 237 275 headers: { 238 - 'Authorization': `Bearer ${token}`, 276 + Authorization: `Bearer ${token}`, 239 277 }, 240 278 }); 241 279 242 280 if (!response.ok) { 243 - throw new Error('Failed to load invites'); 281 + throw new Error("Failed to load invites"); 244 282 } 245 283 246 284 const data = await response.json(); 247 - 285 + 248 286 if (data.invites.length === 0) { 249 - invitesList.innerHTML = '<div class="loading">No invites created yet</div>'; 287 + invitesList.innerHTML = 288 + '<div class="loading">No invites created yet</div>'; 250 289 return; 251 290 } 252 291 253 - invitesList.innerHTML = data.invites.map((invite: { 254 - id: number; 255 - code: string; 256 - maxUses: number; 257 - currentUses: number; 258 - isExpired: boolean; 259 - isFullyUsed: boolean; 260 - expiresAt: number | null; 261 - note: string | null; 262 - message: string | null; 263 - createdAt: number; 264 - createdBy: string; 265 - inviteUrl: string; 266 - appRoles: Array<{ clientId: string; name: string | null; role: string }>; 267 - usedBy: Array<{ username: string; usedAt: number }>; 268 - }) => { 269 - const createdDate = new Date(invite.createdAt * 1000).toLocaleDateString(); 270 - 271 - let status = `${invite.currentUses}/${invite.maxUses} used`; 272 - if (invite.isExpired) { 273 - status += ' (expired)'; 274 - } else if (invite.isFullyUsed) { 275 - status += ' (fully used)'; 276 - } 277 - 278 - const expiryInfo = invite.expiresAt 279 - ? `Expires: ${new Date(invite.expiresAt * 1000).toLocaleString()}` 280 - : 'No expiry'; 281 - 282 - const roleInfo = invite.appRoles.length > 0 283 - ? `<div class="invite-roles">App roles: ${invite.appRoles.map(r => { 284 - const appName = r.name || r.clientId; 285 - return `${appName} (${r.role})`; 286 - }).join(', ')}</div>` 287 - : ''; 288 - 289 - const usedByInfo = invite.usedBy.length > 0 290 - ? `<div class="invite-used-by">Used by: ${invite.usedBy.map(u => `${u.username} (${new Date(u.usedAt * 1000).toLocaleDateString()})`).join(', ')}</div>` 291 - : ''; 292 - 293 - const noteInfo = invite.note 294 - ? `<div class="invite-note">Internal note: ${invite.note}</div>` 295 - : ''; 296 - 297 - const messageInfo = invite.message 298 - ? `<div class="invite-message">Message to invitees: ${invite.message}</div>` 299 - : ''; 300 - 301 - const isActive = !invite.isExpired && !invite.isFullyUsed; 302 - 303 - return ` 304 - <div class="invite-item ${isActive ? '' : 'invite-inactive'}"> 292 + invitesList.innerHTML = data.invites 293 + .map( 294 + (invite: { 295 + id: number; 296 + code: string; 297 + maxUses: number; 298 + currentUses: number; 299 + isExpired: boolean; 300 + isFullyUsed: boolean; 301 + expiresAt: number | null; 302 + note: string | null; 303 + message: string | null; 304 + createdAt: number; 305 + createdBy: string; 306 + inviteUrl: string; 307 + appRoles: Array<{ 308 + clientId: string; 309 + name: string | null; 310 + role: string; 311 + }>; 312 + usedBy: Array<{ username: string; usedAt: number }>; 313 + }) => { 314 + const createdDate = new Date( 315 + invite.createdAt * 1000, 316 + ).toLocaleDateString(); 317 + 318 + let status = `${invite.currentUses}/${invite.maxUses} used`; 319 + if (invite.isExpired) { 320 + status += " (expired)"; 321 + } else if (invite.isFullyUsed) { 322 + status += " (fully used)"; 323 + } 324 + 325 + const expiryInfo = invite.expiresAt 326 + ? `Expires: ${new Date(invite.expiresAt * 1000).toLocaleString()}` 327 + : "No expiry"; 328 + 329 + const roleInfo = 330 + invite.appRoles.length > 0 331 + ? `<div class="invite-roles">App roles: ${invite.appRoles 332 + .map((r) => { 333 + const appName = r.name || r.clientId; 334 + return `${appName} (${r.role})`; 335 + }) 336 + .join(", ")}</div>` 337 + : ""; 338 + 339 + const usedByInfo = 340 + invite.usedBy.length > 0 341 + ? `<div class="invite-used-by">Used by: ${invite.usedBy.map((u) => `${u.username} (${new Date(u.usedAt * 1000).toLocaleDateString()})`).join(", ")}</div>` 342 + : ""; 343 + 344 + const noteInfo = invite.note 345 + ? `<div class="invite-note">Internal note: ${invite.note}</div>` 346 + : ""; 347 + 348 + const messageInfo = invite.message 349 + ? `<div class="invite-message">Message to invitees: ${invite.message}</div>` 350 + : ""; 351 + 352 + const isActive = !invite.isExpired && !invite.isFullyUsed; 353 + 354 + return ` 355 + <div class="invite-item ${isActive ? "" : "invite-inactive"}"> 305 356 <div> 306 357 <div class="invite-code">${invite.code}</div> 307 358 <div class="invite-meta">Created by ${invite.createdBy} on ${createdDate} • ${status}</div> ··· 313 364 <div class="invite-url">${invite.inviteUrl}</div> 314 365 </div> 315 366 <div class="invite-actions-btns"> 316 - <button class="btn-copy" data-invite-id="${invite.id}" data-invite-url="${invite.inviteUrl}" ${isActive ? '' : 'disabled'}>copy link</button> 317 - <button class="btn-edit" onclick="editInvite(${invite.id})" ${isActive ? '' : 'disabled'}>edit</button> 367 + <button class="btn-copy" data-invite-id="${invite.id}" data-invite-url="${invite.inviteUrl}" ${isActive ? "" : "disabled"}>copy link</button> 368 + <button class="btn-edit" onclick="editInvite(${invite.id})" ${isActive ? "" : "disabled"}>edit</button> 318 369 <button class="btn-delete" onclick="deleteInvite(${invite.id}, event)">delete</button> 319 370 </div> 320 371 </div> 321 372 `; 322 - }).join(''); 373 + }, 374 + ) 375 + .join(""); 323 376 324 377 // Add copy button handlers 325 - const copyButtons = invitesList.querySelectorAll('.btn-copy'); 378 + const copyButtons = invitesList.querySelectorAll(".btn-copy"); 326 379 copyButtons.forEach((btn) => { 327 - btn.addEventListener('click', async (e) => { 380 + btn.addEventListener("click", async (e) => { 328 381 const button = e.target as HTMLButtonElement; 329 382 const url = button.dataset.inviteUrl; 330 383 if (!url) return; ··· 332 385 try { 333 386 await navigator.clipboard.writeText(url); 334 387 const originalText = button.textContent; 335 - button.textContent = 'copied!'; 388 + button.textContent = "copied!"; 336 389 setTimeout(() => { 337 390 button.textContent = originalText; 338 391 }, 2000); 339 392 } catch (error) { 340 - console.error('Failed to copy:', error); 393 + console.error("Failed to copy:", error); 341 394 } 342 395 }); 343 396 }); 344 397 } catch (error) { 345 - console.error('Failed to load invites:', error); 398 + console.error("Failed to load invites:", error); 346 399 invitesList.innerHTML = '<div class="error">Failed to load invites</div>'; 347 400 } 348 401 } 349 402 350 403 checkAuth(); 351 404 352 - createInviteBtn.addEventListener('click', createInvite); 405 + createInviteBtn.addEventListener("click", createInvite); 353 406 354 407 // Close modals on escape key 355 - document.addEventListener('keydown', (e) => { 356 - if (e.key === 'Escape') { 408 + document.addEventListener("keydown", (e) => { 409 + if (e.key === "Escape") { 357 410 closeCreateInviteModal(); 358 411 closeEditInviteModal(); 359 412 } 360 413 }); 361 414 362 415 // Close modals on outside click 363 - document.getElementById('createInviteModal')?.addEventListener('click', (e) => { 416 + document.getElementById("createInviteModal")?.addEventListener("click", (e) => { 364 417 if (e.target === e.currentTarget) { 365 418 closeCreateInviteModal(); 366 419 } 367 420 }); 368 421 369 - document.getElementById('editInviteModal')?.addEventListener('click', (e) => { 422 + document.getElementById("editInviteModal")?.addEventListener("click", (e) => { 370 423 if (e.target === e.currentTarget) { 371 424 closeEditInviteModal(); 372 425 } ··· 377 430 // Make editInvite globally available for onclick handler 378 431 (window as any).editInvite = async (inviteId: number) => { 379 432 try { 380 - const response = await fetch('/api/invites', { 433 + const response = await fetch("/api/invites", { 381 434 headers: { 382 - 'Authorization': `Bearer ${token}`, 435 + Authorization: `Bearer ${token}`, 383 436 }, 384 437 }); 385 438 386 439 if (!response.ok) { 387 - throw new Error('Failed to load invite'); 440 + throw new Error("Failed to load invite"); 388 441 } 389 442 390 443 const data = await response.json(); 391 - const invite = data.invites.find((inv: { id: number }) => inv.id === inviteId); 392 - 444 + const invite = data.invites.find( 445 + (inv: { id: number }) => inv.id === inviteId, 446 + ); 447 + 393 448 if (!invite) { 394 - throw new Error('Invite not found'); 449 + throw new Error("Invite not found"); 395 450 } 396 451 397 452 currentEditInviteId = inviteId; 398 453 399 454 // Populate form 400 - (document.getElementById('editMaxUses') as HTMLInputElement).value = String(invite.maxUses); 401 - (document.getElementById('editInviteNote') as HTMLTextAreaElement).value = invite.note || ''; 402 - (document.getElementById('editInviteMessage') as HTMLTextAreaElement).value = invite.message || ''; 403 - 455 + (document.getElementById("editMaxUses") as HTMLInputElement).value = String( 456 + invite.maxUses, 457 + ); 458 + (document.getElementById("editInviteNote") as HTMLTextAreaElement).value = 459 + invite.note || ""; 460 + ( 461 + document.getElementById("editInviteMessage") as HTMLTextAreaElement 462 + ).value = invite.message || ""; 463 + 404 464 // Handle expiration date 405 - const expiresAtInput = document.getElementById('editExpiresAt') as HTMLInputElement; 465 + const expiresAtInput = document.getElementById( 466 + "editExpiresAt", 467 + ) as HTMLInputElement; 406 468 if (invite.expiresAt) { 407 469 const date = new Date(invite.expiresAt * 1000); 408 - const localDatetime = new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16); 470 + const localDatetime = new Date( 471 + date.getTime() - date.getTimezoneOffset() * 60000, 472 + ) 473 + .toISOString() 474 + .slice(0, 16); 409 475 expiresAtInput.value = localDatetime; 410 476 } else { 411 - expiresAtInput.value = ''; 477 + expiresAtInput.value = ""; 412 478 } 413 479 414 480 // Show modal 415 - const modal = document.getElementById('editInviteModal'); 481 + const modal = document.getElementById("editInviteModal"); 416 482 if (modal) { 417 - modal.style.display = 'flex'; 483 + modal.style.display = "flex"; 418 484 } 419 485 } catch (error) { 420 - console.error('Failed to load invite:', error); 421 - alert('Failed to load invite'); 486 + console.error("Failed to load invite:", error); 487 + alert("Failed to load invite"); 422 488 } 423 489 }; 424 490 425 491 (window as any).submitEditInvite = async () => { 426 492 if (currentEditInviteId === null) return; 427 493 428 - const maxUsesInput = document.getElementById('editMaxUses') as HTMLInputElement; 429 - const expiresAtInput = document.getElementById('editExpiresAt') as HTMLInputElement; 430 - const noteInput = document.getElementById('editInviteNote') as HTMLTextAreaElement; 431 - const messageInput = document.getElementById('editInviteMessage') as HTMLTextAreaElement; 432 - const submitBtn = document.getElementById('submitEditInviteBtn') as HTMLButtonElement; 494 + const maxUsesInput = document.getElementById( 495 + "editMaxUses", 496 + ) as HTMLInputElement; 497 + const expiresAtInput = document.getElementById( 498 + "editExpiresAt", 499 + ) as HTMLInputElement; 500 + const noteInput = document.getElementById( 501 + "editInviteNote", 502 + ) as HTMLTextAreaElement; 503 + const messageInput = document.getElementById( 504 + "editInviteMessage", 505 + ) as HTMLTextAreaElement; 506 + const submitBtn = document.getElementById( 507 + "submitEditInviteBtn", 508 + ) as HTMLButtonElement; 433 509 434 510 const maxUses = maxUsesInput.value ? parseInt(maxUsesInput.value, 10) : null; 435 - const expiresAt = expiresAtInput.value ? Math.floor(new Date(expiresAtInput.value).getTime() / 1000) : null; 511 + const expiresAt = expiresAtInput.value 512 + ? Math.floor(new Date(expiresAtInput.value).getTime() / 1000) 513 + : null; 436 514 const note = noteInput.value.trim() || null; 437 515 const message = messageInput.value.trim() || null; 438 516 439 517 submitBtn.disabled = true; 440 - submitBtn.textContent = 'saving...'; 518 + submitBtn.textContent = "saving..."; 441 519 442 520 try { 443 521 const response = await fetch(`/api/invites/${currentEditInviteId}`, { 444 - method: 'PATCH', 522 + method: "PATCH", 445 523 headers: { 446 - 'Authorization': `Bearer ${token}`, 447 - 'Content-Type': 'application/json', 524 + Authorization: `Bearer ${token}`, 525 + "Content-Type": "application/json", 448 526 }, 449 527 body: JSON.stringify({ maxUses, expiresAt, note, message }), 450 528 }); 451 529 452 530 if (!response.ok) { 453 - throw new Error('Failed to update invite'); 531 + throw new Error("Failed to update invite"); 454 532 } 455 533 456 534 await loadInvites(); 457 535 closeEditInviteModal(); 458 536 } catch (error) { 459 - console.error('Failed to update invite:', error); 460 - alert('Failed to update invite'); 537 + console.error("Failed to update invite:", error); 538 + alert("Failed to update invite"); 461 539 } finally { 462 540 submitBtn.disabled = false; 463 - submitBtn.textContent = 'save changes'; 541 + submitBtn.textContent = "save changes"; 464 542 } 465 543 }; 466 544 467 545 (window as any).closeEditInviteModal = () => { 468 - const modal = document.getElementById('editInviteModal'); 546 + const modal = document.getElementById("editInviteModal"); 469 547 if (modal) { 470 - modal.style.display = 'none'; 548 + modal.style.display = "none"; 471 549 currentEditInviteId = null; 472 - (document.getElementById('editMaxUses') as HTMLInputElement).value = ''; 473 - (document.getElementById('editExpiresAt') as HTMLInputElement).value = ''; 474 - (document.getElementById('editInviteNote') as HTMLTextAreaElement).value = ''; 475 - (document.getElementById('editInviteMessage') as HTMLTextAreaElement).value = ''; 550 + (document.getElementById("editMaxUses") as HTMLInputElement).value = ""; 551 + (document.getElementById("editExpiresAt") as HTMLInputElement).value = ""; 552 + (document.getElementById("editInviteNote") as HTMLTextAreaElement).value = 553 + ""; 554 + ( 555 + document.getElementById("editInviteMessage") as HTMLTextAreaElement 556 + ).value = ""; 476 557 } 477 558 }; 478 559 479 560 (window as any).deleteInvite = async (inviteId: number, event?: Event) => { 480 561 const btn = event?.target as HTMLButtonElement | undefined; 481 - 562 + 482 563 // Double-click confirmation pattern 483 - if (btn?.dataset.confirmState === 'pending') { 564 + if (btn?.dataset.confirmState === "pending") { 484 565 // Second click - execute delete 485 566 delete btn.dataset.confirmState; 486 - btn.textContent = 'deleting...'; 567 + btn.textContent = "deleting..."; 487 568 btn.disabled = true; 488 - 569 + 489 570 try { 490 571 const response = await fetch(`/api/invites/${inviteId}`, { 491 - method: 'DELETE', 572 + method: "DELETE", 492 573 headers: { 493 - 'Authorization': `Bearer ${token}`, 574 + Authorization: `Bearer ${token}`, 494 575 }, 495 576 }); 496 577 497 578 if (!response.ok) { 498 - throw new Error('Failed to delete invite'); 579 + throw new Error("Failed to delete invite"); 499 580 } 500 581 501 582 await loadInvites(); 502 583 } catch (error) { 503 - console.error('Failed to delete invite:', error); 504 - alert('Failed to delete invite'); 505 - btn.textContent = 'delete'; 584 + console.error("Failed to delete invite:", error); 585 + alert("Failed to delete invite"); 586 + btn.textContent = "delete"; 506 587 btn.disabled = false; 507 588 } 508 589 } else { 509 590 // First click - set pending state 510 591 if (btn) { 511 592 const originalText = btn.textContent; 512 - btn.dataset.confirmState = 'pending'; 513 - btn.textContent = 'you sure?'; 514 - 593 + btn.dataset.confirmState = "pending"; 594 + btn.textContent = "you sure?"; 595 + 515 596 // Reset after 3 seconds if not confirmed 516 597 setTimeout(() => { 517 - if (btn.dataset.confirmState === 'pending') { 598 + if (btn.dataset.confirmState === "pending") { 518 599 delete btn.dataset.confirmState; 519 600 btn.textContent = originalText; 520 601 }
+96 -83
src/client/admin.ts
··· 1 - const token = localStorage.getItem('indiko_session'); 2 - const footer = document.getElementById('footer') as HTMLElement; 3 - const usersList = document.getElementById('usersList') as HTMLElement; 1 + const token = localStorage.getItem("indiko_session"); 2 + const footer = document.getElementById("footer") as HTMLElement; 3 + const usersList = document.getElementById("usersList") as HTMLElement; 4 4 let currentUserId: number; 5 5 6 6 // Check auth and display user 7 7 async function checkAuth() { 8 8 if (!token) { 9 - window.location.href = '/login'; 9 + window.location.href = "/login"; 10 10 return; 11 11 } 12 12 13 13 try { 14 - const response = await fetch('/api/hello', { 14 + const response = await fetch("/api/hello", { 15 15 headers: { 16 - 'Authorization': `Bearer ${token}`, 16 + Authorization: `Bearer ${token}`, 17 17 }, 18 18 }); 19 19 20 20 if (response.status === 401 || response.status === 403) { 21 - localStorage.removeItem('indiko_session'); 22 - window.location.href = '/login'; 21 + localStorage.removeItem("indiko_session"); 22 + window.location.href = "/login"; 23 23 return; 24 24 } 25 25 ··· 30 30 <div class="back-link"><a href="/">← back to dashboard</a></div>`; 31 31 32 32 // Handle logout 33 - document.getElementById('logoutLink')?.addEventListener('click', async (e) => { 34 - e.preventDefault(); 35 - try { 36 - await fetch('/auth/logout', { 37 - method: 'POST', 38 - headers: { 39 - 'Authorization': `Bearer ${token}`, 40 - }, 41 - }); 42 - } catch { 43 - // Ignore logout errors 44 - } 45 - localStorage.removeItem('indiko_session'); 46 - window.location.href = '/login'; 47 - }); 33 + document 34 + .getElementById("logoutLink") 35 + ?.addEventListener("click", async (e) => { 36 + e.preventDefault(); 37 + try { 38 + await fetch("/auth/logout", { 39 + method: "POST", 40 + headers: { 41 + Authorization: `Bearer ${token}`, 42 + }, 43 + }); 44 + } catch { 45 + // Ignore logout errors 46 + } 47 + localStorage.removeItem("indiko_session"); 48 + window.location.href = "/login"; 49 + }); 48 50 49 51 // Check if admin 50 52 if (!data.isAdmin) { 51 - window.location.href = '/'; 53 + window.location.href = "/"; 52 54 return; 53 55 } 54 56 55 57 // Load users if admin 56 58 loadUsers(); 57 59 } catch (error) { 58 - console.error('Auth check failed:', error); 59 - footer.textContent = 'error loading user info'; 60 + console.error("Auth check failed:", error); 61 + footer.textContent = "error loading user info"; 60 62 usersList.innerHTML = '<div class="error">Failed to load users</div>'; 61 63 } 62 64 } 63 65 64 66 async function loadUsers() { 65 67 try { 66 - const response = await fetch('/api/users', { 68 + const response = await fetch("/api/users", { 67 69 headers: { 68 - 'Authorization': `Bearer ${token}`, 70 + Authorization: `Bearer ${token}`, 69 71 }, 70 72 }); 71 73 72 74 if (!response.ok) { 73 - throw new Error('Failed to load users'); 75 + throw new Error("Failed to load users"); 74 76 } 75 77 76 78 const data = await response.json(); 77 - 79 + 78 80 if (data.users.length === 0) { 79 81 usersList.innerHTML = '<div class="loading">No users found</div>'; 80 82 return; 81 83 } 82 84 83 - usersList.innerHTML = data.users.map((user: { 84 - id: number; 85 - username: string; 86 - name: string; 87 - email: string | null; 88 - photo: string | null; 89 - status: string; 90 - role: string; 91 - isAdmin: boolean; 92 - createdAt: number; 93 - credentialCount: number; 94 - }) => { 95 - const createdDate = new Date(user.createdAt * 1000).toLocaleDateString(); 96 - const initials = user.username.substring(0, 2).toUpperCase(); 97 - const avatarContent = user.photo 98 - ? `<img src="${user.photo}" alt="${user.username}" />` 99 - : initials; 100 - const isSelf = user.id === currentUserId; 101 - 102 - return ` 103 - <div class="user-card ${user.status === 'suspended' ? 'user-suspended' : ''}" data-user-id="${user.id}"> 85 + usersList.innerHTML = data.users 86 + .map( 87 + (user: { 88 + id: number; 89 + username: string; 90 + name: string; 91 + email: string | null; 92 + photo: string | null; 93 + status: string; 94 + role: string; 95 + isAdmin: boolean; 96 + createdAt: number; 97 + credentialCount: number; 98 + }) => { 99 + const createdDate = new Date( 100 + user.createdAt * 1000, 101 + ).toLocaleDateString(); 102 + const initials = user.username.substring(0, 2).toUpperCase(); 103 + const avatarContent = user.photo 104 + ? `<img src="${user.photo}" alt="${user.username}" />` 105 + : initials; 106 + const isSelf = user.id === currentUserId; 107 + 108 + return ` 109 + <div class="user-card ${user.status === "suspended" ? "user-suspended" : ""}" data-user-id="${user.id}"> 104 110 <div class="user-avatar">${avatarContent}</div> 105 111 <div class="user-info"> 106 - <div class="user-name">${user.username}${isSelf ? ' (you)' : ''}</div> 112 + <div class="user-name">${user.username}${isSelf ? " (you)" : ""}</div> 107 113 <div class="user-meta"> 108 - <span class="user-meta-item">${user.credentialCount} passkey${user.credentialCount !== 1 ? 's' : ''}</span> 114 + <span class="user-meta-item">${user.credentialCount} passkey${user.credentialCount !== 1 ? "s" : ""}</span> 109 115 <span class="user-meta-item">joined ${createdDate}</span> 110 - ${user.email ? `<span class="user-meta-item">${user.email}</span>` : ''} 116 + ${user.email ? `<span class="user-meta-item">${user.email}</span>` : ""} 111 117 </div> 112 118 </div> 113 119 <div class="user-badges"> ··· 115 121 <span class="user-badge badge-role">${user.role}</span> 116 122 </div> 117 123 <div class="user-actions"> 118 - ${!isSelf ? (user.status === 'suspended' 119 - ? `<button class="btn-edit" data-action="enable" data-user-id="${user.id}">enable</button>` 120 - : `<button class="btn-disable" data-action="disable" data-user-id="${user.id}">disable</button>` 121 - ) : ''} 122 - ${!isSelf ? `<button class="btn-delete" data-action="delete" data-user-id="${user.id}">delete</button>` : ''} 124 + ${ 125 + !isSelf 126 + ? user.status === "suspended" 127 + ? `<button class="btn-edit" data-action="enable" data-user-id="${user.id}">enable</button>` 128 + : `<button class="btn-disable" data-action="disable" data-user-id="${user.id}">disable</button>` 129 + : "" 130 + } 131 + ${!isSelf ? `<button class="btn-delete" data-action="delete" data-user-id="${user.id}">delete</button>` : ""} 123 132 </div> 124 133 </div> 125 134 `; 126 - }).join(''); 135 + }, 136 + ) 137 + .join(""); 127 138 128 139 // Add event listeners for action buttons 129 - document.querySelectorAll('button[data-action]').forEach(btn => { 130 - btn.addEventListener('click', handleUserAction); 140 + document.querySelectorAll("button[data-action]").forEach((btn) => { 141 + btn.addEventListener("click", handleUserAction); 131 142 }); 132 143 } catch (error) { 133 - console.error('Failed to load users:', error); 144 + console.error("Failed to load users:", error); 134 145 usersList.innerHTML = '<div class="error">Failed to load users</div>'; 135 146 } 136 147 } ··· 139 150 const btn = e.target as HTMLButtonElement; 140 151 const action = btn.dataset.action; 141 152 const userId = btn.dataset.userId; 142 - 153 + 143 154 if (!userId || !action) return; 144 155 145 156 // Check if already in confirmation state 146 - if (btn.dataset.confirmState === 'pending') { 157 + if (btn.dataset.confirmState === "pending") { 147 158 // Second click - perform action 148 - btn.dataset.confirmState = ''; 159 + btn.dataset.confirmState = ""; 149 160 btn.disabled = true; 150 - 161 + 151 162 try { 152 - let endpoint = ''; 153 - let method = 'POST'; 154 - 155 - if (action === 'delete') { 163 + let endpoint = ""; 164 + let method = "POST"; 165 + 166 + if (action === "delete") { 156 167 endpoint = `/api/admin/users/${userId}/delete`; 157 - method = 'DELETE'; 158 - } else if (action === 'disable') { 168 + method = "DELETE"; 169 + } else if (action === "disable") { 159 170 endpoint = `/api/admin/users/${userId}/disable`; 160 - } else if (action === 'enable') { 171 + } else if (action === "enable") { 161 172 endpoint = `/api/admin/users/${userId}/enable`; 162 173 } 163 174 164 175 const response = await fetch(endpoint, { 165 176 method, 166 177 headers: { 167 - 'Authorization': `Bearer ${token}`, 178 + Authorization: `Bearer ${token}`, 168 179 }, 169 180 }); 170 181 171 182 if (!response.ok) { 172 183 const error = await response.json(); 173 - throw new Error(error.error || 'Failed to perform action'); 184 + throw new Error(error.error || "Failed to perform action"); 174 185 } 175 186 176 187 // Reload users list 177 188 loadUsers(); 178 189 } catch (error) { 179 190 console.error(`Failed to ${action} user:`, error); 180 - alert(`Failed to ${action} user: ${error instanceof Error ? error.message : 'Unknown error'}`); 191 + alert( 192 + `Failed to ${action} user: ${error instanceof Error ? error.message : "Unknown error"}`, 193 + ); 181 194 btn.disabled = false; 182 195 } 183 196 } else { 184 197 // First click - set confirmation state 185 198 const originalText = btn.textContent; 186 - btn.dataset.confirmState = 'pending'; 187 - btn.dataset.originalText = originalText || ''; 188 - btn.textContent = 'you sure?'; 189 - 199 + btn.dataset.confirmState = "pending"; 200 + btn.dataset.originalText = originalText || ""; 201 + btn.textContent = "you sure?"; 202 + 190 203 // Reset after 3 seconds if not clicked again 191 204 setTimeout(() => { 192 - if (btn.dataset.confirmState === 'pending') { 193 - btn.dataset.confirmState = ''; 205 + if (btn.dataset.confirmState === "pending") { 206 + btn.dataset.confirmState = ""; 194 207 btn.textContent = btn.dataset.originalText || originalText; 195 208 } 196 209 }, 3000);
+46 -38
src/client/apps.ts
··· 1 - const token = localStorage.getItem('indiko_session'); 2 - const appsList = document.getElementById('appsList') as HTMLElement; 1 + const token = localStorage.getItem("indiko_session"); 2 + const appsList = document.getElementById("appsList") as HTMLElement; 3 3 4 4 if (!token) { 5 - window.location.href = '/login'; 5 + window.location.href = "/login"; 6 6 } 7 7 8 8 interface App { ··· 15 15 16 16 async function loadApps() { 17 17 try { 18 - const response = await fetch('/api/apps', { 18 + const response = await fetch("/api/apps", { 19 19 headers: { 20 - 'Authorization': `Bearer ${token}`, 20 + Authorization: `Bearer ${token}`, 21 21 }, 22 22 }); 23 23 24 24 if (response.status === 401 || response.status === 403) { 25 - localStorage.removeItem('indiko_session'); 26 - window.location.href = '/login'; 25 + localStorage.removeItem("indiko_session"); 26 + window.location.href = "/login"; 27 27 return; 28 28 } 29 29 30 30 if (!response.ok) { 31 - throw new Error('Failed to load apps'); 31 + throw new Error("Failed to load apps"); 32 32 } 33 33 34 34 const data = await response.json(); 35 35 displayApps(data.apps); 36 36 } catch (error) { 37 - console.error('Failed to load apps:', error); 38 - appsList.innerHTML = '<div class="error">Failed to load authorized apps</div>'; 37 + console.error("Failed to load apps:", error); 38 + appsList.innerHTML = 39 + '<div class="error">Failed to load authorized apps</div>'; 39 40 } 40 41 } 41 42 42 43 function displayApps(apps: App[]) { 43 44 if (apps.length === 0) { 44 - appsList.innerHTML = '<div class="empty">No authorized apps yet. Apps will appear here after you grant them access.</div>'; 45 + appsList.innerHTML = 46 + '<div class="empty">No authorized apps yet. Apps will appear here after you grant them access.</div>'; 45 47 return; 46 48 } 47 49 48 - appsList.innerHTML = apps.map((app) => { 49 - const lastUsedDate = new Date(app.lastUsed * 1000).toLocaleDateString(); 50 - const grantedDate = new Date(app.grantedAt * 1000).toLocaleDateString(); 51 - 52 - return ` 50 + appsList.innerHTML = apps 51 + .map((app) => { 52 + const lastUsedDate = new Date(app.lastUsed * 1000).toLocaleDateString(); 53 + const grantedDate = new Date(app.grantedAt * 1000).toLocaleDateString(); 54 + 55 + return ` 53 56 <div class="app-card" data-client-id="${app.clientId}"> 54 57 <div class="app-header"> 55 58 <div> ··· 61 64 <div class="scopes"> 62 65 <div class="scope-title">permissions</div> 63 66 <div class="scope-list"> 64 - ${app.scopes.map(scope => `<span class="scope-badge">${scope}</span>`).join('')} 67 + ${app.scopes.map((scope) => `<span class="scope-badge">${scope}</span>`).join("")} 65 68 </div> 66 69 </div> 67 70 </div> 68 71 `; 69 - }).join(''); 72 + }) 73 + .join(""); 70 74 } 71 75 72 - (window as any).revokeApp = async function(clientId: string, event?: Event) { 76 + (window as any).revokeApp = async (clientId: string, event?: Event) => { 73 77 const btn = event?.target as HTMLButtonElement | undefined; 74 - 78 + 75 79 // Double-click confirmation pattern 76 - if (btn?.dataset.confirmState === 'pending') { 80 + if (btn?.dataset.confirmState === "pending") { 77 81 // Second click - execute revoke 78 82 delete btn.dataset.confirmState; 79 83 btn.disabled = true; 80 - btn.textContent = 'revoking...'; 81 - 84 + btn.textContent = "revoking..."; 85 + 82 86 const card = document.querySelector(`[data-client-id="${clientId}"]`); 83 87 84 88 try { 85 - const response = await fetch(`/api/apps/${encodeURIComponent(clientId)}`, { 86 - method: 'DELETE', 87 - headers: { 88 - 'Authorization': `Bearer ${token}`, 89 + const response = await fetch( 90 + `/api/apps/${encodeURIComponent(clientId)}`, 91 + { 92 + method: "DELETE", 93 + headers: { 94 + Authorization: `Bearer ${token}`, 95 + }, 89 96 }, 90 - }); 97 + ); 91 98 92 99 if (!response.ok) { 93 - throw new Error('Failed to revoke app'); 100 + throw new Error("Failed to revoke app"); 94 101 } 95 102 96 103 // Remove from UI 97 104 card?.remove(); 98 105 99 106 // Check if list is now empty 100 - const remaining = document.querySelectorAll('.app-card'); 107 + const remaining = document.querySelectorAll(".app-card"); 101 108 if (remaining.length === 0) { 102 - appsList.innerHTML = '<div class="empty">No authorized apps yet. Apps will appear here after you grant them access.</div>'; 109 + appsList.innerHTML = 110 + '<div class="empty">No authorized apps yet. Apps will appear here after you grant them access.</div>'; 103 111 } 104 112 } catch (error) { 105 - console.error('Failed to revoke app:', error); 106 - alert('Failed to revoke app access. Please try again.'); 113 + console.error("Failed to revoke app:", error); 114 + alert("Failed to revoke app access. Please try again."); 107 115 if (btn) { 108 116 btn.disabled = false; 109 - btn.textContent = 'revoke'; 117 + btn.textContent = "revoke"; 110 118 } 111 119 } 112 120 } else { 113 121 // First click - set pending state 114 122 if (btn) { 115 123 const originalText = btn.textContent; 116 - btn.dataset.confirmState = 'pending'; 117 - btn.textContent = 'you sure?'; 118 - 124 + btn.dataset.confirmState = "pending"; 125 + btn.textContent = "you sure?"; 126 + 119 127 // Reset after 3 seconds if not confirmed 120 128 setTimeout(() => { 121 - if (btn.dataset.confirmState === 'pending') { 129 + if (btn.dataset.confirmState === "pending") { 122 130 delete btn.dataset.confirmState; 123 131 btn.textContent = originalText; 124 132 }
+257 -201
src/client/docs.ts
··· 1 1 // JSON syntax highlighter 2 2 function highlightJSON(json: string): string { 3 3 return json 4 - .replace(/&/g, '&amp;') 5 - .replace(/</g, '&lt;') 6 - .replace(/>/g, '&gt;') 4 + .replace(/&/g, "&amp;") 5 + .replace(/</g, "&lt;") 6 + .replace(/>/g, "&gt;") 7 7 .replace(/"([^"]+)":/g, '<span class="json-key">"$1"</span>:') 8 8 .replace(/: "([^"]*)"/g, ': <span class="json-string">"$1"</span>') 9 9 .replace(/: (\d+\.?\d*)/g, ': <span class="json-number">$1</span>') ··· 14 14 function highlightHTMLCSS(code: string): string { 15 15 // First escape HTML entities 16 16 let highlighted = code 17 - .replace(/&/g, '&amp;') 18 - .replace(/</g, '&lt;') 19 - .replace(/>/g, '&gt;'); 20 - 17 + .replace(/&/g, "&amp;") 18 + .replace(/</g, "&lt;") 19 + .replace(/>/g, "&gt;"); 20 + 21 21 // HTML comments 22 - highlighted = highlighted.replace(/&lt;!--(.*?)--&gt;/g, '<span class="html-comment">&lt;!--$1--&gt;</span>'); 23 - 22 + highlighted = highlighted.replace( 23 + /&lt;!--(.*?)--&gt;/g, 24 + '<span class="html-comment">&lt;!--$1--&gt;</span>', 25 + ); 26 + 24 27 // Split by <style> tags to handle CSS separately 25 28 const parts = highlighted.split(/(&lt;style&gt;[\s\S]*?&lt;\/style&gt;)/g); 26 - 27 - highlighted = parts.map((part, index) => { 28 - // Even indices are HTML, odd indices are CSS blocks 29 - if (index % 2 === 0) { 30 - // Process HTML 31 - return part.replace(/&lt;(\/?)([\w-]+)([\s\S]*?)&gt;/g, (_match, slash, tag, attrs) => { 32 - let result = `&lt;${slash}<span class="html-tag">${tag}</span>`; 33 - 34 - if (attrs) { 35 - attrs = attrs.replace(/([\w-]+)="([^"]*)"/g, '<span class="html-attr">$1</span>="<span class="html-string">$2</span>"'); 36 - attrs = attrs.replace(/(?<=\s)([\w-]+)(?=\s|$)/g, '<span class="html-attr">$1</span>'); 37 - } 38 - 39 - result += attrs + '&gt;'; 40 - return result; 41 - }); 42 - } else { 43 - // Process CSS (inside <style> tags) 44 - return part 45 - .replace(/&lt;style&gt;/g, '&lt;<span class="html-tag">style</span>&gt;') 46 - .replace(/&lt;\/style&gt;/g, '&lt;/<span class="html-tag">style</span>&gt;') 47 - // CSS selectors (anything before { including pseudo-selectors) 48 - .replace(/^(\s*)([\w.-]+(?::+[\w-]+(?:\([^)]*\))?)*)\s*\{/gm, '$1<span class="css-selector">$2</span> {') 49 - // CSS properties (word followed by colon, but not :: for pseudo-elements) 50 - .replace(/^(\s+)([\w-]+):\s+/gm, '$1<span class="css-property">$2</span>: ') 51 - // CSS values (everything between property: and ;) 52 - .replace(/(<span class="css-property">[\w-]+<\/span>:\s+)([^;]+);/g, (_match, prop, value) => { 53 - const highlightedValue = value 54 - .replace(/(#[0-9a-fA-F]{3,6})/g, '<span class="css-value">$1</span>') 55 - .replace(/([\d.]+(?:px|rem|em|s|%))/g, '<span class="css-value">$1</span>') 56 - .replace(/('.*?')/g, '<span class="css-value">$1</span>') 57 - .replace(/([\w-]+\([^)]*\))/g, '<span class="css-value">$1</span>'); 58 - return `${prop}${highlightedValue};`; 59 - }); 60 - } 61 - }).join(''); 29 + 30 + highlighted = parts 31 + .map((part, index) => { 32 + // Even indices are HTML, odd indices are CSS blocks 33 + if (index % 2 === 0) { 34 + // Process HTML 35 + return part.replace( 36 + /&lt;(\/?)([\w-]+)([\s\S]*?)&gt;/g, 37 + (_match, slash, tag, attrs) => { 38 + let result = `&lt;${slash}<span class="html-tag">${tag}</span>`; 39 + 40 + if (attrs) { 41 + attrs = attrs.replace( 42 + /([\w-]+)="([^"]*)"/g, 43 + '<span class="html-attr">$1</span>="<span class="html-string">$2</span>"', 44 + ); 45 + attrs = attrs.replace( 46 + /(?<=\s)([\w-]+)(?=\s|$)/g, 47 + '<span class="html-attr">$1</span>', 48 + ); 49 + } 50 + 51 + result += attrs + "&gt;"; 52 + return result; 53 + }, 54 + ); 55 + } else { 56 + // Process CSS (inside <style> tags) 57 + return ( 58 + part 59 + .replace( 60 + /&lt;style&gt;/g, 61 + '&lt;<span class="html-tag">style</span>&gt;', 62 + ) 63 + .replace( 64 + /&lt;\/style&gt;/g, 65 + '&lt;/<span class="html-tag">style</span>&gt;', 66 + ) 67 + // CSS selectors (anything before { including pseudo-selectors) 68 + .replace( 69 + /^(\s*)([\w.-]+(?::+[\w-]+(?:\([^)]*\))?)*)\s*\{/gm, 70 + '$1<span class="css-selector">$2</span> {', 71 + ) 72 + // CSS properties (word followed by colon, but not :: for pseudo-elements) 73 + .replace( 74 + /^(\s+)([\w-]+):\s+/gm, 75 + '$1<span class="css-property">$2</span>: ', 76 + ) 77 + // CSS values (everything between property: and ;) 78 + .replace( 79 + /(<span class="css-property">[\w-]+<\/span>:\s+)([^;]+);/g, 80 + (_match, prop, value) => { 81 + const highlightedValue = value 82 + .replace( 83 + /(#[0-9a-fA-F]{3,6})/g, 84 + '<span class="css-value">$1</span>', 85 + ) 86 + .replace( 87 + /([\d.]+(?:px|rem|em|s|%))/g, 88 + '<span class="css-value">$1</span>', 89 + ) 90 + .replace(/('.*?')/g, '<span class="css-value">$1</span>') 91 + .replace( 92 + /([\w-]+\([^)]*\))/g, 93 + '<span class="css-value">$1</span>', 94 + ); 95 + return `${prop}${highlightedValue};`; 96 + }, 97 + ) 98 + ); 99 + } 100 + }) 101 + .join(""); 62 102 63 103 return highlighted; 64 104 } ··· 68 108 const array = new Uint8Array(length); 69 109 crypto.getRandomValues(array); 70 110 return btoa(String.fromCharCode(...array)) 71 - .replace(/\+/g, '-') 72 - .replace(/\//g, '_') 73 - .replace(/=/g, ''); 111 + .replace(/\+/g, "-") 112 + .replace(/\//g, "_") 113 + .replace(/=/g, ""); 74 114 } 75 115 76 116 async function sha256(plain: string): Promise<string> { 77 117 const encoder = new TextEncoder(); 78 118 const data = encoder.encode(plain); 79 - const hash = await crypto.subtle.digest('SHA-256', data); 119 + const hash = await crypto.subtle.digest("SHA-256", data); 80 120 const hashArray = Array.from(new Uint8Array(hash)); 81 121 return btoa(String.fromCharCode(...hashArray)) 82 - .replace(/\+/g, '-') 83 - .replace(/\//g, '_') 84 - .replace(/=/g, ''); 122 + .replace(/\+/g, "-") 123 + .replace(/\//g, "_") 124 + .replace(/=/g, ""); 85 125 } 86 126 87 127 // Elements 88 - const clientIdInput = document.getElementById('clientId') as HTMLInputElement; 89 - const redirectUriInput = document.getElementById('redirectUri') as HTMLInputElement; 90 - const startBtn = document.getElementById('startBtn') as HTMLButtonElement; 91 - const callbackSection = document.getElementById('callbackSection') as HTMLElement; 92 - const callbackInfo = document.getElementById('callbackInfo') as HTMLElement; 93 - const exchangeBtn = document.getElementById('exchangeBtn') as HTMLButtonElement; 94 - const resultSection = document.getElementById('resultSection') as HTMLElement; 95 - const resultDiv = document.getElementById('result') as HTMLElement; 96 - const copyMarkdownBtn = document.getElementById('copyMarkdownBtn') as HTMLButtonElement; 97 - const copyButtonCodeBtn = document.getElementById('copyButtonCode') as HTMLButtonElement; 98 - const demoButton = document.getElementById('demoButton') as HTMLAnchorElement; 99 - const buttonCodeEl = document.getElementById('buttonCode') as HTMLElement; 128 + const clientIdInput = document.getElementById("clientId") as HTMLInputElement; 129 + const redirectUriInput = document.getElementById( 130 + "redirectUri", 131 + ) as HTMLInputElement; 132 + const startBtn = document.getElementById("startBtn") as HTMLButtonElement; 133 + const callbackSection = document.getElementById( 134 + "callbackSection", 135 + ) as HTMLElement; 136 + const callbackInfo = document.getElementById("callbackInfo") as HTMLElement; 137 + const exchangeBtn = document.getElementById("exchangeBtn") as HTMLButtonElement; 138 + const resultSection = document.getElementById("resultSection") as HTMLElement; 139 + const resultDiv = document.getElementById("result") as HTMLElement; 140 + const copyMarkdownBtn = document.getElementById( 141 + "copyMarkdownBtn", 142 + ) as HTMLButtonElement; 143 + const copyButtonCodeBtn = document.getElementById( 144 + "copyButtonCode", 145 + ) as HTMLButtonElement; 146 + const demoButton = document.getElementById("demoButton") as HTMLAnchorElement; 147 + const buttonCodeEl = document.getElementById("buttonCode") as HTMLElement; 100 148 101 149 // Populate and highlight button code 102 150 const buttonCodeRaw = `<!-- Add Google Fonts to your <head> --> ··· 172 220 173 221 // Update documentation examples with current origin 174 222 const origin = window.location.origin; 175 - const authUrlEl = document.getElementById('authUrl'); 176 - const tokenUrlEl = document.getElementById('tokenUrl'); 177 - const profileMeUrlEl = document.getElementById('profileMeUrl'); 223 + const authUrlEl = document.getElementById("authUrl"); 224 + const tokenUrlEl = document.getElementById("tokenUrl"); 225 + const profileMeUrlEl = document.getElementById("profileMeUrl"); 178 226 179 227 if (authUrlEl) authUrlEl.textContent = `${origin}/auth/authorize`; 180 228 if (tokenUrlEl) tokenUrlEl.textContent = `${origin}/auth/token`; ··· 182 230 183 231 // Check if we're handling a callback 184 232 const urlParams = new URLSearchParams(window.location.search); 185 - const code = urlParams.get('code'); 186 - const state = urlParams.get('state'); 187 - const error = urlParams.get('error'); 233 + const code = urlParams.get("code"); 234 + const state = urlParams.get("state"); 235 + const error = urlParams.get("error"); 188 236 189 237 if (error) { 190 238 // OAuth error response 191 - showResult(`Error: ${error}\n${urlParams.get('error_description') || ''}`, 'error'); 192 - resultSection.style.display = 'block'; 239 + showResult( 240 + `Error: ${error}\n${urlParams.get("error_description") || ""}`, 241 + "error", 242 + ); 243 + resultSection.style.display = "block"; 193 244 } else if (code && state) { 194 245 // We have a callback with authorization code 195 246 handleCallback(code, state); 196 247 } 197 248 198 249 // Start OAuth flow 199 - startBtn.addEventListener('click', async () => { 250 + startBtn.addEventListener("click", async () => { 200 251 const clientId = clientIdInput.value.trim(); 201 252 const redirectUri = redirectUriInput.value.trim(); 202 253 203 254 if (!clientId || !redirectUri) { 204 - alert('Please fill in client ID and redirect URI'); 255 + alert("Please fill in client ID and redirect URI"); 205 256 return; 206 257 } 207 258 208 259 // Get selected scopes 209 - const scopeCheckboxes = document.querySelectorAll('input[name="scope"]:checked'); 210 - const scopes = Array.from(scopeCheckboxes).map((cb) => (cb as HTMLInputElement).value); 260 + const scopeCheckboxes = document.querySelectorAll( 261 + 'input[name="scope"]:checked', 262 + ); 263 + const scopes = Array.from(scopeCheckboxes).map( 264 + (cb) => (cb as HTMLInputElement).value, 265 + ); 211 266 212 267 if (scopes.length === 0) { 213 - alert('Please select at least one scope'); 268 + alert("Please select at least one scope"); 214 269 return; 215 270 } 216 271 ··· 220 275 const state = generateRandomString(32); 221 276 222 277 // Store PKCE values in localStorage for callback 223 - localStorage.setItem('oauth_code_verifier', codeVerifier); 224 - localStorage.setItem('oauth_state', state); 225 - localStorage.setItem('oauth_client_id', clientId); 226 - localStorage.setItem('oauth_redirect_uri', redirectUri); 278 + localStorage.setItem("oauth_code_verifier", codeVerifier); 279 + localStorage.setItem("oauth_state", state); 280 + localStorage.setItem("oauth_client_id", clientId); 281 + localStorage.setItem("oauth_redirect_uri", redirectUri); 227 282 228 283 // Build authorization URL 229 - const authUrl = new URL('/auth/authorize', window.location.origin); 230 - authUrl.searchParams.set('response_type', 'code'); 231 - authUrl.searchParams.set('client_id', clientId); 232 - authUrl.searchParams.set('redirect_uri', redirectUri); 233 - authUrl.searchParams.set('state', state); 234 - authUrl.searchParams.set('code_challenge', codeChallenge); 235 - authUrl.searchParams.set('code_challenge_method', 'S256'); 236 - authUrl.searchParams.set('scope', scopes.join(' ')); 284 + const authUrl = new URL("/auth/authorize", window.location.origin); 285 + authUrl.searchParams.set("response_type", "code"); 286 + authUrl.searchParams.set("client_id", clientId); 287 + authUrl.searchParams.set("redirect_uri", redirectUri); 288 + authUrl.searchParams.set("state", state); 289 + authUrl.searchParams.set("code_challenge", codeChallenge); 290 + authUrl.searchParams.set("code_challenge_method", "S256"); 291 + authUrl.searchParams.set("scope", scopes.join(" ")); 237 292 238 293 // Redirect to authorization endpoint 239 294 window.location.href = authUrl.toString(); ··· 241 296 242 297 // Handle OAuth callback 243 298 function handleCallback(code: string, state: string) { 244 - const storedState = localStorage.getItem('oauth_state'); 299 + const storedState = localStorage.getItem("oauth_state"); 245 300 246 301 if (state !== storedState) { 247 - showResult('Error: State mismatch (CSRF attack?)', 'error'); 248 - resultSection.style.display = 'block'; 302 + showResult("Error: State mismatch (CSRF attack?)", "error"); 303 + resultSection.style.display = "block"; 249 304 return; 250 305 } 251 306 252 - callbackSection.style.display = 'block'; 307 + callbackSection.style.display = "block"; 253 308 callbackInfo.innerHTML = ` 254 309 <p style="margin-bottom: 1rem;"><strong>Authorization Code:</strong><br><code style="word-break: break-all;">${code}</code></p> 255 310 <p><strong>State:</strong> <code>${state}</code> ✓ (verified)</p> 256 311 `; 257 312 258 313 // Scroll to callback section 259 - callbackSection.scrollIntoView({ behavior: 'smooth' }); 314 + callbackSection.scrollIntoView({ behavior: "smooth" }); 260 315 } 261 316 262 317 // Exchange authorization code for user profile 263 - exchangeBtn.addEventListener('click', async () => { 264 - const code = urlParams.get('code'); 265 - const codeVerifier = localStorage.getItem('oauth_code_verifier'); 266 - const clientId = localStorage.getItem('oauth_client_id'); 267 - const redirectUri = localStorage.getItem('oauth_redirect_uri'); 318 + exchangeBtn.addEventListener("click", async () => { 319 + const code = urlParams.get("code"); 320 + const codeVerifier = localStorage.getItem("oauth_code_verifier"); 321 + const clientId = localStorage.getItem("oauth_client_id"); 322 + const redirectUri = localStorage.getItem("oauth_redirect_uri"); 268 323 269 324 if (!code || !codeVerifier || !clientId || !redirectUri) { 270 - showResult('Error: Missing OAuth parameters', 'error'); 271 - resultSection.style.display = 'block'; 325 + showResult("Error: Missing OAuth parameters", "error"); 326 + resultSection.style.display = "block"; 272 327 return; 273 328 } 274 329 275 330 exchangeBtn.disabled = true; 276 - exchangeBtn.textContent = 'exchanging...'; 331 + exchangeBtn.textContent = "exchanging..."; 277 332 278 333 try { 279 - const response = await fetch('/auth/token', { 280 - method: 'POST', 334 + const response = await fetch("/auth/token", { 335 + method: "POST", 281 336 headers: { 282 - 'Content-Type': 'application/json', 337 + "Content-Type": "application/json", 283 338 }, 284 339 body: JSON.stringify({ 285 - grant_type: 'authorization_code', 340 + grant_type: "authorization_code", 286 341 code, 287 342 client_id: clientId, 288 343 redirect_uri: redirectUri, ··· 294 349 295 350 if (!response.ok) { 296 351 showResult( 297 - `Error: ${data.error}\n${data.error_description || ''}`, 298 - 'error' 352 + `Error: ${data.error}\n${data.error_description || ""}`, 353 + "error", 299 354 ); 300 355 } else { 301 356 showResult( 302 357 `Success! User authenticated:\n\n${JSON.stringify(data, null, 2)}`, 303 - 'success' 358 + "success", 304 359 ); 305 360 306 361 // Clean up localStorage 307 - localStorage.removeItem('oauth_code_verifier'); 308 - localStorage.removeItem('oauth_state'); 309 - localStorage.removeItem('oauth_client_id'); 310 - localStorage.removeItem('oauth_redirect_uri'); 362 + localStorage.removeItem("oauth_code_verifier"); 363 + localStorage.removeItem("oauth_state"); 364 + localStorage.removeItem("oauth_client_id"); 365 + localStorage.removeItem("oauth_redirect_uri"); 311 366 } 312 367 } catch (error) { 313 - showResult(`Error: ${(error as Error).message}`, 'error'); 368 + showResult(`Error: ${(error as Error).message}`, "error"); 314 369 } finally { 315 370 exchangeBtn.disabled = false; 316 - exchangeBtn.textContent = 'exchange code for profile'; 317 - resultSection.style.display = 'block'; 318 - resultSection.scrollIntoView({ behavior: 'smooth' }); 371 + exchangeBtn.textContent = "exchange code for profile"; 372 + resultSection.style.display = "block"; 373 + resultSection.scrollIntoView({ behavior: "smooth" }); 319 374 } 320 375 }); 321 376 322 - function showResult(text: string, type: 'success' | 'error') { 323 - if (type === 'success' && text.includes('{')) { 377 + function showResult(text: string, type: "success" | "error") { 378 + if (type === "success" && text.includes("{")) { 324 379 // Extract and parse JSON from success message 325 - const jsonStart = text.indexOf('{'); 380 + const jsonStart = text.indexOf("{"); 326 381 const jsonStr = text.substring(jsonStart); 327 382 const prefix = text.substring(0, jsonStart).trim(); 328 - 383 + 329 384 try { 330 385 const data = JSON.parse(jsonStr); 331 386 const formattedJson = JSON.stringify(data, null, 2); 332 - 387 + 333 388 // Apply custom JSON syntax highlighting 334 389 const highlightedJson = highlightJSON(formattedJson); 335 - 390 + 336 391 resultDiv.innerHTML = `<strong style="color: var(--berry-crush); font-size: 1.125rem; display: block; margin-bottom: 1rem;">${prefix}</strong><pre style="margin: 0;"><code>${highlightedJson}</code></pre>`; 337 392 } catch { 338 393 resultDiv.textContent = text; ··· 346 401 // Convert HTML documentation to Markdown by parsing the DOM 347 402 function extractMarkdown(): string { 348 403 const lines: string[] = []; 349 - 404 + 350 405 // Get title and subtitle from header 351 - const h1 = document.querySelector('header h1'); 352 - const subtitle = document.querySelector('header .subtitle'); 353 - 406 + const h1 = document.querySelector("header h1"); 407 + const subtitle = document.querySelector("header .subtitle"); 408 + 354 409 if (h1) { 355 410 lines.push(`# ${h1.textContent}`); 356 - lines.push(''); 411 + lines.push(""); 357 412 } 358 - 413 + 359 414 if (subtitle) { 360 - lines.push(subtitle.textContent || ''); 361 - lines.push(''); 415 + lines.push(subtitle.textContent || ""); 416 + lines.push(""); 362 417 } 363 - 418 + 364 419 // Process each section (skip TOC and OAuth tester) 365 - const sections = document.querySelectorAll('.section'); 366 - 420 + const sections = document.querySelectorAll(".section"); 421 + 367 422 sections.forEach((section) => { 368 423 // Skip the OAuth tester section 369 - if (section.id === 'tester') return; 370 - 424 + if (section.id === "tester") return; 425 + 371 426 processElement(section, lines); 372 - lines.push(''); 427 + lines.push(""); 373 428 }); 374 - 375 - return lines.join('\n'); 429 + 430 + return lines.join("\n"); 376 431 } 377 432 378 433 function processElement(el: Element, lines: string[], indent = 0): void { 379 434 const tag = el.tagName.toLowerCase(); 380 - 435 + 381 436 // Headers 382 - if (tag === 'h2') { 437 + if (tag === "h2") { 383 438 lines.push(`## ${el.textContent}`); 384 - lines.push(''); 385 - } else if (tag === 'h3') { 439 + lines.push(""); 440 + } else if (tag === "h3") { 386 441 lines.push(`### ${el.textContent}`); 387 - lines.push(''); 442 + lines.push(""); 388 443 } 389 444 // Paragraphs 390 - else if (tag === 'p') { 391 - lines.push(el.textContent || ''); 392 - lines.push(''); 445 + else if (tag === "p") { 446 + lines.push(el.textContent || ""); 447 + lines.push(""); 393 448 } 394 449 // Lists 395 - else if (tag === 'ul' || tag === 'ol') { 396 - const items = el.querySelectorAll(':scope > li'); 450 + else if (tag === "ul" || tag === "ol") { 451 + const items = el.querySelectorAll(":scope > li"); 397 452 items.forEach((li, i) => { 398 - const prefix = tag === 'ol' ? `${i + 1}. ` : '- '; 453 + const prefix = tag === "ol" ? `${i + 1}. ` : "- "; 399 454 const text = getTextContent(li); 400 455 lines.push(`${prefix}${text}`); 401 456 }); 402 - lines.push(''); 457 + lines.push(""); 403 458 } 404 459 // Tables 405 - else if (tag === 'table') { 460 + else if (tag === "table") { 406 461 const headers: string[] = []; 407 462 const rows: string[][] = []; 408 - 463 + 409 464 // Get headers 410 - el.querySelectorAll('thead th').forEach((th) => { 411 - headers.push(th.textContent?.trim() || ''); 465 + el.querySelectorAll("thead th").forEach((th) => { 466 + headers.push(th.textContent?.trim() || ""); 412 467 }); 413 - 468 + 414 469 // Get rows 415 - el.querySelectorAll('tbody tr').forEach((tr) => { 470 + el.querySelectorAll("tbody tr").forEach((tr) => { 416 471 const row: string[] = []; 417 - tr.querySelectorAll('td').forEach((td) => { 418 - row.push(td.textContent?.trim() || ''); 472 + tr.querySelectorAll("td").forEach((td) => { 473 + row.push(td.textContent?.trim() || ""); 419 474 }); 420 475 rows.push(row); 421 476 }); 422 - 477 + 423 478 // Format as markdown table 424 479 if (headers.length > 0) { 425 - lines.push(`| ${headers.join(' | ')} |`); 426 - lines.push(`|${headers.map(() => '-------').join('|')}|`); 480 + lines.push(`| ${headers.join(" | ")} |`); 481 + lines.push(`|${headers.map(() => "-------").join("|")}|`); 427 482 rows.forEach((row) => { 428 - lines.push(`| ${row.join(' | ')} |`); 483 + lines.push(`| ${row.join(" | ")} |`); 429 484 }); 430 - lines.push(''); 485 + lines.push(""); 431 486 } 432 487 } 433 488 // Code blocks 434 - else if (tag === 'pre') { 435 - const code = el.querySelector('code'); 489 + else if (tag === "pre") { 490 + const code = el.querySelector("code"); 436 491 if (code) { 437 492 // Detect language from class or content 438 - let lang = ''; 439 - const text = code.textContent || ''; 440 - 441 - if (text.includes('GET ') || text.includes('POST ')) { 442 - lang = 'http'; 443 - } else if (text.includes('{') && text.includes('"')) { 444 - lang = 'json'; 493 + let lang = ""; 494 + const text = code.textContent || ""; 495 + 496 + if (text.includes("GET ") || text.includes("POST ")) { 497 + lang = "http"; 498 + } else if (text.includes("{") && text.includes('"')) { 499 + lang = "json"; 445 500 } 446 - 501 + 447 502 lines.push(`\`\`\`${lang}`); 448 503 lines.push(text.trim()); 449 - lines.push('```'); 450 - lines.push(''); 504 + lines.push("```"); 505 + lines.push(""); 451 506 } 452 507 } 453 508 // Info boxes 454 - else if (el.classList.contains('info-box')) { 455 - const strong = el.querySelector('strong'); 456 - const text = el.textContent?.trim() || ''; 457 - 509 + else if (el.classList.contains("info-box")) { 510 + const strong = el.querySelector("strong"); 511 + const text = el.textContent?.trim() || ""; 512 + 458 513 if (strong) { 459 514 // Extract content after the strong tag 460 - const afterStrong = text.substring(strong.textContent?.length || 0).trim(); 515 + const afterStrong = text 516 + .substring(strong.textContent?.length || 0) 517 + .trim(); 461 518 lines.push(`> **${strong.textContent}** ${afterStrong}`); 462 519 } else { 463 520 lines.push(`> ${text}`); 464 521 } 465 - lines.push(''); 522 + lines.push(""); 466 523 } 467 524 // Process children for sections and divs 468 - else if (tag === 'section' || tag === 'div') { 525 + else if (tag === "section" || tag === "div") { 469 526 Array.from(el.children).forEach((child) => { 470 527 processElement(child, lines, indent); 471 528 }); ··· 474 531 475 532 // Get text content, preserving inline code formatting 476 533 function getTextContent(el: Element): string { 477 - let text = ''; 478 - 534 + let text = ""; 535 + 479 536 el.childNodes.forEach((node) => { 480 537 if (node.nodeType === Node.TEXT_NODE) { 481 538 text += node.textContent; 482 539 } else if (node.nodeType === Node.ELEMENT_NODE) { 483 540 const elem = node as Element; 484 - if (elem.tagName.toLowerCase() === 'code') { 541 + if (elem.tagName.toLowerCase() === "code") { 485 542 text += `\`${elem.textContent}\``; 486 - } else if (elem.tagName.toLowerCase() === 'strong') { 543 + } else if (elem.tagName.toLowerCase() === "strong") { 487 544 text += `**${elem.textContent}**`; 488 545 } else { 489 546 text += elem.textContent; 490 547 } 491 548 } 492 549 }); 493 - 550 + 494 551 return text.trim(); 495 552 } 496 553 497 554 // Copy markdown to clipboard 498 - copyMarkdownBtn.addEventListener('click', async () => { 555 + copyMarkdownBtn.addEventListener("click", async () => { 499 556 const markdown = extractMarkdown(); 500 - 557 + 501 558 try { 502 559 await navigator.clipboard.writeText(markdown); 503 - copyMarkdownBtn.textContent = 'copied! ✓'; 560 + copyMarkdownBtn.textContent = "copied! ✓"; 504 561 setTimeout(() => { 505 - copyMarkdownBtn.textContent = 'copy as markdown'; 562 + copyMarkdownBtn.textContent = "copy as markdown"; 506 563 }, 2000); 507 564 } catch (error) { 508 - console.error('Failed to copy:', error); 509 - alert('Failed to copy to clipboard'); 565 + console.error("Failed to copy:", error); 566 + alert("Failed to copy to clipboard"); 510 567 } 511 568 }); 512 569 513 570 // Copy button code to clipboard 514 - copyButtonCodeBtn.addEventListener('click', async () => { 571 + copyButtonCodeBtn.addEventListener("click", async () => { 515 572 try { 516 573 await navigator.clipboard.writeText(buttonCodeRaw); 517 - copyButtonCodeBtn.textContent = 'copied! ✓'; 574 + copyButtonCodeBtn.textContent = "copied! ✓"; 518 575 setTimeout(() => { 519 - copyButtonCodeBtn.textContent = 'copy button code'; 576 + copyButtonCodeBtn.textContent = "copy button code"; 520 577 }, 2000); 521 578 } catch (error) { 522 - console.error('Failed to copy:', error); 523 - alert('Failed to copy to clipboard'); 579 + console.error("Failed to copy:", error); 580 + alert("Failed to copy to clipboard"); 524 581 } 525 582 }); 526 583 527 584 // Add interactive hover effect to demo button 528 - demoButton.addEventListener('click', (e) => { 585 + demoButton.addEventListener("click", (e) => { 529 586 e.preventDefault(); 530 587 }); 531 -
+111 -100
src/client/index.ts
··· 1 - const token = localStorage.getItem('indiko_session'); 2 - const footer = document.getElementById('footer') as HTMLElement; 3 - const welcome = document.getElementById('welcome') as HTMLElement; 4 - const subtitle = document.getElementById('subtitle') as HTMLElement; 5 - const recentApps = document.getElementById('recentApps') as HTMLElement; 6 - const toast = document.getElementById('toast') as HTMLElement; 1 + const token = localStorage.getItem("indiko_session"); 2 + const footer = document.getElementById("footer") as HTMLElement; 3 + const welcome = document.getElementById("welcome") as HTMLElement; 4 + const subtitle = document.getElementById("subtitle") as HTMLElement; 5 + const recentApps = document.getElementById("recentApps") as HTMLElement; 6 + const toast = document.getElementById("toast") as HTMLElement; 7 7 8 8 // Profile form elements 9 - const profileForm = document.getElementById('profileForm') as HTMLFormElement; 10 - const avatarPreview = document.getElementById('avatarPreview') as HTMLElement; 11 - const usernameInput = document.getElementById('username') as HTMLInputElement; 12 - const nameInput = document.getElementById('name') as HTMLInputElement; 13 - const emailInput = document.getElementById('email') as HTMLInputElement; 14 - const photoInput = document.getElementById('photo') as HTMLInputElement; 15 - const urlInput = document.getElementById('url') as HTMLInputElement; 16 - const saveBtn = document.getElementById('saveBtn') as HTMLButtonElement; 17 - const deleteAccountBtn = document.getElementById('deleteAccountBtn') as HTMLButtonElement; 18 - const dangerZone = document.getElementById('dangerZone') as HTMLElement; 9 + const profileForm = document.getElementById("profileForm") as HTMLFormElement; 10 + const avatarPreview = document.getElementById("avatarPreview") as HTMLElement; 11 + const usernameInput = document.getElementById("username") as HTMLInputElement; 12 + const nameInput = document.getElementById("name") as HTMLInputElement; 13 + const emailInput = document.getElementById("email") as HTMLInputElement; 14 + const photoInput = document.getElementById("photo") as HTMLInputElement; 15 + const urlInput = document.getElementById("url") as HTMLInputElement; 16 + const saveBtn = document.getElementById("saveBtn") as HTMLButtonElement; 17 + const deleteAccountBtn = document.getElementById( 18 + "deleteAccountBtn", 19 + ) as HTMLButtonElement; 20 + const dangerZone = document.getElementById("dangerZone") as HTMLElement; 19 21 20 22 let isAdmin = false; 21 23 22 24 if (!token) { 23 - window.location.href = '/login'; 25 + window.location.href = "/login"; 24 26 } 25 27 26 28 interface App { ··· 40 42 isAdmin?: boolean; 41 43 } 42 44 43 - function showToast(message: string, type: 'success' | 'error' = 'success') { 45 + function showToast(message: string, type: "success" | "error" = "success") { 44 46 toast.textContent = message; 45 47 toast.className = `toast ${type} show`; 46 - 48 + 47 49 setTimeout(() => { 48 - toast.classList.remove('show'); 50 + toast.classList.remove("show"); 49 51 }, 3000); 50 52 } 51 53 ··· 61 63 // Check auth and display user 62 64 async function checkAuth() { 63 65 if (!token) { 64 - window.location.href = '/login'; 66 + window.location.href = "/login"; 65 67 return; 66 68 } 67 69 68 70 try { 69 - const response = await fetch('/api/hello', { 71 + const response = await fetch("/api/hello", { 70 72 headers: { 71 - 'Authorization': `Bearer ${token}`, 73 + Authorization: `Bearer ${token}`, 72 74 }, 73 75 }); 74 76 75 77 if (response.status === 401 || response.status === 403) { 76 - localStorage.removeItem('indiko_session'); 77 - window.location.href = '/login'; 78 + localStorage.removeItem("indiko_session"); 79 + window.location.href = "/login"; 78 80 return; 79 81 } 80 82 81 83 const data = await response.json(); 82 - 84 + 83 85 // Update welcome message 84 86 welcome.textContent = `welcome, ${data.username}`; 85 - subtitle.textContent = 'your identity dashboard'; 87 + subtitle.textContent = "your identity dashboard"; 86 88 87 89 // Build footer with conditional admin link 88 - const adminLink = data.isAdmin ? ' • <a href="/admin">admin</a>' : ''; 90 + const adminLink = data.isAdmin ? ' • <a href="/admin">admin</a>' : ""; 89 91 footer.innerHTML = `signed in as <strong><a href="/u/${data.username}">${data.username}</a></strong> • <a href="/apps">apps</a> • <a href="/docs">docs</a>${adminLink} • <a href="/login" id="logoutLink">sign out</a>`; 90 92 91 93 // Handle logout 92 - document.getElementById('logoutLink')?.addEventListener('click', async (e) => { 93 - e.preventDefault(); 94 - try { 95 - await fetch('/auth/logout', { 96 - method: 'POST', 97 - headers: { 98 - 'Authorization': `Bearer ${token}`, 99 - }, 100 - }); 101 - } catch { 102 - // Ignore logout errors 103 - } 104 - localStorage.removeItem('indiko_session'); 105 - window.location.href = '/login'; 106 - }); 94 + document 95 + .getElementById("logoutLink") 96 + ?.addEventListener("click", async (e) => { 97 + e.preventDefault(); 98 + try { 99 + await fetch("/auth/logout", { 100 + method: "POST", 101 + headers: { 102 + Authorization: `Bearer ${token}`, 103 + }, 104 + }); 105 + } catch { 106 + // Ignore logout errors 107 + } 108 + localStorage.removeItem("indiko_session"); 109 + window.location.href = "/login"; 110 + }); 107 111 108 112 // Load profile and apps 109 113 loadProfile(); 110 114 loadRecentApps(); 111 115 } catch (error) { 112 - console.error('Auth check failed:', error); 113 - footer.textContent = 'error loading user info'; 116 + console.error("Auth check failed:", error); 117 + footer.textContent = "error loading user info"; 114 118 } 115 119 } 116 120 117 121 async function loadProfile() { 118 122 try { 119 - const response = await fetch('/api/profile', { 123 + const response = await fetch("/api/profile", { 120 124 headers: { 121 - 'Authorization': `Bearer ${token}`, 125 + Authorization: `Bearer ${token}`, 122 126 }, 123 127 }); 124 128 125 129 if (!response.ok) { 126 - throw new Error('Failed to load profile'); 130 + throw new Error("Failed to load profile"); 127 131 } 128 132 129 - const profile = await response.json() as Profile; 133 + const profile = (await response.json()) as Profile; 130 134 131 135 // Track admin status to hide delete button for admins 132 136 isAdmin = profile.isAdmin || false; 133 137 if (!isAdmin) { 134 - dangerZone.style.display = 'block'; 138 + dangerZone.style.display = "block"; 135 139 } 136 140 137 141 // Populate form 138 142 usernameInput.value = profile.username; 139 - nameInput.value = profile.name || ''; 140 - emailInput.value = profile.email || ''; 141 - photoInput.value = profile.photo || ''; 142 - urlInput.value = profile.url || ''; 143 + nameInput.value = profile.name || ""; 144 + emailInput.value = profile.email || ""; 145 + photoInput.value = profile.photo || ""; 146 + urlInput.value = profile.url || ""; 143 147 144 148 updateAvatarPreview(profile.photo, profile.username); 145 149 146 150 // Update avatar preview when photo URL changes 147 - photoInput.addEventListener('input', () => { 151 + photoInput.addEventListener("input", () => { 148 152 updateAvatarPreview(photoInput.value || null, profile.username); 149 153 }); 150 154 } catch (error) { 151 - console.error('Failed to load profile:', error); 152 - showToast('Failed to load profile', 'error'); 155 + console.error("Failed to load profile:", error); 156 + showToast("Failed to load profile", "error"); 153 157 } 154 158 } 155 159 156 160 async function loadRecentApps() { 157 161 try { 158 - const response = await fetch('/api/apps', { 162 + const response = await fetch("/api/apps", { 159 163 headers: { 160 - 'Authorization': `Bearer ${token}`, 164 + Authorization: `Bearer ${token}`, 161 165 }, 162 166 }); 163 167 164 168 if (!response.ok) { 165 - throw new Error('Failed to load apps'); 169 + throw new Error("Failed to load apps"); 166 170 } 167 171 168 172 const data = await response.json(); 169 173 const apps = data.apps as App[]; 170 - 174 + 171 175 if (apps.length === 0) { 172 176 recentApps.innerHTML = '<div class="empty">No authorized apps yet</div>'; 173 177 return; ··· 175 179 176 180 // Show top 7 most recent 177 181 const recent = apps.slice(0, 7); 178 - 179 - recentApps.innerHTML = recent.map((app) => { 180 - const lastUsedDate = new Date(app.lastUsed * 1000).toLocaleDateString(); 181 - 182 - return ` 182 + 183 + recentApps.innerHTML = recent 184 + .map((app) => { 185 + const lastUsedDate = new Date(app.lastUsed * 1000).toLocaleDateString(); 186 + 187 + return ` 183 188 <div class="app-item"> 184 189 <div class="app-name">${app.name}</div> 185 190 <div class="app-date">${lastUsedDate}</div> 186 191 </div> 187 192 `; 188 - }).join(''); 193 + }) 194 + .join(""); 189 195 190 196 if (apps.length > 7) { 191 - recentApps.innerHTML += '<a href="/apps" class="view-all">view all apps →</a>'; 197 + recentApps.innerHTML += 198 + '<a href="/apps" class="view-all">view all apps →</a>'; 192 199 } 193 200 } catch (error) { 194 - console.error('Failed to load apps:', error); 201 + console.error("Failed to load apps:", error); 195 202 recentApps.innerHTML = '<div class="empty">Failed to load apps</div>'; 196 203 } 197 204 } 198 205 199 206 // Profile form submission 200 - profileForm.addEventListener('submit', async (e) => { 207 + profileForm.addEventListener("submit", async (e) => { 201 208 e.preventDefault(); 202 209 203 210 saveBtn.disabled = true; 204 - saveBtn.textContent = 'saving...'; 211 + saveBtn.textContent = "saving..."; 205 212 206 213 try { 207 - const response = await fetch('/api/profile', { 208 - method: 'PUT', 214 + const response = await fetch("/api/profile", { 215 + method: "PUT", 209 216 headers: { 210 - 'Authorization': `Bearer ${token}`, 211 - 'Content-Type': 'application/json', 217 + Authorization: `Bearer ${token}`, 218 + "Content-Type": "application/json", 212 219 }, 213 220 body: JSON.stringify({ 214 221 name: nameInput.value, ··· 220 227 221 228 if (!response.ok) { 222 229 const error = await response.json(); 223 - throw new Error(error.error || 'Failed to update profile'); 230 + throw new Error(error.error || "Failed to update profile"); 224 231 } 225 232 226 - showToast('Profile updated successfully!', 'success'); 233 + showToast("Profile updated successfully!", "success"); 227 234 } catch (error) { 228 - showToast((error as Error).message || 'Failed to update profile', 'error'); 235 + showToast((error as Error).message || "Failed to update profile", "error"); 229 236 } finally { 230 237 saveBtn.disabled = false; 231 - saveBtn.textContent = 'save changes'; 238 + saveBtn.textContent = "save changes"; 232 239 } 233 240 }); 234 241 235 242 // Delete account handler 236 - deleteAccountBtn.addEventListener('click', async () => { 237 - const confirmMessage = 'Are you absolutely sure you want to delete your account?\n\n' + 238 - 'This will permanently delete:\n' + 239 - '• Your profile and credentials\n' + 240 - '• All authorized apps\n' + 241 - '• All active sessions\n\n' + 242 - 'This action CANNOT be undone.\n\n' + 243 + deleteAccountBtn.addEventListener("click", async () => { 244 + const confirmMessage = 245 + "Are you absolutely sure you want to delete your account?\n\n" + 246 + "This will permanently delete:\n" + 247 + "• Your profile and credentials\n" + 248 + "• All authorized apps\n" + 249 + "• All active sessions\n\n" + 250 + "This action CANNOT be undone.\n\n" + 243 251 'Type "DELETE" to confirm:'; 244 - 252 + 245 253 const confirmation = prompt(confirmMessage); 246 - 247 - if (confirmation !== 'DELETE') { 254 + 255 + if (confirmation !== "DELETE") { 248 256 if (confirmation !== null) { 249 - showToast('Account deletion cancelled. You must type "DELETE" exactly.', 'error'); 257 + showToast( 258 + 'Account deletion cancelled. You must type "DELETE" exactly.', 259 + "error", 260 + ); 250 261 } 251 262 return; 252 263 } 253 264 254 265 deleteAccountBtn.disabled = true; 255 - deleteAccountBtn.textContent = 'deleting...'; 266 + deleteAccountBtn.textContent = "deleting..."; 256 267 257 268 try { 258 - const response = await fetch('/api/profile', { 259 - method: 'DELETE', 269 + const response = await fetch("/api/profile", { 270 + method: "DELETE", 260 271 headers: { 261 - 'Authorization': `Bearer ${token}`, 272 + Authorization: `Bearer ${token}`, 262 273 }, 263 274 }); 264 275 265 276 if (!response.ok) { 266 277 const error = await response.json(); 267 - throw new Error(error.error || 'Failed to delete account'); 278 + throw new Error(error.error || "Failed to delete account"); 268 279 } 269 280 270 281 // Clear session and redirect 271 - localStorage.removeItem('indiko_session'); 272 - showToast('Account deleted successfully. Redirecting...', 'success'); 282 + localStorage.removeItem("indiko_session"); 283 + showToast("Account deleted successfully. Redirecting...", "success"); 273 284 setTimeout(() => { 274 - window.location.href = '/login'; 285 + window.location.href = "/login"; 275 286 }, 2000); 276 287 } catch (error) { 277 - showToast((error as Error).message || 'Failed to delete account', 'error'); 288 + showToast((error as Error).message || "Failed to delete account", "error"); 278 289 deleteAccountBtn.disabled = false; 279 - deleteAccountBtn.textContent = 'delete my account'; 290 + deleteAccountBtn.textContent = "delete my account"; 280 291 } 281 292 }); 282 293
+103 -80
src/client/login.ts
··· 1 - import { startAuthentication, startRegistration } from '@simplewebauthn/browser'; 1 + import { 2 + startAuthentication, 3 + startRegistration, 4 + } from "@simplewebauthn/browser"; 2 5 3 - const loginForm = document.getElementById('loginForm') as HTMLFormElement; 4 - const registerForm = document.getElementById('registerForm') as HTMLFormElement; 5 - const message = document.getElementById('message') as HTMLDivElement; 6 + const loginForm = document.getElementById("loginForm") as HTMLFormElement; 7 + const registerForm = document.getElementById("registerForm") as HTMLFormElement; 8 + const message = document.getElementById("message") as HTMLDivElement; 6 9 7 10 // Check if registration is allowed on page load 8 11 async function checkRegistrationAllowed() { 9 12 try { 10 13 // Check for invite code in URL 11 14 const urlParams = new URLSearchParams(window.location.search); 12 - const inviteCode = urlParams.get('invite'); 15 + const inviteCode = urlParams.get("invite"); 13 16 14 17 if (inviteCode) { 15 18 // Fetch invite details to show message 16 19 try { 17 - const response = await fetch('/auth/register/options', { 18 - method: 'POST', 19 - headers: {'Content-Type': 'application/json'}, 20 - body: JSON.stringify({username: 'temp', inviteCode}) 20 + const response = await fetch("/auth/register/options", { 21 + method: "POST", 22 + headers: { "Content-Type": "application/json" }, 23 + body: JSON.stringify({ username: "temp", inviteCode }), 21 24 }); 22 25 23 26 if (response.ok) { 24 27 const data = await response.json(); 25 28 if (data.inviteMessage) { 26 - showMessage(data.inviteMessage, 'success', true); 29 + showMessage(data.inviteMessage, "success", true); 27 30 } 28 31 } 29 32 } catch { ··· 31 34 } 32 35 33 36 // Show registration form with invite 34 - const subtitleElement = document.querySelector('.subtitle'); 37 + const subtitleElement = document.querySelector(".subtitle"); 35 38 if (subtitleElement) { 36 - subtitleElement.textContent = 'create your account'; 39 + subtitleElement.textContent = "create your account"; 37 40 } 38 - (document.getElementById('registerUsername') as HTMLInputElement).placeholder = 'choose username'; 39 - (document.getElementById('registerBtn') as HTMLButtonElement).textContent = 'create account'; 40 - loginForm.style.display = 'none'; 41 - registerForm.style.display = 'block'; 41 + ( 42 + document.getElementById("registerUsername") as HTMLInputElement 43 + ).placeholder = "choose username"; 44 + ( 45 + document.getElementById("registerBtn") as HTMLButtonElement 46 + ).textContent = "create account"; 47 + loginForm.style.display = "none"; 48 + registerForm.style.display = "block"; 42 49 return; 43 50 } 44 51 45 - const response = await fetch('/auth/can-register'); 46 - const {canRegister} = await response.json(); 52 + const response = await fetch("/auth/can-register"); 53 + const { canRegister } = await response.json(); 47 54 48 55 if (canRegister) { 49 56 // First user - show as admin registration 50 - const subtitleElement = document.querySelector('.subtitle'); 57 + const subtitleElement = document.querySelector(".subtitle"); 51 58 if (subtitleElement) { 52 - subtitleElement.textContent = 'create admin account'; 59 + subtitleElement.textContent = "create admin account"; 53 60 } 54 - (document.getElementById('registerUsername') as HTMLInputElement).placeholder = 'admin username'; 55 - (document.getElementById('registerBtn') as HTMLButtonElement).textContent = 'create admin account'; 61 + ( 62 + document.getElementById("registerUsername") as HTMLInputElement 63 + ).placeholder = "admin username"; 64 + ( 65 + document.getElementById("registerBtn") as HTMLButtonElement 66 + ).textContent = "create admin account"; 56 67 // Hide login form for first setup 57 - loginForm.style.display = 'none'; 58 - registerForm.style.display = 'block'; 68 + loginForm.style.display = "none"; 69 + registerForm.style.display = "block"; 59 70 } 60 71 } catch (error) { 61 - console.error('Failed to check registration status:', error); 72 + console.error("Failed to check registration status:", error); 62 73 } 63 74 } 64 75 65 76 checkRegistrationAllowed(); 66 77 67 - function showMessage(text: string, type: 'error' | 'success' = 'error', persist = false) { 78 + function showMessage( 79 + text: string, 80 + type: "error" | "success" = "error", 81 + persist = false, 82 + ) { 68 83 message.textContent = text; 69 84 message.className = `message show ${type}`; 70 85 if (!persist) { 71 - setTimeout(() => message.classList.remove('show'), 5000); 86 + setTimeout(() => message.classList.remove("show"), 5000); 72 87 } 73 88 } 74 89 75 90 // Login flow 76 - loginForm.addEventListener('submit', async (e) => { 91 + loginForm.addEventListener("submit", async (e) => { 77 92 e.preventDefault(); 78 - const username = (document.getElementById('username') as HTMLInputElement).value; 79 - const loginBtn = document.getElementById('loginBtn') as HTMLButtonElement; 93 + const username = (document.getElementById("username") as HTMLInputElement) 94 + .value; 95 + const loginBtn = document.getElementById("loginBtn") as HTMLButtonElement; 80 96 81 97 try { 82 98 loginBtn.disabled = true; 83 - loginBtn.textContent = 'preparing...'; 99 + loginBtn.textContent = "preparing..."; 84 100 85 101 // Get authentication options 86 - const optionsRes = await fetch('/auth/login/options', { 87 - method: 'POST', 88 - headers: {'Content-Type': 'application/json'}, 89 - body: JSON.stringify({username}) 102 + const optionsRes = await fetch("/auth/login/options", { 103 + method: "POST", 104 + headers: { "Content-Type": "application/json" }, 105 + body: JSON.stringify({ username }), 90 106 }); 91 107 92 108 if (!optionsRes.ok) { 93 109 const error = await optionsRes.json(); 94 - throw new Error(error.error || 'Failed to get auth options'); 110 + throw new Error(error.error || "Failed to get auth options"); 95 111 } 96 112 97 113 const options = await optionsRes.json(); 98 114 99 - loginBtn.textContent = 'use your passkey...'; 115 + loginBtn.textContent = "use your passkey..."; 100 116 101 117 // Start authentication 102 118 const authResponse = await startAuthentication(options); 103 119 104 - loginBtn.textContent = 'verifying...'; 120 + loginBtn.textContent = "verifying..."; 105 121 106 122 // Verify authentication 107 - const verifyRes = await fetch('/auth/login/verify', { 108 - method: 'POST', 109 - headers: {'Content-Type': 'application/json'}, 110 - body: JSON.stringify({username, response: authResponse}) 123 + const verifyRes = await fetch("/auth/login/verify", { 124 + method: "POST", 125 + headers: { "Content-Type": "application/json" }, 126 + body: JSON.stringify({ username, response: authResponse }), 111 127 }); 112 128 113 129 if (!verifyRes.ok) { 114 130 const error = await verifyRes.json(); 115 - throw new Error(error.error || 'Authentication failed'); 131 + throw new Error(error.error || "Authentication failed"); 116 132 } 117 133 118 - const {token} = await verifyRes.json(); 119 - localStorage.setItem('indiko_session', token); 134 + const { token } = await verifyRes.json(); 135 + localStorage.setItem("indiko_session", token); 120 136 121 - showMessage('Login successful!', 'success'); 122 - 137 + showMessage("Login successful!", "success"); 138 + 123 139 // Check for return URL parameter 124 140 const urlParams = new URLSearchParams(window.location.search); 125 - const returnUrl = urlParams.get('return') || '/'; 126 - 141 + const returnUrl = urlParams.get("return") || "/"; 142 + 127 143 const redirectTimer = setTimeout(() => { 128 144 window.location.href = returnUrl; 129 145 }, 1000); 130 - (redirectTimer as unknown as number); 131 - 146 + redirectTimer as unknown as number; 132 147 } catch (error) { 133 - showMessage((error as Error).message || 'Authentication failed'); 148 + showMessage((error as Error).message || "Authentication failed"); 134 149 loginBtn.disabled = false; 135 - loginBtn.textContent = 'sign in'; 150 + loginBtn.textContent = "sign in"; 136 151 } 137 152 }); 138 153 139 154 // Registration flow 140 - registerForm.addEventListener('submit', async (e) => { 155 + registerForm.addEventListener("submit", async (e) => { 141 156 e.preventDefault(); 142 - const username = (document.getElementById('registerUsername') as HTMLInputElement).value; 143 - const registerBtn = document.getElementById('registerBtn') as HTMLButtonElement; 157 + const username = ( 158 + document.getElementById("registerUsername") as HTMLInputElement 159 + ).value; 160 + const registerBtn = document.getElementById( 161 + "registerBtn", 162 + ) as HTMLButtonElement; 144 163 145 164 try { 146 165 registerBtn.disabled = true; 147 - registerBtn.textContent = 'preparing...'; 166 + registerBtn.textContent = "preparing..."; 148 167 149 168 // Get invite code from URL if present 150 169 const urlParams = new URLSearchParams(window.location.search); 151 - const inviteCode = urlParams.get('invite'); 170 + const inviteCode = urlParams.get("invite"); 152 171 153 172 // Get registration options 154 - const optionsRes = await fetch('/auth/register/options', { 155 - method: 'POST', 156 - headers: {'Content-Type': 'application/json'}, 157 - body: JSON.stringify({username, inviteCode}) 173 + const optionsRes = await fetch("/auth/register/options", { 174 + method: "POST", 175 + headers: { "Content-Type": "application/json" }, 176 + body: JSON.stringify({ username, inviteCode }), 158 177 }); 159 178 160 179 if (!optionsRes.ok) { 161 180 const error = await optionsRes.json(); 162 - throw new Error(error.error || 'Failed to get registration options'); 181 + throw new Error(error.error || "Failed to get registration options"); 163 182 } 164 183 165 184 const options = await optionsRes.json(); 166 185 167 - registerBtn.textContent = 'create your passkey...'; 186 + registerBtn.textContent = "create your passkey..."; 168 187 169 188 // Start registration 170 189 const regResponse = await startRegistration(options); 171 190 172 - registerBtn.textContent = 'verifying...'; 191 + registerBtn.textContent = "verifying..."; 173 192 174 193 // Verify registration 175 - const verifyRes = await fetch('/auth/register/verify', { 176 - method: 'POST', 177 - headers: {'Content-Type': 'application/json'}, 178 - body: JSON.stringify({username, response: regResponse, challenge: options.challenge, inviteCode}) 194 + const verifyRes = await fetch("/auth/register/verify", { 195 + method: "POST", 196 + headers: { "Content-Type": "application/json" }, 197 + body: JSON.stringify({ 198 + username, 199 + response: regResponse, 200 + challenge: options.challenge, 201 + inviteCode, 202 + }), 179 203 }); 180 204 181 205 if (!verifyRes.ok) { 182 206 const error = await verifyRes.json(); 183 - throw new Error(error.error || 'Registration failed'); 207 + throw new Error(error.error || "Registration failed"); 184 208 } 185 209 186 - const {token} = await verifyRes.json(); 187 - localStorage.setItem('indiko_session', token); 210 + const { token } = await verifyRes.json(); 211 + localStorage.setItem("indiko_session", token); 212 + 213 + showMessage("Registration successful!", "success"); 188 214 189 - showMessage('Registration successful!', 'success'); 190 - 191 215 // Check for return URL parameter 192 - const returnUrl = urlParams.get('return') || '/'; 193 - 216 + const returnUrl = urlParams.get("return") || "/"; 217 + 194 218 const redirectTimer = setTimeout(() => { 195 219 window.location.href = returnUrl; 196 220 }, 1000); 197 - (redirectTimer as unknown as number); 198 - 221 + redirectTimer as unknown as number; 199 222 } catch (error) { 200 - showMessage((error as Error).message || 'Registration failed'); 223 + showMessage((error as Error).message || "Registration failed"); 201 224 registerBtn.disabled = false; 202 - registerBtn.textContent = 'register passkey'; 225 + registerBtn.textContent = "register passkey"; 203 226 } 204 227 });
+97 -83
src/client/oauth-test.ts
··· 3 3 const array = new Uint8Array(length); 4 4 crypto.getRandomValues(array); 5 5 return btoa(String.fromCharCode(...array)) 6 - .replace(/\+/g, '-') 7 - .replace(/\//g, '_') 8 - .replace(/=/g, ''); 6 + .replace(/\+/g, "-") 7 + .replace(/\//g, "_") 8 + .replace(/=/g, ""); 9 9 } 10 10 11 11 async function sha256(plain: string): Promise<string> { 12 12 const encoder = new TextEncoder(); 13 13 const data = encoder.encode(plain); 14 - const hash = await crypto.subtle.digest('SHA-256', data); 14 + const hash = await crypto.subtle.digest("SHA-256", data); 15 15 const hashArray = Array.from(new Uint8Array(hash)); 16 16 return btoa(String.fromCharCode(...hashArray)) 17 - .replace(/\+/g, '-') 18 - .replace(/\//g, '_') 19 - .replace(/=/g, ''); 17 + .replace(/\+/g, "-") 18 + .replace(/\//g, "_") 19 + .replace(/=/g, ""); 20 20 } 21 21 22 22 // Elements 23 - const clientIdInput = document.getElementById('clientId') as HTMLInputElement; 24 - const redirectUriInput = document.getElementById('redirectUri') as HTMLInputElement; 25 - const startBtn = document.getElementById('startBtn') as HTMLButtonElement; 26 - const callbackSection = document.getElementById('callbackSection') as HTMLElement; 27 - const callbackInfo = document.getElementById('callbackInfo') as HTMLElement; 28 - const exchangeBtn = document.getElementById('exchangeBtn') as HTMLButtonElement; 29 - const resultSection = document.getElementById('resultSection') as HTMLElement; 30 - const resultDiv = document.getElementById('result') as HTMLElement; 23 + const clientIdInput = document.getElementById("clientId") as HTMLInputElement; 24 + const redirectUriInput = document.getElementById( 25 + "redirectUri", 26 + ) as HTMLInputElement; 27 + const startBtn = document.getElementById("startBtn") as HTMLButtonElement; 28 + const callbackSection = document.getElementById( 29 + "callbackSection", 30 + ) as HTMLElement; 31 + const callbackInfo = document.getElementById("callbackInfo") as HTMLElement; 32 + const exchangeBtn = document.getElementById("exchangeBtn") as HTMLButtonElement; 33 + const resultSection = document.getElementById("resultSection") as HTMLElement; 34 + const resultDiv = document.getElementById("result") as HTMLElement; 31 35 32 36 // Auto-fill redirect URI with current page URL 33 37 const currentUrl = window.location.origin + window.location.pathname; ··· 38 42 39 43 // Check if we're handling a callback 40 44 const urlParams = new URLSearchParams(window.location.search); 41 - const code = urlParams.get('code'); 42 - const state = urlParams.get('state'); 43 - const error = urlParams.get('error'); 45 + const code = urlParams.get("code"); 46 + const state = urlParams.get("state"); 47 + const error = urlParams.get("error"); 44 48 45 49 if (error) { 46 50 // OAuth error response 47 - showResult(`Error: ${error}\n${urlParams.get('error_description') || ''}`, 'error'); 48 - resultSection.style.display = 'block'; 51 + showResult( 52 + `Error: ${error}\n${urlParams.get("error_description") || ""}`, 53 + "error", 54 + ); 55 + resultSection.style.display = "block"; 49 56 } else if (code && state) { 50 57 // We have a callback with authorization code 51 58 handleCallback(code, state); 52 59 } 53 60 54 61 // Start OAuth flow 55 - startBtn.addEventListener('click', async () => { 62 + startBtn.addEventListener("click", async () => { 56 63 const clientId = clientIdInput.value.trim(); 57 64 const redirectUri = redirectUriInput.value.trim(); 58 65 59 66 if (!clientId || !redirectUri) { 60 - alert('Please fill in client ID and redirect URI'); 67 + alert("Please fill in client ID and redirect URI"); 61 68 return; 62 69 } 63 70 64 71 // Get selected scopes 65 - const scopeCheckboxes = document.querySelectorAll('input[name="scope"]:checked'); 66 - const scopes = Array.from(scopeCheckboxes).map((cb) => (cb as HTMLInputElement).value); 72 + const scopeCheckboxes = document.querySelectorAll( 73 + 'input[name="scope"]:checked', 74 + ); 75 + const scopes = Array.from(scopeCheckboxes).map( 76 + (cb) => (cb as HTMLInputElement).value, 77 + ); 67 78 68 79 if (scopes.length === 0) { 69 - alert('Please select at least one scope'); 80 + alert("Please select at least one scope"); 70 81 return; 71 82 } 72 83 ··· 76 87 const state = generateRandomString(32); 77 88 78 89 // Store PKCE values in localStorage for callback 79 - localStorage.setItem('oauth_code_verifier', codeVerifier); 80 - localStorage.setItem('oauth_state', state); 81 - localStorage.setItem('oauth_client_id', clientId); 82 - localStorage.setItem('oauth_redirect_uri', redirectUri); 90 + localStorage.setItem("oauth_code_verifier", codeVerifier); 91 + localStorage.setItem("oauth_state", state); 92 + localStorage.setItem("oauth_client_id", clientId); 93 + localStorage.setItem("oauth_redirect_uri", redirectUri); 83 94 84 95 // Build authorization URL 85 - const authUrl = new URL('/auth/authorize', window.location.origin); 86 - authUrl.searchParams.set('response_type', 'code'); 87 - authUrl.searchParams.set('client_id', clientId); 88 - authUrl.searchParams.set('redirect_uri', redirectUri); 89 - authUrl.searchParams.set('state', state); 90 - authUrl.searchParams.set('code_challenge', codeChallenge); 91 - authUrl.searchParams.set('code_challenge_method', 'S256'); 92 - authUrl.searchParams.set('scope', scopes.join(' ')); 96 + const authUrl = new URL("/auth/authorize", window.location.origin); 97 + authUrl.searchParams.set("response_type", "code"); 98 + authUrl.searchParams.set("client_id", clientId); 99 + authUrl.searchParams.set("redirect_uri", redirectUri); 100 + authUrl.searchParams.set("state", state); 101 + authUrl.searchParams.set("code_challenge", codeChallenge); 102 + authUrl.searchParams.set("code_challenge_method", "S256"); 103 + authUrl.searchParams.set("scope", scopes.join(" ")); 93 104 94 105 // Redirect to authorization endpoint 95 106 window.location.href = authUrl.toString(); ··· 97 108 98 109 // Handle OAuth callback 99 110 function handleCallback(code: string, state: string) { 100 - const storedState = localStorage.getItem('oauth_state'); 111 + const storedState = localStorage.getItem("oauth_state"); 101 112 102 113 if (state !== storedState) { 103 - showResult('Error: State mismatch (CSRF attack?)', 'error'); 104 - resultSection.style.display = 'block'; 114 + showResult("Error: State mismatch (CSRF attack?)", "error"); 115 + resultSection.style.display = "block"; 105 116 return; 106 117 } 107 118 108 - callbackSection.style.display = 'block'; 119 + callbackSection.style.display = "block"; 109 120 callbackInfo.innerHTML = ` 110 121 <p style="margin-bottom: 1rem;"><strong>Authorization Code:</strong><br><code style="word-break: break-all;">${code}</code></p> 111 122 <p><strong>State:</strong> <code>${state}</code> ✓ (verified)</p> 112 123 `; 113 124 114 125 // Scroll to callback section 115 - callbackSection.scrollIntoView({ behavior: 'smooth' }); 126 + callbackSection.scrollIntoView({ behavior: "smooth" }); 116 127 } 117 128 118 129 // Exchange authorization code for user profile 119 - exchangeBtn.addEventListener('click', async () => { 120 - const code = urlParams.get('code'); 121 - const codeVerifier = localStorage.getItem('oauth_code_verifier'); 122 - const clientId = localStorage.getItem('oauth_client_id'); 123 - const redirectUri = localStorage.getItem('oauth_redirect_uri'); 130 + exchangeBtn.addEventListener("click", async () => { 131 + const code = urlParams.get("code"); 132 + const codeVerifier = localStorage.getItem("oauth_code_verifier"); 133 + const clientId = localStorage.getItem("oauth_client_id"); 134 + const redirectUri = localStorage.getItem("oauth_redirect_uri"); 124 135 125 136 if (!code || !codeVerifier || !clientId || !redirectUri) { 126 - showResult('Error: Missing OAuth parameters', 'error'); 127 - resultSection.style.display = 'block'; 137 + showResult("Error: Missing OAuth parameters", "error"); 138 + resultSection.style.display = "block"; 128 139 return; 129 140 } 130 141 131 142 exchangeBtn.disabled = true; 132 - exchangeBtn.textContent = 'exchanging...'; 143 + exchangeBtn.textContent = "exchanging..."; 133 144 134 145 try { 135 - const response = await fetch('/auth/token', { 136 - method: 'POST', 146 + const response = await fetch("/auth/token", { 147 + method: "POST", 137 148 headers: { 138 - 'Content-Type': 'application/json', 149 + "Content-Type": "application/json", 139 150 }, 140 151 body: JSON.stringify({ 141 - grant_type: 'authorization_code', 152 + grant_type: "authorization_code", 142 153 code, 143 154 client_id: clientId, 144 155 redirect_uri: redirectUri, ··· 150 161 151 162 if (!response.ok) { 152 163 showResult( 153 - `Error: ${data.error}\n${data.error_description || ''}`, 154 - 'error' 164 + `Error: ${data.error}\n${data.error_description || ""}`, 165 + "error", 155 166 ); 156 167 } else { 157 168 showResult( 158 169 `Success! User authenticated:\n\n${JSON.stringify(data, null, 2)}`, 159 - 'success' 170 + "success", 160 171 ); 161 172 162 173 // Clean up localStorage 163 - localStorage.removeItem('oauth_code_verifier'); 164 - localStorage.removeItem('oauth_state'); 165 - localStorage.removeItem('oauth_client_id'); 166 - localStorage.removeItem('oauth_redirect_uri'); 174 + localStorage.removeItem("oauth_code_verifier"); 175 + localStorage.removeItem("oauth_state"); 176 + localStorage.removeItem("oauth_client_id"); 177 + localStorage.removeItem("oauth_redirect_uri"); 167 178 } 168 179 } catch (error) { 169 - showResult(`Error: ${(error as Error).message}`, 'error'); 180 + showResult(`Error: ${(error as Error).message}`, "error"); 170 181 } finally { 171 182 exchangeBtn.disabled = false; 172 - exchangeBtn.textContent = 'exchange code for profile'; 173 - resultSection.style.display = 'block'; 174 - resultSection.scrollIntoView({ behavior: 'smooth' }); 183 + exchangeBtn.textContent = "exchange code for profile"; 184 + resultSection.style.display = "block"; 185 + resultSection.scrollIntoView({ behavior: "smooth" }); 175 186 } 176 187 }); 177 188 178 - function showResult(text: string, type: 'success' | 'error') { 179 - if (type === 'success' && text.includes('{')) { 189 + function showResult(text: string, type: "success" | "error") { 190 + if (type === "success" && text.includes("{")) { 180 191 // Extract and parse JSON from success message 181 - const jsonStart = text.indexOf('{'); 192 + const jsonStart = text.indexOf("{"); 182 193 const jsonStr = text.substring(jsonStart); 183 194 const prefix = text.substring(0, jsonStart); 184 - 195 + 185 196 try { 186 197 const data = JSON.parse(jsonStr); 187 198 resultDiv.innerHTML = `${prefix}<pre style="margin: 0; font-family: 'Space Grotesk', monospace;">${syntaxHighlightJSON(data)}</pre>`; ··· 196 207 197 208 function syntaxHighlightJSON(obj: any): string { 198 209 const json = JSON.stringify(obj, null, 2); 199 - return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, (match) => { 200 - let cls = 'json-number'; 201 - if (/^"/.test(match)) { 202 - if (/:$/.test(match)) { 203 - cls = 'json-key'; 204 - } else { 205 - cls = 'json-string'; 210 + return json.replace( 211 + /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, 212 + (match) => { 213 + let cls = "json-number"; 214 + if (/^"/.test(match)) { 215 + if (/:$/.test(match)) { 216 + cls = "json-key"; 217 + } else { 218 + cls = "json-string"; 219 + } 220 + } else if (/true|false/.test(match)) { 221 + cls = "json-boolean"; 222 + } else if (/null/.test(match)) { 223 + cls = "json-null"; 206 224 } 207 - } else if (/true|false/.test(match)) { 208 - cls = 'json-boolean'; 209 - } else if (/null/.test(match)) { 210 - cls = 'json-null'; 211 - } 212 - return `<span class="${cls}">${match}</span>`; 213 - }); 225 + return `<span class="${cls}">${match}</span>`; 226 + }, 227 + ); 214 228 }
+2
src/index.ts
··· 43 43 authorizePost, 44 44 createInvite, 45 45 deleteInvite, 46 + indieauthMetadata, 46 47 listInvites, 47 48 logout, 48 49 token, ··· 140 141 }, 141 142 ); 142 143 }, 144 + "/.well-known/oauth-authorization-server": indieauthMetadata, 143 145 // API endpoints 144 146 "/api/hello": hello, 145 147 "/api/users": listUsers,
+37 -11
src/routes/api.ts
··· 1 1 import { db } from "../db"; 2 2 3 - function getSessionUser(req: Request): { username: string; userId: number; is_admin: boolean } | Response { 3 + function getSessionUser( 4 + req: Request, 5 + ): { username: string; userId: number; is_admin: boolean } | Response { 4 6 const authHeader = req.headers.get("Authorization"); 5 7 6 8 if (!authHeader || !authHeader.startsWith("Bearer ")) { ··· 18 20 WHERE s.token = ?`, 19 21 ) 20 22 .get(token) as 21 - | { expires_at: number; user_id: number; username: string; is_admin: number; status: string } 23 + | { 24 + expires_at: number; 25 + user_id: number; 26 + username: string; 27 + is_admin: number; 28 + status: string; 29 + } 22 30 | undefined; 23 31 24 32 if (!session) { ··· 30 38 return Response.json({ error: "Session expired" }, { status: 401 }); 31 39 } 32 40 33 - if (session.status !== 'active') { 41 + if (session.status !== "active") { 34 42 return Response.json({ error: "Account is suspended" }, { status: 403 }); 35 43 } 36 44 ··· 170 178 return Response.json({ success: true }); 171 179 } catch (error) { 172 180 console.error("Update profile error:", error); 173 - return Response.json({ error: "Failed to update profile" }, { status: 500 }); 181 + return Response.json( 182 + { error: "Failed to update profile" }, 183 + { status: 500 }, 184 + ); 174 185 } 175 186 } 176 187 ··· 414 425 415 426 // Prevent disabling self 416 427 if (targetUserId === user.id) { 417 - return Response.json({ error: "Cannot disable your own account" }, { status: 400 }); 428 + return Response.json( 429 + { error: "Cannot disable your own account" }, 430 + { status: 400 }, 431 + ); 418 432 } 419 433 420 434 const targetUser = db ··· 425 439 return Response.json({ error: "User not found" }, { status: 404 }); 426 440 } 427 441 428 - db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run(targetUserId); 442 + db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run( 443 + targetUserId, 444 + ); 429 445 430 446 db.query("DELETE FROM sessions WHERE user_id = ?").run(targetUserId); 431 447 ··· 476 492 } 477 493 478 494 if (targetUserId === user.userId) { 479 - return Response.json({ error: "Cannot delete your own account" }, { status: 400 }); 495 + return Response.json( 496 + { error: "Cannot delete your own account" }, 497 + { status: 400 }, 498 + ); 480 499 } 481 500 482 501 const targetUser = db ··· 489 508 490 509 // Prevent admins from deleting other admin accounts 491 510 if (targetUser.is_admin === 1) { 492 - return Response.json({ error: "Cannot delete admin accounts" }, { status: 403 }); 511 + return Response.json( 512 + { error: "Cannot delete admin accounts" }, 513 + { status: 403 }, 514 + ); 493 515 } 494 516 495 517 db.query("DELETE FROM sessions WHERE user_id = ?").run(targetUserId); ··· 509 531 510 532 // Prevent admins from deleting their own accounts 511 533 if (user.is_admin) { 512 - return Response.json({ 513 - error: "Admin accounts cannot be self-deleted. Contact another admin for account deletion." 514 - }, { status: 403 }); 534 + return Response.json( 535 + { 536 + error: 537 + "Admin accounts cannot be self-deleted. Contact another admin for account deletion.", 538 + }, 539 + { status: 403 }, 540 + ); 515 541 } 516 542 517 543 // Delete all user data
+97 -70
src/routes/auth.ts
··· 1 - import { db } from "../db"; 2 1 import { 3 - generateRegistrationOptions, 4 - verifyRegistrationResponse, 2 + type AuthenticationResponseJSON, 5 3 generateAuthenticationOptions, 6 - verifyAuthenticationResponse, 7 - type VerifiedRegistrationResponse, 8 - type VerifiedAuthenticationResponse, 4 + generateRegistrationOptions, 9 5 type PublicKeyCredentialCreationOptionsJSON, 6 + type PublicKeyCredentialRequestOptionsJSON, 10 7 type RegistrationResponseJSON, 11 - type PublicKeyCredentialRequestOptionsJSON, 12 - type AuthenticationResponseJSON, 8 + type VerifiedAuthenticationResponse, 9 + type VerifiedRegistrationResponse, 10 + verifyAuthenticationResponse, 11 + verifyRegistrationResponse, 13 12 } from "@simplewebauthn/server"; 13 + import { db } from "../db"; 14 14 15 15 const RP_NAME = "Indiko"; 16 16 17 17 export function canRegister(req: Request): Response { 18 - const userCount = db 19 - .query("SELECT COUNT(*) as count FROM users") 20 - .get() as { count: number }; 18 + const userCount = db.query("SELECT COUNT(*) as count FROM users").get() as { 19 + count: number; 20 + }; 21 21 22 22 return Response.json({ 23 23 canRegister: userCount.count === 0, ··· 47 47 } 48 48 49 49 // Check if this is bootstrap (first user) 50 - const userCount = db 51 - .query("SELECT COUNT(*) as count FROM users") 52 - .get() as { count: number }; 50 + const userCount = db.query("SELECT COUNT(*) as count FROM users").get() as { 51 + count: number; 52 + }; 53 53 54 54 const isBootstrap = userCount.count === 0; 55 55 let inviteMessage: string | null = null; ··· 57 57 // If not bootstrap, require valid invite code 58 58 if (!isBootstrap) { 59 59 if (!inviteCode) { 60 - return Response.json({ error: "Invite code required" }, { status: 403 }); 60 + return Response.json( 61 + { error: "Invite code required" }, 62 + { status: 403 }, 63 + ); 61 64 } 62 65 63 66 // Validate invite code 64 67 const invite = db 65 - .query("SELECT id, max_uses, current_uses, expires_at, message FROM invites WHERE code = ?") 66 - .get(inviteCode) as { id: number; max_uses: number; current_uses: number; expires_at: number | null; message: string | null } | undefined; 68 + .query( 69 + "SELECT id, max_uses, current_uses, expires_at, message FROM invites WHERE code = ?", 70 + ) 71 + .get(inviteCode) as 72 + | { 73 + id: number; 74 + max_uses: number; 75 + current_uses: number; 76 + expires_at: number | null; 77 + message: string | null; 78 + } 79 + | undefined; 67 80 68 81 if (!invite) { 69 82 return Response.json({ error: "Invalid invite code" }, { status: 403 }); ··· 75 88 } 76 89 77 90 if (invite.current_uses >= invite.max_uses) { 78 - return Response.json({ error: "Invite code fully used" }, { status: 403 }); 91 + return Response.json( 92 + { error: "Invite code fully used" }, 93 + { status: 403 }, 94 + ); 79 95 } 80 - 96 + 81 97 // Store invite message to return with options 82 98 inviteMessage = invite.message; 83 99 } ··· 113 129 export async function registerVerify(req: Request): Promise<Response> { 114 130 try { 115 131 const body = await req.json(); 116 - const { username, response, challenge: expectedChallenge, inviteCode } = body as { 132 + const { 133 + username, 134 + response, 135 + challenge: expectedChallenge, 136 + inviteCode, 137 + } = body as { 117 138 username: string; 118 139 response: RegistrationResponseJSON; 119 140 challenge?: string; ··· 158 179 } 159 180 160 181 // Check if this is bootstrap (first user) 161 - const userCount = db 162 - .query("SELECT COUNT(*) as count FROM users") 163 - .get() as { count: number }; 182 + const userCount = db.query("SELECT COUNT(*) as count FROM users").get() as { 183 + count: number; 184 + }; 164 185 165 186 const isBootstrap = userCount.count === 0; 166 187 ··· 169 190 let inviteRoles: Array<{ app_id: number; role: string }> = []; 170 191 if (!isBootstrap) { 171 192 if (!inviteCode) { 172 - return Response.json({ error: "Invite code required" }, { status: 403 }); 193 + return Response.json( 194 + { error: "Invite code required" }, 195 + { status: 403 }, 196 + ); 173 197 } 174 198 175 199 const invite = db 176 - .query("SELECT id, max_uses, current_uses, expires_at FROM invites WHERE code = ?") 177 - .get(inviteCode) as { id: number; max_uses: number; current_uses: number; expires_at: number | null } | undefined; 200 + .query( 201 + "SELECT id, max_uses, current_uses, expires_at FROM invites WHERE code = ?", 202 + ) 203 + .get(inviteCode) as 204 + | { 205 + id: number; 206 + max_uses: number; 207 + current_uses: number; 208 + expires_at: number | null; 209 + } 210 + | undefined; 178 211 179 212 if (!invite) { 180 213 return Response.json({ error: "Invalid invite code" }, { status: 403 }); ··· 186 219 } 187 220 188 221 if (invite.current_uses >= invite.max_uses) { 189 - return Response.json({ error: "Invite code fully used" }, { status: 403 }); 222 + return Response.json( 223 + { error: "Invite code fully used" }, 224 + { status: 403 }, 225 + ); 190 226 } 191 227 192 228 inviteId = invite.id; 193 229 194 230 // Get app role assignments for this invite 195 - inviteRoles = db.query( 196 - "SELECT app_id, role FROM invite_roles WHERE invite_id = ?" 197 - ).all(inviteId) as Array<{ app_id: number; role: string }>; 231 + inviteRoles = db 232 + .query("SELECT app_id, role FROM invite_roles WHERE invite_id = ?") 233 + .all(inviteId) as Array<{ app_id: number; role: string }>; 198 234 } 199 235 200 236 // Verify WebAuthn response ··· 208 244 }); 209 245 } catch (error) { 210 246 console.error("WebAuthn verification failed:", error); 211 - return Response.json( 212 - { error: "Verification failed" }, 213 - { status: 400 }, 214 - ); 247 + return Response.json({ error: "Verification failed" }, { status: 400 }); 215 248 } 216 249 217 250 if (!verification.verified || !verification.registrationInfo) { 218 - return Response.json( 219 - { error: "Verification failed" }, 220 - { status: 400 }, 221 - ); 251 + return Response.json({ error: "Verification failed" }, { status: 400 }); 222 252 } 223 253 224 254 const { credential } = verification.registrationInfo; ··· 227 257 const insertUser = db.query( 228 258 "INSERT INTO users (username, name, is_admin, role) VALUES (?, ?, ?, ?) RETURNING id", 229 259 ); 230 - const user = insertUser.get(username, username, isBootstrap ? 1 : 0, isBootstrap ? 'admin' : 'user') as { 260 + const user = insertUser.get( 261 + username, 262 + username, 263 + isBootstrap ? 1 : 0, 264 + isBootstrap ? "admin" : "user", 265 + ) as { 231 266 id: number; 232 267 }; 233 268 ··· 245 280 // Mark invite as used if applicable 246 281 if (inviteId) { 247 282 const usedAt = Math.floor(Date.now() / 1000); 248 - 283 + 249 284 // Increment invite usage counter 250 285 db.query( 251 286 "UPDATE invites SET current_uses = current_uses + 1 WHERE id = ?", 252 287 ).run(inviteId); 253 - 288 + 254 289 // Record this invite use 255 290 db.query( 256 291 "INSERT INTO invite_uses (invite_id, user_id, used_at) VALUES (?, ?, ?)", 257 292 ).run(inviteId, user.id, usedAt); 258 - 293 + 259 294 // Assign app roles to the new user 260 295 if (inviteRoles.length > 0) { 261 296 const insertPermission = db.query( ··· 318 353 return Response.json({ error: "User not found" }, { status: 404 }); 319 354 } 320 355 321 - if (user.status !== 'active') { 356 + if (user.status !== "active") { 322 357 return Response.json({ error: "Account is suspended" }, { status: 403 }); 323 358 } 324 359 ··· 328 363 .all(user.id) as { credential_id: Buffer }[]; 329 364 330 365 if (credentials.length === 0) { 331 - return Response.json( 332 - { error: "No credentials found" }, 333 - { status: 404 }, 334 - ); 366 + return Response.json({ error: "No credentials found" }, { status: 404 }); 335 367 } 336 368 337 369 // Generate authentication options ··· 372 404 373 405 // Look up credential by ID to discover the username 374 406 const credentialIdString = response.id; 375 - 407 + 376 408 const credentialWithUser = db 377 409 .query( 378 410 "SELECT c.credential_id, c.public_key, c.counter, c.user_id, u.username, u.status FROM credentials c JOIN users u ON c.user_id = u.id WHERE c.credential_id = ?", 379 411 ) 380 412 .get(Buffer.from(credentialIdString)) as 381 - | { credential_id: Buffer; public_key: Buffer; counter: number; user_id: number; username: string; status: string } 413 + | { 414 + credential_id: Buffer; 415 + public_key: Buffer; 416 + counter: number; 417 + user_id: number; 418 + username: string; 419 + status: string; 420 + } 382 421 | undefined; 383 422 384 423 if (!credentialWithUser) { 385 - return Response.json( 386 - { error: "Credential not found" }, 387 - { status: 404 }, 388 - ); 424 + return Response.json({ error: "Credential not found" }, { status: 404 }); 389 425 } 390 426 391 427 // Check if user account is active 392 - if (credentialWithUser.status !== 'active') { 393 - return Response.json( 394 - { error: "Account is suspended" }, 395 - { status: 403 }, 396 - ); 428 + if (credentialWithUser.status !== "active") { 429 + return Response.json({ error: "Account is suspended" }, { status: 403 }); 397 430 } 398 431 399 432 // Verify the username matches ··· 416 449 .query( 417 450 "SELECT challenge, expires_at FROM challenges WHERE username = ? AND type = 'authentication' ORDER BY created_at DESC LIMIT 1", 418 451 ) 419 - .get(username) as 420 - | { challenge: string; expires_at: number } 421 - | undefined; 452 + .get(username) as { challenge: string; expires_at: number } | undefined; 422 453 423 454 if (!challenge) { 424 455 return Response.json({ error: "Invalid challenge" }, { status: 400 }); ··· 445 476 }); 446 477 } catch (error) { 447 478 console.error("WebAuthn verification failed:", error); 448 - return Response.json( 449 - { error: "Verification failed" }, 450 - { status: 400 }, 451 - ); 479 + return Response.json({ error: "Verification failed" }, { status: 400 }); 452 480 } 453 481 454 482 if (!verification.verified) { 455 - return Response.json( 456 - { error: "Verification failed" }, 457 - { status: 400 }, 458 - ); 483 + return Response.json({ error: "Verification failed" }, { status: 400 }); 459 484 } 460 485 461 486 // Update credential counter 462 - db.query("UPDATE credentials SET counter = ? WHERE user_id = ? AND credential_id = ?").run( 487 + db.query( 488 + "UPDATE credentials SET counter = ? WHERE user_id = ? AND credential_id = ?", 489 + ).run( 463 490 verification.authenticationInfo.newCounter, 464 491 user.id, 465 492 credential.credential_id,
+114 -33
src/routes/clients.ts
··· 1 - import { db } from "../db"; 2 1 import crypto from "crypto"; 3 2 import { nanoid } from "nanoid"; 3 + import { db } from "../db"; 4 4 5 5 function hashSecret(secret: string): string { 6 6 return crypto.createHash("sha256").update(secret).digest("hex"); ··· 14 14 return `ikc_${nanoid(21)}`; // indiko client 15 15 } 16 16 17 - function getSessionUser(req: Request): { username: string; userId: number; is_admin: boolean } | Response { 17 + function getSessionUser( 18 + req: Request, 19 + ): { username: string; userId: number; is_admin: boolean } | Response { 18 20 const authHeader = req.headers.get("Authorization"); 19 21 20 22 if (!authHeader || !authHeader.startsWith("Bearer ")) { ··· 31 33 WHERE s.token = ?`, 32 34 ) 33 35 .get(token) as 34 - | { expires_at: number; user_id: number; username: string; is_admin: number; status: string } 36 + | { 37 + expires_at: number; 38 + user_id: number; 39 + username: string; 40 + is_admin: number; 41 + status: string; 42 + } 35 43 | undefined; 36 44 37 45 if (!session) { ··· 43 51 return Response.json({ error: "Session expired" }, { status: 401 }); 44 52 } 45 53 46 - if (session.status !== 'active') { 54 + if (session.status !== "active") { 47 55 return Response.json({ error: "Account is suspended" }, { status: 403 }); 48 56 } 49 57 ··· 140 148 141 149 try { 142 150 const body = await req.json(); 143 - const { name, logoUrl, description, redirectUris, availableRoles, defaultRole } = body; 151 + const { 152 + name, 153 + logoUrl, 154 + description, 155 + redirectUris, 156 + availableRoles, 157 + defaultRole, 158 + } = body; 144 159 145 - if (!redirectUris || !Array.isArray(redirectUris) || redirectUris.length === 0) { 146 - return Response.json({ error: "At least one redirect URI is required" }, { status: 400 }); 160 + if ( 161 + !redirectUris || 162 + !Array.isArray(redirectUris) || 163 + redirectUris.length === 0 164 + ) { 165 + return Response.json( 166 + { error: "At least one redirect URI is required" }, 167 + { status: 400 }, 168 + ); 147 169 } 148 170 149 171 for (const uri of redirectUris) { 150 172 try { 151 173 new URL(uri); 152 174 } catch { 153 - return Response.json({ error: `Invalid redirect URI: ${uri}` }, { status: 400 }); 175 + return Response.json( 176 + { error: `Invalid redirect URI: ${uri}` }, 177 + { status: 400 }, 178 + ); 154 179 } 155 180 } 156 181 ··· 163 188 let rolesArray: string[] = []; 164 189 if (availableRoles) { 165 190 if (!Array.isArray(availableRoles)) { 166 - return Response.json({ error: "Available roles must be an array" }, { status: 400 }); 191 + return Response.json( 192 + { error: "Available roles must be an array" }, 193 + { status: 400 }, 194 + ); 167 195 } 168 - rolesArray = availableRoles.filter((r: unknown) => typeof r === 'string' && r.trim()); 196 + rolesArray = availableRoles.filter( 197 + (r: unknown) => typeof r === "string" && r.trim(), 198 + ); 169 199 } 170 200 171 201 // Validate default role is in available roles 172 - if (defaultRole && rolesArray.length > 0 && !rolesArray.includes(defaultRole)) { 173 - return Response.json({ error: "Default role must be one of the available roles" }, { status: 400 }); 202 + if ( 203 + defaultRole && 204 + rolesArray.length > 0 && 205 + !rolesArray.includes(defaultRole) 206 + ) { 207 + return Response.json( 208 + { error: "Default role must be one of the available roles" }, 209 + { status: 400 }, 210 + ); 174 211 } 175 212 176 213 const result = db ··· 289 326 description: client.description, 290 327 redirectUris: JSON.parse(client.redirect_uris) as string[], 291 328 isPreregistered: client.is_preregistered === 1, 292 - availableRoles: client.available_roles ? JSON.parse(client.available_roles) as string[] : null, 329 + availableRoles: client.available_roles 330 + ? (JSON.parse(client.available_roles) as string[]) 331 + : null, 293 332 defaultRole: client.default_role, 294 333 firstSeen: client.first_seen, 295 334 lastUsed: client.last_used, ··· 305 344 }); 306 345 } 307 346 308 - export async function updateClient(req: Request, clientId: string): Promise<Response> { 347 + export async function updateClient( 348 + req: Request, 349 + clientId: string, 350 + ): Promise<Response> { 309 351 const user = getSessionUser(req); 310 352 if (user instanceof Response) { 311 353 return user; ··· 317 359 318 360 try { 319 361 const body = await req.json(); 320 - const { name, logoUrl, description, redirectUris, availableRoles, defaultRole } = body; 362 + const { 363 + name, 364 + logoUrl, 365 + description, 366 + redirectUris, 367 + availableRoles, 368 + defaultRole, 369 + } = body; 321 370 322 371 const existing = db 323 372 .query("SELECT id, is_preregistered FROM apps WHERE client_id = ?") ··· 329 378 330 379 if (redirectUris) { 331 380 if (!Array.isArray(redirectUris) || redirectUris.length === 0) { 332 - return Response.json({ error: "At least one redirect URI is required" }, { status: 400 }); 381 + return Response.json( 382 + { error: "At least one redirect URI is required" }, 383 + { status: 400 }, 384 + ); 333 385 } 334 386 335 387 for (const uri of redirectUris) { 336 388 try { 337 389 new URL(uri); 338 390 } catch { 339 - return Response.json({ error: `Invalid redirect URI: ${uri}` }, { status: 400 }); 391 + return Response.json( 392 + { error: `Invalid redirect URI: ${uri}` }, 393 + { status: 400 }, 394 + ); 340 395 } 341 396 } 342 397 } ··· 348 403 // Explicitly disable roles 349 404 rolesArray = null; 350 405 } else if (Array.isArray(availableRoles)) { 351 - rolesArray = availableRoles.filter((r: unknown) => typeof r === 'string' && r.trim()); 406 + rolesArray = availableRoles.filter( 407 + (r: unknown) => typeof r === "string" && r.trim(), 408 + ); 352 409 } else { 353 - return Response.json({ error: "Available roles must be an array or null" }, { status: 400 }); 410 + return Response.json( 411 + { error: "Available roles must be an array or null" }, 412 + { status: 400 }, 413 + ); 354 414 } 355 415 } 356 416 357 417 // Validate default role is in available roles 358 - if (defaultRole && rolesArray && rolesArray.length > 0 && !rolesArray.includes(defaultRole)) { 359 - return Response.json({ error: "Default role must be one of the available roles" }, { status: 400 }); 418 + if ( 419 + defaultRole && 420 + rolesArray && 421 + rolesArray.length > 0 && 422 + !rolesArray.includes(defaultRole) 423 + ) { 424 + return Response.json( 425 + { error: "Default role must be one of the available roles" }, 426 + { status: 400 }, 427 + ); 360 428 } 361 429 362 430 db.query( ··· 368 436 logoUrl || null, 369 437 description || null, 370 438 redirectUris ? JSON.stringify(redirectUris) : null, 371 - rolesArray !== null ? (rolesArray.length > 0 ? JSON.stringify(rolesArray) : null) : null, 439 + rolesArray !== null 440 + ? rolesArray.length > 0 441 + ? JSON.stringify(rolesArray) 442 + : null 443 + : null, 372 444 defaultRole || null, 373 445 clientId, 374 446 ); ··· 431 503 432 504 const client = db 433 505 .query("SELECT id, available_roles FROM apps WHERE client_id = ?") 434 - .get(clientId) as { id: number; available_roles: string | null } | undefined; 506 + .get(clientId) as 507 + | { id: number; available_roles: string | null } 508 + | undefined; 435 509 436 510 if (!client) { 437 511 return Response.json({ error: "Client not found" }, { status: 404 }); ··· 441 515 if (role && client.available_roles) { 442 516 const availableRoles = JSON.parse(client.available_roles) as string[]; 443 517 if (!availableRoles.includes(role)) { 444 - return Response.json({ 445 - error: `Role must be one of: ${availableRoles.join(', ')}` 446 - }, { status: 400 }); 518 + return Response.json( 519 + { 520 + error: `Role must be one of: ${availableRoles.join(", ")}`, 521 + }, 522 + { status: 400 }, 523 + ); 447 524 } 448 525 } 449 526 ··· 452 529 .get(targetUser.id, clientId) as { id: number } | undefined; 453 530 454 531 if (!permission) { 455 - return Response.json({ error: "User has not authorized this client" }, { status: 404 }); 532 + return Response.json( 533 + { error: "User has not authorized this client" }, 534 + { status: 404 }, 535 + ); 456 536 } 457 537 458 - db.query("UPDATE permissions SET role = ? WHERE user_id = ? AND client_id = ?").run( 459 - role || null, 460 - targetUser.id, 461 - clientId, 462 - ); 538 + db.query( 539 + "UPDATE permissions SET role = ? WHERE user_id = ? AND client_id = ?", 540 + ).run(role || null, targetUser.id, clientId); 463 541 464 542 return Response.json({ success: true }); 465 543 } catch (error) { ··· 468 546 } 469 547 } 470 548 471 - export function regenerateClientSecret(req: Request, clientId: string): Response { 549 + export function regenerateClientSecret( 550 + req: Request, 551 + clientId: string, 552 + ): Response { 472 553 const user = getSessionUser(req); 473 554 if (user instanceof Response) { 474 555 return user;
+236 -143
src/routes/indieauth.ts
··· 1 - import { db } from "../db"; 2 1 import crypto from "crypto"; 2 + import { db } from "../db"; 3 3 4 4 interface SessionUser { 5 5 username: string; ··· 25 25 WHERE s.token = ?`, 26 26 ) 27 27 .get(token) as 28 - | { expires_at: number; id: number; username: string; is_admin: number; status: string } 28 + | { 29 + expires_at: number; 30 + id: number; 31 + username: string; 32 + is_admin: number; 33 + status: string; 34 + } 29 35 | undefined; 30 36 31 37 if (!session) { ··· 37 43 return Response.json({ error: "Session expired" }, { status: 401 }); 38 44 } 39 45 40 - if (session.status !== 'active') { 46 + if (session.status !== "active") { 41 47 return Response.json({ error: "Account is suspended" }, { status: 403 }); 42 48 } 43 49 ··· 71 77 WHERE s.token = ?`, 72 78 ) 73 79 .get(sessionToken) as 74 - | { expires_at: number; id: number; username: string; is_admin: number; status: string } 80 + | { 81 + expires_at: number; 82 + id: number; 83 + username: string; 84 + is_admin: number; 85 + status: string; 86 + } 75 87 | undefined; 76 88 77 89 if (!session) return null; ··· 79 91 const now = Math.floor(Date.now() / 1000); 80 92 if (session.expires_at < now) return null; 81 93 82 - if (session.status !== 'active') return null; 94 + if (session.status !== "active") return null; 83 95 84 96 return { 85 97 username: session.username, ··· 95 107 } 96 108 97 109 // Auto-register app if it doesn't exist (only for valid URLs, not generated client_ids) 98 - function ensureApp(clientId: string, redirectUri: string): { error?: string; app?: { name: string | null; redirect_uris: string } } { 110 + function ensureApp( 111 + clientId: string, 112 + redirectUri: string, 113 + ): { error?: string; app?: { name: string | null; redirect_uris: string } } { 99 114 const existing = db 100 115 .query("SELECT name, redirect_uris FROM apps WHERE client_id = ?") 101 - .get(clientId) as { name: string | null; redirect_uris: string } | undefined; 116 + .get(clientId) as 117 + | { name: string | null; redirect_uris: string } 118 + | undefined; 102 119 103 120 if (!existing) { 104 121 // Only allow auto-registration for valid URLs (IndieAuth standard) ··· 106 123 try { 107 124 new URL(clientId); 108 125 } catch { 109 - return { error: "Client ID must be a valid URL for auto-registration. Non-URL clients must be pre-registered by an admin." }; 126 + return { 127 + error: 128 + "Client ID must be a valid URL for auto-registration. Non-URL clients must be pre-registered by an admin.", 129 + }; 110 130 } 111 131 112 132 // New app - auto-register (without pre-registration, no client secret or role) 113 133 db.query( 114 134 "INSERT INTO apps (client_id, redirect_uris, is_preregistered, first_seen, last_used) VALUES (?, ?, 0, ?, ?)", 115 - ).run(clientId, JSON.stringify([redirectUri]), Math.floor(Date.now() / 1000), Math.floor(Date.now() / 1000)); 135 + ).run( 136 + clientId, 137 + JSON.stringify([redirectUri]), 138 + Math.floor(Date.now() / 1000), 139 + Math.floor(Date.now() / 1000), 140 + ); 116 141 117 142 // Fetch the newly created app 118 143 const newApp = db 119 144 .query("SELECT name, redirect_uris FROM apps WHERE client_id = ?") 120 145 .get(clientId) as { name: string | null; redirect_uris: string }; 121 - 146 + 122 147 return { app: newApp }; 123 148 } 124 149 ··· 246 271 </div> 247 272 </body> 248 273 </html>`, 249 - { 274 + { 250 275 status: 400, 251 - headers: { "Content-Type": "text/html" } 252 - } 276 + headers: { "Content-Type": "text/html" }, 277 + }, 253 278 ); 254 279 } 255 280 ··· 261 286 262 287 // Verify app is registered 263 288 const appResult = ensureApp(clientId, redirectUri); 264 - 289 + 265 290 if (appResult.error) { 266 291 return new Response( 267 292 `<!DOCTYPE html> ··· 356 381 </div> 357 382 </body> 358 383 </html>`, 359 - { 384 + { 360 385 status: 400, 361 - headers: { "Content-Type": "text/html" } 362 - } 386 + headers: { "Content-Type": "text/html" }, 387 + }, 363 388 ); 364 389 } 365 - 390 + 366 391 const app = appResult.app!; 367 392 368 393 const allowedRedirects = JSON.parse(app.redirect_uris) as string[]; ··· 456 481 </div> 457 482 </body> 458 483 </html>`, 459 - { 484 + { 460 485 status: 400, 461 - headers: { "Content-Type": "text/html" } 462 - } 486 + headers: { "Content-Type": "text/html" }, 487 + }, 463 488 ); 464 489 } 465 490 ··· 474 499 475 500 // Verify app is registered 476 501 const appCheckResult = ensureApp(clientId, redirectUri); 477 - 502 + 478 503 if (appCheckResult.error) { 479 504 return new Response(appCheckResult.error, { status: 400 }); 480 505 } 481 506 482 507 // Check if user has previously granted permission to this app 483 508 const permission = db 484 - .query( 485 - "SELECT scopes FROM permissions WHERE user_id = ? AND client_id = ?", 486 - ) 509 + .query("SELECT scopes FROM permissions WHERE user_id = ? AND client_id = ?") 487 510 .get(user.userId, clientId) as { scopes: string } | undefined; 488 511 489 512 const requestedScopes = scope.split(" ").filter(Boolean); ··· 491 514 // If permission exists and covers all requested scopes, auto-approve 492 515 if (permission) { 493 516 const grantedScopes = JSON.parse(permission.scopes) as string[]; 494 - const hasAllScopes = requestedScopes.every((s) => grantedScopes.includes(s)); 517 + const hasAllScopes = requestedScopes.every((s) => 518 + grantedScopes.includes(s), 519 + ); 495 520 496 521 if (hasAllScopes) { 497 522 // Auto-approve - create auth code and redirect ··· 544 569 // Load app metadata if pre-registered 545 570 const appData = db 546 571 .query("SELECT name, logo_url, description FROM apps WHERE client_id = ?") 547 - .get(clientId) as { name: string | null; logo_url: string | null; description: string | null } | undefined; 548 - 572 + .get(clientId) as 573 + | { 574 + name: string | null; 575 + logo_url: string | null; 576 + description: string | null; 577 + } 578 + | undefined; 579 + 549 580 // Determine app name and URL - custom apps have ikc_ prefix and should use name from DB 550 581 let appName: string; 551 582 let appUrl: string | null = null; 552 - 553 - if (clientId.startsWith('ikc_')) { 583 + 584 + if (clientId.startsWith("ikc_")) { 554 585 // Custom app with generated ID 555 586 appName = appData?.name || clientId; 556 587 } else { ··· 564 595 appName = appData?.name || clientId; 565 596 } 566 597 } 567 - 598 + 568 599 const appLogo = appData?.logo_url; 569 600 const appDescription = appData?.description; 570 601 ··· 805 836 806 837 <div class="app-header"> 807 838 <div class="app-logo"> 808 - ${appLogo ? `<img src="${appLogo}" alt="${appName}" />` : '🔐'} 839 + ${appLogo ? `<img src="${appLogo}" alt="${appName}" />` : "🔐"} 809 840 </div> 810 841 <div class="app-info"> 811 842 <div class="app-name">${appName}</div> 812 - ${appUrl ? `<div class="app-url">${appUrl}</div>` : ''} 813 - ${appDescription ? `<div class="app-description">${appDescription}</div>` : ''} 843 + ${appUrl ? `<div class="app-url">${appUrl}</div>` : ""} 844 + ${appDescription ? `<div class="app-description">${appDescription}</div>` : ""} 814 845 </div> 815 846 </div> 816 847 ··· 822 853 <div class="scope-title">requested permissions</div> 823 854 <ul class="scope-list"> 824 855 ${scopes 825 - .map( 826 - (scope) => { 827 - const isProfile = scope === "profile"; 828 - const description = scope === "profile" ? "Your profile (name, photo, URL)" : scope === "email" ? "Your email address" : scope; 829 - const required = isProfile ? ' <span style="color: var(--old-rose); font-size: 0.875rem; margin-left: 0.5rem;">(required)</span>' : ''; 830 - return ` 856 + .map((scope) => { 857 + const isProfile = scope === "profile"; 858 + const description = 859 + scope === "profile" 860 + ? "Your profile (name, photo, URL)" 861 + : scope === "email" 862 + ? "Your email address" 863 + : scope; 864 + const required = isProfile 865 + ? ' <span style="color: var(--old-rose); font-size: 0.875rem; margin-left: 0.5rem;">(required)</span>' 866 + : ""; 867 + return ` 831 868 <li> 832 869 <label> 833 - <input type="checkbox" name="scope" value="${scope}" ${isProfile ? 'checked disabled' : 'checked'} /> 870 + <input type="checkbox" name="scope" value="${scope}" ${isProfile ? "checked disabled" : "checked"} /> 834 871 <span>${description}${required}</span> 835 872 </label> 836 873 </li> 837 874 `; 838 - }, 839 - ) 875 + }) 840 876 .join("")} 841 877 </ul> 842 878 </div> ··· 923 959 if (existing) { 924 960 db.query( 925 961 "UPDATE permissions SET scopes = ?, last_used = ? WHERE user_id = ? AND client_id = ?", 926 - ).run(JSON.stringify(approvedScopes), Math.floor(Date.now() / 1000), user.userId, clientId); 962 + ).run( 963 + JSON.stringify(approvedScopes), 964 + Math.floor(Date.now() / 1000), 965 + user.userId, 966 + clientId, 967 + ); 927 968 } else { 928 969 // Get app's default role for new permissions 929 970 const app = db 930 971 .query("SELECT default_role FROM apps WHERE client_id = ?") 931 972 .get(clientId) as { default_role: string | null } | undefined; 932 - 973 + 933 974 db.query( 934 975 "INSERT INTO permissions (user_id, client_id, scopes, role) VALUES (?, ?, ?, ?)", 935 - ).run(user.userId, clientId, JSON.stringify(approvedScopes), app?.default_role || null); 976 + ).run( 977 + user.userId, 978 + clientId, 979 + JSON.stringify(approvedScopes), 980 + app?.default_role || null, 981 + ); 936 982 } 937 983 938 984 // Update app last_used ··· 941 987 clientId, 942 988 ); 943 989 944 - return Response.redirect(`${redirectUri}?code=${code}&state=${state}`); 990 + const origin = process.env.ORIGIN || "http://localhost:3000"; 991 + return Response.redirect( 992 + `${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`, 993 + ); 945 994 } 946 995 947 996 // POST /auth/token - Exchange authorization code for user identity ··· 949 998 try { 950 999 const contentType = req.headers.get("Content-Type"); 951 1000 let body: Record<string, string>; 952 - 1001 + 953 1002 // Support both JSON and form-encoded requests 954 1003 if (contentType?.includes("application/json")) { 955 1004 body = await req.json(); ··· 961 1010 return Response.json( 962 1011 { 963 1012 error: "invalid_request", 964 - error_description: "Content-Type must be application/json or application/x-www-form-urlencoded", 1013 + error_description: 1014 + "Content-Type must be application/json or application/x-www-form-urlencoded", 965 1015 }, 966 1016 { status: 400 }, 967 1017 ); 968 1018 } 969 - 1019 + 970 1020 const { 971 - grant_type, 972 - code, 973 - client_id, 974 - client_secret, 975 - redirect_uri, 976 - code_verifier, 977 - } = body; 1021 + grant_type, 1022 + code, 1023 + client_id, 1024 + client_secret, 1025 + redirect_uri, 1026 + code_verifier, 1027 + } = body; 978 1028 979 1029 if (grant_type !== "authorization_code") { 980 - return Response.json( 981 - { 982 - error: "unsupported_grant_type", 983 - error_description: "Only authorization_code grant type is supported", 984 - }, 985 - { status: 400 }, 986 - ); 987 - } 1030 + return Response.json( 1031 + { 1032 + error: "unsupported_grant_type", 1033 + error_description: "Only authorization_code grant type is supported", 1034 + }, 1035 + { status: 400 }, 1036 + ); 1037 + } 988 1038 989 1039 // Check if client is pre-registered and requires secret 990 1040 const app = db 991 - .query("SELECT is_preregistered, client_secret_hash FROM apps WHERE client_id = ?") 992 - .get(client_id) as 993 - | { is_preregistered: number; client_secret_hash: string | null } 994 - | undefined; 1041 + .query( 1042 + "SELECT is_preregistered, client_secret_hash FROM apps WHERE client_id = ?", 1043 + ) 1044 + .get(client_id) as 1045 + | { is_preregistered: number; client_secret_hash: string | null } 1046 + | undefined; 995 1047 996 1048 // If client is pre-registered, verify client secret 997 1049 if (app && app.is_preregistered === 1) { 998 - if (!client_secret) { 999 - return Response.json( 1000 - { 1001 - error: "invalid_client", 1002 - error_description: "client_secret is required for pre-registered clients", 1003 - }, 1004 - { status: 401 }, 1005 - ); 1050 + if (!client_secret) { 1051 + return Response.json( 1052 + { 1053 + error: "invalid_client", 1054 + error_description: 1055 + "client_secret is required for pre-registered clients", 1056 + }, 1057 + { status: 401 }, 1058 + ); 1059 + } 1060 + 1061 + if (!app.client_secret_hash) { 1062 + return Response.json( 1063 + { 1064 + error: "server_error", 1065 + error_description: "Client secret not configured", 1066 + }, 1067 + { status: 500 }, 1068 + ); 1069 + } 1070 + 1071 + // Verify client secret 1072 + const providedSecretHash = crypto 1073 + .createHash("sha256") 1074 + .update(client_secret) 1075 + .digest("hex"); 1076 + 1077 + if (providedSecretHash !== app.client_secret_hash) { 1078 + return Response.json( 1079 + { 1080 + error: "invalid_client", 1081 + error_description: "Invalid client_secret", 1082 + }, 1083 + { status: 401 }, 1084 + ); 1085 + } 1006 1086 } 1007 1087 1008 - if (!app.client_secret_hash) { 1088 + if (!code || !client_id || !redirect_uri) { 1089 + console.error("Token endpoint: missing parameters", { 1090 + code: !!code, 1091 + client_id: !!client_id, 1092 + redirect_uri: !!redirect_uri, 1093 + }); 1009 1094 return Response.json( 1010 1095 { 1011 - error: "server_error", 1012 - error_description: "Client secret not configured", 1096 + error: "invalid_request", 1097 + error_description: "Missing required parameters", 1013 1098 }, 1014 - { status: 500 }, 1099 + { status: 400 }, 1015 1100 ); 1016 1101 } 1017 1102 1018 - // Verify client secret 1019 - const providedSecretHash = crypto 1020 - .createHash("sha256") 1021 - .update(client_secret) 1022 - .digest("hex"); 1023 - 1024 - if (providedSecretHash !== app.client_secret_hash) { 1103 + // PKCE is required for all clients per IndieAuth spec 1104 + if (!code_verifier) { 1025 1105 return Response.json( 1026 1106 { 1027 - error: "invalid_client", 1028 - error_description: "Invalid client_secret", 1107 + error: "invalid_request", 1108 + error_description: "code_verifier is required (PKCE)", 1029 1109 }, 1030 - { status: 401 }, 1110 + { status: 400 }, 1031 1111 ); 1032 1112 } 1033 - } 1034 - 1035 - if (!code || !client_id || !redirect_uri) { 1036 - console.error("Token endpoint: missing parameters", { code: !!code, client_id: !!client_id, redirect_uri: !!redirect_uri }); 1037 - return Response.json( 1038 - { 1039 - error: "invalid_request", 1040 - error_description: "Missing required parameters", 1041 - }, 1042 - { status: 400 }, 1043 - ); 1044 - } 1045 - 1046 - // For auto-registered clients, code_verifier (PKCE) is still required 1047 - if ((!app || app.is_preregistered === 0) && !code_verifier) { 1048 - return Response.json( 1049 - { 1050 - error: "invalid_request", 1051 - error_description: "code_verifier is required for public clients", 1052 - }, 1053 - { status: 400 }, 1054 - ); 1055 - } 1056 1113 1057 1114 // Look up authorization code 1058 1115 const authcode = db ··· 1127 1184 ); 1128 1185 } 1129 1186 1130 - // Verify PKCE code_verifier (only for public clients) 1131 - if ((!app || app.is_preregistered === 0) && code_verifier) { 1187 + // Verify PKCE code_verifier (required for all clients per IndieAuth spec) 1132 1188 if (!verifyPKCE(code_verifier, authcode.code_challenge)) { 1133 - return Response.json( 1134 - { 1135 - error: "invalid_grant", 1136 - error_description: "Invalid code_verifier", 1137 - }, 1138 - { status: 400 }, 1139 - ); 1189 + return Response.json( 1190 + { 1191 + error: "invalid_grant", 1192 + error_description: "Invalid code_verifier", 1193 + }, 1194 + { status: 400 }, 1195 + ); 1140 1196 } 1141 - } 1142 1197 1143 1198 // Mark code as used 1144 1199 db.query("UPDATE authcodes SET used = 1 WHERE code = ?").run(code); 1145 1200 1146 1201 // Get user profile 1147 1202 const user = db 1148 - .query( 1149 - "SELECT username, name, email, photo, url FROM users WHERE id = ?", 1150 - ) 1203 + .query("SELECT username, name, email, photo, url FROM users WHERE id = ?") 1151 1204 .get(authcode.user_id) as 1152 1205 | { 1153 1206 username: string; ··· 1239 1292 if (!username) { 1240 1293 return new Response("Username required", { status: 400 }); 1241 1294 } 1242 - 1295 + 1243 1296 const user = db 1244 - .query("SELECT username, name, email, photo, url FROM users WHERE username = ?") 1297 + .query( 1298 + "SELECT username, name, email, photo, url FROM users WHERE username = ?", 1299 + ) 1245 1300 .get(username) as 1246 1301 | { 1247 1302 username: string; ··· 1264 1319 <title>${user.name} • indiko</title> 1265 1320 <meta name="description" content="${user.name}'s profile on Indiko${user.url ? ` - ${user.url}` : ""}" /> 1266 1321 <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> 1322 + <link rel="indieauth-metadata" href="${process.env.ORIGIN}/.well-known/oauth-authorization-server" /> 1267 1323 <link rel="authorization_endpoint" href="${process.env.ORIGIN}/auth/authorize" /> 1268 1324 <link rel="token_endpoint" href="${process.env.ORIGIN}/auth/token" /> 1269 1325 ${user.url ? `<link rel="me" href="${user.url}" />` : ""} ··· 1436 1492 <p> 1437 1493 You can delegate IndieAuth to this server from your own website. Add these tags to your site's <code>&lt;head&gt;</code>: 1438 1494 </p> 1439 - <div class="code-box"><span class="html-tag">&lt;link</span> <span class="html-attr">rel</span>=<span class="html-value">"authorization_endpoint"</span> <span class="html-attr">href</span>=<span class="html-value">"${process.env.ORIGIN}/auth/authorize"</span> <span class="html-tag">/&gt;</span> 1495 + <div class="code-box"><span class="html-tag">&lt;link</span> <span class="html-attr">rel</span>=<span class="html-value">"indieauth-metadata"</span> <span class="html-attr">href</span>=<span class="html-value">"${process.env.ORIGIN}/.well-known/oauth-authorization-server"</span> <span class="html-tag">/&gt;</span> 1496 + <span class="html-tag">&lt;link</span> <span class="html-attr">rel</span>=<span class="html-value">"authorization_endpoint"</span> <span class="html-attr">href</span>=<span class="html-value">"${process.env.ORIGIN}/auth/authorize"</span> <span class="html-tag">/&gt;</span> 1440 1497 <span class="html-tag">&lt;link</span> <span class="html-attr">rel</span>=<span class="html-value">"token_endpoint"</span> <span class="html-attr">href</span>=<span class="html-value">"${process.env.ORIGIN}/auth/token"</span> <span class="html-tag">/&gt;</span> 1441 1498 <span class="html-tag">&lt;link</span> <span class="html-attr">rel</span>=<span class="html-value">"me"</span> <span class="html-attr">href</span>=<span class="html-value">"${process.env.ORIGIN}/u/${user.username}"</span> <span class="html-tag">/&gt;</span></div> 1442 1499 <p> ··· 1451 1508 </body> 1452 1509 </html>`; 1453 1510 1511 + const origin = process.env.ORIGIN || "http://localhost:3000"; 1454 1512 return new Response(html, { 1455 - headers: { "Content-Type": "text/html" }, 1513 + headers: { 1514 + "Content-Type": "text/html", 1515 + Link: `<${origin}/.well-known/oauth-authorization-server>; rel="indieauth-metadata"`, 1516 + }, 1456 1517 }); 1457 1518 } 1458 1519 ··· 1467 1528 return Response.json({ error: "Admin access required" }, { status: 403 }); 1468 1529 } 1469 1530 1470 - const body = await req.json() as { 1531 + const body = (await req.json()) as { 1471 1532 maxUses?: number; 1472 1533 expiresAt?: number | null; 1473 1534 note?: string | null; ··· 1481 1542 const note = body.note || null; 1482 1543 const message = body.message || null; 1483 1544 1484 - const result = db.query( 1485 - "INSERT INTO invites (code, created_by, max_uses, expires_at, note, message) VALUES (?, ?, ?, ?, ?, ?)", 1486 - ).run(inviteCode, user.userId, maxUses, expiresAt, note, message); 1545 + const result = db 1546 + .query( 1547 + "INSERT INTO invites (code, created_by, max_uses, expires_at, note, message) VALUES (?, ?, ?, ?, ?, ?)", 1548 + ) 1549 + .run(inviteCode, user.userId, maxUses, expiresAt, note, message); 1487 1550 1488 1551 const inviteId = Number(result.lastInsertRowid); 1489 1552 ··· 1514 1577 return Response.json({ error: "Admin access required" }, { status: 403 }); 1515 1578 } 1516 1579 1517 - const invites = db.query(` 1580 + const invites = db 1581 + .query(` 1518 1582 SELECT i.id, i.code, i.max_uses, i.current_uses, i.expires_at, i.note, i.message, i.created_at, 1519 1583 creator.username as created_by_username 1520 1584 FROM invites i 1521 1585 LEFT JOIN users creator ON i.created_by = creator.id 1522 1586 ORDER BY i.created_at DESC 1523 - `).all() as Array<{ 1587 + `) 1588 + .all() as Array<{ 1524 1589 id: number; 1525 1590 code: string; 1526 1591 max_uses: number; ··· 1533 1598 }>; 1534 1599 1535 1600 // Get app roles for each invite 1536 - const inviteRoles = db.query(` 1601 + const inviteRoles = db 1602 + .query(` 1537 1603 SELECT ir.invite_id, ir.app_id, ir.role, a.client_id, a.name 1538 1604 FROM invite_roles ir 1539 1605 JOIN apps a ON ir.app_id = a.id 1540 - `).all() as Array<{ 1606 + `) 1607 + .all() as Array<{ 1541 1608 invite_id: number; 1542 1609 app_id: number; 1543 1610 role: string; ··· 1546 1613 }>; 1547 1614 1548 1615 // Get users who used each invite 1549 - const inviteUses = db.query(` 1616 + const inviteUses = db 1617 + .query(` 1550 1618 SELECT iu.invite_id, iu.used_at, u.username 1551 1619 FROM invite_uses iu 1552 1620 JOIN users u ON iu.user_id = u.id 1553 1621 ORDER BY iu.used_at DESC 1554 - `).all() as Array<{ 1622 + `) 1623 + .all() as Array<{ 1555 1624 invite_id: number; 1556 1625 used_at: number; 1557 1626 username: string; ··· 1610 1679 return Response.json({ error: "Invalid invite ID" }, { status: 400 }); 1611 1680 } 1612 1681 1613 - const body = await req.json() as { 1682 + const body = (await req.json()) as { 1614 1683 maxUses?: number | null; 1615 1684 expiresAt?: number | null; 1616 1685 note?: string | null; ··· 1643 1712 1644 1713 values.push(inviteId); 1645 1714 1646 - db.query(`UPDATE invites SET ${updates.join(", ")} WHERE id = ?`).run(...values); 1715 + db.query(`UPDATE invites SET ${updates.join(", ")} WHERE id = ?`).run( 1716 + ...values, 1717 + ); 1647 1718 1648 1719 return Response.json({ success: true }); 1649 1720 } ··· 1673 1744 return Response.json({ success: true }); 1674 1745 } 1675 1746 1747 + // GET /.well-known/oauth-authorization-server - IndieAuth metadata endpoint 1748 + export function indieauthMetadata(): Response { 1749 + const origin = process.env.ORIGIN || "http://localhost:3000"; 1750 + 1751 + const metadata = { 1752 + issuer: origin, 1753 + authorization_endpoint: `${origin}/auth/authorize`, 1754 + token_endpoint: `${origin}/auth/token`, 1755 + code_challenge_methods_supported: ["S256"], 1756 + scopes_supported: ["profile", "email"], 1757 + response_types_supported: ["code"], 1758 + grant_types_supported: ["authorization_code"], 1759 + service_documentation: `${origin}/docs`, 1760 + }; 1761 + 1762 + return Response.json(metadata, { 1763 + headers: { 1764 + "Content-Type": "application/json", 1765 + "Access-Control-Allow-Origin": "*", 1766 + }, 1767 + }); 1768 + }
+8 -3
src/styles.css
··· 32 32 h1 { 33 33 font-size: 2rem; 34 34 font-weight: 700; 35 - background: linear-gradient(135deg, var(--old-rose), var(--berry-crush), var(--rosewood)); 35 + background: linear-gradient( 36 + 135deg, 37 + var(--old-rose), 38 + var(--berry-crush), 39 + var(--rosewood) 40 + ); 36 41 -webkit-background-clip: text; 37 42 -webkit-text-fill-color: transparent; 38 43 background-clip: text; ··· 147 152 } 148 153 149 154 button::before { 150 - content: ''; 155 + content: ""; 151 156 position: absolute; 152 157 top: -4px; 153 158 left: -4px; ··· 504 509 505 510 .divider::before, 506 511 .divider::after { 507 - content: ''; 512 + content: ""; 508 513 flex: 1; 509 514 border-bottom: 1px solid rgba(188, 141, 160, 0.3); 510 515 }