diff --git a/biome.json b/biome.json
new file mode 100644
index 0000000..62aa291
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.5.4/schema.json",
+ "files": {
+ "includes": ["**", "!src/client/ds/components.css", "!node_modules", "!data"]
+ }
+}
diff --git a/bun.lock b/bun.lock
index 3d79581..a0f4878 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,6 +5,7 @@
"": {
"name": "indiko",
"dependencies": {
+ "@elenajs/core": "^1.0.0",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"bun-sqlite-migrations": "^1.0.2",
@@ -22,6 +23,8 @@
},
},
"packages": {
+ "@elenajs/core": ["@elenajs/core@1.0.0", "", {}, "sha512-YOlSv7qpvVVrzlVYoyx7s1c8cBML0b5Q8WhxXaNeO3Awd2iNcf9HcJKDWuOi9KT53F7L8K9uQNLCzyb7EVxlpw=="],
+
"@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
diff --git a/package.json b/package.json
index 363824a..dca606e 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"typescript": "^5"
},
"dependencies": {
+ "@elenajs/core": "^1.0.0",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"bun-sqlite-migrations": "^1.0.2",
diff --git a/src/client/admin-clients.ts b/src/client/admin-clients.ts
index aa9c956..24b5306 100644
--- a/src/client/admin-clients.ts
+++ b/src/client/admin-clients.ts
@@ -15,15 +15,13 @@ const addRedirectUriBtn = document.getElementById(
const redirectUrisList = document.getElementById(
"redirectUrisList",
) as HTMLElement;
-const toast = document.getElementById("toast") as HTMLElement;
+import "./ds";
+import type IToast from "./ds/toast";
-function showToast(message: string, type: "success" | "error" = "success") {
- toast.textContent = message;
- toast.className = `toast ${type} show`;
+const toast = document.getElementById("toast") as unknown as IToast;
- setTimeout(() => {
- toast.classList.remove("show");
- }, 3000);
+function showToast(message: string, type: "success" | "error" = "success") {
+ toast.show(message, type);
}
async function checkAuth() {
diff --git a/src/client/apps.ts b/src/client/apps.ts
index 84d3c41..25debb6 100644
--- a/src/client/apps.ts
+++ b/src/client/apps.ts
@@ -1,5 +1,7 @@
+import "./ds";
+import type IToast from "./ds/toast";
+
const token = localStorage.getItem("indiko_session");
-const appsList = document.getElementById("appsList") as HTMLElement;
if (!token) {
window.location.href = "/login";
@@ -13,12 +15,27 @@ interface App {
lastUsed: number;
}
+function $(id: string): HTMLElement {
+ const el = document.getElementById(id);
+ if (!el) throw new Error(`#${id} missing from page`);
+ return el;
+}
+
+let appsList: HTMLElement;
+let toast: IToast;
+
+function escapeHtml(value: string): string {
+ return value
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+}
+
async function loadApps() {
try {
const response = await fetch("/api/apps", {
- headers: {
- Authorization: `Bearer ${token}`,
- },
+ headers: { Authorization: `Bearer ${token}` },
});
if (response.status === 401 || response.status === 403) {
@@ -27,9 +44,7 @@ async function loadApps() {
return;
}
- if (!response.ok) {
- throw new Error("Failed to load apps");
- }
+ if (!response.ok) throw new Error("Failed to load apps");
const data = await response.json();
displayApps(data.apps);
@@ -51,88 +66,89 @@ function displayApps(apps: App[]) {
.map((app) => {
const lastUsedDate = new Date(app.lastUsed * 1000).toLocaleDateString();
const grantedDate = new Date(app.grantedAt * 1000).toLocaleDateString();
+ const badges = app.scopes
+ .map((s) => `${escapeHtml(s)}`)
+ .join("");
return `
-
-
+
`;
})
.join("");
}
-(window as any).revokeApp = async (clientId: string, event?: Event) => {
- const btn = event?.target as HTMLButtonElement | undefined;
-
- // Double-click confirmation pattern
- if (btn?.dataset.confirmState === "pending") {
- // Second click - execute revoke
- delete btn.dataset.confirmState;
- btn.disabled = true;
- btn.textContent = "revoking...";
-
- const card = document.querySelector(`[data-client-id="${clientId}"]`);
-
- try {
- const response = await fetch(
- `/api/apps/${encodeURIComponent(clientId)}`,
- {
- method: "DELETE",
- headers: {
- Authorization: `Bearer ${token}`,
- },
- },
- );
-
- if (!response.ok) {
- throw new Error("Failed to revoke app");
+async function handleRevoke(btn: HTMLButtonElement) {
+ const clientId = btn.dataset.clientId;
+ if (!clientId) return;
+
+ // Two-step confirm: first click arms, second click revokes
+ if (btn.dataset.confirmState !== "pending") {
+ btn.dataset.confirmState = "pending";
+ btn.textContent = "you sure?";
+ setTimeout(() => {
+ if (btn.dataset.confirmState === "pending") {
+ delete btn.dataset.confirmState;
+ btn.textContent = "revoke";
}
+ }, 3000);
+ return;
+ }
- // Remove from UI
- card?.remove();
+ delete btn.dataset.confirmState;
+ btn.disabled = true;
+ btn.textContent = "revoking...";
- // Check if list is now empty
- const remaining = document.querySelectorAll(".app-card");
- if (remaining.length === 0) {
- appsList.innerHTML =
- '
No authorized apps yet. Apps will appear here after you grant them access.
';
- }
- } catch (error) {
- console.error("Failed to revoke app:", error);
- alert("Failed to revoke app access. Please try again.");
- if (btn) {
- btn.disabled = false;
- btn.textContent = "revoke";
- }
- }
- } else {
- // First click - set pending state
- if (btn) {
- const originalText = btn.textContent;
- btn.dataset.confirmState = "pending";
- btn.textContent = "you sure?";
-
- // Reset after 3 seconds if not confirmed
- setTimeout(() => {
- if (btn.dataset.confirmState === "pending") {
- delete btn.dataset.confirmState;
- btn.textContent = originalText;
- }
- }, 3000);
+ try {
+ const response = await fetch(`/api/apps/${encodeURIComponent(clientId)}`, {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${token}` },
+ });
+
+ if (!response.ok) throw new Error("Failed to revoke app");
+
+ document.querySelector(`[data-client-id="${clientId}"]`)?.remove();
+ toast.show("App access revoked", "success");
+
+ if (document.querySelectorAll(".app-card").length === 0) {
+ appsList.innerHTML =
+ '
No authorized apps yet. Apps will appear here after you grant them access.
';
}
+ } catch (error) {
+ console.error("Failed to revoke app:", error);
+ toast.show("Failed to revoke app access. Please try again.", "error");
+ btn.disabled = false;
+ btn.textContent = "revoke";
}
-};
+}
+
+function init() {
+ appsList = $("appsList");
+ toast = $("toast") as unknown as IToast;
+
+ appsList.addEventListener("click", (e) => {
+ const btn = (e.target as HTMLElement).closest(
+ ".revoke-btn",
+ ) as HTMLButtonElement | null;
+ if (btn) handleRevoke(btn);
+ });
-loadApps();
+ loadApps();
+}
+
+if (document.readyState === "complete") {
+ init();
+} else {
+ document.addEventListener("DOMContentLoaded", init);
+}
diff --git a/src/client/ds/button.ts b/src/client/ds/button.ts
new file mode 100644
index 0000000..00bbcc3
--- /dev/null
+++ b/src/client/ds/button.ts
@@ -0,0 +1,23 @@
+import { Elena } from "@elenajs/core";
+
+/**
+ *
— brutalist action button. Composite: put a native