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: move invites to its own page

+1442 -694
+21
src/client/admin-clients.ts
··· 575 575 } 576 576 }); 577 577 578 + // Close modals on escape key 579 + document.addEventListener('keydown', (e) => { 580 + if (e.key === 'Escape') { 581 + clientModal?.classList.remove('active'); 582 + secretModal?.classList.remove('active'); 583 + } 584 + }); 585 + 586 + // Close modals on outside click 587 + clientModal?.addEventListener('click', (e) => { 588 + if (e.target === clientModal) { 589 + clientModal.classList.remove('active'); 590 + } 591 + }); 592 + 593 + secretModal?.addEventListener('click', (e) => { 594 + if (e.target === secretModal) { 595 + secretModal.classList.remove('active'); 596 + } 597 + }); 598 + 578 599 checkAuth();
+501
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; 5 + 6 + // Check auth and display user 7 + async function checkAuth() { 8 + if (!token) { 9 + window.location.href = '/login'; 10 + return; 11 + } 12 + 13 + try { 14 + const response = await fetch('/api/hello', { 15 + headers: { 16 + 'Authorization': `Bearer ${token}`, 17 + }, 18 + }); 19 + 20 + if (response.status === 401) { 21 + localStorage.removeItem('indiko_session'); 22 + window.location.href = '/login'; 23 + return; 24 + } 25 + 26 + const data = await response.json(); 27 + 28 + footer.innerHTML = `admin • signed in as <strong><a href="/u/${data.username}">${data.username}</a></strong> • <a href="/login" id="logoutLink">sign out</a> 29 + <div class="back-link"><a href="/">← back to dashboard</a></div>`; 30 + 31 + // 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 + }); 47 + 48 + // Check if admin 49 + if (!data.isAdmin) { 50 + window.location.href = '/'; 51 + return; 52 + } 53 + 54 + // Load invites 55 + loadInvites(); 56 + } catch (error) { 57 + console.error('Auth check failed:', error); 58 + footer.textContent = 'error loading user info'; 59 + usersList.innerHTML = '<div class="error">Failed to load users</div>'; 60 + } 61 + } 62 + 63 + async function createInvite() { 64 + // Show the create invite modal 65 + const modal = document.getElementById('createInviteModal'); 66 + if (modal) { 67 + modal.style.display = 'flex'; 68 + // Load apps for role assignment 69 + await loadAppsForInvite(); 70 + } 71 + } 72 + 73 + async function loadAppsForInvite() { 74 + try { 75 + const response = await fetch('/api/admin/clients', { 76 + headers: { 77 + 'Authorization': `Bearer ${token}`, 78 + }, 79 + }); 80 + 81 + if (!response.ok) { 82 + throw new Error('Failed to load apps'); 83 + } 84 + 85 + const data = await response.json(); 86 + const appRolesContainer = document.getElementById('appRolesContainer'); 87 + 88 + if (!appRolesContainer) return; 89 + 90 + if (data.clients.length === 0) { 91 + appRolesContainer.innerHTML = '<p style="color: var(--old-rose); font-size: 0.875rem;">No pre-registered apps available</p>'; 92 + return; 93 + } 94 + 95 + appRolesContainer.innerHTML = data.clients 96 + .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 ` 105 + <div class="app-role-item"> 106 + <label> 107 + <input type="checkbox" name="appRole" value="${app.id}" data-client-id="${app.clientId}"> 108 + <span>${displayName}</span> 109 + </label> 110 + <select class="role-select" data-app-id="${app.id}" disabled> 111 + <option value="">Select role...</option> 112 + ${roleOptions} 113 + </select> 114 + </div> 115 + `}).join(''); 116 + 117 + // Enable/disable role select when checkbox changes 118 + const checkboxes = appRolesContainer.querySelectorAll('input[name="appRole"]'); 119 + checkboxes.forEach((checkbox) => { 120 + checkbox.addEventListener('change', (e) => { 121 + const target = e.target as HTMLInputElement; 122 + const appId = target.value; 123 + const roleSelect = appRolesContainer.querySelector(`select.role-select[data-app-id="${appId}"]`) as HTMLSelectElement; 124 + 125 + if (roleSelect) { 126 + roleSelect.disabled = !target.checked; 127 + if (!target.checked) { 128 + roleSelect.value = ''; 129 + } 130 + } 131 + }); 132 + }); 133 + } catch (error) { 134 + console.error('Failed to load apps:', error); 135 + } 136 + } 137 + 138 + 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; 144 + 145 + const maxUses = maxUsesInput.value ? parseInt(maxUsesInput.value, 10) : 1; 146 + const expiresAt = expiresAtInput.value ? Math.floor(new Date(expiresAtInput.value).getTime() / 1000) : null; 147 + const note = noteInput.value.trim() || null; 148 + const message = messageInput.value.trim() || null; 149 + 150 + // Collect app roles 151 + const appRolesContainer = document.getElementById('appRolesContainer'); 152 + const appRoles: Array<{ appId: number; role: string }> = []; 153 + 154 + if (appRolesContainer) { 155 + const checkedBoxes = appRolesContainer.querySelectorAll('input[name="appRole"]:checked'); 156 + checkedBoxes.forEach((checkbox) => { 157 + 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 = ''; 161 + if (roleSelect && roleSelect.value) { 162 + role = roleSelect.value; 163 + } 164 + 165 + if (role) { 166 + appRoles.push({ 167 + appId, 168 + role, 169 + }); 170 + } 171 + }); 172 + } 173 + 174 + submitBtn.disabled = true; 175 + submitBtn.textContent = 'creating...'; 176 + 177 + try { 178 + const response = await fetch('/api/invites/create', { 179 + method: 'POST', 180 + headers: { 181 + 'Authorization': `Bearer ${token}`, 182 + 'Content-Type': 'application/json', 183 + }, 184 + body: JSON.stringify({ 185 + maxUses, 186 + expiresAt, 187 + note, 188 + message, 189 + appRoles: appRoles.length > 0 ? appRoles : undefined, 190 + }), 191 + }); 192 + 193 + if (!response.ok) { 194 + throw new Error('Failed to create invite'); 195 + } 196 + 197 + await loadInvites(); 198 + closeCreateInviteModal(); 199 + } catch (error) { 200 + console.error('Failed to create invite:', error); 201 + alert('Failed to create invite'); 202 + } finally { 203 + submitBtn.disabled = false; 204 + submitBtn.textContent = 'create invite'; 205 + } 206 + } 207 + 208 + function closeCreateInviteModal() { 209 + const modal = document.getElementById('createInviteModal'); 210 + if (modal) { 211 + modal.style.display = 'none'; 212 + // 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'); 218 + 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 = ''; 224 + (select as HTMLSelectElement).disabled = true; 225 + }); 226 + } 227 + } 228 + } 229 + 230 + // Expose functions to global scope for HTML onclick handlers 231 + (window as any).submitCreateInvite = submitCreateInvite; 232 + (window as any).closeCreateInviteModal = closeCreateInviteModal; 233 + 234 + async function loadInvites() { 235 + try { 236 + const response = await fetch('/api/invites', { 237 + headers: { 238 + 'Authorization': `Bearer ${token}`, 239 + }, 240 + }); 241 + 242 + if (!response.ok) { 243 + throw new Error('Failed to load invites'); 244 + } 245 + 246 + const data = await response.json(); 247 + 248 + if (data.invites.length === 0) { 249 + invitesList.innerHTML = '<div class="loading">No invites created yet</div>'; 250 + return; 251 + } 252 + 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'}"> 305 + <div> 306 + <div class="invite-code">${invite.code}</div> 307 + <div class="invite-meta">Created by ${invite.createdBy} on ${createdDate} • ${status}</div> 308 + <div class="invite-meta">${expiryInfo}</div> 309 + ${noteInfo} 310 + ${messageInfo} 311 + ${roleInfo} 312 + ${usedByInfo} 313 + <div class="invite-url">${invite.inviteUrl}</div> 314 + </div> 315 + <div class="invite-actions-btns"> 316 + <button class="copy-btn" data-invite-id="${invite.id}" data-invite-url="${invite.inviteUrl}" ${isActive ? '' : 'disabled'}>copy link</button> 317 + <button class="edit-btn" onclick="editInvite(${invite.id})" ${isActive ? '' : 'disabled'}>edit</button> 318 + <button class="delete-btn" onclick="deleteInvite(${invite.id})">delete</button> 319 + </div> 320 + </div> 321 + `; 322 + }).join(''); 323 + 324 + // Add copy button handlers 325 + const copyButtons = invitesList.querySelectorAll('.copy-btn'); 326 + copyButtons.forEach((btn) => { 327 + btn.addEventListener('click', async (e) => { 328 + const button = e.target as HTMLButtonElement; 329 + const url = button.dataset.inviteUrl; 330 + if (!url) return; 331 + 332 + try { 333 + await navigator.clipboard.writeText(url); 334 + const originalText = button.textContent; 335 + button.textContent = 'copied!'; 336 + setTimeout(() => { 337 + button.textContent = originalText; 338 + }, 2000); 339 + } catch (error) { 340 + console.error('Failed to copy:', error); 341 + } 342 + }); 343 + }); 344 + } catch (error) { 345 + console.error('Failed to load invites:', error); 346 + invitesList.innerHTML = '<div class="error">Failed to load invites</div>'; 347 + } 348 + } 349 + 350 + checkAuth(); 351 + 352 + createInviteBtn.addEventListener('click', createInvite); 353 + 354 + // Close modals on escape key 355 + document.addEventListener('keydown', (e) => { 356 + if (e.key === 'Escape') { 357 + closeCreateInviteModal(); 358 + closeEditInviteModal(); 359 + } 360 + }); 361 + 362 + // Close modals on outside click 363 + document.getElementById('createInviteModal')?.addEventListener('click', (e) => { 364 + if (e.target === e.currentTarget) { 365 + closeCreateInviteModal(); 366 + } 367 + }); 368 + 369 + document.getElementById('editInviteModal')?.addEventListener('click', (e) => { 370 + if (e.target === e.currentTarget) { 371 + closeEditInviteModal(); 372 + } 373 + }); 374 + 375 + let currentEditInviteId: number | null = null; 376 + 377 + // Make editInvite globally available for onclick handler 378 + (window as any).editInvite = async (inviteId: number) => { 379 + try { 380 + const response = await fetch('/api/invites', { 381 + headers: { 382 + 'Authorization': `Bearer ${token}`, 383 + }, 384 + }); 385 + 386 + if (!response.ok) { 387 + throw new Error('Failed to load invite'); 388 + } 389 + 390 + const data = await response.json(); 391 + const invite = data.invites.find((inv: { id: number }) => inv.id === inviteId); 392 + 393 + if (!invite) { 394 + throw new Error('Invite not found'); 395 + } 396 + 397 + currentEditInviteId = inviteId; 398 + 399 + // 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 + 404 + // Handle expiration date 405 + const expiresAtInput = document.getElementById('editExpiresAt') as HTMLInputElement; 406 + if (invite.expiresAt) { 407 + const date = new Date(invite.expiresAt * 1000); 408 + const localDatetime = new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16); 409 + expiresAtInput.value = localDatetime; 410 + } else { 411 + expiresAtInput.value = ''; 412 + } 413 + 414 + // Show modal 415 + const modal = document.getElementById('editInviteModal'); 416 + if (modal) { 417 + modal.style.display = 'flex'; 418 + } 419 + } catch (error) { 420 + console.error('Failed to load invite:', error); 421 + alert('Failed to load invite'); 422 + } 423 + }; 424 + 425 + (window as any).submitEditInvite = async () => { 426 + if (currentEditInviteId === null) return; 427 + 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; 433 + 434 + const maxUses = maxUsesInput.value ? parseInt(maxUsesInput.value, 10) : null; 435 + const expiresAt = expiresAtInput.value ? Math.floor(new Date(expiresAtInput.value).getTime() / 1000) : null; 436 + const note = noteInput.value.trim() || null; 437 + const message = messageInput.value.trim() || null; 438 + 439 + submitBtn.disabled = true; 440 + submitBtn.textContent = 'saving...'; 441 + 442 + try { 443 + const response = await fetch(`/api/invites/${currentEditInviteId}`, { 444 + method: 'PATCH', 445 + headers: { 446 + 'Authorization': `Bearer ${token}`, 447 + 'Content-Type': 'application/json', 448 + }, 449 + body: JSON.stringify({ maxUses, expiresAt, note, message }), 450 + }); 451 + 452 + if (!response.ok) { 453 + throw new Error('Failed to update invite'); 454 + } 455 + 456 + await loadInvites(); 457 + closeEditInviteModal(); 458 + } catch (error) { 459 + console.error('Failed to update invite:', error); 460 + alert('Failed to update invite'); 461 + } finally { 462 + submitBtn.disabled = false; 463 + submitBtn.textContent = 'save changes'; 464 + } 465 + }; 466 + 467 + (window as any).closeEditInviteModal = () => { 468 + const modal = document.getElementById('editInviteModal'); 469 + if (modal) { 470 + modal.style.display = 'none'; 471 + 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 = ''; 476 + } 477 + }; 478 + 479 + (window as any).deleteInvite = async (inviteId: number) => { 480 + if (!confirm('Are you sure you want to delete this invite? This action cannot be undone.')) { 481 + return; 482 + } 483 + 484 + try { 485 + const response = await fetch(`/api/invites/${inviteId}`, { 486 + method: 'DELETE', 487 + headers: { 488 + 'Authorization': `Bearer ${token}`, 489 + }, 490 + }); 491 + 492 + if (!response.ok) { 493 + throw new Error('Failed to delete invite'); 494 + } 495 + 496 + await loadInvites(); 497 + } catch (error) { 498 + console.error('Failed to delete invite:', error); 499 + alert('Failed to delete invite'); 500 + } 501 + };
-290
src/client/admin.ts
··· 1 1 const token = localStorage.getItem('indiko_session'); 2 2 const footer = document.getElementById('footer') as HTMLElement; 3 3 const usersList = document.getElementById('usersList') as HTMLElement; 4 - const invitesList = document.getElementById('invitesList') as HTMLElement; 5 - const createInviteBtn = document.getElementById('createInviteBtn') as HTMLButtonElement; 6 4 7 5 // Check auth and display user 8 6 async function checkAuth() { ··· 54 52 55 53 // Load users if admin 56 54 loadUsers(); 57 - loadInvites(); 58 55 } catch (error) { 59 56 console.error('Auth check failed:', error); 60 57 footer.textContent = 'error loading user info'; ··· 62 59 } 63 60 } 64 61 65 - async function createInvite() { 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...'; 209 - 210 - try { 211 - const response = await fetch('/api/invites/create', { 212 - method: 'POST', 213 - headers: { 214 - 'Authorization': `Bearer ${token}`, 215 - 'Content-Type': 'application/json', 216 - }, 217 - body: JSON.stringify({ 218 - maxUses, 219 - expiresIn, 220 - note, 221 - appRoles: appRoles.length > 0 ? appRoles : undefined, 222 - }), 223 - }); 224 - 225 - if (!response.ok) { 226 - throw new Error('Failed to create invite'); 227 - } 228 - 229 - await loadInvites(); 230 - closeCreateInviteModal(); 231 - } catch (error) { 232 - console.error('Failed to create invite:', error); 233 - alert('Failed to create invite'); 234 - } finally { 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 - } 259 - } 260 - } 261 - 262 - // Expose functions to global scope for HTML onclick handlers 263 - (window as any).submitCreateInvite = submitCreateInvite; 264 - (window as any).closeCreateInviteModal = closeCreateInviteModal; 265 - 266 - async function loadInvites() { 267 - try { 268 - const response = await fetch('/api/invites', { 269 - headers: { 270 - 'Authorization': `Bearer ${token}`, 271 - }, 272 - }); 273 - 274 - if (!response.ok) { 275 - throw new Error('Failed to load invites'); 276 - } 277 - 278 - const data = await response.json(); 279 - 280 - if (data.invites.length === 0) { 281 - invitesList.innerHTML = '<div class="loading">No invites created yet</div>'; 282 - return; 283 - } 284 - 285 - invitesList.innerHTML = data.invites.map((invite: { 286 - id: number; 287 - code: string; 288 - maxUses: number; 289 - currentUses: number; 290 - isExpired: boolean; 291 - isFullyUsed: boolean; 292 - expiresAt: number | null; 293 - note: string | null; 294 - createdAt: number; 295 - createdBy: string; 296 - inviteUrl: string; 297 - appRoles: Array<{ clientId: string; role: string }>; 298 - usedBy: Array<{ username: string; usedAt: number }>; 299 - }) => { 300 - const createdDate = new Date(invite.createdAt * 1000).toLocaleDateString(); 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; 326 - 327 - return ` 328 - <div class="invite-item ${isActive ? '' : 'invite-inactive'}"> 329 - <div> 330 - <div class="invite-code">${invite.code}</div> 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} 336 - <div class="invite-url">${invite.inviteUrl}</div> 337 - </div> 338 - <div class="invite-actions-btns"> 339 - <button class="copy-btn" onclick="navigator.clipboard.writeText('${invite.inviteUrl}')" ${isActive ? '' : 'disabled'}>copy link</button> 340 - </div> 341 - </div> 342 - `; 343 - }).join(''); 344 - } catch (error) { 345 - console.error('Failed to load invites:', error); 346 - invitesList.innerHTML = '<div class="error">Failed to load invites</div>'; 347 - } 348 - } 349 - 350 62 async function loadUsers() { 351 63 try { 352 64 const response = await fetch('/api/users', { ··· 409 121 } 410 122 411 123 checkAuth(); 412 - 413 - createInviteBtn.addEventListener('click', createInvite);
+22 -2
src/client/login.ts
··· 12 12 const inviteCode = urlParams.get('invite'); 13 13 14 14 if (inviteCode) { 15 + // Fetch invite details to show message 16 + 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}) 21 + }); 22 + 23 + if (response.ok) { 24 + const data = await response.json(); 25 + if (data.inviteMessage) { 26 + showMessage(data.inviteMessage, 'success', true); 27 + } 28 + } 29 + } catch { 30 + // Ignore errors, just won't show message 31 + } 32 + 15 33 // Show registration form with invite 16 34 const subtitleElement = document.querySelector('.subtitle'); 17 35 if (subtitleElement) { ··· 46 64 47 65 checkRegistrationAllowed(); 48 66 49 - function showMessage(text: string, type: 'error' | 'success' = 'error') { 67 + function showMessage(text: string, type: 'error' | 'success' = 'error', persist = false) { 50 68 message.textContent = text; 51 69 message.className = `message show ${type}`; 52 - setTimeout(() => message.classList.remove('show'), 5000); 70 + if (!persist) { 71 + setTimeout(() => message.classList.remove('show'), 5000); 72 + } 53 73 } 54 74 55 75 // Login flow
+759
src/html/admin-invites.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <title>invites • indiko</title> 8 + <meta name="description" content="Indiko admin panel - manage invites" /> 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" /> 20 + <link rel="preconnect" href="https://fonts.googleapis.com"> 21 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 22 + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> 23 + <style> 24 + :root { 25 + --mahogany: #26242b; 26 + --lavender: #d9d0de; 27 + --old-rose: #bc8da0; 28 + --rosewood: #a04668; 29 + --berry-crush: #ab4967; 30 + } 31 + 32 + * { 33 + margin: 0; 34 + padding: 0; 35 + box-sizing: border-box; 36 + } 37 + 38 + body { 39 + font-family: "Space Grotesk", sans-serif; 40 + background: var(--mahogany); 41 + color: var(--lavender); 42 + min-height: 100vh; 43 + display: flex; 44 + flex-direction: column; 45 + align-items: center; 46 + padding: 2.5rem 1.25rem; 47 + } 48 + 49 + header { 50 + width: 100%; 51 + max-width: 56.25rem; 52 + align-self: flex-start; 53 + margin-left: auto; 54 + margin-right: auto; 55 + margin-bottom: 2rem; 56 + display: flex; 57 + justify-content: space-between; 58 + align-items: flex-start; 59 + } 60 + 61 + .header-nav { 62 + display: flex; 63 + gap: 1rem; 64 + margin-top: 0.5rem; 65 + } 66 + 67 + .header-nav a { 68 + color: var(--old-rose); 69 + text-decoration: none; 70 + font-size: 0.875rem; 71 + font-weight: 500; 72 + padding: 0.5rem 1rem; 73 + border: 1px solid var(--old-rose); 74 + transition: all 0.2s; 75 + } 76 + 77 + .header-nav a:hover { 78 + background: rgba(188, 141, 160, 0.1); 79 + color: var(--berry-crush); 80 + border-color: var(--berry-crush); 81 + } 82 + 83 + .header-nav a.active { 84 + background: var(--berry-crush); 85 + color: var(--lavender); 86 + border-color: var(--berry-crush); 87 + } 88 + 89 + h1 { 90 + font-size: 2rem; 91 + font-weight: 700; 92 + background: linear-gradient(135deg, var(--old-rose), var(--berry-crush), var(--rosewood)); 93 + -webkit-background-clip: text; 94 + -webkit-text-fill-color: transparent; 95 + background-clip: text; 96 + letter-spacing: -0.125rem; 97 + } 98 + 99 + main { 100 + flex: 1; 101 + width: 100%; 102 + max-width: 56.25rem; 103 + padding: 2rem 1.25rem; 104 + } 105 + 106 + footer { 107 + width: 100%; 108 + max-width: 56.25rem; 109 + padding: 1rem; 110 + text-align: center; 111 + color: var(--old-rose); 112 + font-size: 0.875rem; 113 + font-weight: 300; 114 + letter-spacing: 0.05rem; 115 + } 116 + 117 + footer a { 118 + color: var(--berry-crush); 119 + text-decoration: none; 120 + transition: color 0.2s; 121 + } 122 + 123 + footer a:hover { 124 + color: var(--rosewood); 125 + text-decoration: underline; 126 + } 127 + 128 + .back-link { 129 + margin-top: 0.5rem; 130 + font-size: 0.875rem; 131 + color: var(--old-rose); 132 + } 133 + 134 + .users-section { 135 + width: 100%; 136 + } 137 + 138 + .users-section h2 { 139 + font-size: 1.5rem; 140 + font-weight: 600; 141 + color: var(--lavender); 142 + margin-bottom: 1.5rem; 143 + letter-spacing: -0.05rem; 144 + } 145 + 146 + .users-list { 147 + display: flex; 148 + flex-direction: column; 149 + gap: 1rem; 150 + } 151 + 152 + .user-card { 153 + background: rgba(188, 141, 160, 0.05); 154 + border: 1px solid var(--old-rose); 155 + padding: 1.5rem; 156 + display: flex; 157 + gap: 1.5rem; 158 + align-items: center; 159 + transition: background 0.2s; 160 + } 161 + 162 + .user-card:hover { 163 + background: rgba(188, 141, 160, 0.1); 164 + } 165 + 166 + .user-avatar { 167 + width: 4rem; 168 + height: 4rem; 169 + border-radius: 50%; 170 + background: var(--berry-crush); 171 + display: flex; 172 + align-items: center; 173 + justify-content: center; 174 + font-size: 1.5rem; 175 + font-weight: 700; 176 + color: var(--lavender); 177 + flex-shrink: 0; 178 + text-transform: uppercase; 179 + } 180 + 181 + .user-avatar img { 182 + width: 100%; 183 + height: 100%; 184 + border-radius: 50%; 185 + object-fit: cover; 186 + } 187 + 188 + .user-info { 189 + display: flex; 190 + flex-direction: column; 191 + gap: 0.5rem; 192 + flex: 1; 193 + } 194 + 195 + .user-name { 196 + font-size: 1.125rem; 197 + font-weight: 600; 198 + color: var(--lavender); 199 + } 200 + 201 + .user-meta { 202 + font-size: 0.875rem; 203 + color: var(--old-rose); 204 + display: flex; 205 + flex-wrap: wrap; 206 + gap: 1rem; 207 + } 208 + 209 + .user-meta-item { 210 + display: flex; 211 + align-items: center; 212 + gap: 0.25rem; 213 + } 214 + 215 + .user-badges { 216 + display: flex; 217 + gap: 0.5rem; 218 + flex-wrap: wrap; 219 + } 220 + 221 + .user-badge { 222 + display: inline-block; 223 + padding: 0.25rem 0.75rem; 224 + font-size: 0.75rem; 225 + font-weight: 700; 226 + text-transform: uppercase; 227 + letter-spacing: 0.05rem; 228 + } 229 + 230 + .badge-admin { 231 + background: var(--berry-crush); 232 + color: var(--lavender); 233 + } 234 + 235 + .badge-role { 236 + background: rgba(188, 141, 160, 0.2); 237 + color: var(--lavender); 238 + border: 1px solid var(--old-rose); 239 + } 240 + 241 + .badge-status { 242 + border: 1px solid var(--old-rose); 243 + } 244 + 245 + .badge-status.active { 246 + background: rgba(139, 195, 74, 0.2); 247 + color: #a5d6a7; 248 + border-color: #81c784; 249 + } 250 + 251 + .badge-status.suspended { 252 + background: rgba(244, 67, 54, 0.2); 253 + color: #ef9a9a; 254 + border-color: #e57373; 255 + } 256 + 257 + .badge-status.inactive { 258 + background: rgba(158, 158, 158, 0.2); 259 + color: #bdbdbd; 260 + border-color: #9e9e9e; 261 + } 262 + 263 + .loading { 264 + text-align: center; 265 + padding: 2rem; 266 + color: var(--old-rose); 267 + font-size: 1rem; 268 + } 269 + 270 + .error { 271 + text-align: center; 272 + padding: 2rem; 273 + color: var(--rosewood); 274 + font-size: 1rem; 275 + } 276 + 277 + .invites-section { 278 + width: 100%; 279 + } 280 + 281 + .invites-section h2 { 282 + font-size: 1.5rem; 283 + color: var(--lavender); 284 + margin-bottom: 1rem; 285 + } 286 + 287 + .invite-actions { 288 + display: flex; 289 + gap: 1rem; 290 + margin-bottom: 1.5rem; 291 + } 292 + 293 + .invite-btn { 294 + padding: 0.75rem 1.5rem; 295 + background: var(--berry-crush); 296 + color: var(--lavender); 297 + border: none; 298 + cursor: pointer; 299 + font-family: inherit; 300 + font-size: 1rem; 301 + font-weight: 500; 302 + transition: background 0.2s; 303 + } 304 + 305 + .invite-btn:hover { 306 + background: var(--rosewood); 307 + } 308 + 309 + .invite-btn:disabled { 310 + opacity: 0.5; 311 + cursor: not-allowed; 312 + } 313 + 314 + .invite-list { 315 + display: flex; 316 + flex-direction: column; 317 + gap: 0.75rem; 318 + } 319 + 320 + .invite-item { 321 + background: rgba(188, 141, 160, 0.05); 322 + border: 1px solid var(--old-rose); 323 + padding: 1rem; 324 + display: grid; 325 + grid-template-columns: 1fr auto; 326 + gap: 1rem; 327 + align-items: start; 328 + } 329 + 330 + .invite-code { 331 + font-family: monospace; 332 + font-size: 0.875rem; 333 + color: var(--lavender); 334 + word-break: break-all; 335 + } 336 + 337 + .invite-meta { 338 + font-size: 0.75rem; 339 + color: var(--old-rose); 340 + margin-top: 0.25rem; 341 + } 342 + 343 + .invite-actions-btns { 344 + display: flex; 345 + flex-direction: column; 346 + gap: 0.5rem; 347 + min-width: 8rem; 348 + } 349 + 350 + .copy-btn, .delete-btn, .edit-btn { 351 + padding: 0.5rem 1rem; 352 + background: rgba(188, 141, 160, 0.2); 353 + color: var(--lavender); 354 + border: 1px solid var(--old-rose); 355 + cursor: pointer; 356 + font-family: inherit; 357 + font-size: 0.875rem; 358 + transition: background 0.2s; 359 + white-space: nowrap; 360 + } 361 + 362 + .copy-btn:hover { 363 + background: rgba(188, 141, 160, 0.3); 364 + } 365 + 366 + .edit-btn { 367 + background: rgba(171, 73, 103, 0.2); 368 + border-color: var(--berry-crush); 369 + } 370 + 371 + .edit-btn:hover { 372 + background: rgba(171, 73, 103, 0.3); 373 + } 374 + 375 + .delete-btn { 376 + background: rgba(160, 70, 104, 0.2); 377 + border-color: var(--rosewood); 378 + } 379 + 380 + .delete-btn:hover { 381 + background: rgba(160, 70, 104, 0.3); 382 + } 383 + 384 + .invite-url { 385 + background: rgba(12, 23, 19, 0.8); 386 + border: 1px solid var(--rosewood); 387 + padding: 0.75rem; 388 + margin-top: 0.5rem; 389 + font-family: monospace; 390 + font-size: 0.875rem; 391 + word-break: break-all; 392 + color: var(--berry-crush); 393 + border-left: 3px solid var(--berry-crush); 394 + } 395 + 396 + .invite-inactive { 397 + opacity: 0.6; 398 + } 399 + 400 + .invite-note { 401 + font-size: 0.875rem; 402 + color: var(--lavender); 403 + margin-top: 0.5rem; 404 + font-style: italic; 405 + } 406 + 407 + .invite-message { 408 + font-size: 0.875rem; 409 + color: var(--old-rose); 410 + margin-top: 0.5rem; 411 + font-style: italic; 412 + } 413 + 414 + .invite-roles { 415 + font-size: 0.875rem; 416 + color: var(--berry-crush); 417 + margin-top: 0.5rem; 418 + } 419 + 420 + .invite-used-by { 421 + font-size: 0.75rem; 422 + color: var(--old-rose); 423 + margin-top: 0.5rem; 424 + } 425 + 426 + /* Modal styles */ 427 + .modal { 428 + display: none; 429 + position: fixed; 430 + top: 0; 431 + left: 0; 432 + width: 100%; 433 + height: 100%; 434 + background: rgba(0, 0, 0, 0.8); 435 + justify-content: center; 436 + align-items: center; 437 + z-index: 1000; 438 + } 439 + 440 + .modal-content { 441 + background: var(--mahogany); 442 + border: 2px solid var(--old-rose); 443 + padding: 2rem; 444 + max-width: 40rem; 445 + width: 90%; 446 + max-height: 90vh; 447 + overflow-y: auto; 448 + } 449 + 450 + .modal-header { 451 + display: flex; 452 + justify-content: space-between; 453 + align-items: center; 454 + margin-bottom: 1.5rem; 455 + } 456 + 457 + .modal-header h3 { 458 + font-size: 1.5rem; 459 + color: var(--lavender); 460 + margin: 0; 461 + } 462 + 463 + .modal-close { 464 + background: none; 465 + border: none; 466 + color: var(--old-rose); 467 + font-size: 1.5rem; 468 + cursor: pointer; 469 + padding: 0; 470 + line-height: 1; 471 + } 472 + 473 + .modal-close:hover { 474 + color: var(--berry-crush); 475 + } 476 + 477 + .form-group { 478 + margin-bottom: 1.5rem; 479 + } 480 + 481 + .form-group label { 482 + display: block; 483 + color: var(--lavender); 484 + margin-bottom: 0.5rem; 485 + font-size: 0.875rem; 486 + } 487 + 488 + .form-group input, 489 + .form-group textarea { 490 + width: 100%; 491 + background: rgba(0, 0, 0, 0.3); 492 + border: 1px solid var(--old-rose); 493 + color: var(--lavender); 494 + padding: 0.75rem; 495 + font-family: inherit; 496 + font-size: 1rem; 497 + } 498 + 499 + .form-group textarea { 500 + resize: vertical; 501 + min-height: 4rem; 502 + } 503 + 504 + .form-group input:focus, 505 + .form-group textarea:focus { 506 + outline: none; 507 + border-color: var(--berry-crush); 508 + } 509 + 510 + .form-help { 511 + font-size: 0.75rem; 512 + color: var(--old-rose); 513 + margin-top: 0.25rem; 514 + } 515 + 516 + .app-role-item { 517 + display: flex; 518 + align-items: center; 519 + gap: 1rem; 520 + padding: 0.75rem; 521 + background: rgba(188, 141, 160, 0.05); 522 + border: 1px solid var(--old-rose); 523 + margin-bottom: 0.5rem; 524 + } 525 + 526 + .app-role-item label { 527 + display: flex; 528 + align-items: center; 529 + gap: 0.5rem; 530 + flex: 1; 531 + margin: 0; 532 + cursor: pointer; 533 + } 534 + 535 + .app-role-item input[type="checkbox"] { 536 + appearance: none; 537 + width: 1.5rem; 538 + height: 1.5rem; 539 + border: 2px solid var(--old-rose); 540 + background: rgba(12, 23, 19, 0.6); 541 + cursor: pointer; 542 + flex-shrink: 0; 543 + position: relative; 544 + transition: all 0.2s; 545 + } 546 + 547 + .app-role-item input[type="checkbox"]:checked { 548 + background: var(--berry-crush); 549 + border-color: var(--berry-crush); 550 + } 551 + 552 + .app-role-item input[type="checkbox"]:checked::after { 553 + content: "✓"; 554 + position: absolute; 555 + top: 50%; 556 + left: 50%; 557 + transform: translate(-50%, -50%); 558 + color: var(--lavender); 559 + font-size: 1rem; 560 + font-weight: 700; 561 + } 562 + 563 + .app-role-item input[type="checkbox"]:disabled { 564 + cursor: not-allowed; 565 + opacity: 0.5; 566 + } 567 + 568 + .app-role-item input.role-input-custom { 569 + flex: 1; 570 + padding: 0.5rem; 571 + font-size: 0.875rem; 572 + } 573 + 574 + .app-role-item select.role-select { 575 + flex: 1; 576 + background: rgba(0, 0, 0, 0.3); 577 + border: 1px solid var(--old-rose); 578 + color: var(--lavender); 579 + padding: 0.5rem 2.5rem 0.5rem 0.5rem; 580 + font-family: inherit; 581 + font-size: 0.875rem; 582 + cursor: pointer; 583 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23d9d0de' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); 584 + background-repeat: no-repeat; 585 + background-position: right 0.75rem center; 586 + appearance: none; 587 + } 588 + 589 + .app-role-item select.role-select:disabled { 590 + opacity: 0.5; 591 + cursor: not-allowed; 592 + } 593 + 594 + .app-role-item select.role-select:focus { 595 + outline: none; 596 + border-color: var(--berry-crush); 597 + } 598 + 599 + .modal-actions { 600 + display: flex; 601 + gap: 1rem; 602 + justify-content: flex-end; 603 + margin-top: 2rem; 604 + } 605 + 606 + .modal-btn { 607 + padding: 0.75rem 1.5rem; 608 + font-family: inherit; 609 + font-size: 1rem; 610 + border: none; 611 + cursor: pointer; 612 + transition: background 0.2s; 613 + } 614 + 615 + .modal-btn-primary { 616 + background: var(--berry-crush); 617 + color: var(--lavender); 618 + } 619 + 620 + .modal-btn-primary:hover { 621 + background: var(--rosewood); 622 + } 623 + 624 + .modal-btn-primary:disabled { 625 + opacity: 0.5; 626 + cursor: not-allowed; 627 + } 628 + 629 + .modal-btn-secondary { 630 + background: rgba(188, 141, 160, 0.2); 631 + color: var(--lavender); 632 + border: 1px solid var(--old-rose); 633 + } 634 + 635 + .modal-btn-secondary:hover { 636 + background: rgba(188, 141, 160, 0.3); 637 + } 638 + </style> 639 + </head> 640 + 641 + <body> 642 + <header> 643 + <div> 644 + <img src="../../public/logo.svg" alt="indiko" style="height: 2rem;" /> 645 + </div> 646 + <div class="header-nav"> 647 + <a href="/admin">users</a> 648 + <a href="/admin/invites" class="active">invites</a> 649 + <a href="/admin/clients">apps</a> 650 + </div> 651 + </header> 652 + 653 + <main> 654 + <div class="invites-section"> 655 + <h2>invites</h2> 656 + <div class="invite-actions"> 657 + <button class="invite-btn" id="createInviteBtn">create invite link</button> 658 + </div> 659 + <div id="invitesList" class="invite-list"> 660 + <div class="loading">loading invites...</div> 661 + </div> 662 + </div> 663 + </main> 664 + 665 + <footer id="footer"> 666 + loading... 667 + <div class="back-link"><a href="/">← back to dashboard</a></div> 668 + </footer> 669 + 670 + <!-- Create Invite Modal --> 671 + <div id="createInviteModal" class="modal"> 672 + <div class="modal-content"> 673 + <div class="modal-header"> 674 + <h3>create invite</h3> 675 + <button class="modal-close" onclick="closeCreateInviteModal()">&times;</button> 676 + </div> 677 + 678 + <div class="form-group"> 679 + <label for="maxUses">maximum uses</label> 680 + <input type="number" id="maxUses" min="1" max="999" value="1"> 681 + <div class="form-help">How many people can use this invite (1-999)</div> 682 + </div> 683 + 684 + <div class="form-group"> 685 + <label for="expiresAt">expiration date</label> 686 + <input type="datetime-local" id="expiresAt"> 687 + <div class="form-help">Optional: leave empty for no expiry</div> 688 + </div> 689 + 690 + <div class="form-group"> 691 + <label for="inviteNote">internal note</label> 692 + <textarea id="inviteNote" placeholder="Optional internal note (not visible to invitees)"></textarea> 693 + <div class="form-help">Private admin note to help you remember what this invite is for</div> 694 + </div> 695 + 696 + <div class="form-group"> 697 + <label for="inviteMessage">message to invitees</label> 698 + <textarea id="inviteMessage" placeholder="Optional welcome message shown to invitees"></textarea> 699 + <div class="form-help">Public message that will be shown to users when they use this invite</div> 700 + </div> 701 + 702 + <div class="form-group"> 703 + <label>app role assignments (optional)</label> 704 + <div class="form-help">Users who register with this invite will automatically be assigned these roles</div> 705 + <div id="appRolesContainer" style="margin-top: 1rem;"> 706 + <div class="loading">Loading apps...</div> 707 + </div> 708 + </div> 709 + 710 + <div class="modal-actions"> 711 + <button class="modal-btn modal-btn-secondary" onclick="closeCreateInviteModal()">cancel</button> 712 + <button class="modal-btn modal-btn-primary" id="submitInviteBtn" onclick="submitCreateInvite()">create invite</button> 713 + </div> 714 + </div> 715 + </div> 716 + 717 + <!-- Edit Invite Modal --> 718 + <div id="editInviteModal" class="modal"> 719 + <div class="modal-content"> 720 + <div class="modal-header"> 721 + <h3>edit invite</h3> 722 + <button class="modal-close" onclick="closeEditInviteModal()">&times;</button> 723 + </div> 724 + 725 + <div class="form-group"> 726 + <label for="editMaxUses">maximum uses remaining</label> 727 + <input type="number" id="editMaxUses" min="0" max="999"> 728 + <div class="form-help">Total number of times this invite can be used</div> 729 + </div> 730 + 731 + <div class="form-group"> 732 + <label for="editExpiresAt">expiration date</label> 733 + <input type="datetime-local" id="editExpiresAt"> 734 + <div class="form-help">Leave empty for no expiry</div> 735 + </div> 736 + 737 + <div class="form-group"> 738 + <label for="editInviteNote">internal note</label> 739 + <textarea id="editInviteNote" placeholder="Optional internal note (not visible to invitees)"></textarea> 740 + <div class="form-help">Private admin note to help you remember what this invite is for</div> 741 + </div> 742 + 743 + <div class="form-group"> 744 + <label for="editInviteMessage">message to invitees</label> 745 + <textarea id="editInviteMessage" placeholder="Optional welcome message shown to invitees"></textarea> 746 + <div class="form-help">Public message that will be shown to users when they use this invite</div> 747 + </div> 748 + 749 + <div class="modal-actions"> 750 + <button class="modal-btn modal-btn-secondary" onclick="closeEditInviteModal()">cancel</button> 751 + <button class="modal-btn modal-btn-primary" id="submitEditInviteBtn" onclick="submitEditInvite()">save changes</button> 752 + </div> 753 + </div> 754 + </div> 755 + 756 + <script type="module" src="../client/admin-invites.ts"></script> 757 + </body> 758 + 759 + </html>
+4 -393
src/html/admin.html
··· 98 98 99 99 main { 100 100 flex: 1; 101 - display: grid; 102 - grid-template-columns: 2fr 1fr; 103 - gap: 2rem; 104 - align-items: start; 105 101 width: 100%; 106 - max-width: 80rem; 102 + max-width: 56.25rem; 107 103 padding: 2rem 1.25rem; 108 104 } 109 105 ··· 278 274 font-size: 1rem; 279 275 } 280 276 281 - .invites-section { 282 - width: 100%; 283 - } 284 - 285 - .invites-section h2 { 286 - font-size: 1.5rem; 287 - color: var(--lavender); 288 - margin-bottom: 1rem; 289 - } 290 - 291 - .invite-actions { 292 - display: flex; 293 - gap: 1rem; 294 - margin-bottom: 1.5rem; 295 - } 296 - 297 - .invite-btn { 298 - padding: 0.75rem 1.5rem; 299 - background: var(--berry-crush); 300 - color: var(--lavender); 301 - border: none; 302 - cursor: pointer; 303 - font-family: inherit; 304 - font-size: 1rem; 305 - font-weight: 500; 306 - transition: background 0.2s; 307 - } 308 - 309 - .invite-btn:hover { 310 - background: var(--rosewood); 311 - } 312 - 313 - .invite-btn:disabled { 314 - opacity: 0.5; 315 - cursor: not-allowed; 316 - } 317 - 318 - .invite-list { 319 - display: flex; 320 - flex-direction: column; 321 - gap: 0.75rem; 322 - } 323 - 324 - .invite-item { 325 - background: rgba(188, 141, 160, 0.05); 326 - border: 1px solid var(--old-rose); 327 - padding: 1rem; 328 - display: grid; 329 - grid-template-columns: 1fr auto; 330 - gap: 1rem; 331 - align-items: center; 332 - } 333 - 334 - .invite-code { 335 - font-family: monospace; 336 - font-size: 0.875rem; 337 - color: var(--lavender); 338 - word-break: break-all; 339 - } 340 - 341 - .invite-meta { 342 - font-size: 0.75rem; 343 - color: var(--old-rose); 344 - margin-top: 0.25rem; 345 - } 346 - 347 - .invite-actions-btns { 348 - display: flex; 349 - gap: 0.5rem; 350 - } 351 - 352 - .copy-btn, .delete-btn { 353 - padding: 0.5rem 1rem; 354 - background: rgba(188, 141, 160, 0.2); 355 - color: var(--lavender); 356 - border: 1px solid var(--old-rose); 357 - cursor: pointer; 358 - font-family: inherit; 359 - font-size: 0.875rem; 360 - transition: background 0.2s; 361 - } 362 - 363 - .copy-btn:hover { 364 - background: rgba(188, 141, 160, 0.3); 365 - } 366 - 367 - .delete-btn { 368 - background: rgba(160, 70, 104, 0.2); 369 - border-color: var(--rosewood); 370 - } 371 - 372 - .delete-btn:hover { 373 - background: rgba(160, 70, 104, 0.3); 374 - } 375 - 376 - .invite-url { 377 - background: rgba(0, 0, 0, 0.2); 378 - padding: 0.5rem; 379 - margin-top: 0.5rem; 380 - font-family: monospace; 381 - font-size: 0.75rem; 382 - word-break: break-all; 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 - } 616 277 </style> 617 278 </head> 618 279 ··· 622 283 <img src="../../public/logo.svg" alt="indiko" style="height: 2rem;" /> 623 284 </div> 624 285 <div class="header-nav"> 625 - <a href="/admin" class="active">users</a> 626 - <a href="/admin/clients">apps</a> 286 + <a href="/admin" class="active">users</a> 287 + <a href="/admin/invites">invites</a> 288 + <a href="/admin/clients">apps</a> 627 289 </div> 628 290 </header> 629 291 ··· 634 296 <div class="loading">loading users...</div> 635 297 </div> 636 298 </div> 637 - 638 - <div class="invites-section"> 639 - <h2>invites</h2> 640 - <div class="invite-actions"> 641 - <button class="invite-btn" id="createInviteBtn">create invite link</button> 642 - </div> 643 - <div id="invitesList" class="invite-list"> 644 - <div class="loading">loading invites...</div> 645 - </div> 646 - </div> 647 299 </main> 648 300 649 301 <footer id="footer"> 650 302 loading... 651 303 <div class="back-link"><a href="/">← back to dashboard</a></div> 652 304 </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> 694 305 695 306 <script type="module" src="../client/admin.ts"></script> 696 307 </body>
+9
src/index.ts
··· 2 2 import { db } from "./db"; 3 3 import indexHTML from "./html/index.html"; 4 4 import adminHTML from "./html/admin.html"; 5 + import adminInvitesHTML from "./html/admin-invites.html"; 5 6 import adminClientsHTML from "./html/admin-clients.html"; 6 7 import loginHTML from "./html/login.html"; 7 8 import profileHTML from "./html/profile.html"; ··· 33 34 userProfile, 34 35 createInvite, 35 36 listInvites, 37 + updateInvite, 38 + deleteInvite, 36 39 } from "./routes/indieauth"; 37 40 import { 38 41 listClients, ··· 62 65 routes: { 63 66 "/": indexHTML, 64 67 "/admin": adminHTML, 68 + "/admin/invites": adminInvitesHTML, 65 69 "/admin/apps": () => Response.redirect("/admin/clients", 302), 66 70 "/admin/clients": adminClientsHTML, 67 71 "/login": loginHTML, ··· 95 99 }, 96 100 "/api/invites": (req: Request) => { 97 101 if (req.method === "GET") return listInvites(req); 102 + return new Response("Method not allowed", { status: 405 }); 103 + }, 104 + "/api/invites/:id": (req: Request) => { 105 + if (req.method === "PATCH") return updateInvite(req); 106 + if (req.method === "DELETE") return deleteInvite(req); 98 107 return new Response("Method not allowed", { status: 405 }); 99 108 }, 100 109 // IndieAuth/OAuth 2.0 endpoints
+2
src/migrations/006_add_invite_message.sql
··· 1 + -- Add public message field to invites 2 + ALTER TABLE invites ADD COLUMN message TEXT;
+7 -3
src/routes/auth.ts
··· 52 52 .get() as { count: number }; 53 53 54 54 const isBootstrap = userCount.count === 0; 55 + let inviteMessage: string | null = null; 55 56 56 57 // If not bootstrap, require valid invite code 57 58 if (!isBootstrap) { ··· 61 62 62 63 // Validate invite code 63 64 const invite = db 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; 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; 66 67 67 68 if (!invite) { 68 69 return Response.json({ error: "Invalid invite code" }, { status: 403 }); ··· 76 77 if (invite.current_uses >= invite.max_uses) { 77 78 return Response.json({ error: "Invite code fully used" }, { status: 403 }); 78 79 } 80 + 81 + // Store invite message to return with options 82 + inviteMessage = invite.message; 79 83 } 80 84 81 85 // Generate WebAuthn registration options ··· 99 103 "INSERT INTO challenges (challenge, username, type, expires_at) VALUES (?, ?, 'registration', ?)", 100 104 ).run(options.challenge, username, expiresAt); 101 105 102 - return Response.json(options); 106 + return Response.json({ ...options, inviteMessage }); 103 107 } catch (error) { 104 108 console.error("Registration options error:", error); 105 109 return Response.json({ error: "Internal server error" }, { status: 500 });
+117 -6
src/routes/indieauth.ts
··· 1291 1291 } 1292 1292 1293 1293 // POST /api/invites/create - Create invite link (admin only) 1294 - export function createInvite(req: Request): Response { 1294 + export async function createInvite(req: Request): Promise<Response> { 1295 1295 const user = getSessionUser(req); 1296 1296 if (user instanceof Response) { 1297 1297 return user; ··· 1301 1301 return Response.json({ error: "Admin access required" }, { status: 403 }); 1302 1302 } 1303 1303 1304 + const body = await req.json() as { 1305 + maxUses?: number; 1306 + expiresAt?: number | null; 1307 + note?: string | null; 1308 + message?: string | null; 1309 + appRoles?: Array<{ appId: number; role: string }>; 1310 + }; 1311 + 1304 1312 const inviteCode = crypto.randomBytes(16).toString("base64url"); 1313 + const maxUses = body.maxUses || 1; 1314 + const expiresAt = body.expiresAt || null; 1315 + const note = body.note || null; 1316 + const message = body.message || null; 1305 1317 1306 - db.query( 1307 - "INSERT INTO invites (code, created_by) VALUES (?, ?)", 1308 - ).run(inviteCode, user.userId); 1318 + const result = db.query( 1319 + "INSERT INTO invites (code, created_by, max_uses, expires_at, note, message) VALUES (?, ?, ?, ?, ?, ?)", 1320 + ).run(inviteCode, user.userId, maxUses, expiresAt, note, message); 1321 + 1322 + const inviteId = Number(result.lastInsertRowid); 1323 + 1324 + // Insert app role assignments if provided 1325 + if (body.appRoles && body.appRoles.length > 0) { 1326 + const stmt = db.prepare( 1327 + "INSERT INTO invite_roles (invite_id, app_id, role) VALUES (?, ?, ?)", 1328 + ); 1329 + for (const appRole of body.appRoles) { 1330 + stmt.run(inviteId, appRole.appId, appRole.role); 1331 + } 1332 + } 1309 1333 1310 1334 return Response.json({ 1311 1335 inviteCode, ··· 1325 1349 } 1326 1350 1327 1351 const invites = db.query(` 1328 - SELECT i.id, i.code, i.max_uses, i.current_uses, i.expires_at, i.note, i.created_at, 1352 + SELECT i.id, i.code, i.max_uses, i.current_uses, i.expires_at, i.note, i.message, i.created_at, 1329 1353 creator.username as created_by_username 1330 1354 FROM invites i 1331 1355 LEFT JOIN users creator ON i.created_by = creator.id ··· 1337 1361 current_uses: number; 1338 1362 expires_at: number | null; 1339 1363 note: string | null; 1364 + message: string | null; 1340 1365 created_at: number; 1341 1366 created_by_username: string; 1342 1367 }>; 1343 1368 1344 1369 // Get app roles for each invite 1345 1370 const inviteRoles = db.query(` 1346 - SELECT ir.invite_id, ir.app_id, ir.role, a.client_id 1371 + SELECT ir.invite_id, ir.app_id, ir.role, a.client_id, a.name 1347 1372 FROM invite_roles ir 1348 1373 JOIN apps a ON ir.app_id = a.id 1349 1374 `).all() as Array<{ ··· 1351 1376 app_id: number; 1352 1377 role: string; 1353 1378 client_id: string; 1379 + name: string | null; 1354 1380 }>; 1355 1381 1356 1382 // Get users who used each invite ··· 1377 1403 isFullyUsed: inv.current_uses >= inv.max_uses, 1378 1404 expiresAt: inv.expires_at, 1379 1405 note: inv.note, 1406 + message: inv.message, 1380 1407 createdAt: inv.created_at, 1381 1408 createdBy: inv.created_by_username, 1382 1409 inviteUrl: `${process.env.ORIGIN}/login?invite=${inv.code}`, ··· 1385 1412 .map((r) => ({ 1386 1413 appId: r.app_id, 1387 1414 clientId: r.client_id, 1415 + name: r.name, 1388 1416 role: r.role, 1389 1417 })), 1390 1418 usedBy: inviteUses ··· 1396 1424 })), 1397 1425 }); 1398 1426 } 1427 + 1428 + // PATCH /api/invites/:id - Update invite (admin only) 1429 + export async function updateInvite(req: Request): Promise<Response> { 1430 + const user = getSessionUser(req); 1431 + if (user instanceof Response) { 1432 + return user; 1433 + } 1434 + 1435 + if (!user.isAdmin) { 1436 + return Response.json({ error: "Admin access required" }, { status: 403 }); 1437 + } 1438 + 1439 + const url = new URL(req.url); 1440 + const parts = url.pathname.split("/"); 1441 + const inviteId = parts[parts.length - 1]; 1442 + 1443 + if (!inviteId || Number.isNaN(Number(inviteId))) { 1444 + return Response.json({ error: "Invalid invite ID" }, { status: 400 }); 1445 + } 1446 + 1447 + const body = await req.json() as { 1448 + maxUses?: number | null; 1449 + expiresAt?: number | null; 1450 + note?: string | null; 1451 + message?: string | null; 1452 + }; 1453 + 1454 + const updates: string[] = []; 1455 + const values: (number | string | null)[] = []; 1456 + 1457 + if (body.maxUses !== undefined) { 1458 + updates.push("max_uses = ?"); 1459 + values.push(body.maxUses); 1460 + } 1461 + if (body.expiresAt !== undefined) { 1462 + updates.push("expires_at = ?"); 1463 + values.push(body.expiresAt); 1464 + } 1465 + if (body.note !== undefined) { 1466 + updates.push("note = ?"); 1467 + values.push(body.note); 1468 + } 1469 + if (body.message !== undefined) { 1470 + updates.push("message = ?"); 1471 + values.push(body.message); 1472 + } 1473 + 1474 + if (updates.length === 0) { 1475 + return Response.json({ error: "No fields to update" }, { status: 400 }); 1476 + } 1477 + 1478 + values.push(inviteId); 1479 + 1480 + db.query(`UPDATE invites SET ${updates.join(", ")} WHERE id = ?`).run(...values); 1481 + 1482 + return Response.json({ success: true }); 1483 + } 1484 + 1485 + // DELETE /api/invites/:id - Delete an invite (admin only) 1486 + export function deleteInvite(req: Request): Response { 1487 + const user = getSessionUser(req); 1488 + if (user instanceof Response) { 1489 + return user; 1490 + } 1491 + 1492 + if (!user.isAdmin) { 1493 + return Response.json({ error: "Admin access required" }, { status: 403 }); 1494 + } 1495 + 1496 + const url = new URL(req.url); 1497 + const parts = url.pathname.split("/"); 1498 + const inviteId = parts[parts.length - 1]; 1499 + 1500 + if (!inviteId || Number.isNaN(Number(inviteId))) { 1501 + return Response.json({ error: "Invalid invite ID" }, { status: 400 }); 1502 + } 1503 + 1504 + // Delete invite (cascade will handle invite_roles and invite_uses) 1505 + db.query("DELETE FROM invites WHERE id = ?").run(inviteId); 1506 + 1507 + return Response.json({ success: true }); 1508 + } 1509 +