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 = () => ( + + + +
+
+
+
+
+
+

Configure Provider

+
+ +
+
+ + +
+ +
+ Credentials +
+ +
+ + +
+
+ +
+ + +
+
+ +
Endpoints
+ +
+ + +
+
+ + +
+
+ +
+ + +
+
+ + Must match provider's callback URL + +
+
+
+ + +
+ +
+ +
+
+
+
+ +
+
+
+
+
+

Provider Registry

+
+ +
+ +
+
+ +
+ +
+
+ +

Enter Master API Key to access registry

+
+
+ +
+
+ +

No providers configured yet

+
+
+ +
+ +
+
+
+
+
+
+ +
+
+ +
+
+
+); + +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 - - - - - - - - - - - - - -
-
-
-
-
-
-

Configure Provider

-
- -
-
- - -
- -
Credentials -
- -
- - -
-
- -
- - -
-
- -
Endpoints -
- -
- - -
-
- - -
-
- -
- - -
- -
-
- - -
- -
- -
-
-
-
- -
-
-
-
-
-

Provider 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"] },