my own indieAuth provider!
indiko.dunkirk.sh/docs
indieauth
oauth2-server
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();