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.

at main 545 lines 15 kB view raw
1import { startRegistration } from "@simplewebauthn/browser"; 2 3const token = localStorage.getItem("indiko_session"); 4const footer = document.getElementById("footer") as HTMLElement; 5const welcome = document.getElementById("welcome") as HTMLElement; 6const subtitle = document.getElementById("subtitle") as HTMLElement; 7const recentApps = document.getElementById("recentApps") as HTMLElement; 8const passkeysList = document.getElementById("passkeysList") as HTMLElement; 9const addPasskeyBtn = document.getElementById( 10 "addPasskeyBtn", 11) as HTMLButtonElement; 12const toast = document.getElementById("toast") as HTMLElement; 13 14// Profile form elements 15const profileForm = document.getElementById("profileForm") as HTMLFormElement; 16const avatarPreview = document.getElementById("avatarPreview") as HTMLElement; 17const usernameInput = document.getElementById("username") as HTMLInputElement; 18const nameInput = document.getElementById("name") as HTMLInputElement; 19const emailInput = document.getElementById("email") as HTMLInputElement; 20const photoInput = document.getElementById("photo") as HTMLInputElement; 21const urlInput = document.getElementById("url") as HTMLInputElement; 22const saveBtn = document.getElementById("saveBtn") as HTMLButtonElement; 23const deleteAccountBtn = document.getElementById( 24 "deleteAccountBtn", 25) as HTMLButtonElement; 26const dangerZone = document.getElementById("dangerZone") as HTMLElement; 27 28let isAdmin = false; 29 30if (!token) { 31 window.location.href = "/login"; 32} 33 34interface App { 35 clientId: string; 36 name: string; 37 scopes: string[]; 38 grantedAt: number; 39 lastUsed: number; 40} 41 42interface Profile { 43 username: string; 44 name: string; 45 email: string | null; 46 photo: string | null; 47 url: string | null; 48 isAdmin?: boolean; 49} 50 51interface Passkey { 52 id: number; 53 name: string; 54 created_at: number; 55} 56 57function showToast(message: string, type: "success" | "error" = "success") { 58 toast.textContent = message; 59 toast.className = `toast ${type} show`; 60 61 setTimeout(() => { 62 toast.classList.remove("show"); 63 }, 3000); 64} 65 66function updateAvatarPreview(photo: string | null, username: string) { 67 if (photo) { 68 avatarPreview.innerHTML = `<img src="${photo}" alt="${username}" style="width: 100%; height: 100%; object-fit: cover; border-radius: 50%;" />`; 69 } else { 70 const initials = username.substring(0, 2).toUpperCase(); 71 avatarPreview.textContent = initials; 72 } 73} 74 75// Check auth and display user 76async function checkAuth() { 77 if (!token) { 78 window.location.href = "/login"; 79 return; 80 } 81 82 try { 83 const response = await fetch("/api/hello", { 84 headers: { 85 Authorization: `Bearer ${token}`, 86 }, 87 }); 88 89 if (response.status === 401 || response.status === 403) { 90 localStorage.removeItem("indiko_session"); 91 window.location.href = "/login"; 92 return; 93 } 94 95 const data = await response.json(); 96 97 // Update welcome message 98 welcome.textContent = `welcome, ${data.username}`; 99 subtitle.textContent = "your identity dashboard"; 100 101 // Build footer with conditional admin link 102 const adminLink = data.isAdmin ? ' • <a href="/admin">admin</a>' : ""; 103 footer.innerHTML = `signed in as <strong><a href="/u/${data.username}">${data.username}</a></strong> • <a href="/apps">apps</a> • <a href="/docs">docs</a>${adminLink} • <a href="/login" id="logoutLink">sign out</a>`; 104 105 // Handle logout 106 document 107 .getElementById("logoutLink") 108 ?.addEventListener("click", async (e) => { 109 e.preventDefault(); 110 try { 111 await fetch("/auth/logout", { 112 method: "POST", 113 headers: { 114 Authorization: `Bearer ${token}`, 115 }, 116 }); 117 } catch { 118 // Ignore logout errors 119 } 120 localStorage.removeItem("indiko_session"); 121 window.location.href = "/login"; 122 }); 123 124 // Load profile and apps 125 loadProfile(); 126 loadRecentApps(); 127 loadPasskeys(); 128 } catch (error) { 129 console.error("Auth check failed:", error); 130 footer.textContent = "error loading user info"; 131 } 132} 133 134async function loadProfile() { 135 try { 136 const response = await fetch("/api/profile", { 137 headers: { 138 Authorization: `Bearer ${token}`, 139 }, 140 }); 141 142 if (!response.ok) { 143 throw new Error("Failed to load profile"); 144 } 145 146 const profile = (await response.json()) as Profile; 147 148 // Track admin status to hide delete button for admins 149 isAdmin = profile.isAdmin || false; 150 if (!isAdmin) { 151 dangerZone.style.display = "block"; 152 } 153 154 // Populate form 155 usernameInput.value = profile.username; 156 nameInput.value = profile.name || ""; 157 emailInput.value = profile.email || ""; 158 photoInput.value = profile.photo || ""; 159 urlInput.value = profile.url || ""; 160 161 updateAvatarPreview(profile.photo, profile.username); 162 163 // Update avatar preview when photo URL changes 164 photoInput.addEventListener("input", () => { 165 updateAvatarPreview(photoInput.value || null, profile.username); 166 }); 167 } catch (error) { 168 console.error("Failed to load profile:", error); 169 showToast("Failed to load profile", "error"); 170 } 171} 172 173async function loadRecentApps() { 174 try { 175 const response = await fetch("/api/apps", { 176 headers: { 177 Authorization: `Bearer ${token}`, 178 }, 179 }); 180 181 if (!response.ok) { 182 throw new Error("Failed to load apps"); 183 } 184 185 const data = await response.json(); 186 const apps = data.apps as App[]; 187 188 if (apps.length === 0) { 189 recentApps.innerHTML = '<div class="empty">No authorized apps yet</div>'; 190 return; 191 } 192 193 // Show top 7 most recent 194 const recent = apps.slice(0, 7); 195 196 recentApps.innerHTML = recent 197 .map((app) => { 198 const lastUsedDate = new Date(app.lastUsed * 1000).toLocaleDateString(); 199 200 return ` 201 <div class="app-item"> 202 <div class="app-name">${app.name}</div> 203 <div class="app-date">${lastUsedDate}</div> 204 </div> 205 `; 206 }) 207 .join(""); 208 209 if (apps.length > 7) { 210 recentApps.innerHTML += 211 '<a href="/apps" class="view-all">view all apps →</a>'; 212 } 213 } catch (error) { 214 console.error("Failed to load apps:", error); 215 recentApps.innerHTML = '<div class="empty">Failed to load apps</div>'; 216 } 217} 218 219// Profile form submission 220profileForm.addEventListener("submit", async (e) => { 221 e.preventDefault(); 222 223 saveBtn.disabled = true; 224 saveBtn.textContent = "saving..."; 225 226 try { 227 const response = await fetch("/api/profile", { 228 method: "PUT", 229 headers: { 230 Authorization: `Bearer ${token}`, 231 "Content-Type": "application/json", 232 }, 233 body: JSON.stringify({ 234 name: nameInput.value, 235 email: emailInput.value || null, 236 photo: photoInput.value || null, 237 url: urlInput.value || null, 238 }), 239 }); 240 241 if (!response.ok) { 242 const error = await response.json(); 243 throw new Error(error.error || "Failed to update profile"); 244 } 245 246 showToast("Profile updated successfully!", "success"); 247 } catch (error) { 248 showToast((error as Error).message || "Failed to update profile", "error"); 249 } finally { 250 saveBtn.disabled = false; 251 saveBtn.textContent = "save changes"; 252 } 253}); 254 255// Delete account handler 256deleteAccountBtn.addEventListener("click", async () => { 257 const confirmMessage = 258 "Are you absolutely sure you want to delete your account?\n\n" + 259 "This will permanently delete:\n" + 260 "• Your profile and credentials\n" + 261 "• All authorized apps\n" + 262 "• All active sessions\n\n" + 263 "This action CANNOT be undone.\n\n" + 264 'Type "DELETE" to confirm:'; 265 266 const confirmation = prompt(confirmMessage); 267 268 if (confirmation !== "DELETE") { 269 if (confirmation !== null) { 270 showToast( 271 'Account deletion cancelled. You must type "DELETE" exactly.', 272 "error", 273 ); 274 } 275 return; 276 } 277 278 deleteAccountBtn.disabled = true; 279 deleteAccountBtn.textContent = "deleting..."; 280 281 try { 282 const response = await fetch("/api/profile", { 283 method: "DELETE", 284 headers: { 285 Authorization: `Bearer ${token}`, 286 }, 287 }); 288 289 if (!response.ok) { 290 const error = await response.json(); 291 throw new Error(error.error || "Failed to delete account"); 292 } 293 294 // Clear session and redirect 295 localStorage.removeItem("indiko_session"); 296 showToast("Account deleted successfully. Redirecting...", "success"); 297 setTimeout(() => { 298 window.location.href = "/login"; 299 }, 2000); 300 } catch (error) { 301 showToast((error as Error).message || "Failed to delete account", "error"); 302 deleteAccountBtn.disabled = false; 303 deleteAccountBtn.textContent = "delete my account"; 304 } 305}); 306 307async function loadPasskeys() { 308 try { 309 const response = await fetch("/api/passkeys", { 310 headers: { 311 Authorization: `Bearer ${token}`, 312 }, 313 }); 314 315 if (!response.ok) { 316 throw new Error("Failed to load passkeys"); 317 } 318 319 const data = await response.json(); 320 const passkeys = data.passkeys as Passkey[]; 321 322 if (passkeys.length === 0) { 323 passkeysList.innerHTML = 324 '<div class="empty">No passkeys registered</div>'; 325 return; 326 } 327 328 passkeysList.innerHTML = passkeys 329 .map((passkey) => { 330 const createdDate = new Date( 331 passkey.created_at * 1000, 332 ).toLocaleDateString(); 333 334 return ` 335 <div class="passkey-item" data-passkey-id="${passkey.id}"> 336 <div class="passkey-info"> 337 <div class="passkey-name">${passkey.name}</div> 338 <div class="passkey-date">added ${createdDate}</div> 339 </div> 340 <div class="passkey-actions"> 341 <button type="button" class="rename-passkey-btn" data-passkey-id="${passkey.id}">rename</button> 342 ${passkeys.length > 1 ? `<button type="button" class="delete-passkey-btn" data-passkey-id="${passkey.id}">delete</button>` : ""} 343 </div> 344 </div> 345 `; 346 }) 347 .join(""); 348 349 // Add event listeners for rename buttons 350 document.querySelectorAll(".rename-passkey-btn").forEach((btn) => { 351 btn.addEventListener("click", () => { 352 const passkeyId = btn.getAttribute("data-passkey-id"); 353 showRenameForm(Number(passkeyId)); 354 }); 355 }); 356 357 // Add event listeners for delete buttons 358 document.querySelectorAll(".delete-passkey-btn").forEach((btn) => { 359 btn.addEventListener("click", async () => { 360 const passkeyId = btn.getAttribute("data-passkey-id"); 361 await deletePasskeyHandler(Number(passkeyId)); 362 }); 363 }); 364 } catch (error) { 365 console.error("Failed to load passkeys:", error); 366 passkeysList.innerHTML = '<div class="empty">Failed to load passkeys</div>'; 367 } 368} 369 370function showRenameForm(passkeyId: number) { 371 const passkeyItem = document.querySelector( 372 `[data-passkey-id="${passkeyId}"]`, 373 ); 374 if (!passkeyItem) return; 375 376 const infoDiv = passkeyItem.querySelector(".passkey-info"); 377 const nameDiv = infoDiv?.querySelector(".passkey-name"); 378 if (!nameDiv) return; 379 380 const currentName = nameDiv.textContent || ""; 381 382 // Replace the info div with a rename form 383 if (infoDiv) { 384 infoDiv.innerHTML = ` 385 <div class="rename-form"> 386 <input type="text" value="${currentName}" class="rename-input" data-passkey-id="${passkeyId}" /> 387 <button type="button" class="save-rename-btn" data-passkey-id="${passkeyId}">save</button> 388 <button type="button" class="cancel-rename-btn" data-passkey-id="${passkeyId}">cancel</button> 389 </div> 390 `; 391 392 const input = infoDiv.querySelector(".rename-input") as HTMLInputElement; 393 input.focus(); 394 input.select(); 395 396 // Save button 397 infoDiv 398 .querySelector(".save-rename-btn") 399 ?.addEventListener("click", async () => { 400 await renamePasskeyHandler(passkeyId, input.value); 401 }); 402 403 // Cancel button 404 infoDiv 405 .querySelector(".cancel-rename-btn") 406 ?.addEventListener("click", () => { 407 loadPasskeys(); 408 }); 409 410 // Enter to save 411 input.addEventListener("keypress", async (e) => { 412 if (e.key === "Enter") { 413 await renamePasskeyHandler(passkeyId, input.value); 414 } 415 }); 416 417 // Escape to cancel 418 input.addEventListener("keydown", (e) => { 419 if (e.key === "Escape") { 420 loadPasskeys(); 421 } 422 }); 423 } 424} 425 426async function renamePasskeyHandler(passkeyId: number, newName: string) { 427 if (!newName.trim()) { 428 showToast("Passkey name cannot be empty", "error"); 429 return; 430 } 431 432 try { 433 const response = await fetch(`/api/passkeys/${passkeyId}`, { 434 method: "PATCH", 435 headers: { 436 Authorization: `Bearer ${token}`, 437 "Content-Type": "application/json", 438 }, 439 body: JSON.stringify({ name: newName }), 440 }); 441 442 if (!response.ok) { 443 const error = await response.json(); 444 throw new Error(error.error || "Failed to rename passkey"); 445 } 446 447 showToast("Passkey renamed successfully!", "success"); 448 loadPasskeys(); 449 } catch (error) { 450 showToast((error as Error).message || "Failed to rename passkey", "error"); 451 } 452} 453 454async function deletePasskeyHandler(passkeyId: number) { 455 if ( 456 !confirm( 457 "Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.", 458 ) 459 ) { 460 return; 461 } 462 463 try { 464 const response = await fetch(`/api/passkeys/${passkeyId}`, { 465 method: "DELETE", 466 headers: { 467 Authorization: `Bearer ${token}`, 468 }, 469 }); 470 471 if (!response.ok) { 472 const error = await response.json(); 473 throw new Error(error.error || "Failed to delete passkey"); 474 } 475 476 showToast("Passkey deleted successfully!", "success"); 477 loadPasskeys(); 478 } catch (error) { 479 showToast((error as Error).message || "Failed to delete passkey", "error"); 480 } 481} 482 483// Add passkey button handler 484addPasskeyBtn.addEventListener("click", async () => { 485 addPasskeyBtn.disabled = true; 486 addPasskeyBtn.textContent = "preparing..."; 487 488 try { 489 // Get registration options 490 const optionsRes = await fetch("/api/passkeys/add/options", { 491 method: "POST", 492 headers: { 493 Authorization: `Bearer ${token}`, 494 }, 495 }); 496 497 if (!optionsRes.ok) { 498 const error = await optionsRes.json(); 499 throw new Error(error.error || "Failed to get passkey options"); 500 } 501 502 const options = await optionsRes.json(); 503 504 addPasskeyBtn.textContent = "create your passkey..."; 505 506 // Start registration 507 const regResponse = await startRegistration(options); 508 509 addPasskeyBtn.textContent = "verifying..."; 510 511 // Ask for a name 512 const name = prompt( 513 "Give this passkey a name (e.g., 'iPhone', 'Work Laptop'):", 514 ); 515 516 // Verify registration 517 const verifyRes = await fetch("/api/passkeys/add/verify", { 518 method: "POST", 519 headers: { 520 Authorization: `Bearer ${token}`, 521 "Content-Type": "application/json", 522 }, 523 body: JSON.stringify({ 524 response: regResponse, 525 challenge: options.challenge, 526 name: name || undefined, 527 }), 528 }); 529 530 if (!verifyRes.ok) { 531 const error = await verifyRes.json(); 532 throw new Error(error.error || "Failed to add passkey"); 533 } 534 535 showToast("Passkey added successfully!", "success"); 536 loadPasskeys(); 537 } catch (error) { 538 showToast((error as Error).message || "Failed to add passkey", "error"); 539 } finally { 540 addPasskeyBtn.disabled = false; 541 addPasskeyBtn.textContent = "add new passkey"; 542 } 543}); 544 545checkAuth();