refactor: modularize template view #4
@@ -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);
|
||||
|
||||
+1
-6
@@ -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}`);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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`<!DOCTYPE html>`}
|
||||
<html lang="en" data-theme="abyss">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{props.title}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css"
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
/>
|
||||
<script src="https://unpkg.com/@phosphor-icons/web@2.1.1"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
{`
|
||||
.font-mono {
|
||||
font-family: "DM Mono", monospace;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</head>
|
||||
<body
|
||||
class="bg-base-200/50 min-h-screen font-['DM_Sans',sans-serif] antialiased text-base-content tracking-tight"
|
||||
x-data="dashboard"
|
||||
>
|
||||
{props.children}
|
||||
<script src="/app/dashboard.js"></script>
|
||||
<script src="//unpkg.com/alpinejs@3" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
</>
|
||||
);
|
||||
|
||||
export const Dashboard = () => (
|
||||
<Layout title="toknd — Auth Broker Dashboard">
|
||||
<div class="navbar bg-base-100 shadow-sm px-4 md:px-8 border-b border-base-300">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center text-primary-content font-semibold text-lg">
|
||||
<i class="ph-duotone ph-fingerprint"></i>
|
||||
</div>
|
||||
<div class="text-xl font-semibold tracking-tight">
|
||||
toknd <span class="text-xs font-normal opacity-50 ml-1">auth broker</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-none hidden sm:flex">
|
||||
<div class="join border border-base-200/50 bg-base-200/50 rounded-xl overflow-hidden focus-within:border-primary transition-colors">
|
||||
<div class="join-item flex items-center px-4 bg-base-200">
|
||||
<i class="ph-duotone ph-key text-secondary text-lg"></i>
|
||||
</div>
|
||||
<div class="relative flex-1" x-data="{ show: false }">
|
||||
<input
|
||||
x-bind:type="show ? 'text' : 'password'"
|
||||
id="apiKey"
|
||||
name="apiKey"
|
||||
x-model="apiKey"
|
||||
aria-label="Master API Key"
|
||||
placeholder="API_KEY"
|
||||
class="input join-item input-sm bg-transparent border-none focus:outline-none w-48 lg:w-64 text-xs pr-10 font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="show = !show"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-square"
|
||||
>
|
||||
<i x-bind:class="show ? 'ph-duotone ph-eye-slash text-base opacity-50' : 'ph-duotone ph-eye text-base opacity-50'"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
x-on:click="unlock()"
|
||||
type="submit"
|
||||
class="btn btn-primary btn-sm join-item px-6"
|
||||
x-bind:disabled="loading"
|
||||
>
|
||||
<i class="ph-duotone ph-lock-key-open text-lg" x-show="!loading"></i>
|
||||
<span class="loading loading-spinner loading-xs" x-show="loading"></span>
|
||||
<span class="ml-1 hidden md:inline" x-text="loading ? 'Unlocking...' : 'Unlock'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto p-4 md:p-8 max-w-7xl">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 md:gap-8">
|
||||
<div class="card bg-base-100 shadow-xl border border-base-300 lg:col-span-4 self-start">
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="w-2 h-6 bg-primary rounded-full"></div>
|
||||
<h2 class="card-title text-xl font-semibold">Configure Provider</h2>
|
||||
</div>
|
||||
|
||||
<form x-on:submit="saveConfig" class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label htmlFor="providerName" class="label py-1">
|
||||
<span class="label-text flex items-center gap-2">
|
||||
Provider ID
|
||||
<span class="tooltip tooltip-top" data-tip="Internal name for this service.">
|
||||
<i class="ph ph-info opacity-50 cursor-help"></i>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="providerName"
|
||||
x-model="form.providerName"
|
||||
placeholder="e.g. trakt"
|
||||
required
|
||||
class="input input-bordered w-full focus:input-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="divider text-xs opacity-50 my-2 uppercase tracking-widest">
|
||||
Credentials
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label htmlFor="clientId" class="label py-1">
|
||||
<span class="label-text">Client ID</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="clientId"
|
||||
x-model="form.clientId"
|
||||
placeholder="OAuth client id"
|
||||
required
|
||||
class="input input-bordered w-full focus:input-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control" x-data="{ show: false }">
|
||||
<label htmlFor="clientSecret" class="label py-1">
|
||||
<span class="label-text">Client Secret</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
x-bind:type="show ? 'text' : 'password'"
|
||||
id="clientSecret"
|
||||
x-model="form.clientSecret"
|
||||
placeholder="OAuth client secret"
|
||||
required
|
||||
class="input input-bordered w-full focus:input-primary pr-12"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="show = !show"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-square"
|
||||
>
|
||||
<i x-bind:class="show ? 'ph-duotone ph-eye-slash text-lg opacity-40' : 'ph-duotone ph-eye text-lg opacity-40'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider text-xs opacity-50 my-2 uppercase tracking-widest">Endpoints</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label htmlFor="authUrl" class="label py-1">
|
||||
<span class="label-text">Auth URL</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="authUrl"
|
||||
x-model="form.authUrl"
|
||||
placeholder="https://trakt.tv/oauth/authorize"
|
||||
required
|
||||
class="input input-bordered w-full focus:input-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label htmlFor="tokenUrl" class="label py-1">
|
||||
<span class="label-text">Token URL</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="tokenUrl"
|
||||
x-model="form.tokenUrl"
|
||||
placeholder="https://api.trakt.tv/oauth/token"
|
||||
required
|
||||
class="input input-bordered w-full focus:input-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label htmlFor="redirectUri" class="label py-1">
|
||||
<span class="label-text">Redirect URI</span>
|
||||
</label>
|
||||
<div class="relative group">
|
||||
<input
|
||||
type="url"
|
||||
id="redirectUri"
|
||||
x-bind:value="getRedirectUri()"
|
||||
readonly
|
||||
class="input input-bordered w-full pr-12 focus:outline-none cursor-default opacity-80"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="copyToClipboard(getRedirectUri())"
|
||||
class="btn btn-ghost btn-xs absolute right-2 top-1/2 -translate-y-1/2 text-base-content/40 hover:text-primary transition-colors"
|
||||
>
|
||||
<i class="ph-duotone ph-copy text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="label py-0.5">
|
||||
<span class="label-text-alt opacity-40 italic text-xs">
|
||||
Must match provider's callback URL
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label htmlFor="scope" class="label py-1">
|
||||
<span class="label-text">Scope</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="scope"
|
||||
x-model="form.scope"
|
||||
placeholder="public"
|
||||
class="input input-bordered w-full focus:input-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card-actions pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary w-full shadow-md"
|
||||
x-bind:disabled="loading"
|
||||
>
|
||||
<i class="ph ph-plus-bold mr-1"></i>
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-base-300 lg:col-span-8 overflow-hidden">
|
||||
<div class="card-body p-0">
|
||||
<div class="p-6 pb-4 flex justify-between items-center bg-base-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-6 bg-primary rounded-full"></div>
|
||||
<h2 class="card-title text-xl font-semibold">Provider Registry</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="fetchProviders()"
|
||||
class="btn btn-sm btn-base"
|
||||
x-bind:disabled="!isUnlocked || loading"
|
||||
>
|
||||
<i x-bind:class="loading ? 'ph ph-arrows-clockwise animate-spin mr-1' : 'ph ph-arrows-clockwise mr-1'"></i>
|
||||
Refresh List
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative min-h-[400px]">
|
||||
<div
|
||||
x-show="loading && providers.length > 0"
|
||||
class="absolute inset-0 bg-base-100/50 backdrop-blur-md z-10 flex items-center justify-center"
|
||||
>
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
|
||||
<div x-show="!isUnlocked" class="p-20 text-center opacity-30">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<i class="ph ph-lock-key text-6xl"></i>
|
||||
<p class="font-medium">Enter Master API Key to access registry</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="isUnlocked && providers.length === 0 && !loading"
|
||||
class="p-20 text-center opacity-30"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<i class="ph ph-folder-open text-6xl"></i>
|
||||
<p class="font-medium">No providers configured yet</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="isUnlocked && providers.length > 0"
|
||||
class="p-6 grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
<template x-for="provider in providers" x-bind:key="provider.name">
|
||||
<div class="card bg-base-200/50 border border-base-300 shadow-sm hover:shadow-md transition-all group">
|
||||
<div class="card-body p-5">
|
||||
<div class="flex flex-col mb-4">
|
||||
<span
|
||||
x-text="provider.name"
|
||||
class="text-lg font-black text-base-content/90 uppercase"
|
||||
></span>
|
||||
<span
|
||||
x-text="provider.config.clientId"
|
||||
x-bind:title="provider.config.clientId"
|
||||
class="text-xs opacity-40 truncate font-mono"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div x-data="{ show: false }">
|
||||
<div class="text-xs uppercase font-semibold opacity-30 block mb-1">
|
||||
Access Token
|
||||
</div>
|
||||
<div
|
||||
x-show="provider.status.accessToken"
|
||||
class="flex items-center gap-2 bg-base-100 rounded border border-base-300 p-1 pl-3"
|
||||
>
|
||||
<input
|
||||
x-bind:type="show ? 'text' : 'password'"
|
||||
x-bind:value="provider.status.accessToken"
|
||||
readonly
|
||||
class="bg-transparent border-none outline-none shadow-none focus:ring-0 text-xs flex-1 min-w-0 font-mono"
|
||||
/>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="show = !show"
|
||||
class="btn btn-ghost btn-xs btn-square"
|
||||
>
|
||||
<i x-bind:class="show ? 'ph-duotone ph-eye-slash text-base opacity-50' : 'ph-duotone ph-eye text-base opacity-50'"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="copyToClipboard(provider.status.accessToken)"
|
||||
class="btn btn-ghost btn-xs btn-square"
|
||||
>
|
||||
<i class="ph-duotone ph-copy-simple text-base opacity-50"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
x-show="!provider.status.accessToken"
|
||||
class="h-8 flex items-center px-3 bg-base-300/30 rounded text-xs italic opacity-40"
|
||||
>
|
||||
Not Authenticated
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-data="{ show: false }">
|
||||
<div class="text-xs uppercase font-semibold opacity-30 block mb-1">
|
||||
Refresh Token
|
||||
</div>
|
||||
<div
|
||||
x-show="provider.status.refreshToken"
|
||||
class="flex items-center gap-2 bg-base-100 rounded border border-base-300 p-1 pl-3"
|
||||
>
|
||||
<input
|
||||
x-bind:type="show ? 'text' : 'password'"
|
||||
x-bind:value="provider.status.refreshToken"
|
||||
readonly
|
||||
class="bg-transparent border-none outline-none shadow-none focus:ring-0 text-xs flex-1 min-w-0 font-mono"
|
||||
/>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="show = !show"
|
||||
class="btn btn-ghost btn-xs btn-square"
|
||||
>
|
||||
<i x-bind:class="show ? 'ph-duotone ph-eye-slash text-base opacity-50' : 'ph-duotone ph-eye text-base opacity-50'"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="copyToClipboard(provider.status.refreshToken)"
|
||||
class="btn btn-ghost btn-xs btn-square"
|
||||
>
|
||||
<i class="ph-duotone ph-copy-simple text-base opacity-50"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
x-show="!provider.status.refreshToken"
|
||||
class="h-8 flex items-center px-3 bg-base-300/30 rounded text-xs italic opacity-40"
|
||||
>
|
||||
Not Authenticated
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider my-3 opacity-10"></div>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<span class="text-xs font-semibold opacity-30 uppercase">Last Updated</span>
|
||||
<span
|
||||
x-text="formatTime(provider.status.lastUpdated)"
|
||||
class="text-xs font-medium opacity-60"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="window.open('/auth/' + provider.name + '/login', '_blank')"
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
<i class="ph-bold ph-link"></i> Connect
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="editProvider(provider)"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
<i class="ph-bold ph-pencil-simple"></i> Edit
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="forceRefresh(provider.name)"
|
||||
class="btn btn-base w-full"
|
||||
x-bind:disabled="loading"
|
||||
>
|
||||
<i class="ph-bold ph-arrows-clockwise text-base mr-1"></i>
|
||||
<span class="text-xs uppercase font-bold tracking-widest">
|
||||
Refresh Tokens
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast toast-center toast-top z-100" x-show="notification.show">
|
||||
<div
|
||||
class="alert shadow-lg border border-base-300"
|
||||
x-bind:class="notification.type === 'error' ? 'alert-error' : 'alert-success'"
|
||||
>
|
||||
<span x-text="notification.message"></span>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
export const Success = (props: { provider: string }) => (
|
||||
<Layout title="Authenticated!">
|
||||
<div class="min-h-[80vh] flex items-center justify-center p-4">
|
||||
<div class="card bg-base-100 shadow-xl border border-base-300 max-w-md w-full">
|
||||
<div class="card-body items-center text-center p-8 md:p-12">
|
||||
<div class="w-20 h-20 bg-success/10 text-success rounded-2xl flex items-center justify-center mb-6 shadow-inner animate-pulse-slow">
|
||||
<i class="ph-duotone ph-check-circle text-5xl"></i>
|
||||
</div>
|
||||
|
||||
<h2 class="card-title text-3xl font-black tracking-tight mb-2 uppercase">
|
||||
Authenticated!
|
||||
</h2>
|
||||
<p class="text-base-content/60 leading-relaxed">
|
||||
Successfully connected to{" "}
|
||||
<span class="font-bold text-base-content uppercase">{props.provider}</span>. You can now
|
||||
close this window or return to the dashboard.
|
||||
</p>
|
||||
|
||||
<div class="divider my-8 opacity-50"></div>
|
||||
|
||||
<div class="card-actions w-full">
|
||||
<a
|
||||
href="/app"
|
||||
class="btn btn-primary btn-block shadow-lg hover:shadow-primary/20 transition-all"
|
||||
>
|
||||
<i class="ph-bold ph-house mr-2"></i>
|
||||
Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
dashboardRoutes.get("/", async (c) => {
|
||||
return c.html(<Dashboard />);
|
||||
});
|
||||
|
||||
dashboardRoutes.get("/success", async (c) => {
|
||||
const provider = c.req.query("provider") || "Provider";
|
||||
return c.html(<Success provider={provider} />);
|
||||
});
|
||||
|
||||
export { dashboardRoutes };
|
||||
@@ -1,557 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="abyss">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>toknd — Auth Broker Dashboard</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://unpkg.com/@phosphor-icons/web@2.1.1"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--font-sans: "DM Sans", sans-serif;
|
||||
--font-mono: "DM Mono", monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.font-mono,
|
||||
.input-mono,
|
||||
#apiKey,
|
||||
[type="password"],
|
||||
[id^="token-"] {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* UI elements prioritization */
|
||||
.btn, .card-title, .navbar a, .label-text, .tooltip {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-base-200/50 min-h-screen font-sans antialiased text-base-content">
|
||||
<div class="navbar bg-base-100 shadow-sm px-4 md:px-8 border-b border-base-300">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center text-primary-content font-semibold text-lg">
|
||||
<i class="ph-duotone ph-fingerprint"></i>
|
||||
</div>
|
||||
<a class="text-xl font-semibold tracking-tight">toknd <span
|
||||
class="text-xs font-normal opacity-50 ml-1">auth
|
||||
broker</span></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-none hidden sm:flex">
|
||||
<div class="join border border-base-300 bg-base-200/50 rounded-xl overflow-hidden focus-within:border-primary transition-colors">
|
||||
<div class="join-item flex items-center px-4 bg-base-100">
|
||||
<i class="ph-duotone ph-key text-primary text-lg animate-pulse-slow"></i>
|
||||
</div>
|
||||
<div class="relative flex-1">
|
||||
<input type="password" id="apiKey" placeholder="API_KEY"
|
||||
class="input join-item input-sm bg-transparent border-none focus:outline-none w-48 lg:w-64 text-xs pr-10 font-mono" />
|
||||
<button type="button" onclick="window.toggleToken('apiKey')"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-square">
|
||||
<i class="ph-duotone ph-eye text-base opacity-50" id="eye-apiKey"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button onclick="fetchProviders()" type="button"
|
||||
class="btn btn-primary btn-sm join-item px-6">
|
||||
<i class="ph-duotone ph-lock-key-open text-lg"></i>
|
||||
<span class="ml-1 hidden md:inline">Unlock</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto p-4 md:p-8 max-w-7xl">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 md:gap-8">
|
||||
<div class="card bg-base-100 shadow-xl border border-base-300 lg:col-span-4 self-start">
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="w-2 h-6 bg-primary rounded-full"></div>
|
||||
<h2 class="card-title text-xl font-semibold">Configure Provider</h2>
|
||||
</div>
|
||||
|
||||
<form id="configForm" class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text flex items-center gap-2">
|
||||
Provider ID
|
||||
<div class="tooltip tooltip-top"
|
||||
data-tip="Internal name for this service. This will define your login URL (e.g. /auth/trakt/login).">
|
||||
<i class="ph ph-info opacity-50 cursor-help"></i>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<input type="text" id="providerName" placeholder="e.g. trakt" required
|
||||
class="input input-bordered w-full focus:input-primary" />
|
||||
</div>
|
||||
|
||||
<div class="divider text-xs opacity-50 my-2 uppercase tracking-widest text-xs">Credentials
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text flex items-center gap-2">
|
||||
Client ID
|
||||
<div class="tooltip tooltip-top"
|
||||
data-tip="Found in the "API" or "Developer" section of the provider. Sometimes called "App ID" or "Consumer Key".">
|
||||
<i class="ph ph-info opacity-50 cursor-help"></i>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<input type="text" id="clientId" placeholder="OAuth client id" required
|
||||
class="input input-bordered w-full focus:input-primary" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text flex items-center gap-2">
|
||||
Client Secret
|
||||
<div class="tooltip tooltip-top"
|
||||
data-tip="Found next to the Client ID. This is your private key—never share it or put it in client-side code.">
|
||||
<i class="ph-duotone ph-info opacity-50 cursor-help"></i>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="password" id="clientSecret" placeholder="OAuth client secret" required
|
||||
class="input input-bordered w-full focus:input-primary pr-12" />
|
||||
<button type="button" onclick="window.toggleToken('clientSecret')"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-square">
|
||||
<i class="ph-duotone ph-eye text-lg opacity-40" id="eye-clientSecret"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider text-xs opacity-50 my-2 uppercase tracking-widest text-xs">Endpoints
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text flex items-center gap-2">
|
||||
Auth URL
|
||||
<div class="tooltip tooltip-top"
|
||||
data-tip="The page where users click "Authorize". Usually found in OAuth2 docs under "Endpoints" or "Authorize".">
|
||||
<i class="ph ph-info opacity-50 cursor-help"></i>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<input type="url" id="authUrl" placeholder="https://trakt.tv/oauth/authorize" required
|
||||
class="input input-bordered w-full focus:input-primary" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text flex items-center gap-2">
|
||||
Token URL
|
||||
<div class="tooltip tooltip-top"
|
||||
data-tip="The background API used to trade the code for a token. Usually ends in "/token" or "/access_token".">
|
||||
<i class="ph ph-info opacity-50 cursor-help"></i>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<input type="url" id="tokenUrl" placeholder="https://api.trakt.tv/oauth/token" required
|
||||
class="input input-bordered w-full focus:input-primary" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text flex items-center gap-2">
|
||||
Redirect URI
|
||||
<div class="tooltip tooltip-top"
|
||||
data-tip="Copy this URL and paste it into the "Redirect URI" or "Callback URL" field in your OAuth provider's settings.">
|
||||
<i class="ph ph-info opacity-50 cursor-help"></i>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<div class="relative group">
|
||||
<input type="url" id="redirectUri" readonly
|
||||
class="input input-bordered w-full pr-12 focus:outline-none cursor-default opacity-80" />
|
||||
<button type="button" onclick="copyRedirectUri()"
|
||||
class="btn btn-ghost btn-xs absolute right-2 top-1/2 -translate-y-1/2 text-base-content/40 hover:text-primary transition-colors">
|
||||
<i class="ph ph-copy text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
<label class="label py-0.5">
|
||||
<span class="label-text-alt opacity-40 italic text-xs">Must match provider's
|
||||
callback URL</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text flex items-center gap-2">
|
||||
Scope
|
||||
<div class="tooltip tooltip-top"
|
||||
data-tip="Determines what data you're allowed to access. Multiple scopes are usually space-separated.">
|
||||
<i class="ph ph-info opacity-50 cursor-help"></i>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<input type="text" id="scope" placeholder="public"
|
||||
class="input input-bordered w-full focus:input-primary" />
|
||||
</div>
|
||||
|
||||
<div class="card-actions pt-4">
|
||||
<button type="submit" class="btn btn-primary w-full shadow-md">
|
||||
<i class="ph ph-plus-bold mr-1"></i>
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-base-300 lg:col-span-8 overflow-hidden">
|
||||
<div class="card-body p-0">
|
||||
<div class="p-6 pb-4 flex justify-between items-center bg-base-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-6 bg-primary rounded-full"></div>
|
||||
<h2 class="card-title text-xl font-semibold">Provider Registry</h2>
|
||||
</div>
|
||||
<button type="button" onclick="fetchProviders()" class="btn btn-sm btn-ghost border-base-300">
|
||||
<i id="refreshIcon" class="ph ph-arrows-clockwise mr-1"></i>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative min-h-[400px]">
|
||||
<!-- Loading Overlay -->
|
||||
<div id="registryLoading"
|
||||
class="absolute inset-0 bg-base-100/50 backdrop-blur-[2px] z-10 flex items-center justify-center hidden transition-all duration-300">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<span class="text-xs font-semibold uppercase tracking-widest opacity-40">Syncing
|
||||
Registry...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="providerRegistry" class="p-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Empty State -->
|
||||
<div class="col-span-full text-center py-20 opacity-30">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<i class="ph ph-lock-key text-6xl"></i>
|
||||
<p class="font-medium">Enter Master API Key to access registry</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast toast-end" id="notificationToast" style="display: none; z-index: 100;">
|
||||
<div class="alert shadow-lg border border-base-300">
|
||||
<span id="notificationMessage"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const apiKeyInput = document.getElementById('apiKey');
|
||||
const configForm = document.getElementById('configForm');
|
||||
const redirectUriInput = document.getElementById('redirectUri');
|
||||
const providerRegistry = document.getElementById('providerRegistry');
|
||||
const registryLoading = document.getElementById('registryLoading');
|
||||
|
||||
function setDefaultRedirectUri() {
|
||||
if (redirectUriInput) {
|
||||
redirectUriInput.value = `${window.location.origin}/auth/callback`;
|
||||
}
|
||||
}
|
||||
|
||||
window.copyRedirectUri = () => {
|
||||
if (redirectUriInput) {
|
||||
navigator.clipboard.writeText(redirectUriInput.value).then(() => {
|
||||
showNotification('Redirect URI copied!');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
setDefaultRedirectUri();
|
||||
|
||||
let providerData = {};
|
||||
let tokenStatus = {};
|
||||
|
||||
function showNotification(message, type = 'success') {
|
||||
const toast = document.getElementById('notificationToast');
|
||||
const alert = toast.querySelector('.alert');
|
||||
const msgSpan = document.getElementById('notificationMessage');
|
||||
|
||||
alert.className = `alert alert-${type} shadow-lg`;
|
||||
msgSpan.textContent = message;
|
||||
toast.style.display = 'block';
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.display = 'none';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async function fetchProviders() {
|
||||
const apiKey = apiKeyInput.value.trim();
|
||||
const refreshIcon = document.getElementById('refreshIcon');
|
||||
|
||||
if (!apiKey) {
|
||||
providerRegistry.innerHTML = `
|
||||
<div class="col-span-full text-center py-20 opacity-30">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<i class="ph-duotone ph-fingerprint text-6xl"></i>
|
||||
<p class="font-medium italic">Unlock toknd to access the broker registry</p>
|
||||
</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (refreshIcon) refreshIcon.classList.add('animate-spin');
|
||||
if (registryLoading) registryLoading.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const configRes = await fetch('/api/config', {
|
||||
headers: { 'Authorization': `Bearer ${apiKey}` }
|
||||
});
|
||||
if (!configRes.ok) throw new Error('Unauthorized or missing API Key');
|
||||
providerData = await configRes.json();
|
||||
|
||||
const statusRes = await fetch('/api/status', {
|
||||
headers: { 'Authorization': `Bearer ${apiKey}` }
|
||||
});
|
||||
if (statusRes.ok) {
|
||||
tokenStatus = await statusRes.json();
|
||||
}
|
||||
|
||||
renderRegistry();
|
||||
localStorage.setItem('toknd_api_key', apiKey);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
providerRegistry.innerHTML = `<div class="col-span-full text-center text-error py-12 font-medium">${error.message}</div>`;
|
||||
} finally {
|
||||
if (refreshIcon) refreshIcon.classList.remove('animate-spin');
|
||||
if (registryLoading) registryLoading.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeAgo(dateString) {
|
||||
if (!dateString) return '<span class="opacity-30">Never</span>';
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now - date) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return 'Just now';
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function renderRegistry() {
|
||||
providerRegistry.innerHTML = '';
|
||||
const entries = Object.entries(providerData);
|
||||
|
||||
if (entries.length === 0) {
|
||||
providerRegistry.innerHTML = '<div class="col-span-full text-center py-16 opacity-50 font-medium italic">No providers configured yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [name, config] of entries) {
|
||||
const status = tokenStatus[name] || { accessToken: null, refreshToken: null, lastUpdated: null };
|
||||
const card = document.createElement('div');
|
||||
card.className = "card bg-base-200/50 border border-base-300 shadow-sm hover:shadow-md transition-all group";
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-body p-5">
|
||||
<div class="flex flex-col mb-4">
|
||||
<span class="text-lg font-black text-base-content/90 uppercase">${name}</span>
|
||||
<span class="text-xs opacity-40 truncate font-mono" title="${config.clientId}">${config.clientId}</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs uppercase font-semibold opacity-30 block mb-1">Access Token</label>
|
||||
${renderTokenField(name, 'access', status.accessToken)}
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs uppercase font-semibold opacity-30 block mb-1">Refresh Token</label>
|
||||
${renderTokenField(name, 'refresh', status.refreshToken)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider my-3 opacity-10"></div>
|
||||
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<span class="text-xs font-semibold opacity-30 uppercase">Last Updated</span>
|
||||
<span class="text-xs font-medium opacity-60">${formatTimeAgo(status.lastUpdated)}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<a href="/auth/${name}/login" target="_blank" class="btn btn-primary btn-sm shadow-sm">
|
||||
<i class="ph-bold ph-link"></i> Connect
|
||||
</a>
|
||||
<button type="button" onclick="window.editProvider('${name}')" class="btn btn-outline btn-sm">
|
||||
<i class="ph-bold ph-pencil-simple"></i> Edit
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onclick="window.forceRefresh('${name}')" id="btn-refresh-${name}"
|
||||
class="btn btn-ghost btn-sm border border-base-300 w-full">
|
||||
<i class="ph-duotone ph-arrows-clockwise text-base mr-1" id="icon-refresh-${name}"></i>
|
||||
<span class="text-[10px] uppercase font-bold tracking-wider">Manual Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
providerRegistry.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTokenField(provider, type, value) {
|
||||
if (!value) {
|
||||
return `<div class="h-8 flex items-center px-3 bg-base-300/30 rounded text-xs italic opacity-40">Not Authenticated</div>`;
|
||||
}
|
||||
const id = `token-${provider}-${type}`;
|
||||
return `
|
||||
<div class="flex items-center gap-2 bg-base-100 rounded border border-base-300 p-1 pl-3 group/token transition-all focus-within:border-primary/50">
|
||||
<input type="password" id="${id}" value="${value}" readonly
|
||||
class="bg-transparent border-none outline-none shadow-none focus:ring-0 text-xs flex-1 min-w-0 font-mono" />
|
||||
<div class="flex gap-1">
|
||||
<button onclick="window.toggleToken('${id}')" class="btn btn-ghost btn-xs btn-square">
|
||||
<i class="ph-duotone ph-eye text-base opacity-50" id="eye-${id}"></i>
|
||||
</button>
|
||||
<button onclick="window.copyToken('${value}')" class="btn btn-ghost btn-xs btn-square">
|
||||
<i class="ph-duotone ph-copy-simple text-base opacity-50"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
window.toggleToken = (id) => {
|
||||
const input = document.getElementById(id);
|
||||
const eye = document.getElementById(`eye-${id}`);
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
eye.className = 'ph-duotone ph-eye-slash text-base';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
eye.className = 'ph-duotone ph-eye text-base opacity-50';
|
||||
}
|
||||
};
|
||||
|
||||
window.copyToken = (value) => {
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
showNotification('Token copied to clipboard!');
|
||||
}).catch(_err => {
|
||||
showNotification('Failed to copy token', 'error');
|
||||
});
|
||||
};
|
||||
|
||||
window.editProvider = (name) => {
|
||||
const config = providerData[name];
|
||||
if (!config) return;
|
||||
|
||||
document.getElementById('providerName').value = name;
|
||||
document.getElementById('clientId').value = config.clientId;
|
||||
document.getElementById('clientSecret').value = config.clientSecret;
|
||||
document.getElementById('authUrl').value = config.authUrl;
|
||||
document.getElementById('tokenUrl').value = config.tokenUrl;
|
||||
setDefaultRedirectUri();
|
||||
document.getElementById('scope').value = config.scope;
|
||||
|
||||
document.getElementById('configForm').scrollIntoView({ behavior: 'smooth' });
|
||||
document.getElementById('providerName').focus();
|
||||
};
|
||||
|
||||
window.forceRefresh = async (name) => {
|
||||
const apiKey = apiKeyInput.value.trim();
|
||||
const btn = document.getElementById(`btn-refresh-${name}`);
|
||||
const icon = document.getElementById(`icon-refresh-${name}`);
|
||||
|
||||
if (btn) btn.classList.add('btn-disabled', 'opacity-50');
|
||||
if (icon) icon.classList.add('animate-spin');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/refresh/${name}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${apiKey}` }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Refresh failed');
|
||||
|
||||
showNotification(`${name} tokens refreshed successfully!`);
|
||||
// Update local data and re-render
|
||||
tokenStatus[name] = data.status;
|
||||
renderRegistry();
|
||||
} catch (error) {
|
||||
console.error('Refresh error:', error);
|
||||
showNotification(error.message, 'error');
|
||||
} finally {
|
||||
if (btn) btn.classList.remove('btn-disabled', 'opacity-50');
|
||||
if (icon) icon.classList.remove('animate-spin');
|
||||
}
|
||||
};
|
||||
|
||||
configForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const apiKey = apiKeyInput.value.trim();
|
||||
if (!apiKey) {
|
||||
showNotification('Please enter your Master API Key', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = document.getElementById('providerName').value.trim();
|
||||
const config = {
|
||||
clientId: document.getElementById('clientId').value.trim(),
|
||||
clientSecret: document.getElementById('clientSecret').value.trim(),
|
||||
authUrl: document.getElementById('authUrl').value.trim(),
|
||||
tokenUrl: document.getElementById('tokenUrl').value.trim(),
|
||||
redirectUri: document.getElementById('redirectUri').value.trim() || undefined,
|
||||
scope: document.getElementById('scope').value.trim(),
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/config/${name}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
|
||||
showNotification('Configuration saved successfully!');
|
||||
fetchProviders();
|
||||
configForm.reset();
|
||||
setDefaultRedirectUri();
|
||||
} catch (error) {
|
||||
console.error('Error saving config:', error);
|
||||
showNotification(error.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const savedKey = localStorage.getItem('toknd_api_key');
|
||||
if (savedKey) {
|
||||
apiKeyInput.value = savedKey;
|
||||
fetchProviders();
|
||||
}
|
||||
setDefaultRedirectUri();
|
||||
});
|
||||
|
||||
apiKeyInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
fetchProviders();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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);
|
||||
},
|
||||
}));
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="abyss">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>toknd — Authentication Successful</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://unpkg.com/@phosphor-icons/web@2.1.1"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: "DM Sans", sans-serif;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.font-mono {
|
||||
font-family: "DM Mono", monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-base-200/50 min-h-screen flex items-center justify-center p-4 antialiased">
|
||||
<div class="card bg-base-100 shadow-xl border border-base-300 max-w-md w-full">
|
||||
<div class="card-body items-center text-center p-8 md:p-12">
|
||||
<div
|
||||
class="w-20 h-20 bg-success/10 text-success rounded-2xl flex items-center justify-center mb-6 shadow-inner animate-pulse">
|
||||
<i class="ph ph-check-circle text-5xl"></i>
|
||||
</div>
|
||||
|
||||
<h2 class="card-title text-3xl font-black tracking-tight mb-2">Authenticated!</h2>
|
||||
<p class="text-base-content/60 leading-relaxed">
|
||||
Successfully connected to <span class="font-bold text-base-content">__PROVIDER_NAME__</span>.
|
||||
You can now close this window or return to the dashboard.
|
||||
</p>
|
||||
|
||||
<div class="divider my-8 opacity-50"></div>
|
||||
|
||||
<div class="card-actions w-full">
|
||||
<a href="/app" class="btn btn-primary btn-block shadow-lg hover:shadow-primary/20 transition-all">
|
||||
<i class="ph ph-house-bold mr-2"></i>
|
||||
Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"lib": ["ESNext"],
|
||||
"types": ["node", "bun-types"]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user