9 Commits

Author SHA1 Message Date
ramvignesh-b 1d417d672c fix: disable refresh button when provider access token is missing
CI / build (pull_request) Successful in 24s
2026-05-12 04:25:07 +05:30
ramvignesh-b 9d6df8a8df feat: implement secure session management using HttpOnly cookies for API key authentication 2026-05-12 04:20:03 +05:30
me 72357ed9ee refactor: modularize template view (#4)
CI / build (push) Successful in 23s
Co-authored-by: ramvignesh-b <ramvignesh-b@github.com>
Reviewed-on: #4
2026-05-11 22:45:11 +00:00
me 4728eaa578 refactor: re-configure env vars and healthcheck (#3)
CI / build (push) Successful in 22s
Co-authored-by: ramvignesh-b <ramvignesh-b@github.com>
Reviewed-on: #3
2026-05-11 19:58:31 +00:00
ramvignesh-b 7c4ef8a51c chore: update healthcheck to use bun fetch instead of curl
CI / build (push) Successful in 22s
2026-05-12 00:57:23 +05:30
ramvignesh-b 2eab4b92cc test: implement redis unavailibility health check
CI / build (push) Successful in 1m27s
2026-05-11 23:59:01 +05:30
ramvignesh-b 51502055db feat: add healthcheck configuration
CI / build (push) Failing after 24s
2026-05-11 23:44:40 +05:30
ramvignesh-b 553d9647c2 chore: add production start script and update readme
CI / build (push) Successful in 21s
2026-05-11 17:44:19 +05:30
me b954ce5f72 ci: integrate tests workflow
CI / build (push) Successful in 22s
Reviewed-on: #2
2026-05-11 12:01:48 +00:00
20 changed files with 873 additions and 678 deletions
+3 -4
View File
@@ -1,6 +1,5 @@
# Core Server Configuration APP_PORT=3000
PORT=3000
API_KEY=your_secret_api_key_here API_KEY=your_secret_api_key_here
# Redis Configuration (Use redis://redis:6379 for Docker) REDIS_HOST=redis
REDIS_URL=redis://localhost:6379 REDIS_PORT=6379
+4 -1
View File
@@ -11,4 +11,7 @@ ENV NODE_ENV=production
USER bun USER bun
EXPOSE 3000 EXPOSE 3000
CMD ["bun", "run", "src/index.ts"] HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD bun -e "fetch('http://localhost:3000/health').then(res => res.ok ? process.exit(0) : process.exit(1)).catch(e => process.exit(1))"
CMD ["bun", "run", "start"]
+41 -25
View File
@@ -20,47 +20,63 @@
- **Styling**: Tailwind CSS & DaisyUI - **Styling**: Tailwind CSS & DaisyUI
- **Schema & Validation**: Zod - **Schema & Validation**: Zod
## Quick Start ## Getting Started
toknd can be deployed either as a containerized service or self-hosted directly on your hardware.
### 1. Environment Setup ### 1. Environment Setup
Clone the repository and create your environment file: Clone the repository and create your environment file:
```bash ```bash
cp .env.example .env cp .env.example .env
``` ```
Ensure you define a strong `API_KEY` in your `.env`. Define a strong `API_KEY` and ensure `REDIS_URL` points to a valid Redis instance.
### 2. Local Development (with Auto-Watch) ### 2. Choose Deployment Method
We use a Docker Compose override system to enable hot-reloading locally:
```bash
podman compose up --build
```
*Note: This mounts your ./src directory into the container and uses bun --hot to restart on any code changes.*
### 3. Production Deployment #### Option A: Containerized (Recommended)
For production, only the core docker-compose.yml is used: This is the easiest way to get up and running, as it bundles the application and a Redis instance together.
```bash
docker compose up -d --build - **Development (with Hot-Reload)**:
``` ```bash
podman compose up --build
```
- **Production**:
```bash
docker compose up -d --build
```
#### Option B: Self-Hosting (Bare Metal)
Ideal for lightweight deployments or custom environments where you already have Bun and Redis.
1. **Install Dependencies**:
```bash
bun install
```
2. **Start the Server**:
- **Development**: `bun run dev` (with hot-reload)
- **Production**: `bun run start`
*Note: Ensure your Redis server is running and accessible via the `REDIS_URL` in your `.env`.*
---
## API Reference ## API Reference
All protected endpoints require an Authorization header: toknd provides a built-in **Scalar API Reference** that allows you to explore and test all endpoints directly from your browser.
- **Interactive UI**: [http://localhost:3000/api](http://localhost:3000/api) (or `/docs`)
- **OpenAPI Spec (JSON)**: [http://localhost:3000/doc](http://localhost:3000/doc)
All protected endpoints require a Bearer token in the `Authorization` header:
`Authorization: Bearer <your_master_api_key>` `Authorization: Bearer <your_master_api_key>`
### Token Brokerage ### Core Concepts
- **Get Valid Token**: `GET /api/token/:provider` - **Token Brokerage**: Automated access token retrieval and background refreshes for all configured providers.
- Returns a valid access token. Automatically triggers a refresh if the current one is expired. - **Provider Management**: Register and manage OAuth2 providers via the Dashboard or the configuration API.
- **Registry Status**: `GET /api/status`
- Returns the connectivity and refresh status of all configured providers.
### Authentication Flow
1. **Initiate**: `GET /auth/:provider/login`
2. **Callback**: `GET /auth/callback` (Handled internally by toknd)
## Dashboard ## Dashboard
Access the toknd dashboard at: Access the **toknd** dashboard at:
`http://localhost:3000/app` `http://localhost:3000/app`
Authenticate the registry using your Master API Key to manage your providers and view live token status. The dashboard allows you to manage provider configurations, view live token statuses, and manually trigger refreshes. Authenticate using your **Master API Key**.
--- ---
+5 -2
View File
@@ -3,9 +3,10 @@ services:
build: . build: .
restart: always restart: always
ports: ports:
- "${PORT:-3000}:3000" - "${APP_PORT:-3000}:3000"
environment: environment:
- REDIS_URL=redis://redis:6379 - REDIS_HOST=redis
- REDIS_PORT=6379
- API_KEY=${API_KEY} - API_KEY=${API_KEY}
depends_on: depends_on:
- redis - redis
@@ -13,6 +14,8 @@ services:
redis: redis:
image: redis:alpine image: redis:alpine
restart: always restart: always
ports:
- "${REDIS_PORT:-6379}:6379"
volumes: volumes:
- redis-data:/data - redis-data:/data
+1
View File
@@ -4,6 +4,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "bun run --hot src/index.ts", "dev": "bun run --hot src/index.ts",
"start": "bun src/index.ts",
"test": "bun test", "test": "bun test",
"lint": "bunx @biomejs/biome check src", "lint": "bunx @biomejs/biome check src",
"check-all": "bunx @biomejs/biome check .", "check-all": "bunx @biomejs/biome check .",
+3 -2
View File
@@ -1,8 +1,9 @@
import { z } from "zod"; import { z } from "zod";
const configSchema = z.object({ const configSchema = z.object({
PORT: z.string().default("3000"), APP_PORT: z.string().default("3000"),
REDIS_URL: z.string(), REDIS_HOST: z.string().default("redis"),
REDIS_PORT: z.coerce.number().default(6379),
API_KEY: z.string(), API_KEY: z.string(),
}); });
+4 -1
View File
@@ -1,4 +1,7 @@
import { Redis } from "ioredis"; import { Redis } from "ioredis";
import { config } from "../config"; import { config } from "../config";
export const redis = new Redis(config.REDIS_URL); export const redis = new Redis({
host: config.REDIS_HOST,
port: config.REDIS_PORT,
});
+10 -2
View File
@@ -1,8 +1,10 @@
import { OpenAPIHono } from "@hono/zod-openapi"; import { OpenAPIHono } from "@hono/zod-openapi";
import { Scalar } from "@scalar/hono-api-reference"; import { Scalar } from "@scalar/hono-api-reference";
import { serveStatic } from "hono/bun";
import { logger } from "hono/logger"; import { logger } from "hono/logger";
import { prettyJSON } from "hono/pretty-json"; import { prettyJSON } from "hono/pretty-json";
import { config } from "./config"; import { config } from "./config";
import { redis } from "./core/RedisClient";
import { apiRoutes } from "./routes/api"; import { apiRoutes } from "./routes/api";
import { authRoutes } from "./routes/auth"; import { authRoutes } from "./routes/auth";
import { configRoutes } from "./routes/config"; import { configRoutes } from "./routes/config";
@@ -40,6 +42,7 @@ app.use("*", prettyJSON());
app.get("/", (c) => c.redirect("/app")); app.get("/", (c) => c.redirect("/app"));
app.get("/app/dashboard.js", serveStatic({ path: "./src/views/dashboard.js" }));
app.route("/auth", authRoutes); app.route("/auth", authRoutes);
app.route("/api/config", configRoutes); app.route("/api/config", configRoutes);
app.route("/api", apiRoutes); app.route("/api", apiRoutes);
@@ -63,11 +66,16 @@ app.onError((err, c) => {
return c.json({ error: "Internal Server Error", message: err.message }, 500); return c.json({ error: "Internal Server Error", message: err.message }, 500);
}); });
app.get("/health", (c) => c.json({ status: "ok" })); app.get("/health", async (c) => {
if (redis.status !== "ready") {
return c.json({ status: "error", message: "Redis down", redis: redis.status }, 503);
}
return c.json({ status: "ok" });
});
export { app }; export { app };
export default { export default {
port: Number.parseInt(config.PORT, 10), port: Number.parseInt(config.APP_PORT, 10),
fetch: app.fetch, fetch: app.fetch,
}; };
+11 -3
View File
@@ -1,14 +1,22 @@
import type { Context, Next } from "hono"; import type { Context, Next } from "hono";
import { getCookie } from "hono/cookie";
import { config } from "../config"; import { config } from "../config";
export const authMiddleware = async (c: Context, next: Next) => { export const authMiddleware = async (c: Context, next: Next) => {
const authHeader = c.req.header("Authorization"); const authHeader = c.req.header("Authorization");
const cookieToken = getCookie(c, "toknd_api_key");
if (!authHeader?.startsWith("Bearer ")) { let token: string | undefined;
return c.json({ error: "Missing or invalid authorization header" }, 401);
if (authHeader?.startsWith("Bearer ")) {
token = authHeader.split(" ")[1];
} else if (cookieToken) {
token = cookieToken;
} }
const token = authHeader.split(" ")[1]; if (!token) {
return c.json({ error: "Missing or invalid authorization" }, 401);
}
if (token !== config.API_KEY) { if (token !== config.API_KEY) {
return c.json({ error: "Invalid API key" }, 403); return c.json({ error: "Invalid API key" }, 403);
+1 -6
View File
@@ -1,5 +1,3 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
import { ConfigManager } from "../core/ConfigManager"; import { ConfigManager } from "../core/ConfigManager";
import { redis } from "../core/RedisClient"; import { redis } from "../core/RedisClient";
@@ -115,10 +113,7 @@ authRoutes.openapi(callbackRoute, async (c) => {
const tokens = await provider.exchangeCode(code, redirectUri); const tokens = await provider.exchangeCode(code, redirectUri);
await tokenManager.saveTokens(providerName, tokens); await tokenManager.saveTokens(providerName, tokens);
const htmlPath = join(process.cwd(), "src/views/success.html"); return c.redirect(`/app/success?provider=${providerName}`);
let html = await readFile(htmlPath, "utf-8");
html = html.replaceAll("__PROVIDER_NAME__", providerName);
return c.html(html);
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred."; const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred.";
console.error(`[OAuth Error] ${errorMessage}`); console.error(`[OAuth Error] ${errorMessage}`);
-17
View File
@@ -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 };
+526
View File
@@ -0,0 +1,526 @@
/** @jsxImportSource hono/jsx */
import { Hono } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import { html } from "hono/html";
import type { Child } from "hono/jsx";
import { config } from "../config";
const dashboardRoutes = new Hono({ strict: false });
export const Layout = (props: { title: string; children: Child; isUnlocked?: boolean }) => (
<>
{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({ initialIsUnlocked: ${props.isUnlocked || false} })`}
>
{props.children}
<script src="/app/dashboard.js"></script>
<script src="//unpkg.com/alpinejs@3" defer></script>
</body>
</html>
</>
);
export const Dashboard = (props: { isUnlocked: boolean }) => (
<Layout title="toknd — Auth Broker Dashboard" isUnlocked={props.isUnlocked}>
<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>
<button
x-show="isUnlocked"
x-on:click="logout()"
type="button"
class="btn btn-ghost btn-sm join-item text-error hover:bg-error/10"
>
<i class="ph-bold ph-power text-lg"></i>
</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 || !provider.status.accessToken"
>
<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) => {
const isUnlocked = getCookie(c, "toknd_api_key") === config.API_KEY;
return c.html(<Dashboard isUnlocked={isUnlocked} />);
});
dashboardRoutes.post("/unlock", async (c) => {
const { apiKey } = await c.req.json();
if (apiKey !== config.API_KEY) {
return c.json({ error: "Invalid API Key" }, 401);
}
setCookie(c, "toknd_api_key", apiKey, {
httpOnly: true,
secure: true,
sameSite: "Strict",
path: "/",
maxAge: 60 * 60 * 24 * 7, // 1 week
});
return c.json({ success: true });
});
dashboardRoutes.post("/logout", async (c) => {
deleteCookie(c, "toknd_api_key", { path: "/" });
return c.json({ success: true });
});
dashboardRoutes.get("/success", async (c) => {
const provider = c.req.query("provider") || "Provider";
return c.html(<Success provider={provider} />);
});
export { dashboardRoutes };
-557
View File
@@ -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 &quot;API&quot; or &quot;Developer&quot; section of the provider. Sometimes called &quot;App ID&quot; or &quot;Consumer Key&quot;.">
<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 &quot;Authorize&quot;. Usually found in OAuth2 docs under &quot;Endpoints&quot; or &quot;Authorize&quot;.">
<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 &quot;/token&quot; or &quot;/access_token&quot;.">
<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 &quot;Redirect URI&quot; or &quot;Callback URL&quot; 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&apos;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>
+203
View File
@@ -0,0 +1,203 @@
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", ({ initialIsUnlocked }) => ({
apiKey: "",
isUnlocked: initialIsUnlocked,
loading: false,
providers: [],
form: {
providerName: "",
clientId: "",
clientSecret: "",
authUrl: "",
tokenUrl: "",
scope: "public",
},
notification: {
show: false,
message: "",
type: "success",
},
init() {
if (this.isUnlocked) {
this.fetchProviders();
}
},
async unlock() {
if (!this.apiKey) return;
this.loading = true;
try {
const res = await fetch("/app/unlock", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: this.apiKey }),
});
if (!res.ok) throw new Error("Invalid API Key");
this.isUnlocked = true;
await this.fetchProviders();
this.apiKey = ""; // Clear after success
} catch (err) {
this.showNotification(err.message, "error");
this.isUnlocked = false;
} finally {
this.loading = false;
}
},
async logout() {
this.loading = true;
try {
await fetch("/app/logout", { method: "POST" });
this.isUnlocked = false;
this.providers = [];
this.showNotification("Logged out successfully");
} catch (err) {
this.showNotification(`Logout failed: ${err.message}`, "error");
} finally {
this.loading = false;
}
},
async fetchProviders() {
this.loading = true;
try {
const [configRes, statusRes] = await Promise.all([
fetch("/api/config"),
fetch("/api/status"),
]);
if (configRes.status === 401 || statusRes.status === 401) {
return this.handleSessionExpired();
}
if (!configRes.ok || !statusRes.ok) throw new Error("Failed to fetch data");
const [config, status] = await Promise.all([configRes.json(), statusRes.json()]);
this.providers = this.mapProviders(config, status);
} catch (err) {
this.showNotification(err.message, "error");
} finally {
this.loading = false;
}
},
mapProviders(config, status) {
return Object.entries(config).map(([name, cfg]) => ({
name,
config: cfg,
status: status[name] || { accessToken: null, refreshToken: null, lastUpdated: null },
}));
},
handleSessionExpired() {
this.isUnlocked = false;
this.providers = [];
this.showNotification("Session expired", "error");
},
async saveConfig() {
this.loading = true;
try {
const res = await fetch(`/api/config/${this.form.providerName}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form),
});
if (res.status === 401) {
this.isUnlocked = false;
throw new Error("Session expired");
}
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",
});
if (res.status === 401) {
this.isUnlocked = false;
throw new Error("Session expired");
}
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);
},
}));
});
-52
View File
@@ -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>
+31 -1
View File
@@ -24,7 +24,7 @@ describe("API Integration", () => {
expect(res.status).toBe(401); expect(res.status).toBe(401);
const body = await res.json(); const body = await res.json();
expect(body).toEqual({ error: "Missing or invalid authorization header" }); expect(body).toEqual({ error: "Missing or invalid authorization" });
}); });
it("should return 200 for health check (no auth needed)", async () => { it("should return 200 for health check (no auth needed)", async () => {
@@ -35,6 +35,17 @@ describe("API Integration", () => {
expect(body.status).toBe("ok"); expect(body.status).toBe("ok");
}); });
it("should return 503 for health check if redis is down", async () => {
redis.status = "connecting";
const res = await app.request("/health");
const body = await res.json();
expect(res.status).toBe(503);
expect(body.status).toBe("error");
expect(body.redis).toBe("connecting");
});
it("should return 200 for status with valid API Key", async () => { it("should return 200 for status with valid API Key", async () => {
redis.keys.mockReturnValue(Promise.resolve(["config:trakt"])); redis.keys.mockReturnValue(Promise.resolve(["config:trakt"]));
redis.get.mockImplementation((key) => { redis.get.mockImplementation((key) => {
@@ -56,6 +67,25 @@ describe("API Integration", () => {
expect(body.trakt.accessToken).toBe("current-access-token"); expect(body.trakt.accessToken).toBe("current-access-token");
}); });
it("should return 200 for status with valid Cookie", async () => {
redis.keys.mockReturnValue(Promise.resolve(["config:trakt"]));
redis.get.mockImplementation((key) => {
if (key.includes("config")) return Promise.resolve(mockTraktConfig);
if (key.includes("access_token")) return Promise.resolve("current-access-token");
return Promise.resolve(null);
});
const res = await app.request("/api/status", {
headers: {
Cookie: "toknd_api_key=test-api-key",
},
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.trakt).toBeDefined();
});
it("should return 404 for unknown provider token", async () => { it("should return 404 for unknown provider token", async () => {
const res = await app.request("/api/token/unconfigured-provider", { const res = await app.request("/api/token/unconfigured-provider", {
headers: { headers: {
+2 -3
View File
@@ -50,9 +50,8 @@ describe("Auth Integration", () => {
const res = await app.request("/auth/callback?state=trakt&code=temporary-auth-code"); const res = await app.request("/auth/callback?state=trakt&code=temporary-auth-code");
expect(res.status).toBe(200); expect(res.status).toBe(302);
const html = await res.text(); expect(res.headers.get("Location")).toBe("/app/success?provider=trakt");
expect(html).toContain("trakt");
expect(redis.set).toHaveBeenCalled(); expect(redis.set).toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalled(); expect(fetchSpy).toHaveBeenCalled();
}); });
+21
View File
@@ -34,4 +34,25 @@ describe("Dashboard & Common Integration", () => {
const body = await res.json(); const body = await res.json();
expect(body.error).toBe("Internal Server Error"); expect(body.error).toBe("Internal Server Error");
}); });
it("should set a cookie on successful unlock", async () => {
const res = await app.request("/app/unlock", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: "test-api-key" }),
});
expect(res.status).toBe(200);
expect(res.headers.get("Set-Cookie")).toContain("toknd_api_key=test-api-key");
expect(res.headers.get("Set-Cookie")).toContain("HttpOnly");
});
it("should clear the cookie on logout", async () => {
const res = await app.request("/app/logout", {
method: "POST",
});
expect(res.status).toBe(200);
expect(res.headers.get("Set-Cookie")).toContain("toknd_api_key=;");
});
}); });
+5 -2
View File
@@ -2,14 +2,17 @@ import { mock } from "bun:test";
// Global test setup to stub environment variables // Global test setup to stub environment variables
process.env.API_KEY = "test-api-key"; process.env.API_KEY = "test-api-key";
process.env.REDIS_URL = "redis://localhost:6379"; process.env.REDIS_HOST = "localhost";
process.env.PORT = "3000"; process.env.REDIS_PORT = "6379";
process.env.APP_PORT = "3000";
// Global Redis mock // Global Redis mock
mock.module("../src/core/RedisClient", () => ({ mock.module("../src/core/RedisClient", () => ({
redis: { redis: {
status: "ready",
get: mock(() => Promise.resolve(null)), get: mock(() => Promise.resolve(null)),
set: mock(() => Promise.resolve()), set: mock(() => Promise.resolve()),
keys: mock(() => Promise.resolve([])), keys: mock(() => Promise.resolve([])),
on: mock(() => {}),
}, },
})); }));
+2
View File
@@ -5,6 +5,8 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"lib": ["ESNext"], "lib": ["ESNext"],
"types": ["node", "bun-types"] "types": ["node", "bun-types"]
}, },