Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a004322970 | |||
| 282618bc1c | |||
| dcf5cd3551 | |||
| 9edb7fd989 | |||
| 0a276b9a63 | |||
| 5d8a9ccb3e | |||
| cf37904083 | |||
| f3349fced4 | |||
| 4554b2e734 | |||
| 106edb8bb7 | |||
| f89b5b4437 | |||
| 558bc9e034 | |||
| 72357ed9ee | |||
| 4728eaa578 |
+3
-4
@@ -1,6 +1,5 @@
|
|||||||
# Core Server Configuration
|
APP_PORT=3000
|
||||||
PORT=3000
|
|
||||||
API_KEY=your_secret_api_key_here
|
API_KEY=your_secret_api_key_here
|
||||||
|
|
||||||
# Redis Configuration (Use redis://redis:6379 for Docker)
|
REDIS_HOST=redis
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_PORT=6379
|
||||||
|
|||||||
@@ -11,4 +11,7 @@ ENV NODE_ENV=production
|
|||||||
USER bun
|
USER bun
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
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"]
|
CMD ["bun", "run", "start"]
|
||||||
|
|||||||
+5
-8
@@ -3,22 +3,19 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-3000}:3000"
|
- "${APP_PORT:-3000}:3000"
|
||||||
environment:
|
environment:
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
- API_KEY=${API_KEY}
|
- API_KEY=${API_KEY}
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
healthcheck:
|
|
||||||
test: [ "CMD", "bun", "-e", "fetch('http://localhost:3000/health').then(res => res.ok ? process.exit(0) : process.exit(1)).catch(e => process.exit(1))" ]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 10s
|
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:alpine
|
image: redis:alpine
|
||||||
restart: always
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT:-6379}:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -1,8 +1,9 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const configSchema = z.object({
|
const configSchema = z.object({
|
||||||
PORT: z.string().default("3000"),
|
APP_PORT: z.string().default("3000"),
|
||||||
REDIS_URL: z.string(),
|
REDIS_HOST: z.string().default("redis"),
|
||||||
|
REDIS_PORT: z.coerce.number().default(6379),
|
||||||
API_KEY: z.string(),
|
API_KEY: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { API_VERSION, APP_VERSION } from "./version";
|
||||||
|
|
||||||
|
export { API_VERSION, APP_VERSION };
|
||||||
|
export const API_PREFIX = `/api/${API_VERSION}`;
|
||||||
|
export const AUTH_PREFIX = `/${API_VERSION}/auth`;
|
||||||
|
export const DOCS_PREFIX = `/docs/${API_VERSION}`;
|
||||||
@@ -40,4 +40,13 @@ export class ConfigManager {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteProviderConfig(provider: string): Promise<void> {
|
||||||
|
await this.redis.del(`config:${provider}`);
|
||||||
|
// Also clean up tokens
|
||||||
|
const tokenKeys = await this.redis.keys(`provider:${provider}:*`);
|
||||||
|
if (tokenKeys.length > 0) {
|
||||||
|
await this.redis.del(...tokenKeys);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { Redis } from "ioredis";
|
import { Redis } from "ioredis";
|
||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
|
|
||||||
export const redis = new Redis(config.REDIS_URL);
|
export const redis = new Redis({
|
||||||
|
host: config.REDIS_HOST,
|
||||||
|
port: config.REDIS_PORT,
|
||||||
|
});
|
||||||
|
|||||||
+14
-20
@@ -1,9 +1,12 @@
|
|||||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||||
import { Scalar } from "@scalar/hono-api-reference";
|
import { Scalar } from "@scalar/hono-api-reference";
|
||||||
|
import { serveStatic } from "hono/bun";
|
||||||
import { logger } from "hono/logger";
|
import { logger } from "hono/logger";
|
||||||
import { prettyJSON } from "hono/pretty-json";
|
import { prettyJSON } from "hono/pretty-json";
|
||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
|
import { API_PREFIX, AUTH_PREFIX, DOCS_PREFIX } from "./constants";
|
||||||
import { redis } from "./core/RedisClient";
|
import { redis } from "./core/RedisClient";
|
||||||
|
import { openApiSpec, securityScheme } from "./openapi";
|
||||||
import { apiRoutes } from "./routes/api";
|
import { apiRoutes } from "./routes/api";
|
||||||
import { authRoutes } from "./routes/auth";
|
import { authRoutes } from "./routes/auth";
|
||||||
import { configRoutes } from "./routes/config";
|
import { configRoutes } from "./routes/config";
|
||||||
@@ -12,38 +15,29 @@ import { dashboardRoutes } from "./routes/dashboard";
|
|||||||
const app = new OpenAPIHono({ strict: false });
|
const app = new OpenAPIHono({ strict: false });
|
||||||
|
|
||||||
// OpenAPI specs
|
// OpenAPI specs
|
||||||
app.doc("/doc", {
|
app.doc(`${DOCS_PREFIX}/openapi.json`, openApiSpec);
|
||||||
openapi: "3.0.0",
|
app.openAPIRegistry.registerComponent("securitySchemes", "API_KEY", securityScheme);
|
||||||
info: {
|
|
||||||
version: "1.0.0",
|
|
||||||
title: "toknd — Auth Broker API",
|
|
||||||
description: "Centralized token management and OAuth2 broker service.",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
app.openAPIRegistry.registerComponent("securitySchemes", "API_KEY", {
|
|
||||||
type: "http",
|
|
||||||
scheme: "bearer",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scalar API Reference
|
// Scalar API Reference
|
||||||
app.get(
|
app.get(
|
||||||
"/api",
|
DOCS_PREFIX,
|
||||||
Scalar({
|
Scalar({
|
||||||
theme: "solarized",
|
theme: "solarized",
|
||||||
url: "/doc",
|
url: `${DOCS_PREFIX}/openapi.json`,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.get("/docs", (c) => c.redirect("/api"));
|
app.get("/docs", (c) => c.redirect(DOCS_PREFIX));
|
||||||
|
app.get("/api", (c) => c.redirect(DOCS_PREFIX));
|
||||||
|
|
||||||
app.use("*", logger());
|
app.use("*", logger());
|
||||||
app.use("*", prettyJSON());
|
app.use("*", prettyJSON());
|
||||||
|
|
||||||
app.get("/", (c) => c.redirect("/app"));
|
app.get("/", (c) => c.redirect("/app"));
|
||||||
|
|
||||||
app.route("/auth", authRoutes);
|
app.get("/app/dashboard.js", serveStatic({ path: "./src/views/dashboard.js" }));
|
||||||
app.route("/api/config", configRoutes);
|
app.route(AUTH_PREFIX, authRoutes);
|
||||||
app.route("/api", apiRoutes);
|
app.route(`${API_PREFIX}/config`, configRoutes);
|
||||||
|
app.route(API_PREFIX, apiRoutes);
|
||||||
app.route("/app", dashboardRoutes);
|
app.route("/app", dashboardRoutes);
|
||||||
|
|
||||||
app.notFound((c) => {
|
app.notFound((c) => {
|
||||||
@@ -74,6 +68,6 @@ app.get("/health", async (c) => {
|
|||||||
export { app };
|
export { app };
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
port: Number.parseInt(config.PORT, 10),
|
port: Number.parseInt(config.APP_PORT, 10),
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
};
|
};
|
||||||
|
|||||||
+11
-3
@@ -1,14 +1,22 @@
|
|||||||
import type { Context, Next } from "hono";
|
import type { Context, Next } from "hono";
|
||||||
|
import { getCookie } from "hono/cookie";
|
||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
|
|
||||||
export const authMiddleware = async (c: Context, next: Next) => {
|
export const authMiddleware = async (c: Context, next: Next) => {
|
||||||
const authHeader = c.req.header("Authorization");
|
const authHeader = c.req.header("Authorization");
|
||||||
|
const cookieToken = getCookie(c, "toknd_api_key");
|
||||||
|
|
||||||
if (!authHeader?.startsWith("Bearer ")) {
|
let token: string | undefined;
|
||||||
return c.json({ error: "Missing or invalid authorization header" }, 401);
|
|
||||||
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
|
token = authHeader.split(" ")[1];
|
||||||
|
} else if (cookieToken) {
|
||||||
|
token = cookieToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = authHeader.split(" ")[1];
|
if (!token) {
|
||||||
|
return c.json({ error: "Missing or invalid authorization" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
if (token !== config.API_KEY) {
|
if (token !== config.API_KEY) {
|
||||||
return c.json({ error: "Invalid API key" }, 403);
|
return c.json({ error: "Invalid API key" }, 403);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { API_VERSION, APP_VERSION } from "./constants";
|
||||||
|
|
||||||
|
export const openApiSpec = {
|
||||||
|
openapi: "3.0.0",
|
||||||
|
info: {
|
||||||
|
version: `${API_VERSION}.${APP_VERSION}`,
|
||||||
|
title: "toknd Auth Broker API",
|
||||||
|
description:
|
||||||
|
"A high-performance OAuth2 broker and token management service. Designed to centralize provider configurations and automate token lifecycle management across distributed systems.",
|
||||||
|
},
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
name: "Tokens",
|
||||||
|
description: "Endpoint operations for accessing and force-refreshing active provider tokens.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Management",
|
||||||
|
description: "Administrative operations for provider lifecycle and configuration.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Auth (Internal)",
|
||||||
|
description: "System-level OAuth2 handshake and callback processing.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
security: [{ API_KEY: [] }],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const securityScheme = {
|
||||||
|
type: "http",
|
||||||
|
scheme: "bearer",
|
||||||
|
} as const;
|
||||||
@@ -47,6 +47,7 @@ const statusRoute = createRoute({
|
|||||||
method: "get",
|
method: "get",
|
||||||
path: "/status",
|
path: "/status",
|
||||||
security: [{ API_KEY: [] }],
|
security: [{ API_KEY: [] }],
|
||||||
|
tags: ["Tokens"],
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
content: { "application/json": { schema: StatusResponseSchema } },
|
content: { "application/json": { schema: StatusResponseSchema } },
|
||||||
@@ -59,6 +60,7 @@ const tokenRoute = createRoute({
|
|||||||
method: "get",
|
method: "get",
|
||||||
path: "/token/{provider}",
|
path: "/token/{provider}",
|
||||||
security: [{ API_KEY: [] }],
|
security: [{ API_KEY: [] }],
|
||||||
|
tags: ["Tokens"],
|
||||||
request: {
|
request: {
|
||||||
params: z.object({
|
params: z.object({
|
||||||
provider: z.string().openapi({ example: "trakt" }),
|
provider: z.string().openapi({ example: "trakt" }),
|
||||||
@@ -80,6 +82,7 @@ const refreshRoute = createRoute({
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/refresh/{provider}",
|
path: "/refresh/{provider}",
|
||||||
security: [{ API_KEY: [] }],
|
security: [{ API_KEY: [] }],
|
||||||
|
tags: ["Tokens"],
|
||||||
request: {
|
request: {
|
||||||
params: z.object({
|
params: z.object({
|
||||||
provider: z.string().openapi({ example: "trakt" }),
|
provider: z.string().openapi({ example: "trakt" }),
|
||||||
|
|||||||
+5
-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 { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
||||||
import { ConfigManager } from "../core/ConfigManager";
|
import { ConfigManager } from "../core/ConfigManager";
|
||||||
import { redis } from "../core/RedisClient";
|
import { redis } from "../core/RedisClient";
|
||||||
@@ -19,6 +17,8 @@ const AuthErrorResponse = z
|
|||||||
const loginRoute = createRoute({
|
const loginRoute = createRoute({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/{provider}/login",
|
path: "/{provider}/login",
|
||||||
|
tags: ["Auth (Internal)"],
|
||||||
|
summary: "Start OAuth2 flow (Managed by System)",
|
||||||
request: {
|
request: {
|
||||||
params: z.object({
|
params: z.object({
|
||||||
provider: z.string().openapi({ example: "trakt" }),
|
provider: z.string().openapi({ example: "trakt" }),
|
||||||
@@ -38,6 +38,8 @@ const loginRoute = createRoute({
|
|||||||
const callbackRoute = createRoute({
|
const callbackRoute = createRoute({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/callback",
|
path: "/callback",
|
||||||
|
tags: ["Auth (Internal)"],
|
||||||
|
summary: "OAuth2 callback handler (Managed by System)",
|
||||||
request: {
|
request: {
|
||||||
query: z.object({
|
query: z.object({
|
||||||
state: z
|
state: z
|
||||||
@@ -115,10 +117,7 @@ authRoutes.openapi(callbackRoute, async (c) => {
|
|||||||
const tokens = await provider.exchangeCode(code, redirectUri);
|
const tokens = await provider.exchangeCode(code, redirectUri);
|
||||||
await tokenManager.saveTokens(providerName, tokens);
|
await tokenManager.saveTokens(providerName, tokens);
|
||||||
|
|
||||||
const htmlPath = join(process.cwd(), "src/views/success.html");
|
return c.redirect(`/app/success?provider=${providerName}`);
|
||||||
let html = await readFile(htmlPath, "utf-8");
|
|
||||||
html = html.replaceAll("__PROVIDER_NAME__", providerName);
|
|
||||||
return c.html(html);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred.";
|
const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred.";
|
||||||
console.error(`[OAuth Error] ${errorMessage}`);
|
console.error(`[OAuth Error] ${errorMessage}`);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const listConfigRoute = createRoute({
|
|||||||
method: "get",
|
method: "get",
|
||||||
path: "/",
|
path: "/",
|
||||||
security: [{ API_KEY: [] }],
|
security: [{ API_KEY: [] }],
|
||||||
|
tags: ["Management"],
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
content: { "application/json": { schema: AllProvidersResponse } },
|
content: { "application/json": { schema: AllProvidersResponse } },
|
||||||
@@ -50,6 +51,7 @@ const setConfigRoute = createRoute({
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/{provider}",
|
path: "/{provider}",
|
||||||
security: [{ API_KEY: [] }],
|
security: [{ API_KEY: [] }],
|
||||||
|
tags: ["Management"],
|
||||||
request: {
|
request: {
|
||||||
params: z.object({
|
params: z.object({
|
||||||
provider: z.string().openapi({ example: "trakt" }),
|
provider: z.string().openapi({ example: "trakt" }),
|
||||||
@@ -70,6 +72,24 @@ const setConfigRoute = createRoute({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteConfigRoute = createRoute({
|
||||||
|
method: "delete",
|
||||||
|
path: "/{provider}",
|
||||||
|
security: [{ API_KEY: [] }],
|
||||||
|
tags: ["Management"],
|
||||||
|
request: {
|
||||||
|
params: z.object({
|
||||||
|
provider: z.string().openapi({ example: "trakt" }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: { "application/json": { schema: SuccessMessage } },
|
||||||
|
description: "Delete a provider configuration and its tokens",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Implementations
|
// Implementations
|
||||||
configRoutes.use("*", authMiddleware);
|
configRoutes.use("*", authMiddleware);
|
||||||
|
|
||||||
@@ -88,4 +108,12 @@ configRoutes.openapi(setConfigRoute, async (c) => {
|
|||||||
return c.json({ message: `Config for ${provider} saved successfully` }, 200);
|
return c.json({ message: `Config for ${provider} saved successfully` }, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
configRoutes.openapi(deleteConfigRoute, async (c) => {
|
||||||
|
const provider = c.req.valid("param").provider;
|
||||||
|
const configManager = new ConfigManager(redis);
|
||||||
|
|
||||||
|
await configManager.deleteProviderConfig(provider);
|
||||||
|
return c.json({ message: `Config for ${provider} deleted successfully` }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
export { configRoutes };
|
export { configRoutes };
|
||||||
|
|||||||
@@ -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,570 @@
|
|||||||
|
/** @jsxImportSource hono/jsx */
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
||||||
|
import { html } from "hono/html";
|
||||||
|
import type { Child } from "hono/jsx";
|
||||||
|
import { config } from "../config";
|
||||||
|
import { API_PREFIX, API_VERSION, APP_VERSION, AUTH_PREFIX, DOCS_PREFIX } from "../constants";
|
||||||
|
|
||||||
|
const dashboardRoutes = new Hono({ strict: false });
|
||||||
|
|
||||||
|
export const Layout = (props: { title: string; children: Child; isUnlocked?: boolean }) => (
|
||||||
|
<>
|
||||||
|
{html`<!DOCTYPE html>`}
|
||||||
|
<html lang="en" data-theme="abyss">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{props.title}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||||
|
<link
|
||||||
|
href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
type="text/css"
|
||||||
|
/>
|
||||||
|
<script src="https://unpkg.com/@phosphor-icons/web@2.1.1"></script>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
.font-mono {
|
||||||
|
font-family: "DM Mono", monospace;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
class="bg-base-200/50 min-h-screen font-['DM_Sans',sans-serif] antialiased text-base-content tracking-tight"
|
||||||
|
x-data={`dashboard({
|
||||||
|
initialIsUnlocked: ${props.isUnlocked || false},
|
||||||
|
apiVersion: '${API_VERSION}',
|
||||||
|
appVersion: '${APP_VERSION}',
|
||||||
|
apiPrefix: '${API_PREFIX}',
|
||||||
|
authPrefix: '${AUTH_PREFIX}',
|
||||||
|
docsPrefix: '${DOCS_PREFIX}'
|
||||||
|
})`}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
<script src="/app/dashboard.js"></script>
|
||||||
|
<script src="//unpkg.com/alpinejs@3" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Dashboard = (props: { isUnlocked: boolean }) => (
|
||||||
|
<Layout title="toknd — Auth Broker Dashboard" isUnlocked={props.isUnlocked}>
|
||||||
|
<div class="navbar bg-base-100 shadow-sm px-4 md:px-8 border-b border-base-300">
|
||||||
|
<div class="flex-1 flex items-center gap-6">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<nav class="hidden md:flex items-center gap-1">
|
||||||
|
<a
|
||||||
|
href={DOCS_PREFIX}
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-ghost btn-sm text-base-content/60 hover:text-primary gap-2 px-3"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<i class="ph-duotone ph-book-open text-lg"></i>
|
||||||
|
<span class="font-bold uppercase tracking-widest text-xs">
|
||||||
|
API Reference{" "}
|
||||||
|
<sup class="text-[8px] opacity-50 ml-0.5">
|
||||||
|
{API_VERSION}.{APP_VERSION}
|
||||||
|
</sup>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="flex-none hidden sm:flex">
|
||||||
|
<template x-if="!isUnlocked">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="isUnlocked">
|
||||||
|
<button
|
||||||
|
x-on:click="logout()"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm text-error hover:bg-error/10 gap-2 px-4"
|
||||||
|
x-bind:disabled="loading"
|
||||||
|
>
|
||||||
|
<i class="ph-bold ph-power text-lg"></i>
|
||||||
|
<span class="font-bold uppercase tracking-wider text-xs">Logout</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</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">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span
|
||||||
|
x-text="provider.name"
|
||||||
|
class="text-lg font-black text-base-content/90 uppercase"
|
||||||
|
></span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-on:click="deleteProvider(provider.name)"
|
||||||
|
class="btn btn-error btn-xs mt-1 opacity-0 group-hover:opacity-100 transition-all duration-300"
|
||||||
|
title="Delete Provider"
|
||||||
|
>
|
||||||
|
<i class="ph-bold ph-trash text-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<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_PREFIX}/' + provider.name + '/login', '_blank')`}
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
>
|
||||||
|
<i class="ph-bold ph-link"></i> Connect
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-on:click="editProvider(provider)"
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
>
|
||||||
|
<i class="ph-bold ph-pencil-simple"></i> Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-on:click="forceRefresh(provider.name)"
|
||||||
|
class="btn btn-base w-full"
|
||||||
|
x-bind:disabled="loading || !provider.status.accessToken"
|
||||||
|
>
|
||||||
|
<i class="ph-bold ph-arrows-clockwise text-base mr-1"></i>
|
||||||
|
<span class="text-xs uppercase font-bold tracking-widest">
|
||||||
|
Refresh Tokens
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast toast-center toast-top z-100" x-show="notification.show">
|
||||||
|
<div
|
||||||
|
class="alert shadow-lg border border-base-300"
|
||||||
|
x-bind:class="notification.type === 'error' ? 'alert-error' : 'alert-success'"
|
||||||
|
>
|
||||||
|
<span x-text="notification.message"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Success = (props: { provider: string }) => (
|
||||||
|
<Layout title="Authenticated!">
|
||||||
|
<div class="min-h-[80vh] flex items-center justify-center p-4">
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-300 max-w-md w-full">
|
||||||
|
<div class="card-body items-center text-center p-8 md:p-12">
|
||||||
|
<div class="w-20 h-20 bg-success/10 text-success rounded-2xl flex items-center justify-center mb-6 shadow-inner animate-pulse-slow">
|
||||||
|
<i class="ph-duotone ph-check-circle text-5xl"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="card-title text-3xl font-black tracking-tight mb-2 uppercase">
|
||||||
|
Authenticated!
|
||||||
|
</h2>
|
||||||
|
<p class="text-base-content/60 leading-relaxed">
|
||||||
|
Successfully connected to{" "}
|
||||||
|
<span class="font-bold text-base-content uppercase">{props.provider}</span>. You can now
|
||||||
|
close this window or return to the dashboard.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="divider my-8 opacity-50"></div>
|
||||||
|
|
||||||
|
<div class="card-actions w-full">
|
||||||
|
<a
|
||||||
|
href="/app"
|
||||||
|
class="btn btn-primary btn-block shadow-lg hover:shadow-primary/20 transition-all"
|
||||||
|
>
|
||||||
|
<i class="ph-bold ph-house mr-2"></i>
|
||||||
|
Back to Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
dashboardRoutes.get("/", async (c) => {
|
||||||
|
const isUnlocked = getCookie(c, "toknd_api_key") === config.API_KEY;
|
||||||
|
return c.html(<Dashboard isUnlocked={isUnlocked} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
dashboardRoutes.post("/unlock", async (c) => {
|
||||||
|
const { apiKey } = await c.req.json();
|
||||||
|
if (apiKey !== config.API_KEY) {
|
||||||
|
return c.json({ error: "Invalid API Key" }, 401);
|
||||||
|
}
|
||||||
|
setCookie(c, "toknd_api_key", apiKey, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "Strict",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24 * 7, // 1 week
|
||||||
|
});
|
||||||
|
return c.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
dashboardRoutes.post("/logout", async (c) => {
|
||||||
|
deleteCookie(c, "toknd_api_key", { path: "/" });
|
||||||
|
return c.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
dashboardRoutes.get("/success", async (c) => {
|
||||||
|
const provider = c.req.query("provider") || "Provider";
|
||||||
|
return c.html(<Success provider={provider} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { dashboardRoutes };
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const API_VERSION = "v1";
|
||||||
|
export const APP_VERSION = "1.0";
|
||||||
@@ -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,239 @@
|
|||||||
|
document.addEventListener("alpine:init", () => {
|
||||||
|
const formatTime = (timestamp) => {
|
||||||
|
if (!timestamp) return "Never";
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const diff = Math.floor((Date.now() - date) / 1000);
|
||||||
|
|
||||||
|
if (diff < 60) return "Just now";
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Alpine.data(
|
||||||
|
"dashboard",
|
||||||
|
({ initialIsUnlocked, apiPrefix, authPrefix, docsPrefix, apiVersion, appVersion }) => ({
|
||||||
|
apiKey: "",
|
||||||
|
isUnlocked: initialIsUnlocked,
|
||||||
|
apiPrefix,
|
||||||
|
authPrefix,
|
||||||
|
docsPrefix,
|
||||||
|
apiVersion,
|
||||||
|
appVersion,
|
||||||
|
loading: false,
|
||||||
|
providers: [],
|
||||||
|
form: {
|
||||||
|
providerName: "",
|
||||||
|
clientId: "",
|
||||||
|
clientSecret: "",
|
||||||
|
authUrl: "",
|
||||||
|
tokenUrl: "",
|
||||||
|
scope: "public",
|
||||||
|
},
|
||||||
|
notification: {
|
||||||
|
show: false,
|
||||||
|
message: "",
|
||||||
|
type: "success",
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (this.isUnlocked) {
|
||||||
|
this.fetchProviders();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async unlock() {
|
||||||
|
if (!this.apiKey) return;
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/app/unlock", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ apiKey: this.apiKey }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Invalid API Key");
|
||||||
|
|
||||||
|
this.isUnlocked = true;
|
||||||
|
await this.fetchProviders();
|
||||||
|
this.apiKey = ""; // Clear after success
|
||||||
|
} catch (err) {
|
||||||
|
this.showNotification(err.message, "error");
|
||||||
|
this.isUnlocked = false;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
await fetch("/app/logout", { method: "POST" });
|
||||||
|
this.isUnlocked = false;
|
||||||
|
this.providers = [];
|
||||||
|
this.showNotification("Logged out successfully");
|
||||||
|
} catch (err) {
|
||||||
|
this.showNotification(`Logout failed: ${err.message}`, "error");
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchProviders() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const [configRes, statusRes] = await Promise.all([
|
||||||
|
fetch(`${this.apiPrefix}/config`),
|
||||||
|
fetch(`${this.apiPrefix}/status`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (configRes.status === 401 || statusRes.status === 401) {
|
||||||
|
return this.handleSessionExpired();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!configRes.ok || !statusRes.ok) throw new Error("Failed to fetch data");
|
||||||
|
|
||||||
|
const [config, status] = await Promise.all([configRes.json(), statusRes.json()]);
|
||||||
|
this.providers = this.mapProviders(config, status);
|
||||||
|
} catch (err) {
|
||||||
|
this.showNotification(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mapProviders(config, status) {
|
||||||
|
return Object.entries(config).map(([name, cfg]) => ({
|
||||||
|
name,
|
||||||
|
config: cfg,
|
||||||
|
status: status[name] || { accessToken: null, refreshToken: null, lastUpdated: null },
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSessionExpired() {
|
||||||
|
this.isUnlocked = false;
|
||||||
|
this.providers = [];
|
||||||
|
this.showNotification("Session expired", "error");
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveConfig() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.apiPrefix}/config/${this.form.providerName}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(this.form),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
this.isUnlocked = false;
|
||||||
|
throw new Error("Session expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Failed to save");
|
||||||
|
|
||||||
|
this.showNotification("Saved successfully");
|
||||||
|
await this.fetchProviders();
|
||||||
|
|
||||||
|
this.form = {
|
||||||
|
providerName: "",
|
||||||
|
clientId: "",
|
||||||
|
clientSecret: "",
|
||||||
|
authUrl: "",
|
||||||
|
tokenUrl: "",
|
||||||
|
scope: "public",
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
this.showNotification(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async forceRefresh(name) {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.apiPrefix}/refresh/${name}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
return this.handleSessionExpired();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteProvider(name) {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Are you sure you want to delete ${name}? This will also remove all associated tokens.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.apiPrefix}/config/${name}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
return this.handleSessionExpired();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Delete failed");
|
||||||
|
|
||||||
|
this.showNotification(`Deleted ${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}${this.authPrefix}/${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>
|
|
||||||
@@ -1,16 +1,10 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";
|
import { describe, expect, it, spyOn } from "bun:test";
|
||||||
|
import { API_PREFIX } from "../../src/constants";
|
||||||
import { redis } from "../../src/core/RedisClient";
|
import { redis } from "../../src/core/RedisClient";
|
||||||
import { app } from "../../src/index";
|
import { app } from "../../src/index";
|
||||||
|
|
||||||
describe("API Integration", () => {
|
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({
|
const mockTraktConfig = JSON.stringify({
|
||||||
clientId: "trakt-client-id",
|
clientId: "trakt-client-id",
|
||||||
clientSecret: "trakt-client-secret",
|
clientSecret: "trakt-client-secret",
|
||||||
@@ -20,11 +14,11 @@ describe("API Integration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should return 401 if API Key is missing", async () => {
|
it("should return 401 if API Key is missing", async () => {
|
||||||
const res = await app.request("/api/status");
|
const res = await app.request(`${API_PREFIX}/status`);
|
||||||
|
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body).toEqual({ error: "Missing or invalid authorization header" });
|
expect(body).toEqual({ error: "Missing or invalid authorization" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return 200 for health check (no auth needed)", async () => {
|
it("should return 200 for health check (no auth needed)", async () => {
|
||||||
@@ -55,7 +49,7 @@ describe("API Integration", () => {
|
|||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await app.request("/api/status", {
|
const res = await app.request(`${API_PREFIX}/status`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer test-api-key",
|
Authorization: "Bearer test-api-key",
|
||||||
},
|
},
|
||||||
@@ -67,8 +61,27 @@ describe("API Integration", () => {
|
|||||||
expect(body.trakt.accessToken).toBe("current-access-token");
|
expect(body.trakt.accessToken).toBe("current-access-token");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return 200 for status with valid Cookie", async () => {
|
||||||
|
redis.keys.mockReturnValue(Promise.resolve(["config:trakt"]));
|
||||||
|
redis.get.mockImplementation((key) => {
|
||||||
|
if (key.includes("config")) return Promise.resolve(mockTraktConfig);
|
||||||
|
if (key.includes("access_token")) return Promise.resolve("current-access-token");
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request(`${API_PREFIX}/status`, {
|
||||||
|
headers: {
|
||||||
|
Cookie: "toknd_api_key=test-api-key",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.trakt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("should return 404 for unknown provider token", async () => {
|
it("should return 404 for unknown provider token", async () => {
|
||||||
const res = await app.request("/api/token/unconfigured-provider", {
|
const res = await app.request(`${API_PREFIX}/token/unconfigured-provider`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer test-api-key",
|
Authorization: "Bearer test-api-key",
|
||||||
},
|
},
|
||||||
@@ -84,7 +97,7 @@ describe("API Integration", () => {
|
|||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await app.request("/api/token/trakt", {
|
const res = await app.request(`${API_PREFIX}/token/trakt`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer test-api-key",
|
Authorization: "Bearer test-api-key",
|
||||||
},
|
},
|
||||||
@@ -101,7 +114,7 @@ describe("API Integration", () => {
|
|||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await app.request("/api/token/trakt", {
|
const res = await app.request(`${API_PREFIX}/token/trakt`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer test-api-key",
|
Authorization: "Bearer test-api-key",
|
||||||
},
|
},
|
||||||
@@ -131,7 +144,7 @@ describe("API Integration", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const res = await app.request("/api/refresh/trakt", {
|
const res = await app.request(`${API_PREFIX}/refresh/trakt`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer test-api-key",
|
Authorization: "Bearer test-api-key",
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";
|
import { describe, expect, it, spyOn } from "bun:test";
|
||||||
|
import { AUTH_PREFIX } from "../../src/constants";
|
||||||
import { redis } from "../../src/core/RedisClient";
|
import { redis } from "../../src/core/RedisClient";
|
||||||
import { app } from "../../src/index";
|
import { app } from "../../src/index";
|
||||||
|
|
||||||
describe("Auth Integration", () => {
|
describe("Auth Integration", () => {
|
||||||
afterEach(() => {
|
|
||||||
mock.restore();
|
|
||||||
redis.get.mockImplementation(() => Promise.resolve(null));
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockProviderConfig = JSON.stringify({
|
const mockProviderConfig = JSON.stringify({
|
||||||
clientId: "trakt-client-id",
|
clientId: "trakt-client-id",
|
||||||
clientSecret: "trakt-client-secret",
|
clientSecret: "trakt-client-secret",
|
||||||
@@ -23,7 +19,7 @@ describe("Auth Integration", () => {
|
|||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await app.request("/auth/trakt/login");
|
const res = await app.request(`${AUTH_PREFIX}/trakt/login`);
|
||||||
|
|
||||||
expect(res.status).toBe(302);
|
expect(res.status).toBe(302);
|
||||||
expect(res.headers.get("Location")).toContain("trakt.tv/oauth/authorize");
|
expect(res.headers.get("Location")).toContain("trakt.tv/oauth/authorize");
|
||||||
@@ -48,23 +44,22 @@ describe("Auth Integration", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const res = await app.request("/auth/callback?state=trakt&code=temporary-auth-code");
|
const res = await app.request(`${AUTH_PREFIX}/callback?state=trakt&code=temporary-auth-code`);
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(302);
|
||||||
const html = await res.text();
|
expect(res.headers.get("Location")).toBe("/app/success?provider=trakt");
|
||||||
expect(html).toContain("trakt");
|
|
||||||
expect(redis.set).toHaveBeenCalled();
|
expect(redis.set).toHaveBeenCalled();
|
||||||
expect(fetchSpy).toHaveBeenCalled();
|
expect(fetchSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return 404 if provider not configured during login", async () => {
|
it("should return 404 if provider not configured during login", async () => {
|
||||||
const res = await app.request("/auth/unknown-provider/login");
|
const res = await app.request(`${AUTH_PREFIX}/unknown-provider/login`);
|
||||||
|
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return 400 if callback is missing state or code", async () => {
|
it("should return 400 if callback is missing state or code", async () => {
|
||||||
const res = await app.request("/auth/callback?code=some-code");
|
const res = await app.request(`${AUTH_PREFIX}/callback?code=some-code`);
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { afterEach, describe, expect, it, mock } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { API_PREFIX } from "../../src/constants";
|
||||||
import { redis } from "../../src/core/RedisClient";
|
import { redis } from "../../src/core/RedisClient";
|
||||||
import { app } from "../../src/index";
|
import { app } from "../../src/index";
|
||||||
|
|
||||||
describe("Config Integration", () => {
|
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 () => {
|
it("should list all configured providers", async () => {
|
||||||
redis.keys.mockReturnValue(Promise.resolve(["config:trakt"]));
|
redis.keys.mockReturnValue(Promise.resolve(["config:trakt"]));
|
||||||
redis.get.mockImplementation(() =>
|
redis.get.mockImplementation(() =>
|
||||||
@@ -25,7 +19,7 @@ describe("Config Integration", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const res = await app.request("/api/config", {
|
const res = await app.request(`${API_PREFIX}/config`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer test-api-key",
|
Authorization: "Bearer test-api-key",
|
||||||
},
|
},
|
||||||
@@ -46,7 +40,7 @@ describe("Config Integration", () => {
|
|||||||
scope: "user:email",
|
scope: "user:email",
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await app.request("/api/config/github", {
|
const res = await app.request(`${API_PREFIX}/config/github`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer test-api-key",
|
Authorization: "Bearer test-api-key",
|
||||||
@@ -67,7 +61,7 @@ describe("Config Integration", () => {
|
|||||||
clientId: "missing-other-required-fields",
|
clientId: "missing-other-required-fields",
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await app.request("/api/config/invalid", {
|
const res = await app.request(`${API_PREFIX}/config/invalid`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer test-api-key",
|
Authorization: "Bearer test-api-key",
|
||||||
@@ -78,4 +72,16 @@ describe("Config Integration", () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should delete a provider configuration", async () => {
|
||||||
|
const res = await app.request(`${API_PREFIX}/config/trakt`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(redis.del).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { API_PREFIX } from "../../src/constants";
|
||||||
import { redis } from "../../src/core/RedisClient";
|
import { redis } from "../../src/core/RedisClient";
|
||||||
import { app } from "../../src/index";
|
import { app } from "../../src/index";
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ describe("Dashboard & Common Integration", () => {
|
|||||||
throw new Error("Redis Crash");
|
throw new Error("Redis Crash");
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await app.request("/api/status", {
|
const res = await app.request(`${API_PREFIX}/status`, {
|
||||||
headers: { Authorization: "Bearer test-api-key" },
|
headers: { Authorization: "Bearer test-api-key" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,4 +35,25 @@ describe("Dashboard & Common Integration", () => {
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.error).toBe("Internal Server Error");
|
expect(body.error).toBe("Internal Server Error");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should set a cookie on successful unlock", async () => {
|
||||||
|
const res = await app.request("/app/unlock", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ apiKey: "test-api-key" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Set-Cookie")).toContain("toknd_api_key=test-api-key");
|
||||||
|
expect(res.headers.get("Set-Cookie")).toContain("HttpOnly");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear the cookie on logout", async () => {
|
||||||
|
const res = await app.request("/app/logout", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Set-Cookie")).toContain("toknd_api_key=;");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+26
-5
@@ -1,9 +1,20 @@
|
|||||||
import { mock } from "bun:test";
|
// @ts-nocheck
|
||||||
|
|
||||||
// Global test setup to stub environment variables
|
|
||||||
process.env.API_KEY = "test-api-key";
|
process.env.API_KEY = "test-api-key";
|
||||||
process.env.REDIS_URL = "redis://localhost:6379";
|
process.env.REDIS_HOST = "localhost";
|
||||||
process.env.PORT = "3000";
|
process.env.REDIS_PORT = "6379";
|
||||||
|
process.env.APP_PORT = "3000";
|
||||||
|
|
||||||
|
import { afterEach, mock } from "bun:test";
|
||||||
|
|
||||||
|
// Global config mock
|
||||||
|
mock.module("../src/config", () => ({
|
||||||
|
config: {
|
||||||
|
API_KEY: "test-api-key",
|
||||||
|
REDIS_HOST: "localhost",
|
||||||
|
REDIS_PORT: 6379,
|
||||||
|
APP_PORT: "3000",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// Global Redis mock
|
// Global Redis mock
|
||||||
mock.module("../src/core/RedisClient", () => ({
|
mock.module("../src/core/RedisClient", () => ({
|
||||||
@@ -11,7 +22,17 @@ mock.module("../src/core/RedisClient", () => ({
|
|||||||
status: "ready",
|
status: "ready",
|
||||||
get: mock(() => Promise.resolve(null)),
|
get: mock(() => Promise.resolve(null)),
|
||||||
set: mock(() => Promise.resolve()),
|
set: mock(() => Promise.resolve()),
|
||||||
|
del: mock(() => Promise.resolve(1)),
|
||||||
keys: mock(() => Promise.resolve([])),
|
keys: mock(() => Promise.resolve([])),
|
||||||
on: mock(() => {}),
|
on: mock(() => {}),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
const { redis } = await import("../src/core/RedisClient");
|
||||||
|
mock.restore();
|
||||||
|
redis.get.mockImplementation(() => Promise.resolve(null));
|
||||||
|
redis.set.mockImplementation(() => Promise.resolve());
|
||||||
|
redis.del.mockImplementation(() => Promise.resolve(1));
|
||||||
|
redis.keys.mockImplementation(() => Promise.resolve([]));
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx",
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext"],
|
||||||
"types": ["node", "bun-types"]
|
"types": ["node", "bun-types"]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user