my own indieAuth provider!
indiko.dunkirk.sh/docs
indieauth
oauth2-server
1const token = localStorage.getItem("indiko_session");
2const appsList = document.getElementById("appsList") as HTMLElement;
3
4if (!token) {
5 window.location.href = "/login";
6}
7
8interface App {
9 clientId: string;
10 name: string;
11 scopes: string[];
12 grantedAt: number;
13 lastUsed: number;
14}
15
16async function loadApps() {
17 try {
18 const response = await fetch("/api/apps", {
19 headers: {
20 Authorization: `Bearer ${token}`,
21 },
22 });
23
24 if (response.status === 401 || response.status === 403) {
25 localStorage.removeItem("indiko_session");
26 window.location.href = "/login";
27 return;
28 }
29
30 if (!response.ok) {
31 throw new Error("Failed to load apps");
32 }
33
34 const data = await response.json();
35 displayApps(data.apps);
36 } catch (error) {
37 console.error("Failed to load apps:", error);
38 appsList.innerHTML =
39 '<div class="error">Failed to load authorized apps</div>';
40 }
41}
42
43function displayApps(apps: App[]) {
44 if (apps.length === 0) {
45 appsList.innerHTML =
46 '<div class="empty">No authorized apps yet. Apps will appear here after you grant them access.</div>';
47 return;
48 }
49
50 appsList.innerHTML = apps
51 .map((app) => {
52 const lastUsedDate = new Date(app.lastUsed * 1000).toLocaleDateString();
53 const grantedDate = new Date(app.grantedAt * 1000).toLocaleDateString();
54
55 return `
56 <div class="app-card" data-client-id="${app.clientId}">
57 <div class="app-header">
58 <div>
59 <div class="app-name">${app.name}</div>
60 <div class="app-meta">Granted ${grantedDate} • Last used ${lastUsedDate}</div>
61 </div>
62 <button class="revoke-btn" onclick="revokeApp('${app.clientId}', event)">revoke</button>
63 </div>
64 <div class="scopes">
65 <div class="scope-title">permissions</div>
66 <div class="scope-list">
67 ${app.scopes.map((scope) => `<span class="scope-badge">${scope}</span>`).join("")}
68 </div>
69 </div>
70 </div>
71 `;
72 })
73 .join("");
74}
75
76(window as any).revokeApp = async (clientId: string, event?: Event) => {
77 const btn = event?.target as HTMLButtonElement | undefined;
78
79 // Double-click confirmation pattern
80 if (btn?.dataset.confirmState === "pending") {
81 // Second click - execute revoke
82 delete btn.dataset.confirmState;
83 btn.disabled = true;
84 btn.textContent = "revoking...";
85
86 const card = document.querySelector(`[data-client-id="${clientId}"]`);
87
88 try {
89 const response = await fetch(
90 `/api/apps/${encodeURIComponent(clientId)}`,
91 {
92 method: "DELETE",
93 headers: {
94 Authorization: `Bearer ${token}`,
95 },
96 },
97 );
98
99 if (!response.ok) {
100 throw new Error("Failed to revoke app");
101 }
102
103 // Remove from UI
104 card?.remove();
105
106 // Check if list is now empty
107 const remaining = document.querySelectorAll(".app-card");
108 if (remaining.length === 0) {
109 appsList.innerHTML =
110 '<div class="empty">No authorized apps yet. Apps will appear here after you grant them access.</div>';
111 }
112 } catch (error) {
113 console.error("Failed to revoke app:", error);
114 alert("Failed to revoke app access. Please try again.");
115 if (btn) {
116 btn.disabled = false;
117 btn.textContent = "revoke";
118 }
119 }
120 } else {
121 // First click - set pending state
122 if (btn) {
123 const originalText = btn.textContent;
124 btn.dataset.confirmState = "pending";
125 btn.textContent = "you sure?";
126
127 // Reset after 3 seconds if not confirmed
128 setTimeout(() => {
129 if (btn.dataset.confirmState === "pending") {
130 delete btn.dataset.confirmState;
131 btn.textContent = originalText;
132 }
133 }, 3000);
134 }
135 }
136};
137
138loadApps();