14 Commits

Author SHA1 Message Date
ramvignesh-b eac5aa7056 test: update auth callback assertions to verify redirect status and location header
CI / build (pull_request) Successful in 22s
2026-05-12 04:14:26 +05:30
ramvignesh-b 0be9a05c09 refactor: fix lint and formatting
CI / build (pull_request) Failing after 22s
2026-05-12 04:07:34 +05:30
ramvignesh-b ced524dc31 refactor: enhance dashboard UI styling
CI / build (pull_request) Failing after 23s
2026-05-12 03:59:06 +05:30
ramvignesh-b bd6c3790e6 chore: update Alpine.js to latest major version via unpkg CDN 2026-05-12 03:42:39 +05:30
ramvignesh-b 7192ba381d fix: clear invalid API key and reset state on unlock failure 2026-05-12 03:41:08 +05:30
ramvignesh-b 72548db9af refactor: migrate success view from static HTML to dynamic JSX route component 2026-05-12 03:39:39 +05:30
ramvignesh-b 2c2a2eb6c4 feat: migrate dashboard to JSX and move client-side scripts to JS 2026-05-12 03:32:44 +05:30
ramvignesh-b eedab2347c feat: add static file serving and refactor dashboard form to use FormData 2026-05-12 01:59:25 +05:30
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
18 changed files with 754 additions and 674 deletions
+3 -4
View File
@@ -1,6 +1,5 @@
# Core Server Configuration
PORT=3000
APP_PORT=3000
API_KEY=your_secret_api_key_here
# Redis Configuration (Use redis://redis:6379 for Docker)
REDIS_URL=redis://localhost:6379
REDIS_HOST=redis
REDIS_PORT=6379
+4 -1
View File
@@ -11,4 +11,7 @@ ENV NODE_ENV=production
USER bun
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
- **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
Clone the repository and create your environment file:
```bash
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)
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.*
### 2. Choose Deployment Method
### 3. Production Deployment
For production, only the core docker-compose.yml is used:
```bash
docker compose up -d --build
```
#### Option A: Containerized (Recommended)
This is the easiest way to get up and running, as it bundles the application and a Redis instance together.
- **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
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>`
### Token Brokerage
- **Get Valid Token**: `GET /api/token/:provider`
- Returns a valid access token. Automatically triggers a refresh if the current one is expired.
- **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)
### Core Concepts
- **Token Brokerage**: Automated access token retrieval and background refreshes for all configured providers.
- **Provider Management**: Register and manage OAuth2 providers via the Dashboard or the configuration API.
## Dashboard
Access the toknd dashboard at:
Access the **toknd** dashboard at:
`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: .
restart: always
ports:
- "${PORT:-3000}:3000"
- "${APP_PORT:-3000}:3000"
environment:
- REDIS_URL=redis://redis:6379
- REDIS_HOST=redis
- REDIS_PORT=6379
- API_KEY=${API_KEY}
depends_on:
- redis
@@ -13,6 +14,8 @@ services:
redis:
image: redis:alpine
restart: always
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
- redis-data:/data
+1
View File
@@ -4,6 +4,7 @@
"type": "module",
"scripts": {
"dev": "bun run --hot src/index.ts",
"start": "bun src/index.ts",
"test": "bun test",
"lint": "bunx @biomejs/biome check src",
"check-all": "bunx @biomejs/biome check .",
+3 -2
View File
@@ -1,8 +1,9 @@
import { z } from "zod";
const configSchema = z.object({
PORT: z.string().default("3000"),
REDIS_URL: z.string(),
APP_PORT: z.string().default("3000"),
REDIS_HOST: z.string().default("redis"),
REDIS_PORT: z.coerce.number().default(6379),
API_KEY: z.string(),
});
+4 -1
View File
@@ -1,4 +1,7 @@
import { Redis } from "ioredis";
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 { 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";
import { redis } from "./core/RedisClient";
import { apiRoutes } from "./routes/api";
import { authRoutes } from "./routes/auth";
import { configRoutes } from "./routes/config";
@@ -40,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);
@@ -63,11 +66,16 @@ app.onError((err, c) => {
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 default {
port: Number.parseInt(config.PORT, 10),
port: Number.parseInt(config.APP_PORT, 10),
fetch: app.fetch,
};
+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 { 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}`);
-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 };
+495
View File
@@ -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 };
-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>
+167
View File
@@ -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);
},
}));
});
-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>
+11
View File
@@ -35,6 +35,17 @@ describe("API Integration", () => {
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 () => {
redis.keys.mockReturnValue(Promise.resolve(["config:trakt"]));
redis.get.mockImplementation((key) => {
+2 -3
View File
@@ -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 -2
View File
@@ -2,14 +2,17 @@ import { mock } from "bun:test";
// Global test setup to stub environment variables
process.env.API_KEY = "test-api-key";
process.env.REDIS_URL = "redis://localhost:6379";
process.env.PORT = "3000";
process.env.REDIS_HOST = "localhost";
process.env.REDIS_PORT = "6379";
process.env.APP_PORT = "3000";
// Global Redis mock
mock.module("../src/core/RedisClient", () => ({
redis: {
status: "ready",
get: mock(() => Promise.resolve(null)),
set: mock(() => Promise.resolve()),
keys: mock(() => Promise.resolve([])),
on: mock(() => {}),
},
}));
+2
View File
@@ -5,6 +5,8 @@
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"lib": ["ESNext"],
"types": ["node", "bun-types"]
},