diff --git a/src/index.ts b/src/index.ts
index ef8a144..d88247e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,5 +1,6 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { Scalar } from "@scalar/hono-api-reference";
+import { serveStatic } from "hono/bun";
import { logger } from "hono/logger";
import { prettyJSON } from "hono/pretty-json";
import { config } from "./config";
@@ -41,6 +42,7 @@ app.use("*", prettyJSON());
app.get("/", (c) => c.redirect("/app"));
+app.get("/app/dashboard.js", serveStatic({ path: "./src/views/dashboard.js" }));
app.route("/auth", authRoutes);
app.route("/api/config", configRoutes);
app.route("/api", apiRoutes);
diff --git a/src/routes/auth.ts b/src/routes/auth.ts
index 45fc314..0b3d3b0 100644
--- a/src/routes/auth.ts
+++ b/src/routes/auth.ts
@@ -1,5 +1,3 @@
-import { readFile } from "node:fs/promises";
-import { join } from "node:path";
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
import { ConfigManager } from "../core/ConfigManager";
import { redis } from "../core/RedisClient";
@@ -115,10 +113,7 @@ authRoutes.openapi(callbackRoute, async (c) => {
const tokens = await provider.exchangeCode(code, redirectUri);
await tokenManager.saveTokens(providerName, tokens);
- const htmlPath = join(process.cwd(), "src/views/success.html");
- let html = await readFile(htmlPath, "utf-8");
- html = html.replaceAll("__PROVIDER_NAME__", providerName);
- return c.html(html);
+ return c.redirect(`/app/success?provider=${providerName}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred.";
console.error(`[OAuth Error] ${errorMessage}`);
diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts
deleted file mode 100644
index 0c8c4aa..0000000
--- a/src/routes/dashboard.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { readFile } from "node:fs/promises";
-import { join } from "node:path";
-import { Hono } from "hono";
-
-const dashboardRoutes = new Hono({ strict: false });
-
-dashboardRoutes.get("/", async (c) => {
- try {
- const htmlPath = join(process.cwd(), "src/views/dashboard.html");
- const html = await readFile(htmlPath, "utf-8");
- return c.html(html);
- } catch (_error) {
- return c.text("Error loading dashboard", 500);
- }
-});
-
-export { dashboardRoutes };
diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx
new file mode 100644
index 0000000..8bf833a
--- /dev/null
+++ b/src/routes/dashboard.tsx
@@ -0,0 +1,495 @@
+/** @jsxImportSource hono/jsx */
+import { Hono } from "hono";
+import { html } from "hono/html";
+import type { Child } from "hono/jsx";
+
+const dashboardRoutes = new Hono({ strict: false });
+
+export const Layout = (props: { title: string; children: Child }) => (
+ <>
+ {html``}
+
+
+
+
+ {props.title}
+
+
+
+
+
+
+
+
+
+
+ {props.children}
+
+
+
+
+ >
+);
+
+export const Dashboard = () => (
+
+
+
+
+
+
+
+
+ toknd auth broker
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Enter Master API Key to access registry
+
+
+
+
+
+
+
No providers configured yet
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Access Token
+
+
+
+ Not Authenticated
+
+
+
+
+
+ Refresh Token
+
+
+
+ Not Authenticated
+
+
+
+
+
+
+ Last Updated
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export const Success = (props: { provider: string }) => (
+
+
+
+
+
+
+
+
+
+ Authenticated!
+
+
+ Successfully connected to{" "}
+ {props.provider}. You can now
+ close this window or return to the dashboard.
+
+
+
+
+
+
+
+
+
+);
+
+dashboardRoutes.get("/", async (c) => {
+ return c.html();
+});
+
+dashboardRoutes.get("/success", async (c) => {
+ const provider = c.req.query("provider") || "Provider";
+ return c.html();
+});
+
+export { dashboardRoutes };
diff --git a/src/views/dashboard.html b/src/views/dashboard.html
deleted file mode 100644
index c4f7c02..0000000
--- a/src/views/dashboard.html
+++ /dev/null
@@ -1,557 +0,0 @@
-
-
-
-
-
-
- toknd — Auth Broker Dashboard
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Syncing
- Registry...
-
-
-
-
-
-
-
-
-
Enter Master API Key to access registry
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/views/dashboard.js b/src/views/dashboard.js
new file mode 100644
index 0000000..17bd765
--- /dev/null
+++ b/src/views/dashboard.js
@@ -0,0 +1,167 @@
+document.addEventListener("alpine:init", () => {
+ const formatTime = (timestamp) => {
+ if (!timestamp) return "Never";
+ const date = new Date(timestamp);
+ const diff = Math.floor((Date.now() - date) / 1000);
+
+ if (diff < 60) return "Just now";
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
+ return date.toLocaleDateString();
+ };
+
+ window.Alpine.data("dashboard", () => ({
+ apiKey: localStorage.getItem("toknd_api_key") || "",
+ isUnlocked: false,
+ loading: false,
+ providers: [],
+ form: {
+ providerName: "",
+ clientId: "",
+ clientSecret: "",
+ authUrl: "",
+ tokenUrl: "",
+ scope: "public",
+ },
+ notification: {
+ show: false,
+ message: "",
+ type: "success",
+ },
+
+ init() {
+ if (this.apiKey) {
+ this.unlock();
+ }
+ },
+
+ async unlock() {
+ if (!this.apiKey) return;
+ this.loading = true;
+ try {
+ localStorage.setItem("toknd_api_key", this.apiKey);
+ await this.fetchProviders();
+ this.isUnlocked = true;
+ } catch (err) {
+ this.showNotification(`Failed to unlock: ${err.message}. Check your API Key`, "error");
+ localStorage.removeItem("toknd_api_key");
+ this.isUnlocked = false;
+ this.apiKey = "";
+ } finally {
+ this.loading = false;
+ }
+ },
+
+ async fetchProviders() {
+ this.loading = true;
+ try {
+ const [configRes, statusRes] = await Promise.all([
+ fetch("/api/config", { headers: { Authorization: `Bearer ${this.apiKey}` } }),
+ fetch("/api/status", { headers: { Authorization: `Bearer ${this.apiKey}` } }),
+ ]);
+
+ if (!configRes.ok || !statusRes.ok) throw new Error("Unauthorized");
+
+ const config = await configRes.json();
+ const status = await statusRes.json();
+
+ this.providers = Object.entries(config).map(([name, cfg]) => ({
+ name,
+ config: cfg,
+ status: status[name] || { accessToken: null, refreshToken: null, lastUpdated: null },
+ }));
+ } catch (err) {
+ this.showNotification(err.message, "error");
+ throw err;
+ } finally {
+ this.loading = false;
+ }
+ },
+
+ async saveConfig(event) {
+ if (event) event.preventDefault();
+ this.loading = true;
+ try {
+ const res = await fetch(`/api/config/${this.form.providerName}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${this.apiKey}`,
+ },
+ body: JSON.stringify(this.form),
+ });
+
+ if (!res.ok) throw new Error("Failed to save");
+
+ this.showNotification("Saved successfully");
+ await this.fetchProviders();
+
+ this.form = {
+ providerName: "",
+ clientId: "",
+ clientSecret: "",
+ authUrl: "",
+ tokenUrl: "",
+ scope: "public",
+ };
+ } catch (err) {
+ this.showNotification(err.message, "error");
+ } finally {
+ this.loading = false;
+ }
+ },
+
+ async forceRefresh(name) {
+ this.loading = true;
+ try {
+ const res = await fetch(`/api/refresh/${name}`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${this.apiKey}` },
+ });
+
+ if (!res.ok) throw new Error("Refresh failed");
+
+ this.showNotification(`Refreshed ${name}`);
+ await this.fetchProviders();
+ } catch (err) {
+ this.showNotification(err.message, "error");
+ } finally {
+ this.loading = false;
+ }
+ },
+
+ editProvider(provider) {
+ this.form = {
+ providerName: provider.name,
+ clientId: provider.config.clientId,
+ clientSecret: provider.config.clientSecret,
+ authUrl: provider.config.authUrl,
+ tokenUrl: provider.config.tokenUrl,
+ scope: provider.config.scope,
+ };
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ },
+
+ getRedirectUri() {
+ return `${window.location.origin}/auth/${this.form.providerName || "{provider}"}/callback`;
+ },
+
+ copyToClipboard(text) {
+ if (!text) return;
+ navigator.clipboard.writeText(text).then(() => {
+ this.showNotification("Copied");
+ });
+ },
+
+ showNotification(message, type = "success") {
+ this.notification = { show: true, message, type };
+ setTimeout(() => {
+ this.notification.show = false;
+ }, 3000);
+ },
+
+ formatTime(timestamp) {
+ return formatTime(timestamp);
+ },
+ }));
+});
diff --git a/src/views/success.html b/src/views/success.html
deleted file mode 100644
index 9099337..0000000
--- a/src/views/success.html
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
-
-
- toknd — Authentication Successful
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Authenticated!
-
- Successfully connected to __PROVIDER_NAME__.
- You can now close this window or return to the dashboard.
-
-
-
-
-
-
-
-
-
-
diff --git a/tests/integration/auth.test.ts b/tests/integration/auth.test.ts
index f342b58..02b8026 100644
--- a/tests/integration/auth.test.ts
+++ b/tests/integration/auth.test.ts
@@ -50,9 +50,8 @@ describe("Auth Integration", () => {
const res = await app.request("/auth/callback?state=trakt&code=temporary-auth-code");
- expect(res.status).toBe(200);
- const html = await res.text();
- expect(html).toContain("trakt");
+ expect(res.status).toBe(302);
+ expect(res.headers.get("Location")).toBe("/app/success?provider=trakt");
expect(redis.set).toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalled();
});
diff --git a/tsconfig.json b/tsconfig.json
index 1fac6c7..8fdce01 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -5,6 +5,8 @@
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "hono/jsx",
"lib": ["ESNext"],
"types": ["node", "bun-types"]
},