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

Configure Feed

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

feat: polish pre registered clients and add better docs

+1248 -116
+71 -25
src/client/admin-clients.ts
··· 81 81 description: string | null; 82 82 redirectUris: string[]; 83 83 isPreregistered: boolean; 84 + availableRoles: string[] | null; 85 + defaultRole: string | null; 84 86 firstSeen: number; 85 87 lastUsed: number; 86 88 } ··· 234 236 <div class="user-item"> 235 237 <div class="user-info"> 236 238 <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> 237 - ${data.client.isPreregistered ? ` 239 + ${data.client.isPreregistered && data.client.availableRoles !== null ? ` 238 240 <div class="user-role-input"> 239 - <label style="color: var(--old-rose); font-size: 0.75rem;">ROLE:</label> 240 - <input type="text" value="${user.role || ''}" placeholder="none" data-username="${user.username}" data-client-id="${clientId}" /> 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;"> 244 + <option value="">No role</option> 245 + ${data.client.availableRoles.map((role: string) => ` 246 + <option value="${role}" ${user.role === role ? 'selected' : ''}>${role}</option> 247 + `).join('')} 248 + </select>` 249 + : `<input type="text" value="${user.role || ''}" placeholder="e.g. admin, editor, viewer" data-username="${user.username}" data-client-id="${clientId}" />` 250 + } 241 251 <button onclick="event.stopPropagation(); setUserRole('${clientId}', '${user.username}', this.previousElementSibling.value)">update</button> 242 252 </div> 243 253 ` : ''} ··· 305 315 (document.getElementById('clientName') as HTMLInputElement).value = client.name || ''; 306 316 (document.getElementById('logoUrl') as HTMLInputElement).value = client.logoUrl || ''; 307 317 (document.getElementById('description') as HTMLTextAreaElement).value = client.description || ''; 318 + (document.getElementById('availableRoles') as HTMLTextAreaElement).value = client.availableRoles ? client.availableRoles.join('\n') : ''; 319 + (document.getElementById('defaultRole') as HTMLInputElement).value = client.defaultRole || ''; 308 320 309 321 redirectUrisList.innerHTML = client.redirectUris.map((uri: string) => ` 310 322 <div class="redirect-uri-item"> ··· 393 405 const name = (document.getElementById('clientName') as HTMLInputElement).value; 394 406 const logoUrl = (document.getElementById('logoUrl') as HTMLInputElement).value; 395 407 const description = (document.getElementById('description') as HTMLTextAreaElement).value; 408 + const availableRolesText = (document.getElementById('availableRoles') as HTMLTextAreaElement).value; 409 + const defaultRole = (document.getElementById('defaultRole') as HTMLInputElement).value; 396 410 397 411 const redirectUriInputs = Array.from(redirectUrisList.querySelectorAll('.redirect-uri-input')) as HTMLInputElement[]; 398 412 const redirectUris = redirectUriInputs.map(input => input.value).filter(uri => uri.trim()); 399 413 414 + // Parse available roles from textarea (one per line) 415 + const availableRoles = availableRolesText 416 + .split('\n') 417 + .map(r => r.trim()) 418 + .filter(r => r); 419 + 420 + // Validate default role is in available roles 421 + if (defaultRole && availableRoles.length > 0 && !availableRoles.includes(defaultRole)) { 422 + showToast('Default role must be one of the available roles', 'error'); 423 + return; 424 + } 425 + 400 426 if (redirectUris.length === 0) { 401 427 showToast('At least one redirect URI is required', 'error'); 402 428 return; ··· 421 447 logoUrl, 422 448 description, 423 449 redirectUris, 450 + availableRoles: availableRolesText.trim() ? availableRoles : null, 451 + defaultRole: defaultRole || undefined, 424 452 }), 425 453 }); 426 454 ··· 431 459 432 460 clientModal.classList.remove('active'); 433 461 434 - // If creating a new client, show the secret 462 + // If creating a new client, show the secret in modal 435 463 if (!isEdit) { 436 464 const result = await response.json(); 437 465 if (result.client && result.client.clientSecret) { 438 - // Show secret in modal 439 - const secretText = result.client.clientSecret; 440 - navigator.clipboard.writeText(secretText); 441 - showToast(`Client created! Secret copied to clipboard: ${secretText}`); 466 + const secretModal = document.getElementById('secretModal') as HTMLElement; 467 + const generatedSecret = document.getElementById('generatedSecret') as HTMLElement; 468 + 469 + if (generatedSecret && secretModal) { 470 + generatedSecret.textContent = result.client.clientSecret; 471 + secretModal.classList.add('active'); 472 + } 442 473 } 443 474 } else { 444 475 showToast('Client updated successfully'); ··· 470 501 471 502 const data = await response.json(); 472 503 473 - // Show the new secret in an alert (could also show in a modal) 474 - const secretInput = document.getElementById(`secret-${encodeURIComponent(clientId)}`) as HTMLInputElement; 475 - if (secretInput) { 476 - secretInput.type = 'text'; 477 - secretInput.value = data.clientSecret; 478 - secretInput.select(); 479 - 480 - // Copy to clipboard 481 - navigator.clipboard.writeText(data.clientSecret); 482 - 483 - showToast(`New secret generated and copied: ${data.clientSecret}`); 484 - 485 - // Reset to password field after a delay 486 - setTimeout(() => { 487 - secretInput.type = 'password'; 488 - secretInput.value = '••••••••••••••••••••••••'; 489 - }, 5000); 504 + // Show the secret in modal 505 + const secretModal = document.getElementById('secretModal') as HTMLElement; 506 + const generatedSecret = document.getElementById('generatedSecret') as HTMLElement; 507 + 508 + if (generatedSecret && secretModal) { 509 + generatedSecret.textContent = data.clientSecret; 510 + secretModal.classList.add('active'); 490 511 } 491 512 } catch (error) { 492 513 console.error('Failed to regenerate secret:', error); ··· 528 549 showToast('Failed to revoke permission. Please try again.', 'error'); 529 550 } 530 551 }; 552 + 553 + // Secret modal handlers 554 + const secretModal = document.getElementById('secretModal') as HTMLElement; 555 + const secretModalClose = document.getElementById('secretModalClose') as HTMLButtonElement; 556 + const copySecretBtn = document.getElementById('copySecretBtn') as HTMLButtonElement; 557 + 558 + secretModalClose?.addEventListener('click', () => { 559 + secretModal?.classList.remove('active'); 560 + }); 561 + 562 + copySecretBtn?.addEventListener('click', async () => { 563 + const generatedSecret = document.getElementById('generatedSecret') as HTMLElement; 564 + if (generatedSecret) { 565 + try { 566 + await navigator.clipboard.writeText(generatedSecret.textContent || ''); 567 + copySecretBtn.textContent = 'copied! ✓'; 568 + setTimeout(() => { 569 + copySecretBtn.textContent = 'copy to clipboard'; 570 + }, 2000); 571 + } catch (error) { 572 + console.error('Failed to copy:', error); 573 + showToast('Failed to copy to clipboard', 'error'); 574 + } 575 + } 576 + }); 531 577 532 578 checkAuth();
+218 -12
src/client/admin.ts
··· 63 63 } 64 64 65 65 async function createInvite() { 66 - createInviteBtn.disabled = true; 67 - createInviteBtn.textContent = 'creating...'; 66 + // Show the create invite modal 67 + const modal = document.getElementById('createInviteModal'); 68 + if (modal) { 69 + modal.style.display = 'flex'; 70 + // Load apps for role assignment 71 + await loadAppsForInvite(); 72 + } 73 + } 74 + 75 + async function loadAppsForInvite() { 76 + try { 77 + const response = await fetch('/api/admin/clients', { 78 + headers: { 79 + 'Authorization': `Bearer ${token}`, 80 + }, 81 + }); 82 + 83 + if (!response.ok) { 84 + throw new Error('Failed to load apps'); 85 + } 86 + 87 + const data = await response.json(); 88 + const appRolesContainer = document.getElementById('appRolesContainer'); 89 + 90 + if (!appRolesContainer) return; 91 + 92 + if (data.clients.length === 0) { 93 + appRolesContainer.innerHTML = '<p style="color: var(--old-rose); font-size: 0.875rem;">No pre-registered apps available</p>'; 94 + return; 95 + } 96 + 97 + appRolesContainer.innerHTML = data.clients 98 + .filter((app: { isPreregistered: boolean }) => app.isPreregistered) 99 + .map((app: { id: number; clientId: string; roles: string[] }) => { 100 + const roleOptions = app.roles.length > 0 101 + ? app.roles.map(role => `<option value="${role}">${role}</option>`).join('') 102 + : '<option value="" disabled>No roles defined yet</option>'; 103 + 104 + return ` 105 + <div class="app-role-item"> 106 + <label> 107 + <input type="checkbox" name="appRole" value="${app.id}" data-client-id="${app.clientId}"> 108 + <span>${app.clientId}</span> 109 + </label> 110 + <select class="role-select" data-app-id="${app.id}" disabled> 111 + <option value="">Select role...</option> 112 + ${roleOptions} 113 + <option value="__custom__">Custom...</option> 114 + </select> 115 + <input type="text" class="role-input-custom" placeholder="Enter custom role" data-app-id="${app.id}" style="display: none;" disabled> 116 + </div> 117 + `}).join(''); 118 + 119 + // Enable/disable role select and handle custom input 120 + const checkboxes = appRolesContainer.querySelectorAll('input[name="appRole"]'); 121 + checkboxes.forEach((checkbox) => { 122 + checkbox.addEventListener('change', (e) => { 123 + const target = e.target as HTMLInputElement; 124 + const appId = target.value; 125 + const roleSelect = appRolesContainer.querySelector(`select.role-select[data-app-id="${appId}"]`) as HTMLSelectElement; 126 + const customInput = appRolesContainer.querySelector(`input.role-input-custom[data-app-id="${appId}"]`) as HTMLInputElement; 127 + 128 + if (roleSelect) { 129 + roleSelect.disabled = !target.checked; 130 + if (!target.checked) { 131 + roleSelect.value = ''; 132 + if (customInput) { 133 + customInput.style.display = 'none'; 134 + customInput.disabled = true; 135 + customInput.value = ''; 136 + } 137 + } 138 + } 139 + }); 140 + }); 141 + 142 + // Handle custom role input toggle 143 + const roleSelects = appRolesContainer.querySelectorAll('select.role-select'); 144 + roleSelects.forEach((select) => { 145 + select.addEventListener('change', (e) => { 146 + const target = e.target as HTMLSelectElement; 147 + const appId = target.dataset.appId; 148 + const customInput = appRolesContainer.querySelector(`input.role-input-custom[data-app-id="${appId}"]`) as HTMLInputElement; 149 + 150 + if (customInput) { 151 + if (target.value === '__custom__') { 152 + customInput.style.display = 'block'; 153 + customInput.disabled = false; 154 + customInput.focus(); 155 + } else { 156 + customInput.style.display = 'none'; 157 + customInput.disabled = true; 158 + customInput.value = ''; 159 + } 160 + } 161 + }); 162 + }); 163 + } catch (error) { 164 + console.error('Failed to load apps:', error); 165 + } 166 + } 167 + 168 + async function submitCreateInvite() { 169 + const maxUsesInput = document.getElementById('maxUses') as HTMLInputElement; 170 + const expiresInInput = document.getElementById('expiresIn') as HTMLInputElement; 171 + const noteInput = document.getElementById('inviteNote') as HTMLTextAreaElement; 172 + const submitBtn = document.getElementById('submitInviteBtn') as HTMLButtonElement; 173 + 174 + const maxUses = maxUsesInput.value ? parseInt(maxUsesInput.value) : 1; 175 + const expiresIn = expiresInInput.value ? parseInt(expiresInInput.value) : null; 176 + const note = noteInput.value.trim() || null; 177 + 178 + // Collect app roles 179 + const appRolesContainer = document.getElementById('appRolesContainer'); 180 + const appRoles: Array<{ appId: number; role: string }> = []; 181 + 182 + if (appRolesContainer) { 183 + const checkedBoxes = appRolesContainer.querySelectorAll('input[name="appRole"]:checked'); 184 + checkedBoxes.forEach((checkbox) => { 185 + const appId = parseInt((checkbox as HTMLInputElement).value, 10); 186 + const roleSelect = appRolesContainer.querySelector(`select.role-select[data-app-id="${appId}"]`) as HTMLSelectElement; 187 + const customInput = appRolesContainer.querySelector(`input.role-input-custom[data-app-id="${appId}"]`) as HTMLInputElement; 188 + 189 + let role = ''; 190 + if (roleSelect && roleSelect.value) { 191 + if (roleSelect.value === '__custom__' && customInput && customInput.value.trim()) { 192 + role = customInput.value.trim(); 193 + } else if (roleSelect.value !== '__custom__') { 194 + role = roleSelect.value; 195 + } 196 + } 197 + 198 + if (role) { 199 + appRoles.push({ 200 + appId, 201 + role, 202 + }); 203 + } 204 + }); 205 + } 206 + 207 + submitBtn.disabled = true; 208 + submitBtn.textContent = 'creating...'; 68 209 69 210 try { 70 211 const response = await fetch('/api/invites/create', { 71 212 method: 'POST', 72 213 headers: { 73 214 'Authorization': `Bearer ${token}`, 215 + 'Content-Type': 'application/json', 74 216 }, 217 + body: JSON.stringify({ 218 + maxUses, 219 + expiresIn, 220 + note, 221 + appRoles: appRoles.length > 0 ? appRoles : undefined, 222 + }), 75 223 }); 76 224 77 225 if (!response.ok) { ··· 79 227 } 80 228 81 229 await loadInvites(); 230 + closeCreateInviteModal(); 82 231 } catch (error) { 83 232 console.error('Failed to create invite:', error); 84 233 alert('Failed to create invite'); 85 234 } finally { 86 - createInviteBtn.disabled = false; 87 - createInviteBtn.textContent = 'create invite link'; 235 + submitBtn.disabled = false; 236 + submitBtn.textContent = 'create invite'; 237 + } 238 + } 239 + 240 + function closeCreateInviteModal() { 241 + const modal = document.getElementById('createInviteModal'); 242 + if (modal) { 243 + modal.style.display = 'none'; 244 + // Reset form 245 + (document.getElementById('maxUses') as HTMLInputElement).value = '1'; 246 + (document.getElementById('expiresIn') as HTMLInputElement).value = ''; 247 + (document.getElementById('inviteNote') as HTMLTextAreaElement).value = ''; 248 + const appRolesContainer = document.getElementById('appRolesContainer'); 249 + if (appRolesContainer) { 250 + appRolesContainer.querySelectorAll('input').forEach((input) => { 251 + if (input.type === 'checkbox') { 252 + (input as HTMLInputElement).checked = false; 253 + } else { 254 + (input as HTMLInputElement).value = ''; 255 + (input as HTMLInputElement).disabled = true; 256 + } 257 + }); 258 + } 88 259 } 89 260 } 261 + 262 + // Expose functions to global scope for HTML onclick handlers 263 + (window as any).submitCreateInvite = submitCreateInvite; 264 + (window as any).closeCreateInviteModal = closeCreateInviteModal; 90 265 91 266 async function loadInvites() { 92 267 try { ··· 110 285 invitesList.innerHTML = data.invites.map((invite: { 111 286 id: number; 112 287 code: string; 113 - used: boolean; 288 + maxUses: number; 289 + currentUses: number; 290 + isExpired: boolean; 291 + isFullyUsed: boolean; 292 + expiresAt: number | null; 293 + note: string | null; 114 294 createdAt: number; 115 - usedAt: number | null; 116 295 createdBy: string; 117 - usedBy: string | null; 118 296 inviteUrl: string; 297 + appRoles: Array<{ clientId: string; role: string }>; 298 + usedBy: Array<{ username: string; usedAt: number }>; 119 299 }) => { 120 300 const createdDate = new Date(invite.createdAt * 1000).toLocaleDateString(); 121 - const status = invite.used 122 - ? `Used by ${invite.usedBy} on ${new Date(invite.usedAt! * 1000).toLocaleDateString()}` 123 - : 'Unused'; 301 + 302 + let status = `${invite.currentUses}/${invite.maxUses} used`; 303 + if (invite.isExpired) { 304 + status += ' (expired)'; 305 + } else if (invite.isFullyUsed) { 306 + status += ' (fully used)'; 307 + } 308 + 309 + const expiryInfo = invite.expiresAt 310 + ? `Expires: ${new Date(invite.expiresAt * 1000).toLocaleString()}` 311 + : 'No expiry'; 312 + 313 + const roleInfo = invite.appRoles.length > 0 314 + ? `<div class="invite-roles">Roles: ${invite.appRoles.map(r => `${r.clientId}: ${r.role}`).join(', ')}</div>` 315 + : ''; 316 + 317 + const usedByInfo = invite.usedBy.length > 0 318 + ? `<div class="invite-used-by">Used by: ${invite.usedBy.map(u => `${u.username} (${new Date(u.usedAt * 1000).toLocaleDateString()})`).join(', ')}</div>` 319 + : ''; 320 + 321 + const noteInfo = invite.note 322 + ? `<div class="invite-note">Note: ${invite.note}</div>` 323 + : ''; 324 + 325 + const isActive = !invite.isExpired && !invite.isFullyUsed; 124 326 125 327 return ` 126 - <div class="invite-item"> 328 + <div class="invite-item ${isActive ? '' : 'invite-inactive'}"> 127 329 <div> 128 330 <div class="invite-code">${invite.code}</div> 129 331 <div class="invite-meta">Created by ${invite.createdBy} on ${createdDate} • ${status}</div> 332 + <div class="invite-meta">${expiryInfo}</div> 333 + ${noteInfo} 334 + ${roleInfo} 335 + ${usedByInfo} 130 336 <div class="invite-url">${invite.inviteUrl}</div> 131 337 </div> 132 338 <div class="invite-actions-btns"> 133 - <button class="copy-btn" onclick="navigator.clipboard.writeText('${invite.inviteUrl}')">copy link</button> 339 + <button class="copy-btn" onclick="navigator.clipboard.writeText('${invite.inviteUrl}')" ${isActive ? '' : 'disabled'}>copy link</button> 134 340 </div> 135 341 </div> 136 342 `;
+178
src/client/docs.ts
··· 40 40 const exchangeBtn = document.getElementById('exchangeBtn') as HTMLButtonElement; 41 41 const resultSection = document.getElementById('resultSection') as HTMLElement; 42 42 const resultDiv = document.getElementById('result') as HTMLElement; 43 + const copyMarkdownBtn = document.getElementById('copyMarkdownBtn') as HTMLButtonElement; 43 44 44 45 // Auto-fill redirect URI with current page URL 45 46 const currentUrl = window.location.origin + window.location.pathname; ··· 47 48 48 49 // Auto-fill client ID with a test URL 49 50 clientIdInput.value = window.location.origin; 51 + 52 + // Update documentation examples with current origin 53 + const origin = window.location.origin; 54 + const authUrlEl = document.getElementById('authUrl'); 55 + const tokenUrlEl = document.getElementById('tokenUrl'); 56 + const profileMeUrlEl = document.getElementById('profileMeUrl'); 57 + 58 + if (authUrlEl) authUrlEl.textContent = `${origin}/auth/authorize`; 59 + if (tokenUrlEl) tokenUrlEl.textContent = `${origin}/auth/token`; 60 + if (profileMeUrlEl) profileMeUrlEl.textContent = `"${origin}/u/username"`; 50 61 51 62 // Check if we're handling a callback 52 63 const urlParams = new URLSearchParams(window.location.search); ··· 210 221 } 211 222 resultDiv.className = `result show ${type}`; 212 223 } 224 + 225 + // Convert HTML documentation to Markdown by parsing the DOM 226 + function extractMarkdown(): string { 227 + const lines: string[] = []; 228 + 229 + // Get title and subtitle from header 230 + const h1 = document.querySelector('header h1'); 231 + const subtitle = document.querySelector('header .subtitle'); 232 + 233 + if (h1) { 234 + lines.push(`# ${h1.textContent}`); 235 + lines.push(''); 236 + } 237 + 238 + if (subtitle) { 239 + lines.push(subtitle.textContent || ''); 240 + lines.push(''); 241 + } 242 + 243 + // Process each section (skip TOC and OAuth tester) 244 + const sections = document.querySelectorAll('.section'); 245 + 246 + sections.forEach((section) => { 247 + // Skip the OAuth tester section 248 + if (section.id === 'tester') return; 249 + 250 + processElement(section, lines); 251 + lines.push(''); 252 + }); 253 + 254 + return lines.join('\n'); 255 + } 256 + 257 + function processElement(el: Element, lines: string[], indent = 0): void { 258 + const tag = el.tagName.toLowerCase(); 259 + 260 + // Headers 261 + if (tag === 'h2') { 262 + lines.push(`## ${el.textContent}`); 263 + lines.push(''); 264 + } else if (tag === 'h3') { 265 + lines.push(`### ${el.textContent}`); 266 + lines.push(''); 267 + } 268 + // Paragraphs 269 + else if (tag === 'p') { 270 + lines.push(el.textContent || ''); 271 + lines.push(''); 272 + } 273 + // Lists 274 + else if (tag === 'ul' || tag === 'ol') { 275 + const items = el.querySelectorAll(':scope > li'); 276 + items.forEach((li, i) => { 277 + const prefix = tag === 'ol' ? `${i + 1}. ` : '- '; 278 + const text = getTextContent(li); 279 + lines.push(`${prefix}${text}`); 280 + }); 281 + lines.push(''); 282 + } 283 + // Tables 284 + else if (tag === 'table') { 285 + const headers: string[] = []; 286 + const rows: string[][] = []; 287 + 288 + // Get headers 289 + el.querySelectorAll('thead th').forEach((th) => { 290 + headers.push(th.textContent?.trim() || ''); 291 + }); 292 + 293 + // Get rows 294 + el.querySelectorAll('tbody tr').forEach((tr) => { 295 + const row: string[] = []; 296 + tr.querySelectorAll('td').forEach((td) => { 297 + row.push(td.textContent?.trim() || ''); 298 + }); 299 + rows.push(row); 300 + }); 301 + 302 + // Format as markdown table 303 + if (headers.length > 0) { 304 + lines.push(`| ${headers.join(' | ')} |`); 305 + lines.push(`|${headers.map(() => '-------').join('|')}|`); 306 + rows.forEach((row) => { 307 + lines.push(`| ${row.join(' | ')} |`); 308 + }); 309 + lines.push(''); 310 + } 311 + } 312 + // Code blocks 313 + else if (tag === 'pre') { 314 + const code = el.querySelector('code'); 315 + if (code) { 316 + // Detect language from class or content 317 + let lang = ''; 318 + const text = code.textContent || ''; 319 + 320 + if (text.includes('GET ') || text.includes('POST ')) { 321 + lang = 'http'; 322 + } else if (text.includes('{') && text.includes('"')) { 323 + lang = 'json'; 324 + } 325 + 326 + lines.push(`\`\`\`${lang}`); 327 + lines.push(text.trim()); 328 + lines.push('```'); 329 + lines.push(''); 330 + } 331 + } 332 + // Info boxes 333 + else if (el.classList.contains('info-box')) { 334 + const strong = el.querySelector('strong'); 335 + const text = el.textContent?.trim() || ''; 336 + 337 + if (strong) { 338 + // Extract content after the strong tag 339 + const afterStrong = text.substring(strong.textContent?.length || 0).trim(); 340 + lines.push(`> **${strong.textContent}** ${afterStrong}`); 341 + } else { 342 + lines.push(`> ${text}`); 343 + } 344 + lines.push(''); 345 + } 346 + // Process children for sections and divs 347 + else if (tag === 'section' || tag === 'div') { 348 + Array.from(el.children).forEach((child) => { 349 + processElement(child, lines, indent); 350 + }); 351 + } 352 + } 353 + 354 + // Get text content, preserving inline code formatting 355 + function getTextContent(el: Element): string { 356 + let text = ''; 357 + 358 + el.childNodes.forEach((node) => { 359 + if (node.nodeType === Node.TEXT_NODE) { 360 + text += node.textContent; 361 + } else if (node.nodeType === Node.ELEMENT_NODE) { 362 + const elem = node as Element; 363 + if (elem.tagName.toLowerCase() === 'code') { 364 + text += `\`${elem.textContent}\``; 365 + } else if (elem.tagName.toLowerCase() === 'strong') { 366 + text += `**${elem.textContent}**`; 367 + } else { 368 + text += elem.textContent; 369 + } 370 + } 371 + }); 372 + 373 + return text.trim(); 374 + } 375 + 376 + // Copy markdown to clipboard 377 + copyMarkdownBtn.addEventListener('click', async () => { 378 + const markdown = extractMarkdown(); 379 + 380 + try { 381 + await navigator.clipboard.writeText(markdown); 382 + copyMarkdownBtn.textContent = 'copied! ✓'; 383 + setTimeout(() => { 384 + copyMarkdownBtn.textContent = 'copy as markdown'; 385 + }, 2000); 386 + } catch (error) { 387 + console.error('Failed to copy:', error); 388 + alert('Failed to copy to clipboard'); 389 + } 390 + });
+39
src/html/admin-clients.html
··· 5 5 <meta charset="UTF-8" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 7 <title>oauth clients • admin • indiko</title> 8 + <meta name="description" content="Manage OAuth clients and application registrations" /> 8 9 <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" /> 10 + 11 + <!-- Open Graph / Facebook --> 12 + <meta property="og:type" content="website" /> 13 + <meta property="og:title" content="OAuth Clients • Indiko Admin" /> 14 + <meta property="og:description" content="Manage OAuth clients and application registrations" /> 15 + 16 + <!-- Twitter --> 17 + <meta name="twitter:card" content="summary" /> 18 + <meta name="twitter:title" content="OAuth Clients • Indiko Admin" /> 19 + <meta name="twitter:description" content="Manage OAuth clients and application registrations" /> 9 20 <link rel="preconnect" href="https://fonts.googleapis.com"> 10 21 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 11 22 <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> ··· 649 660 </div> 650 661 <button type="button" class="btn-add" id="addRedirectUriBtn">add redirect uri</button> 651 662 </div> 663 + <div class="form-group"> 664 + <label class="form-label">Available Roles (one per line)</label> 665 + <textarea class="form-input form-textarea" id="availableRoles" placeholder="admin&#10;editor&#10;viewer" style="min-height: 6rem;"></textarea> 666 + <p style="font-size: 0.75rem; color: var(--old-rose); margin-top: 0.5rem;">Define which roles can be assigned to users for this app. Leave empty to allow free-text roles.</p> 667 + </div> 668 + <div class="form-group"> 669 + <label class="form-label" for="defaultRole">Default Role</label> 670 + <input type="text" class="form-input" id="defaultRole" placeholder="Leave empty for no default" /> 671 + <p style="font-size: 0.75rem; color: var(--old-rose); margin-top: 0.5rem;">Automatically assigned when users first authorize this app.</p> 672 + </div> 652 673 <div class="form-actions"> 653 674 <button type="button" class="btn" style="background: rgba(188, 141, 160, 0.2);" id="cancelBtn">cancel</button> 654 675 <button type="submit" class="btn">save</button> 655 676 </div> 656 677 </form> 678 + </div> 679 + </div> 680 + 681 + <div id="secretModal" class="modal"> 682 + <div class="modal-content"> 683 + <div class="modal-header"> 684 + <h3 class="modal-title">Client Secret Generated</h3> 685 + <button class="modal-close" id="secretModalClose">&times;</button> 686 + </div> 687 + <div style="margin-bottom: 1.5rem;"> 688 + <p style="color: var(--rosewood); font-weight: 600; margin-bottom: 1rem;"> 689 + ⚠️ Save this secret now. You won't be able to see it again! 690 + </p> 691 + <div style="background: rgba(0, 0, 0, 0.3); padding: 1rem; border: 1px solid var(--old-rose); margin-bottom: 1rem;"> 692 + <code id="generatedSecret" style="color: var(--lavender); font-size: 0.875rem; word-break: break-all; display: block;"></code> 693 + </div> 694 + <button class="btn" id="copySecretBtn" style="width: 100%;">copy to clipboard</button> 695 + </div> 657 696 </div> 658 697 </div> 659 698
+284
src/html/admin.html
··· 5 5 <meta charset="UTF-8" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 7 <title>admin • indiko</title> 8 + <meta name="description" content="Indiko admin panel - manage users and invites" /> 8 9 <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" /> 10 + 11 + <!-- Open Graph / Facebook --> 12 + <meta property="og:type" content="website" /> 13 + <meta property="og:title" content="Admin • Indiko" /> 14 + <meta property="og:description" content="Indiko admin panel - manage users and invites" /> 15 + 16 + <!-- Twitter --> 17 + <meta name="twitter:card" content="summary" /> 18 + <meta name="twitter:title" content="Admin • Indiko" /> 19 + <meta name="twitter:description" content="Indiko admin panel - manage users and invites" /> 9 20 <link rel="preconnect" href="https://fonts.googleapis.com"> 10 21 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 11 22 <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> ··· 370 381 font-size: 0.75rem; 371 382 word-break: break-all; 372 383 } 384 + 385 + .invite-inactive { 386 + opacity: 0.6; 387 + } 388 + 389 + .invite-note { 390 + font-size: 0.875rem; 391 + color: var(--lavender); 392 + margin-top: 0.5rem; 393 + font-style: italic; 394 + } 395 + 396 + .invite-roles { 397 + font-size: 0.875rem; 398 + color: var(--berry-crush); 399 + margin-top: 0.5rem; 400 + } 401 + 402 + .invite-used-by { 403 + font-size: 0.75rem; 404 + color: var(--old-rose); 405 + margin-top: 0.5rem; 406 + } 407 + 408 + /* Modal styles */ 409 + .modal { 410 + display: none; 411 + position: fixed; 412 + top: 0; 413 + left: 0; 414 + width: 100%; 415 + height: 100%; 416 + background: rgba(0, 0, 0, 0.8); 417 + justify-content: center; 418 + align-items: center; 419 + z-index: 1000; 420 + } 421 + 422 + .modal-content { 423 + background: var(--mahogany); 424 + border: 2px solid var(--old-rose); 425 + padding: 2rem; 426 + max-width: 40rem; 427 + width: 90%; 428 + max-height: 90vh; 429 + overflow-y: auto; 430 + } 431 + 432 + .modal-header { 433 + display: flex; 434 + justify-content: space-between; 435 + align-items: center; 436 + margin-bottom: 1.5rem; 437 + } 438 + 439 + .modal-header h3 { 440 + font-size: 1.5rem; 441 + color: var(--lavender); 442 + margin: 0; 443 + } 444 + 445 + .modal-close { 446 + background: none; 447 + border: none; 448 + color: var(--old-rose); 449 + font-size: 1.5rem; 450 + cursor: pointer; 451 + padding: 0; 452 + line-height: 1; 453 + } 454 + 455 + .modal-close:hover { 456 + color: var(--berry-crush); 457 + } 458 + 459 + .form-group { 460 + margin-bottom: 1.5rem; 461 + } 462 + 463 + .form-group label { 464 + display: block; 465 + color: var(--lavender); 466 + margin-bottom: 0.5rem; 467 + font-size: 0.875rem; 468 + } 469 + 470 + .form-group input, 471 + .form-group textarea { 472 + width: 100%; 473 + background: rgba(0, 0, 0, 0.3); 474 + border: 1px solid var(--old-rose); 475 + color: var(--lavender); 476 + padding: 0.75rem; 477 + font-family: inherit; 478 + font-size: 1rem; 479 + } 480 + 481 + .form-group textarea { 482 + resize: vertical; 483 + min-height: 4rem; 484 + } 485 + 486 + .form-group input:focus, 487 + .form-group textarea:focus { 488 + outline: none; 489 + border-color: var(--berry-crush); 490 + } 491 + 492 + .form-help { 493 + font-size: 0.75rem; 494 + color: var(--old-rose); 495 + margin-top: 0.25rem; 496 + } 497 + 498 + .app-role-item { 499 + display: flex; 500 + align-items: center; 501 + gap: 1rem; 502 + padding: 0.75rem; 503 + background: rgba(188, 141, 160, 0.05); 504 + border: 1px solid var(--old-rose); 505 + margin-bottom: 0.5rem; 506 + } 507 + 508 + .app-role-item label { 509 + display: flex; 510 + align-items: center; 511 + gap: 0.5rem; 512 + flex: 1; 513 + margin: 0; 514 + cursor: pointer; 515 + } 516 + 517 + .app-role-item input[type="checkbox"] { 518 + appearance: none; 519 + width: 1.5rem; 520 + height: 1.5rem; 521 + border: 2px solid var(--old-rose); 522 + background: rgba(12, 23, 19, 0.6); 523 + cursor: pointer; 524 + flex-shrink: 0; 525 + position: relative; 526 + transition: all 0.2s; 527 + } 528 + 529 + .app-role-item input[type="checkbox"]:checked { 530 + background: var(--berry-crush); 531 + border-color: var(--berry-crush); 532 + } 533 + 534 + .app-role-item input[type="checkbox"]:checked::after { 535 + content: "✓"; 536 + position: absolute; 537 + top: 50%; 538 + left: 50%; 539 + transform: translate(-50%, -50%); 540 + color: var(--lavender); 541 + font-size: 1rem; 542 + font-weight: 700; 543 + } 544 + 545 + .app-role-item input[type="checkbox"]:disabled { 546 + cursor: not-allowed; 547 + opacity: 0.5; 548 + } 549 + 550 + .app-role-item input.role-input-custom { 551 + flex: 1; 552 + padding: 0.5rem; 553 + font-size: 0.875rem; 554 + } 555 + 556 + .app-role-item select.role-select { 557 + flex: 1; 558 + background: rgba(0, 0, 0, 0.3); 559 + border: 1px solid var(--old-rose); 560 + color: var(--lavender); 561 + padding: 0.5rem; 562 + font-family: inherit; 563 + font-size: 0.875rem; 564 + cursor: pointer; 565 + } 566 + 567 + .app-role-item select.role-select:disabled { 568 + opacity: 0.5; 569 + cursor: not-allowed; 570 + } 571 + 572 + .app-role-item select.role-select:focus { 573 + outline: none; 574 + border-color: var(--berry-crush); 575 + } 576 + 577 + .modal-actions { 578 + display: flex; 579 + gap: 1rem; 580 + justify-content: flex-end; 581 + margin-top: 2rem; 582 + } 583 + 584 + .modal-btn { 585 + padding: 0.75rem 1.5rem; 586 + font-family: inherit; 587 + font-size: 1rem; 588 + border: none; 589 + cursor: pointer; 590 + transition: background 0.2s; 591 + } 592 + 593 + .modal-btn-primary { 594 + background: var(--berry-crush); 595 + color: var(--lavender); 596 + } 597 + 598 + .modal-btn-primary:hover { 599 + background: var(--rosewood); 600 + } 601 + 602 + .modal-btn-primary:disabled { 603 + opacity: 0.5; 604 + cursor: not-allowed; 605 + } 606 + 607 + .modal-btn-secondary { 608 + background: rgba(188, 141, 160, 0.2); 609 + color: var(--lavender); 610 + border: 1px solid var(--old-rose); 611 + } 612 + 613 + .modal-btn-secondary:hover { 614 + background: rgba(188, 141, 160, 0.3); 615 + } 373 616 </style> 374 617 </head> 375 618 ··· 407 650 loading... 408 651 <div class="back-link"><a href="/">← back to dashboard</a></div> 409 652 </footer> 653 + 654 + <!-- Create Invite Modal --> 655 + <div id="createInviteModal" class="modal"> 656 + <div class="modal-content"> 657 + <div class="modal-header"> 658 + <h3>create invite</h3> 659 + <button class="modal-close" onclick="closeCreateInviteModal()">&times;</button> 660 + </div> 661 + 662 + <div class="form-group"> 663 + <label for="maxUses">maximum uses</label> 664 + <input type="number" id="maxUses" min="1" max="999" value="1"> 665 + <div class="form-help">How many people can use this invite (1-999)</div> 666 + </div> 667 + 668 + <div class="form-group"> 669 + <label for="expiresIn">expires in (hours)</label> 670 + <input type="number" id="expiresIn" min="1" placeholder="Leave empty for no expiry"> 671 + <div class="form-help">Optional: invite will expire after this many hours</div> 672 + </div> 673 + 674 + <div class="form-group"> 675 + <label for="inviteNote">note</label> 676 + <textarea id="inviteNote" placeholder="Optional note about this invite"></textarea> 677 + <div class="form-help">Internal note to help you remember what this invite is for</div> 678 + </div> 679 + 680 + <div class="form-group"> 681 + <label>app role assignments (optional)</label> 682 + <div class="form-help">Users who register with this invite will automatically be assigned these roles</div> 683 + <div id="appRolesContainer" style="margin-top: 1rem;"> 684 + <div class="loading">Loading apps...</div> 685 + </div> 686 + </div> 687 + 688 + <div class="modal-actions"> 689 + <button class="modal-btn modal-btn-secondary" onclick="closeCreateInviteModal()">cancel</button> 690 + <button class="modal-btn modal-btn-primary" id="submitInviteBtn" onclick="submitCreateInvite()">create invite</button> 691 + </div> 692 + </div> 693 + </div> 410 694 411 695 <script type="module" src="../client/admin.ts"></script> 412 696 </body>
+11
src/html/apps.html
··· 5 5 <meta charset="UTF-8" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 7 <title>authorized apps • indiko</title> 8 + <meta name="description" content="Manage apps you've authorized with your Indiko account" /> 8 9 <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" /> 10 + 11 + <!-- Open Graph / Facebook --> 12 + <meta property="og:type" content="website" /> 13 + <meta property="og:title" content="Authorized Apps • Indiko" /> 14 + <meta property="og:description" content="Manage apps you've authorized with your Indiko account" /> 15 + 16 + <!-- Twitter --> 17 + <meta name="twitter:card" content="summary" /> 18 + <meta name="twitter:title" content="Authorized Apps • Indiko" /> 19 + <meta name="twitter:description" content="Manage apps you've authorized with your Indiko account" /> 9 20 <link rel="preconnect" href="https://fonts.googleapis.com"> 10 21 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 11 22 <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
+148 -48
src/html/docs.html
··· 5 5 <meta charset="UTF-8" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 7 <title>documentation • indiko</title> 8 + <meta name="description" content="IndieAuth/OAuth 2.0 server documentation and interactive API testing" /> 8 9 <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" /> 10 + 11 + <!-- Open Graph / Facebook --> 12 + <meta property="og:type" content="website" /> 13 + <meta property="og:title" content="Documentation • Indiko" /> 14 + <meta property="og:description" content="IndieAuth/OAuth 2.0 server documentation and interactive API testing" /> 15 + 16 + <!-- Twitter --> 17 + <meta name="twitter:card" content="summary" /> 18 + <meta name="twitter:title" content="Documentation • Indiko" /> 19 + <meta name="twitter:description" content="IndieAuth/OAuth 2.0 server documentation and interactive API testing" /> 9 20 <link rel="preconnect" href="https://fonts.googleapis.com"> 10 21 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 11 22 <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> ··· 135 146 background: rgba(12, 23, 19, 0.8); 136 147 border: 1px solid var(--rosewood); 137 148 } 138 - 149 + 139 150 /* HTTP syntax highlighting */ 140 151 .http-method { 141 152 color: var(--berry-crush); 142 153 font-weight: 700; 143 154 } 144 - 155 + 145 156 .http-url { 146 157 color: #a5d6a7; 147 158 } 148 - 159 + 149 160 .http-header { 150 161 color: var(--old-rose); 151 162 } 152 - 163 + 153 164 .http-param { 154 165 color: #81c784; 155 166 } 156 - 167 + 157 168 /* JSON syntax highlighting */ 158 169 .json-key { 159 170 color: var(--berry-crush); 160 171 } 161 - 172 + 162 173 .json-string { 163 174 color: #a5d6a7; 164 175 } 165 - 176 + 166 177 .json-number { 167 178 color: #81c784; 168 179 } 169 - 180 + 170 181 .json-boolean { 171 182 color: var(--old-rose); 172 183 } 173 - 184 + 174 185 .token.property, 175 186 .token.tag, 176 187 .token.boolean, ··· 179 190 .token.symbol { 180 191 color: #81c784; 181 192 } 182 - 193 + 183 194 .token.selector, 184 195 .token.attr-name, 185 196 .token.string, ··· 187 198 .token.builtin { 188 199 color: #a5d6a7; 189 200 } 190 - 201 + 191 202 .token.punctuation { 192 203 color: var(--lavender); 193 204 } 194 - 205 + 195 206 .token.operator, 196 207 .token.entity, 197 208 .token.url, ··· 200 211 color: var(--old-rose); 201 212 } 202 213 203 - ul, ol { 214 + ul, 215 + ol { 204 216 margin-left: 1.5rem; 205 217 margin-bottom: 1rem; 206 218 line-height: 1.8; ··· 281 293 .toc a:hover { 282 294 color: var(--berry-crush); 283 295 text-decoration: underline; 296 + } 297 + 298 + .copy-btn { 299 + display: block; 300 + width: auto; 301 + padding: 0.75rem 1.5rem; 302 + font-size: 0.875rem; 303 + margin: 2rem auto; 284 304 } 285 305 286 306 .back-link { ··· 457 477 <p class="subtitle">IndieAuth/OAuth 2.0 server with passkey authentication</p> 458 478 </header> 459 479 480 + <button id="copyMarkdownBtn" class="copy-btn">copy as markdown</button> 481 + 460 482 <nav class="toc"> 461 483 <h3>table of contents</h3> 462 484 <ul> ··· 465 487 <li><a href="#endpoints">endpoints</a></li> 466 488 <li><a href="#authorization">authorization flow</a></li> 467 489 <li><a href="#scopes">scopes</a></li> 490 + <li><a href="#roles">roles</a></li> 468 491 <li><a href="#clients">client types</a></li> 469 492 <li><a href="#tester">oauth tester</a></li> 470 493 </ul> ··· 473 496 <section id="overview" class="section"> 474 497 <h2>overview</h2> 475 498 <p> 476 - Indiko is a self-hosted IndieAuth/OAuth 2.0 authorization server with passwordless authentication using WebAuthn passkeys. 499 + Indiko is a self-hosted IndieAuth/OAuth 2.0 authorization server with passwordless authentication using WebAuthn 500 + passkeys. 477 501 It provides single sign-on (SSO) for your apps and services. 478 502 </p> 479 503 ··· 505 529 <div class="info-box"> 506 530 <strong>Auto-registration:</strong> 507 531 Apps are automatically registered on first use. You don't need admin approval to get started. 508 - For advanced features like client secrets and role assignment, contact your Indiko admin to pre-register your app. 532 + For advanced features like client secrets and role assignment, contact your Indiko admin to pre-register your 533 + app. 509 534 </div> 510 535 511 536 <h3>for users</h3> ··· 604 629 <h2>authorization flow</h2> 605 630 606 631 <h3>1. redirect to authorization endpoint</h3> 607 - <pre><code><span class="http-method">GET</span> <span class="http-url">/auth/authorize</span>?<span class="http-param">response_type</span>=code 608 - &<span class="http-param">client_id</span>=https://myapp.example.com 609 - &<span class="http-param">redirect_uri</span>=https://myapp.example.com/callback 610 - &<span class="http-param">state</span>=random_state_string 611 - &<span class="http-param">code_challenge</span>=base64url_encoded_challenge 612 - &<span class="http-param">code_challenge_method</span>=S256 613 - &<span class="http-param">scope</span>=profile email</code></pre> 632 + <pre><code><span class="http-method">GET</span> <span class="http-url" id="authUrl">http://localhost:3000/auth/authorize</span>?<span 633 + class="http-param">response_type</span>=code 634 + &<span class="http-param">client_id</span>=https://myapp.example.com 635 + &<span class="http-param">redirect_uri</span>=https://myapp.example.com/callback 636 + &<span class="http-param">state</span>=random_state_string 637 + &<span class="http-param">code_challenge</span>=base64url_encoded_challenge 638 + &<span class="http-param">code_challenge_method</span>=S256 639 + &<span class="http-param">scope</span>=profile email</code></pre> 614 640 615 641 <div class="info-box"> 616 642 <strong>PKCE is required:</strong> 617 - Generate a random <code>code_verifier</code> (43-128 characters), then create <code>code_challenge</code> by hashing it with SHA-256 and base64url encoding. 643 + Generate a random <code>code_verifier</code> (43-128 characters), then create <code>code_challenge</code> by 644 + hashing it with SHA-256 and base64url encoding. 618 645 </div> 619 646 620 647 <h3>2. user authenticates and approves</h3> ··· 628 655 </ul> 629 656 630 657 <h3>3. redirect back with code</h3> 631 - <pre><code><span class="http-url">https://myapp.example.com/callback</span>?<span class="http-param">code</span>=short_lived_authorization_code 632 - &<span class="http-param">state</span>=random_state_string</code></pre> 658 + <pre><code><span class="http-url">https://myapp.example.com/callback</span>?<span 659 + class="http-param">code</span>=short_lived_authorization_code 660 + &<span class="http-param">state</span>=random_state_string</code></pre> 633 661 634 662 <h3>4. exchange code for token</h3> 635 - <pre><code><span class="http-method">POST</span> <span class="http-url">/auth/token</span> 636 - <span class="http-header">Content-Type</span>: application/x-www-form-urlencoded 663 + <pre><code><span class="http-method">POST</span> <span class="http-url" id="tokenUrl">http://localhost:3000/auth/token</span> 664 + <span class="http-header">Content-Type</span>: application/x-www-form-urlencoded 665 + 666 + <span class="http-param">grant_type</span>=authorization_code 667 + &<span class="http-param">code</span>=authorization_code 668 + &<span class="http-param">client_id</span>=https://myapp.example.com 669 + &<span class="http-param">redirect_uri</span>=https://myapp.example.com/callback 670 + &<span class="http-param">code_verifier</span>=original_code_verifier 671 + &<span class="http-param">client_secret</span>=your_client_secret (if pre-registered)</code></pre> 637 672 638 - <span class="http-param">grant_type</span>=authorization_code 639 - &<span class="http-param">code</span>=authorization_code 640 - &<span class="http-param">client_id</span>=https://myapp.example.com 641 - &<span class="http-param">redirect_uri</span>=https://myapp.example.com/callback 642 - &<span class="http-param">code_verifier</span>=original_code_verifier</code></pre> 673 + <div class="info-box"> 674 + <strong>Client authentication:</strong> 675 + Public clients (auto-registered) use PKCE for security. Pre-registered confidential clients must include <code>client_secret</code> in the token request. 676 + </div> 643 677 644 678 <h3>5. receive user profile</h3> 645 679 <pre><code>{ 646 - <span class="json-key">"me"</span>: <span class="json-string">"https://indiko.example.com/u/username"</span>, 647 - <span class="json-key">"profile"</span>: { 648 - <span class="json-key">"name"</span>: <span class="json-string">"Jane Doe"</span>, 649 - <span class="json-key">"email"</span>: <span class="json-string">"jane@example.com"</span>, 650 - <span class="json-key">"photo"</span>: <span class="json-string">"https://example.com/photo.jpg"</span>, 651 - <span class="json-key">"url"</span>: <span class="json-string">"https://jane.example.com"</span> 652 - }, 653 - <span class="json-key">"scope"</span>: <span class="json-string">"profile email"</span> 654 - }</code></pre> 680 + <span class="json-key">"me"</span>: <span class="json-string" id="profileMeUrl">"http://localhost:3000/u/username"</span>, 681 + <span class="json-key">"profile"</span>: { 682 + <span class="json-key">"name"</span>: <span class="json-string">"Jane Doe"</span>, 683 + <span class="json-key">"email"</span>: <span class="json-string">"jane@example.com"</span>, 684 + <span class="json-key">"photo"</span>: <span class="json-string">"https://example.com/photo.jpg"</span>, 685 + <span class="json-key">"url"</span>: <span class="json-string">"https://jane.example.com"</span> 686 + }, 687 + <span class="json-key">"scope"</span>: <span class="json-string">"profile email"</span>, 688 + <span class="json-key">"role"</span>: <span class="json-string">"admin"</span> 689 + }</code></pre> 690 + 691 + <div class="info-box"> 692 + <strong>Roles:</strong> 693 + If an admin has assigned a role to this user for your app, it will be included in the response. Roles are 694 + arbitrary strings that you can use for role-based access control (RBAC) in your application. 695 + </div> 655 696 </section> 656 697 657 698 <section id="scopes" class="section"> ··· 685 726 </div> 686 727 </section> 687 728 729 + <section id="roles" class="section"> 730 + <h2>roles</h2> 731 + <p> 732 + Roles enable role-based access control (RBAC) in your applications. <strong>Only pre-registered clients with client secrets support role assignment.</strong> 733 + </p> 734 + 735 + <div class="info-box"> 736 + <strong>Pre-registration required:</strong> 737 + To use roles, contact your Indiko admin to pre-register your app with a client secret. Auto-registered (public) clients cannot use roles. 738 + </div> 739 + 740 + <h3>how roles work</h3> 741 + <ul> 742 + <li>Roles are assigned by admins for specific user-app combinations</li> 743 + <li>Role strings are arbitrary (e.g., <code>"admin"</code>, <code>"editor"</code>, <code>"viewer"</code>)</li> 744 + <li>Only one role per user per app</li> 745 + <li>Included in token response if assigned</li> 746 + <li>Your app interprets the role string and enforces permissions</li> 747 + </ul> 748 + 749 + <div class="info-box"> 750 + <strong>Example use case:</strong> 751 + A CMS app could use roles like <code>"admin"</code>, <code>"editor"</code>, and <code>"viewer"</code>. When 752 + users authenticate via Indiko, the app checks their role and grants appropriate permissions. 753 + </div> 754 + 755 + <h3>defining app roles</h3> 756 + <p> 757 + Apps can define available roles in three ways: 758 + </p> 759 + <ul> 760 + <li><strong>Disabled (default):</strong> Leave "Available Roles" empty. No roles can be assigned.</li> 761 + <li><strong>Predefined roles:</strong> Specify allowed roles (one per line). Creates a dropdown for role selection, preventing typos.</li> 762 + <li><strong>Default role:</strong> Automatically assign a role when users first authorize your app.</li> 763 + </ul> 764 + 765 + <h3>assigning roles</h3> 766 + <p> 767 + Roles can be assigned in multiple ways: 768 + </p> 769 + <ol> 770 + <li><strong>Default role (automatic):</strong> If configured, users automatically receive the default role on first authorization.</li> 771 + <li><strong>Via invite codes:</strong> Admins can create invites with pre-assigned roles for specific apps. New 772 + users automatically get those roles on signup.</li> 773 + <li><strong>Via admin dashboard:</strong> Admins can assign or change roles for existing users in the clients 774 + management interface.</li> 775 + </ol> 776 + 777 + <div class="info-box"> 778 + <strong>Note:</strong> 779 + Roles are optional. If no role is assigned, the <code>role</code> field will not appear in the token response. 780 + </div> 781 + </section> 782 + 688 783 <section id="clients" class="section"> 689 784 <h2>client types</h2> 690 785 691 786 <h3>auto-registered clients</h3> 692 787 <p> 693 - Any app can use Indiko without pre-registration. On first authorization, the client is automatically registered with: 788 + Any app can use Indiko without pre-registration. On first authorization, the client is automatically registered 789 + with: 694 790 </p> 695 791 <ul> 696 792 <li>Client ID (must be a valid URL)</li> ··· 698 794 <li>Last used timestamp</li> 699 795 </ul> 700 796 <p> 701 - Auto-registered clients <strong>cannot</strong> use client secrets or assign user roles. 797 + Auto-registered clients <strong>cannot</strong> use client secrets or role assignment. They must use PKCE for security. 702 798 </p> 703 799 704 800 <h3>pre-registered clients</h3> 705 801 <p> 706 - Admins can pre-register clients for advanced features: 802 + Admins can pre-register clients for advanced features. <strong>All pre-registered clients require a client secret.</strong> 707 803 </p> 708 804 <ul> 709 - <li><strong>Client secret:</strong> Confidential clients can authenticate with secrets</li> 805 + <li><strong>Client secret:</strong> Required for all pre-registered clients (used in token exchange)</li> 710 806 <li><strong>Role assignment:</strong> Admins can assign per-user roles for RBAC</li> 807 + <li><strong>Available roles:</strong> Define which roles can be assigned (enforces dropdown selection)</li> 808 + <li><strong>Default role:</strong> Automatically assigned to users on first authorization</li> 711 809 <li><strong>Metadata:</strong> Custom name, logo, description</li> 712 810 </ul> 713 811 714 812 <div class="info-box"> 715 813 <strong>Tip:</strong> 716 - Contact your Indiko admin to pre-register your app if you need client authentication or role-based access control. 814 + Contact your Indiko admin to pre-register your app if you need client authentication or role-based access 815 + control. 717 816 </div> 718 817 </section> 719 818 ··· 761 860 762 861 <div class="info-box" style="margin-top: 2rem;"> 763 862 <strong>How it works:</strong> 764 - This page uses the current URL as the redirect URI. After authorization, the code is automatically detected and you can exchange it for user profile data. 863 + This page uses the current URL as the redirect URI. After authorization, the code is automatically detected and 864 + you can exchange it for user profile data. 765 865 </div> 766 866 </section> 767 867 ··· 773 873 <script type="module" src="../client/docs.ts"></script> 774 874 </body> 775 875 776 - </html> 876 + </html>
+11
src/html/index.html
··· 5 5 <meta charset="UTF-8" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 7 <title>dashboard • indiko</title> 8 + <meta name="description" content="Your Indiko dashboard - manage your profile, apps, and passkeys" /> 8 9 <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" /> 10 + 11 + <!-- Open Graph / Facebook --> 12 + <meta property="og:type" content="website" /> 13 + <meta property="og:title" content="Dashboard • Indiko" /> 14 + <meta property="og:description" content="Your Indiko dashboard - manage your profile, apps, and passkeys" /> 15 + 16 + <!-- Twitter --> 17 + <meta name="twitter:card" content="summary" /> 18 + <meta name="twitter:title" content="Dashboard • Indiko" /> 19 + <meta name="twitter:description" content="Your Indiko dashboard - manage your profile, apps, and passkeys" /> 9 20 <link rel="preconnect" href="https://fonts.googleapis.com"> 10 21 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 11 22 <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
+11
src/html/login.html
··· 5 5 <meta charset="UTF-8" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 7 <title>login • indiko</title> 8 + <meta name="description" content="Sign in to your Indiko account with passkey authentication" /> 8 9 <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" /> 10 + 11 + <!-- Open Graph / Facebook --> 12 + <meta property="og:type" content="website" /> 13 + <meta property="og:title" content="Login • Indiko" /> 14 + <meta property="og:description" content="Sign in to your Indiko account with passkey authentication" /> 15 + 16 + <!-- Twitter --> 17 + <meta name="twitter:card" content="summary" /> 18 + <meta name="twitter:title" content="Login • Indiko" /> 19 + <meta name="twitter:description" content="Sign in to your Indiko account with passkey authentication" /> 9 20 <link rel="preconnect" href="https://fonts.googleapis.com"> 10 21 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 11 22 <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
+11
src/html/profile.html
··· 5 5 <meta charset="UTF-8" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 7 <title>profile • indiko</title> 8 + <meta name="description" content="Edit your Indiko profile information" /> 8 9 <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" /> 10 + 11 + <!-- Open Graph / Facebook --> 12 + <meta property="og:type" content="website" /> 13 + <meta property="og:title" content="Edit Profile • Indiko" /> 14 + <meta property="og:description" content="Edit your Indiko profile information" /> 15 + 16 + <!-- Twitter --> 17 + <meta name="twitter:card" content="summary" /> 18 + <meta name="twitter:title" content="Edit Profile • Indiko" /> 19 + <meta name="twitter:description" content="Edit your Indiko profile information" /> 9 20 <link rel="preconnect" href="https://fonts.googleapis.com"> 10 21 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 11 22 <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
+48
src/migrations/004_enhance_invites.sql
··· 1 + -- Enhance invites table with usage limits, expiry, and app role assignments 2 + -- Note: SQLite doesn't support DROP COLUMN, so we keep old columns for backward compatibility 3 + -- But we'll use the new columns going forward 4 + 5 + -- Add new columns to invites table 6 + ALTER TABLE invites ADD COLUMN max_uses INTEGER DEFAULT 1; 7 + ALTER TABLE invites ADD COLUMN current_uses INTEGER NOT NULL DEFAULT 0; 8 + ALTER TABLE invites ADD COLUMN expires_at INTEGER; 9 + ALTER TABLE invites ADD COLUMN note TEXT; 10 + 11 + -- Create invite_roles table for app-specific role assignments 12 + CREATE TABLE IF NOT EXISTS invite_roles ( 13 + id INTEGER PRIMARY KEY AUTOINCREMENT, 14 + invite_id INTEGER NOT NULL, 15 + app_id INTEGER NOT NULL, 16 + role TEXT NOT NULL, 17 + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), 18 + FOREIGN KEY (invite_id) REFERENCES invites(id) ON DELETE CASCADE, 19 + FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE, 20 + UNIQUE(invite_id, app_id) 21 + ); 22 + 23 + CREATE INDEX IF NOT EXISTS idx_invite_roles_invite_id ON invite_roles(invite_id); 24 + CREATE INDEX IF NOT EXISTS idx_invite_roles_app_id ON invite_roles(app_id); 25 + 26 + -- Create invite_uses table to track each use (supports multi-use invites) 27 + CREATE TABLE IF NOT EXISTS invite_uses ( 28 + id INTEGER PRIMARY KEY AUTOINCREMENT, 29 + invite_id INTEGER NOT NULL, 30 + user_id INTEGER NOT NULL, 31 + used_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), 32 + FOREIGN KEY (invite_id) REFERENCES invites(id) ON DELETE CASCADE, 33 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 34 + ); 35 + 36 + CREATE INDEX IF NOT EXISTS idx_invite_uses_invite_id ON invite_uses(invite_id); 37 + CREATE INDEX IF NOT EXISTS idx_invite_uses_user_id ON invite_uses(user_id); 38 + 39 + -- Migrate existing single-use invites to new structure 40 + -- For invites that have been used, set current_uses = 1 and max_uses = 1 41 + UPDATE invites SET current_uses = 1, max_uses = 1 WHERE used = 1; 42 + 43 + -- For unused invites, set max_uses = 1 and current_uses = 0 44 + UPDATE invites SET max_uses = 1, current_uses = 0 WHERE used = 0; 45 + 46 + -- Migrate old invite uses to new invite_uses table 47 + INSERT INTO invite_uses (invite_id, user_id, used_at) 48 + SELECT id, used_by, used_at FROM invites WHERE used = 1 AND used_by IS NOT NULL AND used_at IS NOT NULL;
+5
src/migrations/005_add_app_roles.sql
··· 1 + -- Add available_roles column to apps table (JSON array of role names) 2 + ALTER TABLE apps ADD COLUMN available_roles TEXT; 3 + 4 + -- Add default_role column to apps table 5 + ALTER TABLE apps ADD COLUMN default_role TEXT;
+43 -10
src/routes/auth.ts
··· 61 61 62 62 // Validate invite code 63 63 const invite = db 64 - .query("SELECT id, used FROM invites WHERE code = ?") 65 - .get(inviteCode) as { id: number; used: number } | undefined; 64 + .query("SELECT id, max_uses, current_uses, expires_at FROM invites WHERE code = ?") 65 + .get(inviteCode) as { id: number; max_uses: number; current_uses: number; expires_at: number | null } | undefined; 66 66 67 67 if (!invite) { 68 68 return Response.json({ error: "Invalid invite code" }, { status: 403 }); 69 69 } 70 70 71 - if (invite.used === 1) { 72 - return Response.json({ error: "Invite code already used" }, { status: 403 }); 71 + const now = Math.floor(Date.now() / 1000); 72 + if (invite.expires_at && invite.expires_at < now) { 73 + return Response.json({ error: "Invite code expired" }, { status: 403 }); 74 + } 75 + 76 + if (invite.current_uses >= invite.max_uses) { 77 + return Response.json({ error: "Invite code fully used" }, { status: 403 }); 73 78 } 74 79 } 75 80 ··· 157 162 158 163 // If not bootstrap, validate invite code 159 164 let inviteId: number | undefined; 165 + let inviteRoles: Array<{ app_id: number; role: string }> = []; 160 166 if (!isBootstrap) { 161 167 if (!inviteCode) { 162 168 return Response.json({ error: "Invite code required" }, { status: 403 }); 163 169 } 164 170 165 171 const invite = db 166 - .query("SELECT id, used FROM invites WHERE code = ?") 167 - .get(inviteCode) as { id: number; used: number } | undefined; 172 + .query("SELECT id, max_uses, current_uses, expires_at FROM invites WHERE code = ?") 173 + .get(inviteCode) as { id: number; max_uses: number; current_uses: number; expires_at: number | null } | undefined; 168 174 169 175 if (!invite) { 170 176 return Response.json({ error: "Invalid invite code" }, { status: 403 }); 171 177 } 172 178 173 - if (invite.used === 1) { 174 - return Response.json({ error: "Invite code already used" }, { status: 403 }); 179 + const now = Math.floor(Date.now() / 1000); 180 + if (invite.expires_at && invite.expires_at < now) { 181 + return Response.json({ error: "Invite code expired" }, { status: 403 }); 182 + } 183 + 184 + if (invite.current_uses >= invite.max_uses) { 185 + return Response.json({ error: "Invite code fully used" }, { status: 403 }); 175 186 } 176 187 177 188 inviteId = invite.id; 189 + 190 + // Get app role assignments for this invite 191 + inviteRoles = db.query( 192 + "SELECT app_id, role FROM invite_roles WHERE invite_id = ?" 193 + ).all(inviteId) as Array<{ app_id: number; role: string }>; 178 194 } 179 195 180 196 // Verify WebAuthn response ··· 225 241 // Mark invite as used if applicable 226 242 if (inviteId) { 227 243 const usedAt = Math.floor(Date.now() / 1000); 244 + 245 + // Increment invite usage counter 228 246 db.query( 229 - "UPDATE invites SET used = 1, used_by = ?, used_at = ? WHERE id = ?", 230 - ).run(user.id, usedAt, inviteId); 247 + "UPDATE invites SET current_uses = current_uses + 1 WHERE id = ?", 248 + ).run(inviteId); 249 + 250 + // Record this invite use 251 + db.query( 252 + "INSERT INTO invite_uses (invite_id, user_id, used_at) VALUES (?, ?, ?)", 253 + ).run(inviteId, user.id, usedAt); 254 + 255 + // Assign app roles to the new user 256 + if (inviteRoles.length > 0) { 257 + const insertPermission = db.query( 258 + "INSERT INTO permissions (user_id, app_id, role) VALUES (?, ?, ?)", 259 + ); 260 + for (const { app_id, role } of inviteRoles) { 261 + insertPermission.run(user.id, app_id, role); 262 + } 263 + } 231 264 } 232 265 233 266 // Delete challenge
+81 -7
src/routes/clients.ts
··· 82 82 last_used: number; 83 83 }>; 84 84 85 + // Get distinct roles for each app 86 + const appRoles = db 87 + .query( 88 + `SELECT a.id as app_id, p.role 89 + FROM permissions p 90 + JOIN apps a ON p.client_id = a.client_id 91 + WHERE p.role IS NOT NULL AND p.role != '' 92 + GROUP BY a.id, p.role 93 + ORDER BY a.id, p.role`, 94 + ) 95 + .all() as Array<{ app_id: number; role: string }>; 96 + 97 + // Group roles by app_id 98 + const rolesByApp = new Map<number, string[]>(); 99 + for (const { app_id, role } of appRoles) { 100 + if (!rolesByApp.has(app_id)) { 101 + rolesByApp.set(app_id, []); 102 + } 103 + rolesByApp.get(app_id)!.push(role); 104 + } 105 + 85 106 return Response.json({ 86 107 clients: clients.map((c) => ({ 87 108 id: c.id, ··· 93 114 isPreregistered: c.is_preregistered === 1, 94 115 firstSeen: c.first_seen, 95 116 lastUsed: c.last_used, 117 + roles: rolesByApp.get(c.id) || [], 96 118 })), 97 119 }); 98 120 } ··· 109 131 110 132 try { 111 133 const body = await req.json(); 112 - const { clientId, name, logoUrl, description, redirectUris } = body; 134 + const { clientId, name, logoUrl, description, redirectUris, availableRoles, defaultRole } = body; 113 135 114 136 if (!clientId || typeof clientId !== "string") { 115 137 return Response.json({ error: "Client ID is required" }, { status: 400 }); ··· 145 167 const clientSecret = generateClientSecret(); 146 168 const clientSecretHash = hashSecret(clientSecret); 147 169 170 + // Validate roles if provided 171 + let rolesArray: string[] = []; 172 + if (availableRoles) { 173 + if (!Array.isArray(availableRoles)) { 174 + return Response.json({ error: "Available roles must be an array" }, { status: 400 }); 175 + } 176 + rolesArray = availableRoles.filter((r: unknown) => typeof r === 'string' && r.trim()); 177 + } 178 + 179 + // Validate default role is in available roles 180 + if (defaultRole && rolesArray.length > 0 && !rolesArray.includes(defaultRole)) { 181 + return Response.json({ error: "Default role must be one of the available roles" }, { status: 400 }); 182 + } 183 + 148 184 const result = db 149 185 .query( 150 - `INSERT INTO apps (client_id, name, logo_url, description, redirect_uris, is_preregistered, client_secret_hash, first_seen, last_used) 151 - VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)`, 186 + `INSERT INTO apps (client_id, name, logo_url, description, redirect_uris, is_preregistered, client_secret_hash, available_roles, default_role, first_seen, last_used) 187 + VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)`, 152 188 ) 153 189 .run( 154 190 clientId, ··· 157 193 description || null, 158 194 JSON.stringify(redirectUris), 159 195 clientSecretHash, 196 + rolesArray.length > 0 ? JSON.stringify(rolesArray) : null, 197 + defaultRole || null, 160 198 Math.floor(Date.now() / 1000), 161 199 Math.floor(Date.now() / 1000), 162 200 ); ··· 200 238 description, 201 239 redirect_uris, 202 240 is_preregistered, 241 + available_roles, 242 + default_role, 203 243 first_seen, 204 244 last_used 205 245 FROM apps ··· 214 254 description: string | null; 215 255 redirect_uris: string; 216 256 is_preregistered: number; 257 + available_roles: string | null; 258 + default_role: string | null; 217 259 first_seen: number; 218 260 last_used: number; 219 261 } ··· 255 297 description: client.description, 256 298 redirectUris: JSON.parse(client.redirect_uris) as string[], 257 299 isPreregistered: client.is_preregistered === 1, 300 + availableRoles: client.available_roles ? JSON.parse(client.available_roles) as string[] : null, 301 + defaultRole: client.default_role, 258 302 firstSeen: client.first_seen, 259 303 lastUsed: client.last_used, 260 304 }, ··· 281 325 282 326 try { 283 327 const body = await req.json(); 284 - const { name, logoUrl, description, redirectUris } = body; 328 + const { name, logoUrl, description, redirectUris, availableRoles, defaultRole } = body; 285 329 286 330 const existing = db 287 331 .query("SELECT id, is_preregistered FROM apps WHERE client_id = ?") ··· 305 349 } 306 350 } 307 351 352 + // Validate roles if provided 353 + let rolesArray: string[] | null = null; 354 + if (availableRoles !== undefined) { 355 + if (availableRoles === null) { 356 + // Explicitly disable roles 357 + rolesArray = null; 358 + } else if (Array.isArray(availableRoles)) { 359 + rolesArray = availableRoles.filter((r: unknown) => typeof r === 'string' && r.trim()); 360 + } else { 361 + return Response.json({ error: "Available roles must be an array or null" }, { status: 400 }); 362 + } 363 + } 364 + 365 + // Validate default role is in available roles 366 + if (defaultRole && rolesArray && rolesArray.length > 0 && !rolesArray.includes(defaultRole)) { 367 + return Response.json({ error: "Default role must be one of the available roles" }, { status: 400 }); 368 + } 369 + 308 370 db.query( 309 371 `UPDATE apps 310 - SET name = ?, logo_url = ?, description = ?, redirect_uris = ? 372 + SET name = ?, logo_url = ?, description = ?, redirect_uris = ?, available_roles = ?, default_role = ? 311 373 WHERE client_id = ?`, 312 374 ).run( 313 375 name || null, 314 376 logoUrl || null, 315 377 description || null, 316 378 redirectUris ? JSON.stringify(redirectUris) : null, 379 + rolesArray !== null ? (rolesArray.length > 0 ? JSON.stringify(rolesArray) : null) : null, 380 + defaultRole || null, 317 381 clientId, 318 382 ); 319 383 ··· 374 438 } 375 439 376 440 const client = db 377 - .query("SELECT id FROM apps WHERE client_id = ?") 378 - .get(clientId); 441 + .query("SELECT id, available_roles FROM apps WHERE client_id = ?") 442 + .get(clientId) as { id: number; available_roles: string | null } | undefined; 379 443 380 444 if (!client) { 381 445 return Response.json({ error: "Client not found" }, { status: 404 }); 446 + } 447 + 448 + // Validate role against available roles if defined 449 + if (role && client.available_roles) { 450 + const availableRoles = JSON.parse(client.available_roles) as string[]; 451 + if (!availableRoles.includes(role)) { 452 + return Response.json({ 453 + error: `Role must be one of: ${availableRoles.join(', ')}` 454 + }, { status: 400 }); 455 + } 382 456 } 383 457 384 458 const permission = db
+89 -14
src/routes/indieauth.ts
··· 570 570 "UPDATE permissions SET scopes = ?, last_used = ? WHERE user_id = ? AND client_id = ?", 571 571 ).run(JSON.stringify(approvedScopes), Math.floor(Date.now() / 1000), user.userId, clientId); 572 572 } else { 573 + // Get app's default role for new permissions 574 + const app = db 575 + .query("SELECT default_role FROM apps WHERE client_id = ?") 576 + .get(clientId) as { default_role: string | null } | undefined; 577 + 573 578 db.query( 574 - "INSERT INTO permissions (user_id, client_id, scopes) VALUES (?, ?, ?)", 575 - ).run(user.userId, clientId, JSON.stringify(approvedScopes)); 579 + "INSERT INTO permissions (user_id, client_id, scopes, role) VALUES (?, ?, ?, ?)", 580 + ).run(user.userId, clientId, JSON.stringify(approvedScopes), app?.default_role || null); 576 581 } 577 582 578 583 // Update app last_used ··· 820 825 profile.email = user.email; 821 826 } 822 827 823 - return Response.json({ 828 + // Get user's role for this app (if assigned) 829 + const permission = db 830 + .query("SELECT role FROM permissions WHERE user_id = ? AND client_id = ?") 831 + .get(authcode.user_id, client_id) as { role: string | null } | undefined; 832 + 833 + const response: Record<string, unknown> = { 824 834 me: `${process.env.ORIGIN}/u/${user.username}`, 825 835 profile, 826 - }); 836 + scope: scopes.join(" "), 837 + }; 838 + 839 + // Include role if assigned 840 + if (permission?.role) { 841 + response.role = permission.role; 842 + } 843 + 844 + return Response.json(response); 827 845 } catch (error) { 828 846 console.error("Token exchange error:", error); 829 847 return Response.json( ··· 877 895 <meta charset="UTF-8" /> 878 896 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 879 897 <title>${user.name} • indiko</title> 898 + <meta name="description" content="${user.name}'s profile on Indiko${user.url ? ` - ${user.url}` : ""}" /> 880 899 <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> 881 900 <link rel="authorization_endpoint" href="${process.env.ORIGIN}/auth/authorize" /> 882 901 <link rel="token_endpoint" href="${process.env.ORIGIN}/auth/token" /> 883 902 ${user.url ? `<link rel="me" href="${user.url}" />` : ""} 903 + 904 + <!-- Open Graph / Facebook --> 905 + <meta property="og:type" content="profile" /> 906 + <meta property="og:title" content="${user.name}" /> 907 + <meta property="og:description" content="${user.name}'s profile on Indiko" /> 908 + <meta property="og:url" content="${process.env.ORIGIN}/u/${user.username}" /> 909 + ${user.photo ? `<meta property="og:image" content="${user.photo}" />` : ""} 910 + <meta property="profile:username" content="${user.username}" /> 911 + 912 + <!-- Twitter --> 913 + <meta name="twitter:card" content="summary" /> 914 + <meta name="twitter:title" content="${user.name}" /> 915 + <meta name="twitter:description" content="${user.name}'s profile on Indiko" /> 916 + ${user.photo ? `<meta name="twitter:image" content="${user.photo}" />` : ""} 917 + 884 918 <link rel="preconnect" href="https://fonts.googleapis.com"> 885 919 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 886 920 <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> ··· 1082 1116 } 1083 1117 1084 1118 const invites = db.query(` 1085 - SELECT i.id, i.code, i.used, i.created_at, i.used_at, 1086 - creator.username as created_by_username, 1087 - usedby.username as used_by_username 1119 + SELECT i.id, i.code, i.max_uses, i.current_uses, i.expires_at, i.note, i.created_at, 1120 + creator.username as created_by_username 1088 1121 FROM invites i 1089 1122 LEFT JOIN users creator ON i.created_by = creator.id 1090 - LEFT JOIN users usedby ON i.used_by = usedby.id 1091 1123 ORDER BY i.created_at DESC 1092 1124 `).all() as Array<{ 1093 1125 id: number; 1094 1126 code: string; 1095 - used: number; 1127 + max_uses: number; 1128 + current_uses: number; 1129 + expires_at: number | null; 1130 + note: string | null; 1096 1131 created_at: number; 1097 - used_at: number | null; 1098 1132 created_by_username: string; 1099 - used_by_username: string | null; 1133 + }>; 1134 + 1135 + // Get app roles for each invite 1136 + const inviteRoles = db.query(` 1137 + SELECT ir.invite_id, ir.app_id, ir.role, a.client_id 1138 + FROM invite_roles ir 1139 + JOIN apps a ON ir.app_id = a.id 1140 + `).all() as Array<{ 1141 + invite_id: number; 1142 + app_id: number; 1143 + role: string; 1144 + client_id: string; 1145 + }>; 1146 + 1147 + // Get users who used each invite 1148 + const inviteUses = db.query(` 1149 + SELECT iu.invite_id, iu.used_at, u.username 1150 + FROM invite_uses iu 1151 + JOIN users u ON iu.user_id = u.id 1152 + ORDER BY iu.used_at DESC 1153 + `).all() as Array<{ 1154 + invite_id: number; 1155 + used_at: number; 1156 + username: string; 1100 1157 }>; 1158 + 1159 + const now = Math.floor(Date.now() / 1000); 1101 1160 1102 1161 return Response.json({ 1103 1162 invites: invites.map((inv) => ({ 1104 1163 id: inv.id, 1105 1164 code: inv.code, 1106 - used: inv.used === 1, 1165 + maxUses: inv.max_uses, 1166 + currentUses: inv.current_uses, 1167 + isExpired: inv.expires_at ? inv.expires_at < now : false, 1168 + isFullyUsed: inv.current_uses >= inv.max_uses, 1169 + expiresAt: inv.expires_at, 1170 + note: inv.note, 1107 1171 createdAt: inv.created_at, 1108 - usedAt: inv.used_at, 1109 1172 createdBy: inv.created_by_username, 1110 - usedBy: inv.used_by_username, 1111 1173 inviteUrl: `${process.env.ORIGIN}/login?invite=${inv.code}`, 1174 + appRoles: inviteRoles 1175 + .filter((r) => r.invite_id === inv.id) 1176 + .map((r) => ({ 1177 + appId: r.app_id, 1178 + clientId: r.client_id, 1179 + role: r.role, 1180 + })), 1181 + usedBy: inviteUses 1182 + .filter((u) => u.invite_id === inv.id) 1183 + .map((u) => ({ 1184 + username: u.username, 1185 + usedAt: u.used_at, 1186 + })), 1112 1187 })), 1113 1188 }); 1114 1189 }