Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eac5aa7056 | |||
| 0be9a05c09 | |||
| ced524dc31 | |||
| bd6c3790e6 | |||
| 7192ba381d | |||
| 72548db9af | |||
| 2c2a2eb6c4 | |||
| eedab2347c | |||
| 4728eaa578 | |||
| 7c4ef8a51c | |||
| 2eab4b92cc | |||
| 51502055db | |||
| 553d9647c2 | |||
| b954ce5f72 | |||
| b258ee0a07 | |||
| e6354aae00 | |||
| 78520b9069 | |||
| dfff0e913d | |||
| 21c030fee5 | |||
| 3716c42668 |
+3
-4
@@ -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
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run linting
|
||||
run: bun run check-all
|
||||
|
||||
- name: Run tests
|
||||
run: bun run test
|
||||
+4
-1
@@ -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"]
|
||||
|
||||
@@ -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**.
|
||||
|
||||
---
|
||||
|
||||
+13
-1
@@ -61,5 +61,17 @@
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["tests/**/*"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.15",
|
||||
"@types/node": "^22.19.18",
|
||||
"bun-types": "^1.3.13",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.4",
|
||||
"typescript": "^5.9.3",
|
||||
@@ -66,6 +67,8 @@
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
||||
|
||||
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
||||
|
||||
"cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="],
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
preload = ["./tests/setup.ts"]
|
||||
+5
-2
@@ -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
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
"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 .",
|
||||
"format": "bunx @biomejs/biome format --write src",
|
||||
"prepare": "husky"
|
||||
},
|
||||
@@ -20,6 +22,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.15",
|
||||
"@types/node": "^22.19.18",
|
||||
"bun-types": "^1.3.13",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.4",
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
+3
-2
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
+12
-2
@@ -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,9 +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,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { OAuthProvider, TokenResponse } from "./interface";
|
||||
const TokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
refresh_token: z.string().optional(),
|
||||
expires_in: z.number(),
|
||||
expires_in: z.coerce.number(),
|
||||
});
|
||||
|
||||
export class GenericProvider implements OAuthProvider {
|
||||
|
||||
+1
-6
@@ -1,5 +1,3 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
||||
import { ConfigManager } from "../core/ConfigManager";
|
||||
import { redis } from "../core/RedisClient";
|
||||
@@ -115,10 +113,7 @@ authRoutes.openapi(callbackRoute, async (c) => {
|
||||
const tokens = await provider.exchangeCode(code, redirectUri);
|
||||
await tokenManager.saveTokens(providerName, tokens);
|
||||
|
||||
const htmlPath = join(process.cwd(), "src/views/success.html");
|
||||
let html = await readFile(htmlPath, "utf-8");
|
||||
html = html.replaceAll("__PROVIDER_NAME__", providerName);
|
||||
return c.html(html);
|
||||
return c.redirect(`/app/success?provider=${providerName}`);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred.";
|
||||
console.error(`[OAuth Error] ${errorMessage}`);
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const dashboardRoutes = new Hono({ strict: false });
|
||||
|
||||
dashboardRoutes.get("/", async (c) => {
|
||||
try {
|
||||
const htmlPath = join(process.cwd(), "src/views/dashboard.html");
|
||||
const html = await readFile(htmlPath, "utf-8");
|
||||
return c.html(html);
|
||||
} catch (_error) {
|
||||
return c.text("Error loading dashboard", 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { dashboardRoutes };
|
||||
@@ -0,0 +1,495 @@
|
||||
/** @jsxImportSource hono/jsx */
|
||||
import { Hono } from "hono";
|
||||
import { html } from "hono/html";
|
||||
import type { Child } from "hono/jsx";
|
||||
|
||||
const dashboardRoutes = new Hono({ strict: false });
|
||||
|
||||
export const Layout = (props: { title: string; children: Child }) => (
|
||||
<>
|
||||
{html`<!DOCTYPE html>`}
|
||||
<html lang="en" data-theme="abyss">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{props.title}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css"
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
/>
|
||||
<script src="https://unpkg.com/@phosphor-icons/web@2.1.1"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
{`
|
||||
.font-mono {
|
||||
font-family: "DM Mono", monospace;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</head>
|
||||
<body
|
||||
class="bg-base-200/50 min-h-screen font-['DM_Sans',sans-serif] antialiased text-base-content tracking-tight"
|
||||
x-data="dashboard"
|
||||
>
|
||||
{props.children}
|
||||
<script src="/app/dashboard.js"></script>
|
||||
<script src="//unpkg.com/alpinejs@3" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
</>
|
||||
);
|
||||
|
||||
export const Dashboard = () => (
|
||||
<Layout title="toknd — Auth Broker Dashboard">
|
||||
<div class="navbar bg-base-100 shadow-sm px-4 md:px-8 border-b border-base-300">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center text-primary-content font-semibold text-lg">
|
||||
<i class="ph-duotone ph-fingerprint"></i>
|
||||
</div>
|
||||
<div class="text-xl font-semibold tracking-tight">
|
||||
toknd <span class="text-xs font-normal opacity-50 ml-1">auth broker</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-none hidden sm:flex">
|
||||
<div class="join border border-base-200/50 bg-base-200/50 rounded-xl overflow-hidden focus-within:border-primary transition-colors">
|
||||
<div class="join-item flex items-center px-4 bg-base-200">
|
||||
<i class="ph-duotone ph-key text-secondary text-lg"></i>
|
||||
</div>
|
||||
<div class="relative flex-1" x-data="{ show: false }">
|
||||
<input
|
||||
x-bind:type="show ? 'text' : 'password'"
|
||||
id="apiKey"
|
||||
name="apiKey"
|
||||
x-model="apiKey"
|
||||
aria-label="Master API Key"
|
||||
placeholder="API_KEY"
|
||||
class="input join-item input-sm bg-transparent border-none focus:outline-none w-48 lg:w-64 text-xs pr-10 font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="show = !show"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-square"
|
||||
>
|
||||
<i x-bind:class="show ? 'ph-duotone ph-eye-slash text-base opacity-50' : 'ph-duotone ph-eye text-base opacity-50'"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
x-on:click="unlock()"
|
||||
type="submit"
|
||||
class="btn btn-primary btn-sm join-item px-6"
|
||||
x-bind:disabled="loading"
|
||||
>
|
||||
<i class="ph-duotone ph-lock-key-open text-lg" x-show="!loading"></i>
|
||||
<span class="loading loading-spinner loading-xs" x-show="loading"></span>
|
||||
<span class="ml-1 hidden md:inline" x-text="loading ? 'Unlocking...' : 'Unlock'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto p-4 md:p-8 max-w-7xl">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 md:gap-8">
|
||||
<div class="card bg-base-100 shadow-xl border border-base-300 lg:col-span-4 self-start">
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="w-2 h-6 bg-primary rounded-full"></div>
|
||||
<h2 class="card-title text-xl font-semibold">Configure Provider</h2>
|
||||
</div>
|
||||
|
||||
<form x-on:submit="saveConfig" class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label htmlFor="providerName" class="label py-1">
|
||||
<span class="label-text flex items-center gap-2">
|
||||
Provider ID
|
||||
<span class="tooltip tooltip-top" data-tip="Internal name for this service.">
|
||||
<i class="ph ph-info opacity-50 cursor-help"></i>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="providerName"
|
||||
x-model="form.providerName"
|
||||
placeholder="e.g. trakt"
|
||||
required
|
||||
class="input input-bordered w-full focus:input-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="divider text-xs opacity-50 my-2 uppercase tracking-widest">
|
||||
Credentials
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label htmlFor="clientId" class="label py-1">
|
||||
<span class="label-text">Client ID</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="clientId"
|
||||
x-model="form.clientId"
|
||||
placeholder="OAuth client id"
|
||||
required
|
||||
class="input input-bordered w-full focus:input-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control" x-data="{ show: false }">
|
||||
<label htmlFor="clientSecret" class="label py-1">
|
||||
<span class="label-text">Client Secret</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
x-bind:type="show ? 'text' : 'password'"
|
||||
id="clientSecret"
|
||||
x-model="form.clientSecret"
|
||||
placeholder="OAuth client secret"
|
||||
required
|
||||
class="input input-bordered w-full focus:input-primary pr-12"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="show = !show"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-square"
|
||||
>
|
||||
<i x-bind:class="show ? 'ph-duotone ph-eye-slash text-lg opacity-40' : 'ph-duotone ph-eye text-lg opacity-40'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider text-xs opacity-50 my-2 uppercase tracking-widest">Endpoints</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label htmlFor="authUrl" class="label py-1">
|
||||
<span class="label-text">Auth URL</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="authUrl"
|
||||
x-model="form.authUrl"
|
||||
placeholder="https://trakt.tv/oauth/authorize"
|
||||
required
|
||||
class="input input-bordered w-full focus:input-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label htmlFor="tokenUrl" class="label py-1">
|
||||
<span class="label-text">Token URL</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="tokenUrl"
|
||||
x-model="form.tokenUrl"
|
||||
placeholder="https://api.trakt.tv/oauth/token"
|
||||
required
|
||||
class="input input-bordered w-full focus:input-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label htmlFor="redirectUri" class="label py-1">
|
||||
<span class="label-text">Redirect URI</span>
|
||||
</label>
|
||||
<div class="relative group">
|
||||
<input
|
||||
type="url"
|
||||
id="redirectUri"
|
||||
x-bind:value="getRedirectUri()"
|
||||
readonly
|
||||
class="input input-bordered w-full pr-12 focus:outline-none cursor-default opacity-80"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="copyToClipboard(getRedirectUri())"
|
||||
class="btn btn-ghost btn-xs absolute right-2 top-1/2 -translate-y-1/2 text-base-content/40 hover:text-primary transition-colors"
|
||||
>
|
||||
<i class="ph-duotone ph-copy text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="label py-0.5">
|
||||
<span class="label-text-alt opacity-40 italic text-xs">
|
||||
Must match provider's callback URL
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label htmlFor="scope" class="label py-1">
|
||||
<span class="label-text">Scope</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="scope"
|
||||
x-model="form.scope"
|
||||
placeholder="public"
|
||||
class="input input-bordered w-full focus:input-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card-actions pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary w-full shadow-md"
|
||||
x-bind:disabled="loading"
|
||||
>
|
||||
<i class="ph ph-plus-bold mr-1"></i>
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-base-300 lg:col-span-8 overflow-hidden">
|
||||
<div class="card-body p-0">
|
||||
<div class="p-6 pb-4 flex justify-between items-center bg-base-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-6 bg-primary rounded-full"></div>
|
||||
<h2 class="card-title text-xl font-semibold">Provider Registry</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="fetchProviders()"
|
||||
class="btn btn-sm btn-base"
|
||||
x-bind:disabled="!isUnlocked || loading"
|
||||
>
|
||||
<i x-bind:class="loading ? 'ph ph-arrows-clockwise animate-spin mr-1' : 'ph ph-arrows-clockwise mr-1'"></i>
|
||||
Refresh List
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative min-h-[400px]">
|
||||
<div
|
||||
x-show="loading && providers.length > 0"
|
||||
class="absolute inset-0 bg-base-100/50 backdrop-blur-md z-10 flex items-center justify-center"
|
||||
>
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
|
||||
<div x-show="!isUnlocked" class="p-20 text-center opacity-30">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<i class="ph ph-lock-key text-6xl"></i>
|
||||
<p class="font-medium">Enter Master API Key to access registry</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="isUnlocked && providers.length === 0 && !loading"
|
||||
class="p-20 text-center opacity-30"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<i class="ph ph-folder-open text-6xl"></i>
|
||||
<p class="font-medium">No providers configured yet</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="isUnlocked && providers.length > 0"
|
||||
class="p-6 grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
<template x-for="provider in providers" x-bind:key="provider.name">
|
||||
<div class="card bg-base-200/50 border border-base-300 shadow-sm hover:shadow-md transition-all group">
|
||||
<div class="card-body p-5">
|
||||
<div class="flex flex-col mb-4">
|
||||
<span
|
||||
x-text="provider.name"
|
||||
class="text-lg font-black text-base-content/90 uppercase"
|
||||
></span>
|
||||
<span
|
||||
x-text="provider.config.clientId"
|
||||
x-bind:title="provider.config.clientId"
|
||||
class="text-xs opacity-40 truncate font-mono"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div x-data="{ show: false }">
|
||||
<div class="text-xs uppercase font-semibold opacity-30 block mb-1">
|
||||
Access Token
|
||||
</div>
|
||||
<div
|
||||
x-show="provider.status.accessToken"
|
||||
class="flex items-center gap-2 bg-base-100 rounded border border-base-300 p-1 pl-3"
|
||||
>
|
||||
<input
|
||||
x-bind:type="show ? 'text' : 'password'"
|
||||
x-bind:value="provider.status.accessToken"
|
||||
readonly
|
||||
class="bg-transparent border-none outline-none shadow-none focus:ring-0 text-xs flex-1 min-w-0 font-mono"
|
||||
/>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="show = !show"
|
||||
class="btn btn-ghost btn-xs btn-square"
|
||||
>
|
||||
<i x-bind:class="show ? 'ph-duotone ph-eye-slash text-base opacity-50' : 'ph-duotone ph-eye text-base opacity-50'"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="copyToClipboard(provider.status.accessToken)"
|
||||
class="btn btn-ghost btn-xs btn-square"
|
||||
>
|
||||
<i class="ph-duotone ph-copy-simple text-base opacity-50"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
x-show="!provider.status.accessToken"
|
||||
class="h-8 flex items-center px-3 bg-base-300/30 rounded text-xs italic opacity-40"
|
||||
>
|
||||
Not Authenticated
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-data="{ show: false }">
|
||||
<div class="text-xs uppercase font-semibold opacity-30 block mb-1">
|
||||
Refresh Token
|
||||
</div>
|
||||
<div
|
||||
x-show="provider.status.refreshToken"
|
||||
class="flex items-center gap-2 bg-base-100 rounded border border-base-300 p-1 pl-3"
|
||||
>
|
||||
<input
|
||||
x-bind:type="show ? 'text' : 'password'"
|
||||
x-bind:value="provider.status.refreshToken"
|
||||
readonly
|
||||
class="bg-transparent border-none outline-none shadow-none focus:ring-0 text-xs flex-1 min-w-0 font-mono"
|
||||
/>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="show = !show"
|
||||
class="btn btn-ghost btn-xs btn-square"
|
||||
>
|
||||
<i x-bind:class="show ? 'ph-duotone ph-eye-slash text-base opacity-50' : 'ph-duotone ph-eye text-base opacity-50'"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="copyToClipboard(provider.status.refreshToken)"
|
||||
class="btn btn-ghost btn-xs btn-square"
|
||||
>
|
||||
<i class="ph-duotone ph-copy-simple text-base opacity-50"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
x-show="!provider.status.refreshToken"
|
||||
class="h-8 flex items-center px-3 bg-base-300/30 rounded text-xs italic opacity-40"
|
||||
>
|
||||
Not Authenticated
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider my-3 opacity-10"></div>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<span class="text-xs font-semibold opacity-30 uppercase">Last Updated</span>
|
||||
<span
|
||||
x-text="formatTime(provider.status.lastUpdated)"
|
||||
class="text-xs font-medium opacity-60"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="window.open('/auth/' + provider.name + '/login', '_blank')"
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
<i class="ph-bold ph-link"></i> Connect
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="editProvider(provider)"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
<i class="ph-bold ph-pencil-simple"></i> Edit
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="forceRefresh(provider.name)"
|
||||
class="btn btn-base w-full"
|
||||
x-bind:disabled="loading"
|
||||
>
|
||||
<i class="ph-bold ph-arrows-clockwise text-base mr-1"></i>
|
||||
<span class="text-xs uppercase font-bold tracking-widest">
|
||||
Refresh Tokens
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast toast-center toast-top z-100" x-show="notification.show">
|
||||
<div
|
||||
class="alert shadow-lg border border-base-300"
|
||||
x-bind:class="notification.type === 'error' ? 'alert-error' : 'alert-success'"
|
||||
>
|
||||
<span x-text="notification.message"></span>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
export const Success = (props: { provider: string }) => (
|
||||
<Layout title="Authenticated!">
|
||||
<div class="min-h-[80vh] flex items-center justify-center p-4">
|
||||
<div class="card bg-base-100 shadow-xl border border-base-300 max-w-md w-full">
|
||||
<div class="card-body items-center text-center p-8 md:p-12">
|
||||
<div class="w-20 h-20 bg-success/10 text-success rounded-2xl flex items-center justify-center mb-6 shadow-inner animate-pulse-slow">
|
||||
<i class="ph-duotone ph-check-circle text-5xl"></i>
|
||||
</div>
|
||||
|
||||
<h2 class="card-title text-3xl font-black tracking-tight mb-2 uppercase">
|
||||
Authenticated!
|
||||
</h2>
|
||||
<p class="text-base-content/60 leading-relaxed">
|
||||
Successfully connected to{" "}
|
||||
<span class="font-bold text-base-content uppercase">{props.provider}</span>. You can now
|
||||
close this window or return to the dashboard.
|
||||
</p>
|
||||
|
||||
<div class="divider my-8 opacity-50"></div>
|
||||
|
||||
<div class="card-actions w-full">
|
||||
<a
|
||||
href="/app"
|
||||
class="btn btn-primary btn-block shadow-lg hover:shadow-primary/20 transition-all"
|
||||
>
|
||||
<i class="ph-bold ph-house mr-2"></i>
|
||||
Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
dashboardRoutes.get("/", async (c) => {
|
||||
return c.html(<Dashboard />);
|
||||
});
|
||||
|
||||
dashboardRoutes.get("/success", async (c) => {
|
||||
const provider = c.req.query("provider") || "Provider";
|
||||
return c.html(<Success provider={provider} />);
|
||||
});
|
||||
|
||||
export { dashboardRoutes };
|
||||
@@ -1,557 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="abyss">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>toknd — Auth Broker Dashboard</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://unpkg.com/@phosphor-icons/web@2.1.1"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--font-sans: "DM Sans", sans-serif;
|
||||
--font-mono: "DM Mono", monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.font-mono,
|
||||
.input-mono,
|
||||
#apiKey,
|
||||
[type="password"],
|
||||
[id^="token-"] {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* UI elements prioritization */
|
||||
.btn, .card-title, .navbar a, .label-text, .tooltip {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-base-200/50 min-h-screen font-sans antialiased text-base-content">
|
||||
<div class="navbar bg-base-100 shadow-sm px-4 md:px-8 border-b border-base-300">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center text-primary-content font-semibold text-lg">
|
||||
<i class="ph-duotone ph-fingerprint"></i>
|
||||
</div>
|
||||
<a class="text-xl font-semibold tracking-tight">toknd <span
|
||||
class="text-xs font-normal opacity-50 ml-1">auth
|
||||
broker</span></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-none hidden sm:flex">
|
||||
<div class="join border border-base-300 bg-base-200/50 rounded-xl overflow-hidden focus-within:border-primary transition-colors">
|
||||
<div class="join-item flex items-center px-4 bg-base-100">
|
||||
<i class="ph-duotone ph-key text-primary text-lg animate-pulse-slow"></i>
|
||||
</div>
|
||||
<div class="relative flex-1">
|
||||
<input type="password" id="apiKey" placeholder="API_KEY"
|
||||
class="input join-item input-sm bg-transparent border-none focus:outline-none w-48 lg:w-64 text-xs pr-10 font-mono" />
|
||||
<button type="button" onclick="window.toggleToken('apiKey')"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-square">
|
||||
<i class="ph-duotone ph-eye text-base opacity-50" id="eye-apiKey"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button onclick="fetchProviders()" type="button"
|
||||
class="btn btn-primary btn-sm join-item px-6">
|
||||
<i class="ph-duotone ph-lock-key-open text-lg"></i>
|
||||
<span class="ml-1 hidden md:inline">Unlock</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto p-4 md:p-8 max-w-7xl">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 md:gap-8">
|
||||
<div class="card bg-base-100 shadow-xl border border-base-300 lg:col-span-4 self-start">
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="w-2 h-6 bg-primary rounded-full"></div>
|
||||
<h2 class="card-title text-xl font-semibold">Configure Provider</h2>
|
||||
</div>
|
||||
|
||||
<form id="configForm" class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text flex items-center gap-2">
|
||||
Provider ID
|
||||
<div class="tooltip tooltip-top"
|
||||
data-tip="Internal name for this service. This will define your login URL (e.g. /auth/trakt/login).">
|
||||
<i class="ph ph-info opacity-50 cursor-help"></i>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<input type="text" id="providerName" placeholder="e.g. trakt" required
|
||||
class="input input-bordered w-full focus:input-primary" />
|
||||
</div>
|
||||
|
||||
<div class="divider text-xs opacity-50 my-2 uppercase tracking-widest text-xs">Credentials
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text flex items-center gap-2">
|
||||
Client ID
|
||||
<div class="tooltip tooltip-top"
|
||||
data-tip="Found in the "API" or "Developer" section of the provider. Sometimes called "App ID" or "Consumer Key".">
|
||||
<i class="ph ph-info opacity-50 cursor-help"></i>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<input type="text" id="clientId" placeholder="OAuth client id" required
|
||||
class="input input-bordered w-full focus:input-primary" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text flex items-center gap-2">
|
||||
Client Secret
|
||||
<div class="tooltip tooltip-top"
|
||||
data-tip="Found next to the Client ID. This is your private key—never share it or put it in client-side code.">
|
||||
<i class="ph-duotone ph-info opacity-50 cursor-help"></i>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="password" id="clientSecret" placeholder="OAuth client secret" required
|
||||
class="input input-bordered w-full focus:input-primary pr-12" />
|
||||
<button type="button" onclick="window.toggleToken('clientSecret')"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-square">
|
||||
<i class="ph-duotone ph-eye text-lg opacity-40" id="eye-clientSecret"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider text-xs opacity-50 my-2 uppercase tracking-widest text-xs">Endpoints
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text flex items-center gap-2">
|
||||
Auth URL
|
||||
<div class="tooltip tooltip-top"
|
||||
data-tip="The page where users click "Authorize". Usually found in OAuth2 docs under "Endpoints" or "Authorize".">
|
||||
<i class="ph ph-info opacity-50 cursor-help"></i>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<input type="url" id="authUrl" placeholder="https://trakt.tv/oauth/authorize" required
|
||||
class="input input-bordered w-full focus:input-primary" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text flex items-center gap-2">
|
||||
Token URL
|
||||
<div class="tooltip tooltip-top"
|
||||
data-tip="The background API used to trade the code for a token. Usually ends in "/token" or "/access_token".">
|
||||
<i class="ph ph-info opacity-50 cursor-help"></i>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<input type="url" id="tokenUrl" placeholder="https://api.trakt.tv/oauth/token" required
|
||||
class="input input-bordered w-full focus:input-primary" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text flex items-center gap-2">
|
||||
Redirect URI
|
||||
<div class="tooltip tooltip-top"
|
||||
data-tip="Copy this URL and paste it into the "Redirect URI" or "Callback URL" field in your OAuth provider's settings.">
|
||||
<i class="ph ph-info opacity-50 cursor-help"></i>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<div class="relative group">
|
||||
<input type="url" id="redirectUri" readonly
|
||||
class="input input-bordered w-full pr-12 focus:outline-none cursor-default opacity-80" />
|
||||
<button type="button" onclick="copyRedirectUri()"
|
||||
class="btn btn-ghost btn-xs absolute right-2 top-1/2 -translate-y-1/2 text-base-content/40 hover:text-primary transition-colors">
|
||||
<i class="ph ph-copy text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
<label class="label py-0.5">
|
||||
<span class="label-text-alt opacity-40 italic text-xs">Must match provider's
|
||||
callback URL</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text flex items-center gap-2">
|
||||
Scope
|
||||
<div class="tooltip tooltip-top"
|
||||
data-tip="Determines what data you're allowed to access. Multiple scopes are usually space-separated.">
|
||||
<i class="ph ph-info opacity-50 cursor-help"></i>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<input type="text" id="scope" placeholder="public"
|
||||
class="input input-bordered w-full focus:input-primary" />
|
||||
</div>
|
||||
|
||||
<div class="card-actions pt-4">
|
||||
<button type="submit" class="btn btn-primary w-full shadow-md">
|
||||
<i class="ph ph-plus-bold mr-1"></i>
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-base-300 lg:col-span-8 overflow-hidden">
|
||||
<div class="card-body p-0">
|
||||
<div class="p-6 pb-4 flex justify-between items-center bg-base-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-6 bg-primary rounded-full"></div>
|
||||
<h2 class="card-title text-xl font-semibold">Provider Registry</h2>
|
||||
</div>
|
||||
<button type="button" onclick="fetchProviders()" class="btn btn-sm btn-ghost border-base-300">
|
||||
<i id="refreshIcon" class="ph ph-arrows-clockwise mr-1"></i>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative min-h-[400px]">
|
||||
<!-- Loading Overlay -->
|
||||
<div id="registryLoading"
|
||||
class="absolute inset-0 bg-base-100/50 backdrop-blur-[2px] z-10 flex items-center justify-center hidden transition-all duration-300">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<span class="text-xs font-semibold uppercase tracking-widest opacity-40">Syncing
|
||||
Registry...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="providerRegistry" class="p-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Empty State -->
|
||||
<div class="col-span-full text-center py-20 opacity-30">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<i class="ph ph-lock-key text-6xl"></i>
|
||||
<p class="font-medium">Enter Master API Key to access registry</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast toast-end" id="notificationToast" style="display: none; z-index: 100;">
|
||||
<div class="alert shadow-lg border border-base-300">
|
||||
<span id="notificationMessage"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const apiKeyInput = document.getElementById('apiKey');
|
||||
const configForm = document.getElementById('configForm');
|
||||
const redirectUriInput = document.getElementById('redirectUri');
|
||||
const providerRegistry = document.getElementById('providerRegistry');
|
||||
const registryLoading = document.getElementById('registryLoading');
|
||||
|
||||
function setDefaultRedirectUri() {
|
||||
if (redirectUriInput) {
|
||||
redirectUriInput.value = `${window.location.origin}/auth/callback`;
|
||||
}
|
||||
}
|
||||
|
||||
window.copyRedirectUri = () => {
|
||||
if (redirectUriInput) {
|
||||
navigator.clipboard.writeText(redirectUriInput.value).then(() => {
|
||||
showNotification('Redirect URI copied!');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
setDefaultRedirectUri();
|
||||
|
||||
let providerData = {};
|
||||
let tokenStatus = {};
|
||||
|
||||
function showNotification(message, type = 'success') {
|
||||
const toast = document.getElementById('notificationToast');
|
||||
const alert = toast.querySelector('.alert');
|
||||
const msgSpan = document.getElementById('notificationMessage');
|
||||
|
||||
alert.className = `alert alert-${type} shadow-lg`;
|
||||
msgSpan.textContent = message;
|
||||
toast.style.display = 'block';
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.display = 'none';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async function fetchProviders() {
|
||||
const apiKey = apiKeyInput.value.trim();
|
||||
const refreshIcon = document.getElementById('refreshIcon');
|
||||
|
||||
if (!apiKey) {
|
||||
providerRegistry.innerHTML = `
|
||||
<div class="col-span-full text-center py-20 opacity-30">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<i class="ph-duotone ph-fingerprint text-6xl"></i>
|
||||
<p class="font-medium italic">Unlock toknd to access the broker registry</p>
|
||||
</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (refreshIcon) refreshIcon.classList.add('animate-spin');
|
||||
if (registryLoading) registryLoading.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const configRes = await fetch('/api/config', {
|
||||
headers: { 'Authorization': `Bearer ${apiKey}` }
|
||||
});
|
||||
if (!configRes.ok) throw new Error('Unauthorized or missing API Key');
|
||||
providerData = await configRes.json();
|
||||
|
||||
const statusRes = await fetch('/api/status', {
|
||||
headers: { 'Authorization': `Bearer ${apiKey}` }
|
||||
});
|
||||
if (statusRes.ok) {
|
||||
tokenStatus = await statusRes.json();
|
||||
}
|
||||
|
||||
renderRegistry();
|
||||
localStorage.setItem('toknd_api_key', apiKey);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
providerRegistry.innerHTML = `<div class="col-span-full text-center text-error py-12 font-medium">${error.message}</div>`;
|
||||
} finally {
|
||||
if (refreshIcon) refreshIcon.classList.remove('animate-spin');
|
||||
if (registryLoading) registryLoading.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeAgo(dateString) {
|
||||
if (!dateString) return '<span class="opacity-30">Never</span>';
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now - date) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return 'Just now';
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function renderRegistry() {
|
||||
providerRegistry.innerHTML = '';
|
||||
const entries = Object.entries(providerData);
|
||||
|
||||
if (entries.length === 0) {
|
||||
providerRegistry.innerHTML = '<div class="col-span-full text-center py-16 opacity-50 font-medium italic">No providers configured yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [name, config] of entries) {
|
||||
const status = tokenStatus[name] || { accessToken: null, refreshToken: null, lastUpdated: null };
|
||||
const card = document.createElement('div');
|
||||
card.className = "card bg-base-200/50 border border-base-300 shadow-sm hover:shadow-md transition-all group";
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-body p-5">
|
||||
<div class="flex flex-col mb-4">
|
||||
<span class="text-lg font-black text-base-content/90 uppercase">${name}</span>
|
||||
<span class="text-xs opacity-40 truncate font-mono" title="${config.clientId}">${config.clientId}</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs uppercase font-semibold opacity-30 block mb-1">Access Token</label>
|
||||
${renderTokenField(name, 'access', status.accessToken)}
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs uppercase font-semibold opacity-30 block mb-1">Refresh Token</label>
|
||||
${renderTokenField(name, 'refresh', status.refreshToken)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider my-3 opacity-10"></div>
|
||||
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<span class="text-xs font-semibold opacity-30 uppercase">Last Updated</span>
|
||||
<span class="text-xs font-medium opacity-60">${formatTimeAgo(status.lastUpdated)}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<a href="/auth/${name}/login" target="_blank" class="btn btn-primary btn-sm shadow-sm">
|
||||
<i class="ph-bold ph-link"></i> Connect
|
||||
</a>
|
||||
<button type="button" onclick="window.editProvider('${name}')" class="btn btn-outline btn-sm">
|
||||
<i class="ph-bold ph-pencil-simple"></i> Edit
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onclick="window.forceRefresh('${name}')" id="btn-refresh-${name}"
|
||||
class="btn btn-ghost btn-sm border border-base-300 w-full">
|
||||
<i class="ph-duotone ph-arrows-clockwise text-base mr-1" id="icon-refresh-${name}"></i>
|
||||
<span class="text-[10px] uppercase font-bold tracking-wider">Manual Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
providerRegistry.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTokenField(provider, type, value) {
|
||||
if (!value) {
|
||||
return `<div class="h-8 flex items-center px-3 bg-base-300/30 rounded text-xs italic opacity-40">Not Authenticated</div>`;
|
||||
}
|
||||
const id = `token-${provider}-${type}`;
|
||||
return `
|
||||
<div class="flex items-center gap-2 bg-base-100 rounded border border-base-300 p-1 pl-3 group/token transition-all focus-within:border-primary/50">
|
||||
<input type="password" id="${id}" value="${value}" readonly
|
||||
class="bg-transparent border-none outline-none shadow-none focus:ring-0 text-xs flex-1 min-w-0 font-mono" />
|
||||
<div class="flex gap-1">
|
||||
<button onclick="window.toggleToken('${id}')" class="btn btn-ghost btn-xs btn-square">
|
||||
<i class="ph-duotone ph-eye text-base opacity-50" id="eye-${id}"></i>
|
||||
</button>
|
||||
<button onclick="window.copyToken('${value}')" class="btn btn-ghost btn-xs btn-square">
|
||||
<i class="ph-duotone ph-copy-simple text-base opacity-50"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
window.toggleToken = (id) => {
|
||||
const input = document.getElementById(id);
|
||||
const eye = document.getElementById(`eye-${id}`);
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
eye.className = 'ph-duotone ph-eye-slash text-base';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
eye.className = 'ph-duotone ph-eye text-base opacity-50';
|
||||
}
|
||||
};
|
||||
|
||||
window.copyToken = (value) => {
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
showNotification('Token copied to clipboard!');
|
||||
}).catch(_err => {
|
||||
showNotification('Failed to copy token', 'error');
|
||||
});
|
||||
};
|
||||
|
||||
window.editProvider = (name) => {
|
||||
const config = providerData[name];
|
||||
if (!config) return;
|
||||
|
||||
document.getElementById('providerName').value = name;
|
||||
document.getElementById('clientId').value = config.clientId;
|
||||
document.getElementById('clientSecret').value = config.clientSecret;
|
||||
document.getElementById('authUrl').value = config.authUrl;
|
||||
document.getElementById('tokenUrl').value = config.tokenUrl;
|
||||
setDefaultRedirectUri();
|
||||
document.getElementById('scope').value = config.scope;
|
||||
|
||||
document.getElementById('configForm').scrollIntoView({ behavior: 'smooth' });
|
||||
document.getElementById('providerName').focus();
|
||||
};
|
||||
|
||||
window.forceRefresh = async (name) => {
|
||||
const apiKey = apiKeyInput.value.trim();
|
||||
const btn = document.getElementById(`btn-refresh-${name}`);
|
||||
const icon = document.getElementById(`icon-refresh-${name}`);
|
||||
|
||||
if (btn) btn.classList.add('btn-disabled', 'opacity-50');
|
||||
if (icon) icon.classList.add('animate-spin');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/refresh/${name}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${apiKey}` }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Refresh failed');
|
||||
|
||||
showNotification(`${name} tokens refreshed successfully!`);
|
||||
// Update local data and re-render
|
||||
tokenStatus[name] = data.status;
|
||||
renderRegistry();
|
||||
} catch (error) {
|
||||
console.error('Refresh error:', error);
|
||||
showNotification(error.message, 'error');
|
||||
} finally {
|
||||
if (btn) btn.classList.remove('btn-disabled', 'opacity-50');
|
||||
if (icon) icon.classList.remove('animate-spin');
|
||||
}
|
||||
};
|
||||
|
||||
configForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const apiKey = apiKeyInput.value.trim();
|
||||
if (!apiKey) {
|
||||
showNotification('Please enter your Master API Key', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = document.getElementById('providerName').value.trim();
|
||||
const config = {
|
||||
clientId: document.getElementById('clientId').value.trim(),
|
||||
clientSecret: document.getElementById('clientSecret').value.trim(),
|
||||
authUrl: document.getElementById('authUrl').value.trim(),
|
||||
tokenUrl: document.getElementById('tokenUrl').value.trim(),
|
||||
redirectUri: document.getElementById('redirectUri').value.trim() || undefined,
|
||||
scope: document.getElementById('scope').value.trim(),
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/config/${name}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
|
||||
showNotification('Configuration saved successfully!');
|
||||
fetchProviders();
|
||||
configForm.reset();
|
||||
setDefaultRedirectUri();
|
||||
} catch (error) {
|
||||
console.error('Error saving config:', error);
|
||||
showNotification(error.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const savedKey = localStorage.getItem('toknd_api_key');
|
||||
if (savedKey) {
|
||||
apiKeyInput.value = savedKey;
|
||||
fetchProviders();
|
||||
}
|
||||
setDefaultRedirectUri();
|
||||
});
|
||||
|
||||
apiKeyInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
fetchProviders();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,167 @@
|
||||
document.addEventListener("alpine:init", () => {
|
||||
const formatTime = (timestamp) => {
|
||||
if (!timestamp) return "Never";
|
||||
const date = new Date(timestamp);
|
||||
const diff = Math.floor((Date.now() - date) / 1000);
|
||||
|
||||
if (diff < 60) return "Just now";
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
window.Alpine.data("dashboard", () => ({
|
||||
apiKey: localStorage.getItem("toknd_api_key") || "",
|
||||
isUnlocked: false,
|
||||
loading: false,
|
||||
providers: [],
|
||||
form: {
|
||||
providerName: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "",
|
||||
tokenUrl: "",
|
||||
scope: "public",
|
||||
},
|
||||
notification: {
|
||||
show: false,
|
||||
message: "",
|
||||
type: "success",
|
||||
},
|
||||
|
||||
init() {
|
||||
if (this.apiKey) {
|
||||
this.unlock();
|
||||
}
|
||||
},
|
||||
|
||||
async unlock() {
|
||||
if (!this.apiKey) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
localStorage.setItem("toknd_api_key", this.apiKey);
|
||||
await this.fetchProviders();
|
||||
this.isUnlocked = true;
|
||||
} catch (err) {
|
||||
this.showNotification(`Failed to unlock: ${err.message}. Check your API Key`, "error");
|
||||
localStorage.removeItem("toknd_api_key");
|
||||
this.isUnlocked = false;
|
||||
this.apiKey = "";
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async fetchProviders() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const [configRes, statusRes] = await Promise.all([
|
||||
fetch("/api/config", { headers: { Authorization: `Bearer ${this.apiKey}` } }),
|
||||
fetch("/api/status", { headers: { Authorization: `Bearer ${this.apiKey}` } }),
|
||||
]);
|
||||
|
||||
if (!configRes.ok || !statusRes.ok) throw new Error("Unauthorized");
|
||||
|
||||
const config = await configRes.json();
|
||||
const status = await statusRes.json();
|
||||
|
||||
this.providers = Object.entries(config).map(([name, cfg]) => ({
|
||||
name,
|
||||
config: cfg,
|
||||
status: status[name] || { accessToken: null, refreshToken: null, lastUpdated: null },
|
||||
}));
|
||||
} catch (err) {
|
||||
this.showNotification(err.message, "error");
|
||||
throw err;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async saveConfig(event) {
|
||||
if (event) event.preventDefault();
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/config/${this.form.providerName}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(this.form),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Failed to save");
|
||||
|
||||
this.showNotification("Saved successfully");
|
||||
await this.fetchProviders();
|
||||
|
||||
this.form = {
|
||||
providerName: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "",
|
||||
tokenUrl: "",
|
||||
scope: "public",
|
||||
};
|
||||
} catch (err) {
|
||||
this.showNotification(err.message, "error");
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async forceRefresh(name) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/refresh/${name}`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${this.apiKey}` },
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Refresh failed");
|
||||
|
||||
this.showNotification(`Refreshed ${name}`);
|
||||
await this.fetchProviders();
|
||||
} catch (err) {
|
||||
this.showNotification(err.message, "error");
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
editProvider(provider) {
|
||||
this.form = {
|
||||
providerName: provider.name,
|
||||
clientId: provider.config.clientId,
|
||||
clientSecret: provider.config.clientSecret,
|
||||
authUrl: provider.config.authUrl,
|
||||
tokenUrl: provider.config.tokenUrl,
|
||||
scope: provider.config.scope,
|
||||
};
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
},
|
||||
|
||||
getRedirectUri() {
|
||||
return `${window.location.origin}/auth/${this.form.providerName || "{provider}"}/callback`;
|
||||
},
|
||||
|
||||
copyToClipboard(text) {
|
||||
if (!text) return;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
this.showNotification("Copied");
|
||||
});
|
||||
},
|
||||
|
||||
showNotification(message, type = "success") {
|
||||
this.notification = { show: true, message, type };
|
||||
setTimeout(() => {
|
||||
this.notification.show = false;
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
formatTime(timestamp) {
|
||||
return formatTime(timestamp);
|
||||
},
|
||||
}));
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="abyss">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>toknd — Authentication Successful</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://unpkg.com/@phosphor-icons/web@2.1.1"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: "DM Sans", sans-serif;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.font-mono {
|
||||
font-family: "DM Mono", monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-base-200/50 min-h-screen flex items-center justify-center p-4 antialiased">
|
||||
<div class="card bg-base-100 shadow-xl border border-base-300 max-w-md w-full">
|
||||
<div class="card-body items-center text-center p-8 md:p-12">
|
||||
<div
|
||||
class="w-20 h-20 bg-success/10 text-success rounded-2xl flex items-center justify-center mb-6 shadow-inner animate-pulse">
|
||||
<i class="ph ph-check-circle text-5xl"></i>
|
||||
</div>
|
||||
|
||||
<h2 class="card-title text-3xl font-black tracking-tight mb-2">Authenticated!</h2>
|
||||
<p class="text-base-content/60 leading-relaxed">
|
||||
Successfully connected to <span class="font-bold text-base-content">__PROVIDER_NAME__</span>.
|
||||
You can now close this window or return to the dashboard.
|
||||
</p>
|
||||
|
||||
<div class="divider my-8 opacity-50"></div>
|
||||
|
||||
<div class="card-actions w-full">
|
||||
<a href="/app" class="btn btn-primary btn-block shadow-lg hover:shadow-primary/20 transition-all">
|
||||
<i class="ph ph-house-bold mr-2"></i>
|
||||
Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,62 @@
|
||||
// @ts-nocheck
|
||||
import { describe, expect, it, mock } from "bun:test";
|
||||
import { ConfigManager } from "../../src/core/ConfigManager";
|
||||
|
||||
describe("ConfigManager", () => {
|
||||
it("should save and retrieve provider configuration", async () => {
|
||||
const storage = {};
|
||||
const redis = {
|
||||
set: mock((key, val) => {
|
||||
storage[key] = val;
|
||||
return Promise.resolve();
|
||||
}),
|
||||
get: mock((key) => Promise.resolve(storage[key] || null)),
|
||||
};
|
||||
const manager = new ConfigManager(redis);
|
||||
const traktConfig = {
|
||||
clientId: "trakt-client-id",
|
||||
clientSecret: "trakt-client-secret",
|
||||
authUrl: "https://trakt.tv/oauth/authorize",
|
||||
tokenUrl: "https://api.trakt.tv/oauth/token",
|
||||
scope: "public",
|
||||
};
|
||||
|
||||
await manager.setProviderConfig("trakt", traktConfig);
|
||||
const retrieved = await manager.getProviderConfig("trakt");
|
||||
|
||||
expect(retrieved).toEqual(traktConfig);
|
||||
expect(redis.set).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return all providers", async () => {
|
||||
const redis = {
|
||||
keys: mock(() => Promise.resolve(["config:trakt", "config:github"])),
|
||||
get: mock((key) =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
clientId: `${key}-id`,
|
||||
clientSecret: "secret",
|
||||
authUrl: "https://auth.com",
|
||||
tokenUrl: "https://token.com",
|
||||
scope: "all",
|
||||
}),
|
||||
),
|
||||
),
|
||||
};
|
||||
const manager = new ConfigManager(redis);
|
||||
|
||||
const providers = await manager.getAllProviders();
|
||||
|
||||
expect(Object.keys(providers)).toHaveLength(2);
|
||||
expect(providers.trakt.clientId).toBe("config:trakt-id");
|
||||
});
|
||||
|
||||
it("should return null for non-existent provider", async () => {
|
||||
const redis = { get: mock(() => Promise.resolve(null)) };
|
||||
const manager = new ConfigManager(redis);
|
||||
|
||||
const config = await manager.getProviderConfig("missing-provider");
|
||||
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,65 +1,76 @@
|
||||
// @ts-nocheck
|
||||
import { describe, expect, it, mock } from "bun:test";
|
||||
import { TokenManager } from "../../src/core/TokenManager";
|
||||
|
||||
describe("TokenManager", () => {
|
||||
it("should return token from redis if available", async () => {
|
||||
const redisMock = { get: mock(() => Promise.resolve("valid_token")) };
|
||||
const manager = new TokenManager(redisMock as any, {} as any);
|
||||
const redis = { get: mock(() => Promise.resolve("active-access-token")) };
|
||||
const manager = new TokenManager(redis, {});
|
||||
|
||||
const token = await manager.getAccessToken("trakt");
|
||||
expect(token).toBe("valid_token");
|
||||
|
||||
expect(token).toBe("active-access-token");
|
||||
});
|
||||
|
||||
it("should refresh token if access token is missing but refresh token exists", async () => {
|
||||
const redisMock = {
|
||||
get: mock((key: string) => Promise.resolve(key.includes("refresh") ? "refresh_token" : null)),
|
||||
const redis = {
|
||||
get: mock((key) => Promise.resolve(key.includes("refresh") ? "valid-refresh-token" : null)),
|
||||
set: mock(() => Promise.resolve()),
|
||||
};
|
||||
const providerMock = {
|
||||
const provider = {
|
||||
refreshToken: mock(() =>
|
||||
Promise.resolve({
|
||||
accessToken: "new_token",
|
||||
refreshToken: "new_refresh",
|
||||
accessToken: "newly-refreshed-access-token",
|
||||
refreshToken: "newly-refreshed-refresh-token",
|
||||
expiresIn: 3600,
|
||||
}),
|
||||
),
|
||||
};
|
||||
const manager = new TokenManager(redisMock as any, providerMock as any);
|
||||
const manager = new TokenManager(redis, provider);
|
||||
|
||||
const token = await manager.getAccessToken("trakt");
|
||||
expect(token).toBe("new_token");
|
||||
expect(redisMock.set).toHaveBeenCalled();
|
||||
|
||||
expect(token).toBe("newly-refreshed-access-token");
|
||||
expect(redis.set).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return null if no tokens are found", async () => {
|
||||
const redisMock = { get: mock(() => Promise.resolve(null)) };
|
||||
const manager = new TokenManager(redisMock as any, {} as any);
|
||||
const redis = { get: mock(() => Promise.resolve(null)) };
|
||||
const manager = new TokenManager(redis, {});
|
||||
|
||||
const token = await manager.getAccessToken("trakt");
|
||||
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
it("should refresh token via forceRefresh", async () => {
|
||||
const redisMock = {
|
||||
get: mock(() => Promise.resolve("refresh_token")),
|
||||
it("should refresh token via refreshAccessToken", async () => {
|
||||
const redis = {
|
||||
get: mock(() => Promise.resolve("existing-refresh-token")),
|
||||
set: mock(() => Promise.resolve()),
|
||||
};
|
||||
const providerMock = {
|
||||
const provider = {
|
||||
refreshToken: mock(() =>
|
||||
Promise.resolve({
|
||||
accessToken: "forced_token",
|
||||
refreshToken: "new_refresh",
|
||||
accessToken: "manually-refreshed-access-token",
|
||||
refreshToken: "manually-refreshed-refresh-token",
|
||||
expiresIn: 3600,
|
||||
}),
|
||||
),
|
||||
};
|
||||
const manager = new TokenManager(redisMock as any, providerMock as any);
|
||||
const token = await manager.forceRefresh("trakt");
|
||||
expect(token).toBe("forced_token");
|
||||
expect(providerMock.refreshToken).toHaveBeenCalledWith("refresh_token");
|
||||
const manager = new TokenManager(redis, provider);
|
||||
|
||||
const token = await manager.refreshAccessToken("trakt");
|
||||
|
||||
expect(token).toBe("manually-refreshed-access-token");
|
||||
expect(provider.refreshToken).toHaveBeenCalledWith("existing-refresh-token");
|
||||
});
|
||||
|
||||
it("should return null in forceRefresh if no refresh token is found", async () => {
|
||||
const redisMock = { get: mock(() => Promise.resolve(null)) };
|
||||
const manager = new TokenManager(redisMock as any, {} as any);
|
||||
const token = await manager.forceRefresh("trakt");
|
||||
it("should return null in refreshAccessToken if no refresh token is found", async () => {
|
||||
const redis = { get: mock(() => Promise.resolve(null)) };
|
||||
const manager = new TokenManager(redis, {});
|
||||
|
||||
const token = await manager.refreshAccessToken("trakt");
|
||||
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// @ts-nocheck
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||
import { redis } from "../../src/core/RedisClient";
|
||||
import { app } from "../../src/index";
|
||||
|
||||
describe("API Integration", () => {
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
redis.get.mockImplementation(() => Promise.resolve(null));
|
||||
redis.set.mockImplementation(() => Promise.resolve());
|
||||
redis.keys.mockImplementation(() => Promise.resolve([]));
|
||||
});
|
||||
|
||||
const mockTraktConfig = JSON.stringify({
|
||||
clientId: "trakt-client-id",
|
||||
clientSecret: "trakt-client-secret",
|
||||
authUrl: "https://trakt.tv/oauth/authorize",
|
||||
tokenUrl: "https://api.trakt.tv/oauth/token",
|
||||
scope: "public",
|
||||
});
|
||||
|
||||
it("should return 401 if API Key is missing", async () => {
|
||||
const res = await app.request("/api/status");
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ error: "Missing or invalid authorization header" });
|
||||
});
|
||||
|
||||
it("should return 200 for health check (no auth needed)", async () => {
|
||||
const res = await app.request("/health");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
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) => {
|
||||
if (key.includes("config")) return Promise.resolve(mockTraktConfig);
|
||||
if (key.includes("access_token")) return Promise.resolve("current-access-token");
|
||||
if (key.includes("refresh_token")) return Promise.resolve("current-refresh-token");
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
const res = await app.request("/api/status", {
|
||||
headers: {
|
||||
Authorization: "Bearer test-api-key",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.trakt).toBeDefined();
|
||||
expect(body.trakt.accessToken).toBe("current-access-token");
|
||||
});
|
||||
|
||||
it("should return 404 for unknown provider token", async () => {
|
||||
const res = await app.request("/api/token/unconfigured-provider", {
|
||||
headers: {
|
||||
Authorization: "Bearer test-api-key",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("should return token for a configured provider", async () => {
|
||||
redis.get.mockImplementation((key) => {
|
||||
if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig);
|
||||
if (key.includes("access_token")) return Promise.resolve("trakt-active-token");
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
const res = await app.request("/api/token/trakt", {
|
||||
headers: {
|
||||
Authorization: "Bearer test-api-key",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.access_token).toBe("trakt-active-token");
|
||||
});
|
||||
|
||||
it("should return 404 if no access token is in redis for a valid provider", async () => {
|
||||
redis.get.mockImplementation((key) => {
|
||||
if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig);
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
const res = await app.request("/api/token/trakt", {
|
||||
headers: {
|
||||
Authorization: "Bearer test-api-key",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body.error).toContain("No tokens found");
|
||||
});
|
||||
|
||||
it("should successfully refresh a token", async () => {
|
||||
redis.get.mockImplementation((key) => {
|
||||
if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig);
|
||||
if (key.includes("refresh_token")) return Promise.resolve("old-refresh-token");
|
||||
return Promise.resolve("new-access-token-from-refresh");
|
||||
});
|
||||
|
||||
spyOn(globalThis, "fetch").mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: "new-access-token-from-fetch",
|
||||
refresh_token: "new-refresh-token-from-fetch",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.request("/api/refresh/trakt", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer test-api-key",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.status.accessToken).toBe("new-access-token-from-refresh");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
// @ts-nocheck
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||
import { redis } from "../../src/core/RedisClient";
|
||||
import { app } from "../../src/index";
|
||||
|
||||
describe("Auth Integration", () => {
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
redis.get.mockImplementation(() => Promise.resolve(null));
|
||||
});
|
||||
|
||||
const mockProviderConfig = JSON.stringify({
|
||||
clientId: "trakt-client-id",
|
||||
clientSecret: "trakt-client-secret",
|
||||
authUrl: "https://trakt.tv/oauth/authorize",
|
||||
tokenUrl: "https://api.trakt.tv/oauth/token",
|
||||
scope: "public",
|
||||
});
|
||||
|
||||
it("should redirect to provider login", async () => {
|
||||
redis.get.mockImplementation((key) => {
|
||||
if (key.includes("config:trakt")) return Promise.resolve(mockProviderConfig);
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
const res = await app.request("/auth/trakt/login");
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("Location")).toContain("trakt.tv/oauth/authorize");
|
||||
expect(res.headers.get("Location")).toContain("client_id=trakt-client-id");
|
||||
});
|
||||
|
||||
it("should handle callback and exchange code", async () => {
|
||||
redis.get.mockImplementation((key) => {
|
||||
if (key.includes("config:trakt")) return Promise.resolve(mockProviderConfig);
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: "exchange-access-token",
|
||||
refresh_token: "exchange-refresh-token",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.request("/auth/callback?state=trakt&code=temporary-auth-code");
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("Location")).toBe("/app/success?provider=trakt");
|
||||
expect(redis.set).toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return 404 if provider not configured during login", async () => {
|
||||
const res = await app.request("/auth/unknown-provider/login");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("should return 400 if callback is missing state or code", async () => {
|
||||
const res = await app.request("/auth/callback?code=some-code");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
// @ts-nocheck
|
||||
import { afterEach, describe, expect, it, mock } from "bun:test";
|
||||
import { redis } from "../../src/core/RedisClient";
|
||||
import { app } from "../../src/index";
|
||||
|
||||
describe("Config Integration", () => {
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
redis.get.mockImplementation(() => Promise.resolve(null));
|
||||
redis.set.mockImplementation(() => Promise.resolve());
|
||||
redis.keys.mockImplementation(() => Promise.resolve([]));
|
||||
});
|
||||
|
||||
it("should list all configured providers", async () => {
|
||||
redis.keys.mockReturnValue(Promise.resolve(["config:trakt"]));
|
||||
redis.get.mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
clientId: "trakt-client-id",
|
||||
clientSecret: "trakt-client-secret",
|
||||
authUrl: "https://trakt.tv/oauth/authorize",
|
||||
tokenUrl: "https://api.trakt.tv/oauth/token",
|
||||
scope: "public",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const res = await app.request("/api/config", {
|
||||
headers: {
|
||||
Authorization: "Bearer test-api-key",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.trakt).toBeDefined();
|
||||
expect(body.trakt.clientId).toBe("trakt-client-id");
|
||||
});
|
||||
|
||||
it("should set a new provider config", async () => {
|
||||
const newProviderConfig = {
|
||||
clientId: "github-client-id",
|
||||
clientSecret: "github-client-secret",
|
||||
authUrl: "https://github.com/login/oauth/authorize",
|
||||
tokenUrl: "https://github.com/login/oauth/access_token",
|
||||
scope: "user:email",
|
||||
};
|
||||
|
||||
const res = await app.request("/api/config/github", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer test-api-key",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(newProviderConfig),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(redis.set).toHaveBeenCalledWith(
|
||||
"config:github",
|
||||
expect.stringContaining("github-client-id"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 400 for invalid config body", async () => {
|
||||
const invalidConfig = {
|
||||
clientId: "missing-other-required-fields",
|
||||
};
|
||||
|
||||
const res = await app.request("/api/config/invalid", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer test-api-key",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(invalidConfig),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// @ts-nocheck
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { redis } from "../../src/core/RedisClient";
|
||||
import { app } from "../../src/index";
|
||||
|
||||
describe("Dashboard & Common Integration", () => {
|
||||
it("should serve the dashboard HTML", async () => {
|
||||
const res = await app.request("/app");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toContain("text/html");
|
||||
const html = await res.text();
|
||||
expect(html).toContain("<!DOCTYPE html>");
|
||||
});
|
||||
|
||||
it("should return 404 with custom handler for unknown routes", async () => {
|
||||
const res = await app.request("/unknown-route");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("Not Found");
|
||||
});
|
||||
|
||||
it("should return 500 for internal errors", async () => {
|
||||
redis.keys.mockImplementationOnce(() => {
|
||||
throw new Error("Redis Crash");
|
||||
});
|
||||
|
||||
const res = await app.request("/api/status", {
|
||||
headers: { Authorization: "Bearer test-api-key" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("Internal Server Error");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
// @ts-nocheck
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||
import { GenericProvider } from "../../src/providers/GenericProvider";
|
||||
|
||||
describe("GenericProvider", () => {
|
||||
const traktConfig = {
|
||||
clientId: "trakt-client-id",
|
||||
clientSecret: "trakt-client-secret",
|
||||
authUrl: "https://trakt.tv/oauth/authorize",
|
||||
tokenUrl: "https://api.trakt.tv/oauth/token",
|
||||
scope: "public",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it("should generate correct auth URL", () => {
|
||||
const provider = new GenericProvider("trakt", traktConfig);
|
||||
|
||||
const url = provider.getAuthUrl("random-state-123", "https://callback.com");
|
||||
|
||||
expect(url).toContain("client_id=trakt-client-id");
|
||||
expect(url).toContain("redirect_uri=https%3A%2F%2Fcallback.com");
|
||||
expect(url).toContain("state=random-state-123");
|
||||
});
|
||||
|
||||
it("should handle token response with string expiry", async () => {
|
||||
const provider = new GenericProvider("trakt", traktConfig);
|
||||
const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: "new-access-token",
|
||||
refresh_token: "new-refresh-token",
|
||||
expires_in: "7200",
|
||||
}),
|
||||
text: () => Promise.resolve(""),
|
||||
}),
|
||||
);
|
||||
|
||||
const tokens = await provider.refreshToken("old-refresh-token");
|
||||
|
||||
expect(tokens.accessToken).toBe("new-access-token");
|
||||
expect(tokens.expiresIn).toBe(7200);
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle token response without new refresh token", async () => {
|
||||
const provider = new GenericProvider("trakt", traktConfig);
|
||||
spyOn(globalThis, "fetch").mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: "new-access-token-only",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
text: () => Promise.resolve(""),
|
||||
}),
|
||||
);
|
||||
|
||||
const tokens = await provider.refreshToken("existing-refresh-token");
|
||||
|
||||
expect(tokens.accessToken).toBe("new-access-token-only");
|
||||
expect(tokens.refreshToken).toBe("existing-refresh-token");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { mock } from "bun:test";
|
||||
|
||||
// Global test setup to stub environment variables
|
||||
process.env.API_KEY = "test-api-key";
|
||||
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(() => {}),
|
||||
},
|
||||
}));
|
||||
+4
-2
@@ -5,8 +5,10 @@
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"lib": ["ESNext"],
|
||||
"types": ["node"]
|
||||
"types": ["node", "bun-types"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
"include": ["src/**/*", "tests/**/*"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user