my own indieAuth provider!
indiko.dunkirk.sh/docs
indieauth
oauth2-server
1const token = localStorage.getItem("indiko_session");
2const footer = document.getElementById("footer") as HTMLElement;
3const invitesList = document.getElementById("invitesList") as HTMLElement;
4const createInviteBtn = document.getElementById(
5 "createInviteBtn",
6) as HTMLButtonElement;
7
8// Check auth and display user
9async function checkAuth() {
10 if (!token) {
11 window.location.href = "/login";
12 return;
13 }
14
15 try {
16 const response = await fetch("/api/hello", {
17 headers: {
18 Authorization: `Bearer ${token}`,
19 },
20 });
21
22 if (response.status === 401 || response.status === 403) {
23 localStorage.removeItem("indiko_session");
24 window.location.href = "/login";
25 return;
26 }
27
28 const data = await response.json();
29
30 footer.innerHTML = `admin • signed in as <strong><a href="/u/${data.username}">${data.username}</a></strong> • <a href="/login" id="logoutLink">sign out</a>
31 <div class="back-link"><a href="/">← back to dashboard</a></div>`;
32
33 // Handle logout
34 document
35 .getElementById("logoutLink")
36 ?.addEventListener("click", async (e) => {
37 e.preventDefault();
38 try {
39 await fetch("/auth/logout", {
40 method: "POST",
41 headers: {
42 Authorization: `Bearer ${token}`,
43 },
44 });
45 } catch {
46 // Ignore logout errors
47 }
48 localStorage.removeItem("indiko_session");
49 window.location.href = "/login";
50 });
51
52 // Check if admin
53 if (!data.isAdmin) {
54 window.location.href = "/";
55 return;
56 }
57
58 // Load invites
59 loadInvites();
60 } catch (error) {
61 console.error("Auth check failed:", error);
62 footer.textContent = "error loading user info";
63 usersList.innerHTML = '<div class="error">Failed to load users</div>';
64 }
65}
66
67async function createInvite() {
68 // Show the create invite modal
69 const modal = document.getElementById("createInviteModal");
70 if (modal) {
71 modal.style.display = "flex";
72 // Load apps for role assignment
73 await loadAppsForInvite();
74 }
75}
76
77async function loadAppsForInvite() {
78 try {
79 const response = await fetch("/api/admin/clients", {
80 headers: {
81 Authorization: `Bearer ${token}`,
82 },
83 });
84
85 if (!response.ok) {
86 throw new Error("Failed to load apps");
87 }
88
89 const data = await response.json();
90 const appRolesContainer = document.getElementById("appRolesContainer");
91
92 if (!appRolesContainer) return;
93
94 if (data.clients.length === 0) {
95 appRolesContainer.innerHTML =
96 '<p style="color: var(--old-rose); font-size: 0.875rem;">No pre-registered apps available</p>';
97 return;
98 }
99
100 appRolesContainer.innerHTML = data.clients
101 .filter((app: { isPreregistered: boolean }) => app.isPreregistered)
102 .map(
103 (app: {
104 id: number;
105 clientId: string;
106 name: string;
107 roles: string[];
108 }) => {
109 const roleOptions =
110 app.roles.length > 0
111 ? app.roles
112 .map((role) => `<option value="${role}">${role}</option>`)
113 .join("")
114 : '<option value="" disabled>No roles defined yet</option>';
115
116 const displayName = app.name || app.clientId;
117
118 return `
119 <div class="app-role-item">
120 <label>
121 <input type="checkbox" name="appRole" value="${app.id}" data-client-id="${app.clientId}">
122 <span>${displayName}</span>
123 </label>
124 <select class="role-select" data-app-id="${app.id}" disabled>
125 <option value="">Select role...</option>
126 ${roleOptions}
127 </select>
128 </div>
129 `;
130 },
131 )
132 .join("");
133
134 // Enable/disable role select when checkbox changes
135 const checkboxes = appRolesContainer.querySelectorAll(
136 'input[name="appRole"]',
137 );
138 checkboxes.forEach((checkbox) => {
139 checkbox.addEventListener("change", (e) => {
140 const target = e.target as HTMLInputElement;
141 const appId = target.value;
142 const roleSelect = appRolesContainer.querySelector(
143 `select.role-select[data-app-id="${appId}"]`,
144 ) as HTMLSelectElement;
145
146 if (roleSelect) {
147 roleSelect.disabled = !target.checked;
148 if (!target.checked) {
149 roleSelect.value = "";
150 }
151 }
152 });
153 });
154 } catch (error) {
155 console.error("Failed to load apps:", error);
156 }
157}
158
159async function submitCreateInvite() {
160 const maxUsesInput = document.getElementById("maxUses") as HTMLInputElement;
161 const expiresAtInput = document.getElementById(
162 "expiresAt",
163 ) as HTMLInputElement;
164 const noteInput = document.getElementById(
165 "inviteNote",
166 ) as HTMLTextAreaElement;
167 const messageInput = document.getElementById(
168 "inviteMessage",
169 ) as HTMLTextAreaElement;
170 const submitBtn = document.getElementById(
171 "submitInviteBtn",
172 ) as HTMLButtonElement;
173
174 const maxUses = maxUsesInput.value ? parseInt(maxUsesInput.value, 10) : 1;
175 const expiresAt = expiresAtInput.value
176 ? Math.floor(new Date(expiresAtInput.value).getTime() / 1000)
177 : null;
178 const note = noteInput.value.trim() || null;
179 const message = messageInput.value.trim() || null;
180
181 // Collect app roles
182 const appRolesContainer = document.getElementById("appRolesContainer");
183 const appRoles: Array<{ appId: number; role: string }> = [];
184
185 if (appRolesContainer) {
186 const checkedBoxes = appRolesContainer.querySelectorAll(
187 'input[name="appRole"]:checked',
188 );
189 checkedBoxes.forEach((checkbox) => {
190 const appId = parseInt((checkbox as HTMLInputElement).value, 10);
191 const roleSelect = appRolesContainer.querySelector(
192 `select.role-select[data-app-id="${appId}"]`,
193 ) as HTMLSelectElement;
194
195 let role = "";
196 if (roleSelect && roleSelect.value) {
197 role = roleSelect.value;
198 }
199
200 if (role) {
201 appRoles.push({
202 appId,
203 role,
204 });
205 }
206 });
207 }
208
209 submitBtn.disabled = true;
210 submitBtn.textContent = "creating...";
211
212 try {
213 const response = await fetch("/api/invites/create", {
214 method: "POST",
215 headers: {
216 Authorization: `Bearer ${token}`,
217 "Content-Type": "application/json",
218 },
219 body: JSON.stringify({
220 maxUses,
221 expiresAt,
222 note,
223 message,
224 appRoles: appRoles.length > 0 ? appRoles : undefined,
225 }),
226 });
227
228 if (!response.ok) {
229 throw new Error("Failed to create invite");
230 }
231
232 await loadInvites();
233 closeCreateInviteModal();
234 } catch (error) {
235 console.error("Failed to create invite:", error);
236 alert("Failed to create invite");
237 } finally {
238 submitBtn.disabled = false;
239 submitBtn.textContent = "create invite";
240 }
241}
242
243function closeCreateInviteModal() {
244 const modal = document.getElementById("createInviteModal");
245 if (modal) {
246 modal.style.display = "none";
247 // Reset form
248 (document.getElementById("maxUses") as HTMLInputElement).value = "1";
249 (document.getElementById("expiresAt") as HTMLInputElement).value = "";
250 (document.getElementById("inviteNote") as HTMLTextAreaElement).value = "";
251 (document.getElementById("inviteMessage") as HTMLTextAreaElement).value =
252 "";
253 const appRolesContainer = document.getElementById("appRolesContainer");
254 if (appRolesContainer) {
255 appRolesContainer
256 .querySelectorAll('input[type="checkbox"]')
257 .forEach((input) => {
258 (input as HTMLInputElement).checked = false;
259 });
260 appRolesContainer.querySelectorAll("select").forEach((select) => {
261 (select as HTMLSelectElement).value = "";
262 (select as HTMLSelectElement).disabled = true;
263 });
264 }
265 }
266}
267
268// Expose functions to global scope for HTML onclick handlers
269(window as any).submitCreateInvite = submitCreateInvite;
270(window as any).closeCreateInviteModal = closeCreateInviteModal;
271
272async function loadInvites() {
273 try {
274 const response = await fetch("/api/invites", {
275 headers: {
276 Authorization: `Bearer ${token}`,
277 },
278 });
279
280 if (!response.ok) {
281 throw new Error("Failed to load invites");
282 }
283
284 const data = await response.json();
285
286 if (data.invites.length === 0) {
287 invitesList.innerHTML =
288 '<div class="loading">No invites created yet</div>';
289 return;
290 }
291
292 invitesList.innerHTML = data.invites
293 .map(
294 (invite: {
295 id: number;
296 code: string;
297 maxUses: number;
298 currentUses: number;
299 isExpired: boolean;
300 isFullyUsed: boolean;
301 expiresAt: number | null;
302 note: string | null;
303 message: string | null;
304 createdAt: number;
305 createdBy: string;
306 inviteUrl: string;
307 appRoles: Array<{
308 clientId: string;
309 name: string | null;
310 role: string;
311 }>;
312 usedBy: Array<{ username: string; usedAt: number }>;
313 }) => {
314 const createdDate = new Date(
315 invite.createdAt * 1000,
316 ).toLocaleDateString();
317
318 let status = `${invite.currentUses}/${invite.maxUses} used`;
319 if (invite.isExpired) {
320 status += " (expired)";
321 } else if (invite.isFullyUsed) {
322 status += " (fully used)";
323 }
324
325 const expiryInfo = invite.expiresAt
326 ? `Expires: ${new Date(invite.expiresAt * 1000).toLocaleString()}`
327 : "No expiry";
328
329 const roleInfo =
330 invite.appRoles.length > 0
331 ? `<div class="invite-roles">App roles: ${invite.appRoles
332 .map((r) => {
333 const appName = r.name || r.clientId;
334 return `${appName} (${r.role})`;
335 })
336 .join(", ")}</div>`
337 : "";
338
339 const usedByInfo =
340 invite.usedBy.length > 0
341 ? `<div class="invite-used-by">Used by: ${invite.usedBy.map((u) => `${u.username} (${new Date(u.usedAt * 1000).toLocaleDateString()})`).join(", ")}</div>`
342 : "";
343
344 const noteInfo = invite.note
345 ? `<div class="invite-note">Internal note: ${invite.note}</div>`
346 : "";
347
348 const messageInfo = invite.message
349 ? `<div class="invite-message">Message to invitees: ${invite.message}</div>`
350 : "";
351
352 const isActive = !invite.isExpired && !invite.isFullyUsed;
353
354 return `
355 <div class="invite-item ${isActive ? "" : "invite-inactive"}">
356 <div>
357 <div class="invite-code">${invite.code}</div>
358 <div class="invite-meta">Created by ${invite.createdBy} on ${createdDate} • ${status}</div>
359 <div class="invite-meta">${expiryInfo}</div>
360 ${noteInfo}
361 ${messageInfo}
362 ${roleInfo}
363 ${usedByInfo}
364 <div class="invite-url">${invite.inviteUrl}</div>
365 </div>
366 <div class="invite-actions-btns">
367 <button class="btn-copy" data-invite-id="${invite.id}" data-invite-url="${invite.inviteUrl}" ${isActive ? "" : "disabled"}>copy link</button>
368 <button class="btn-edit" onclick="editInvite(${invite.id})" ${isActive ? "" : "disabled"}>edit</button>
369 <button class="btn-delete" onclick="deleteInvite(${invite.id}, event)">delete</button>
370 </div>
371 </div>
372 `;
373 },
374 )
375 .join("");
376
377 // Add copy button handlers
378 const copyButtons = invitesList.querySelectorAll(".btn-copy");
379 copyButtons.forEach((btn) => {
380 btn.addEventListener("click", async (e) => {
381 const button = e.target as HTMLButtonElement;
382 const url = button.dataset.inviteUrl;
383 if (!url) return;
384
385 try {
386 await navigator.clipboard.writeText(url);
387 const originalText = button.textContent;
388 button.textContent = "copied!";
389 setTimeout(() => {
390 button.textContent = originalText;
391 }, 2000);
392 } catch (error) {
393 console.error("Failed to copy:", error);
394 }
395 });
396 });
397 } catch (error) {
398 console.error("Failed to load invites:", error);
399 invitesList.innerHTML = '<div class="error">Failed to load invites</div>';
400 }
401}
402
403checkAuth();
404
405createInviteBtn.addEventListener("click", createInvite);
406
407// Close modals on escape key
408document.addEventListener("keydown", (e) => {
409 if (e.key === "Escape") {
410 closeCreateInviteModal();
411 closeEditInviteModal();
412 }
413});
414
415// Close modals on outside click
416document.getElementById("createInviteModal")?.addEventListener("click", (e) => {
417 if (e.target === e.currentTarget) {
418 closeCreateInviteModal();
419 }
420});
421
422document.getElementById("editInviteModal")?.addEventListener("click", (e) => {
423 if (e.target === e.currentTarget) {
424 closeEditInviteModal();
425 }
426});
427
428let currentEditInviteId: number | null = null;
429
430// Make editInvite globally available for onclick handler
431(window as any).editInvite = async (inviteId: number) => {
432 try {
433 const response = await fetch("/api/invites", {
434 headers: {
435 Authorization: `Bearer ${token}`,
436 },
437 });
438
439 if (!response.ok) {
440 throw new Error("Failed to load invite");
441 }
442
443 const data = await response.json();
444 const invite = data.invites.find(
445 (inv: { id: number }) => inv.id === inviteId,
446 );
447
448 if (!invite) {
449 throw new Error("Invite not found");
450 }
451
452 currentEditInviteId = inviteId;
453
454 // Populate form
455 (document.getElementById("editMaxUses") as HTMLInputElement).value = String(
456 invite.maxUses,
457 );
458 (document.getElementById("editInviteNote") as HTMLTextAreaElement).value =
459 invite.note || "";
460 (
461 document.getElementById("editInviteMessage") as HTMLTextAreaElement
462 ).value = invite.message || "";
463
464 // Handle expiration date
465 const expiresAtInput = document.getElementById(
466 "editExpiresAt",
467 ) as HTMLInputElement;
468 if (invite.expiresAt) {
469 const date = new Date(invite.expiresAt * 1000);
470 const localDatetime = new Date(
471 date.getTime() - date.getTimezoneOffset() * 60000,
472 )
473 .toISOString()
474 .slice(0, 16);
475 expiresAtInput.value = localDatetime;
476 } else {
477 expiresAtInput.value = "";
478 }
479
480 // Show modal
481 const modal = document.getElementById("editInviteModal");
482 if (modal) {
483 modal.style.display = "flex";
484 }
485 } catch (error) {
486 console.error("Failed to load invite:", error);
487 alert("Failed to load invite");
488 }
489};
490
491(window as any).submitEditInvite = async () => {
492 if (currentEditInviteId === null) return;
493
494 const maxUsesInput = document.getElementById(
495 "editMaxUses",
496 ) as HTMLInputElement;
497 const expiresAtInput = document.getElementById(
498 "editExpiresAt",
499 ) as HTMLInputElement;
500 const noteInput = document.getElementById(
501 "editInviteNote",
502 ) as HTMLTextAreaElement;
503 const messageInput = document.getElementById(
504 "editInviteMessage",
505 ) as HTMLTextAreaElement;
506 const submitBtn = document.getElementById(
507 "submitEditInviteBtn",
508 ) as HTMLButtonElement;
509
510 const maxUses = maxUsesInput.value ? parseInt(maxUsesInput.value, 10) : null;
511 const expiresAt = expiresAtInput.value
512 ? Math.floor(new Date(expiresAtInput.value).getTime() / 1000)
513 : null;
514 const note = noteInput.value.trim() || null;
515 const message = messageInput.value.trim() || null;
516
517 submitBtn.disabled = true;
518 submitBtn.textContent = "saving...";
519
520 try {
521 const response = await fetch(`/api/invites/${currentEditInviteId}`, {
522 method: "PATCH",
523 headers: {
524 Authorization: `Bearer ${token}`,
525 "Content-Type": "application/json",
526 },
527 body: JSON.stringify({ maxUses, expiresAt, note, message }),
528 });
529
530 if (!response.ok) {
531 throw new Error("Failed to update invite");
532 }
533
534 await loadInvites();
535 closeEditInviteModal();
536 } catch (error) {
537 console.error("Failed to update invite:", error);
538 alert("Failed to update invite");
539 } finally {
540 submitBtn.disabled = false;
541 submitBtn.textContent = "save changes";
542 }
543};
544
545(window as any).closeEditInviteModal = () => {
546 const modal = document.getElementById("editInviteModal");
547 if (modal) {
548 modal.style.display = "none";
549 currentEditInviteId = null;
550 (document.getElementById("editMaxUses") as HTMLInputElement).value = "";
551 (document.getElementById("editExpiresAt") as HTMLInputElement).value = "";
552 (document.getElementById("editInviteNote") as HTMLTextAreaElement).value =
553 "";
554 (
555 document.getElementById("editInviteMessage") as HTMLTextAreaElement
556 ).value = "";
557 }
558};
559
560(window as any).deleteInvite = async (inviteId: number, event?: Event) => {
561 const btn = event?.target as HTMLButtonElement | undefined;
562
563 // Double-click confirmation pattern
564 if (btn?.dataset.confirmState === "pending") {
565 // Second click - execute delete
566 delete btn.dataset.confirmState;
567 btn.textContent = "deleting...";
568 btn.disabled = true;
569
570 try {
571 const response = await fetch(`/api/invites/${inviteId}`, {
572 method: "DELETE",
573 headers: {
574 Authorization: `Bearer ${token}`,
575 },
576 });
577
578 if (!response.ok) {
579 throw new Error("Failed to delete invite");
580 }
581
582 await loadInvites();
583 } catch (error) {
584 console.error("Failed to delete invite:", error);
585 alert("Failed to delete invite");
586 btn.textContent = "delete";
587 btn.disabled = false;
588 }
589 } else {
590 // First click - set pending state
591 if (btn) {
592 const originalText = btn.textContent;
593 btn.dataset.confirmState = "pending";
594 btn.textContent = "you sure?";
595
596 // Reset after 3 seconds if not confirmed
597 setTimeout(() => {
598 if (btn.dataset.confirmState === "pending") {
599 delete btn.dataset.confirmState;
600 btn.textContent = originalText;
601 }
602 }, 3000);
603 }
604 }
605};