17 Commits

Author SHA1 Message Date
ramvignesh-b 6b8ce54231 feat: register X-Tenant-ID as a security scheme for Scalar UI 2026-05-13 03:14:33 +05:30
ramvignesh-b 1cf6e3e4d3 docs: update README with multi-tenancy use cases and API details 2026-05-13 03:11:52 +05:30
ramvignesh-b 708a4abba7 refactor: add responsive mobile menu navigation 2026-05-13 03:10:20 +05:30
ramvignesh-b 93d01bc39d feat: add multi-tenancy support rt for dashboard 2026-05-13 02:28:48 +05:30
ramvignesh-b 8c145ae274 Merge remote-tracking branch 'origin' into feat/multi-tenancy 2026-05-13 02:01:32 +05:30
ramvignesh-b 332ee26de2 test: fix internal error test to include X-Tenant-ID 2026-05-13 00:59:05 +05:30
ramvignesh-b 5b72ded2d5 chore: bump version to v1.1 and update OpenAPI spec for multi-tenancy 2026-05-13 00:58:19 +05:30
ramvignesh-b 4e55c1e4aa test: verify wildcard tenant cleanup in config integration tests 2026-05-13 00:57:52 +05:30
ramvignesh-b 3900073086 feat: support tenantId in OAuth redirect flow 2026-05-13 00:57:26 +05:30
ramvignesh-b 7ac46e1d2e test: failing tests for tenantId in auth browser flow 2026-05-13 00:56:49 +05:30
ramvignesh-b 37d4f37206 feat: add X-Tenant-ID header support to API routes 2026-05-13 00:56:17 +05:30
ramvignesh-b eb7a544a0c test: failing tests for X-Tenant-ID header in API routes 2026-05-13 00:55:51 +05:30
ramvignesh-b 2272fac26f fix: cleanup all tenant tokens when deleting provider 2026-05-13 00:55:19 +05:30
ramvignesh-b 96e132c2fb test: failing tests for ConfigManager tenant cleanup 2026-05-13 00:55:04 +05:30
ramvignesh-b f85b6301dd feat: implement multi-tenancy in TokenManager 2026-05-13 00:54:34 +05:30
ramvignesh-b d096627b28 test: failing tests for TokenManager multi-tenancy 2026-05-13 00:54:15 +05:30
ramvignesh-b 672ca10ffe chore: add .worktrees to .gitignore 2026-05-13 00:51:43 +05:30
17 changed files with 736 additions and 671 deletions
-53
View File
@@ -1,53 +0,0 @@
name: Publish Docker Image
on:
push:
branches:
- main
tags:
- 'v*'
jobs:
publish:
name: Publish Docker Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+22 -32
View File
@@ -6,17 +6,18 @@ Building integrations is fun until you have to manage the tokens. Suddenly, you'
If you are building AI agents that need to take actions in the real world, the absolute last thing you want is your LLM trying to debug an OAuth redirect flow.
**toknd** is a minimal, centralized authentication and auth token broker and middleware. Built with **Bun**, **Hono**, and **Redis**, it acts as a secure "wallet" that sits between your applications and the external APIs they need to access.
**toknd** is a minimal, centralized authentication and auth token broker and middleware with **native multi-tenancy**. Built with **Bun**, **Hono**, and **Redis**, it acts as a secure "wallet" that sits between your applications and the external APIs they need to access.
You just need to authenticate once. toknd manages the lifecycle of the tokens forever.
You just need to authenticate once. toknd manages the lifecycle of the tokens forever, securely isolated by tenant.
---
## Why does this exist?
There are massive enterprise identity brokers (like Auth0 or Dex), and then there are reverse proxies (like oauth2-proxy). **toknd** is built to bridge the gap while remaining minimal and open-source. It provides the lightweight, developer-friendly infrastructure of a proxy, but with the specific "Token Vault" capabilities required for modern AI and microservice architectures—without the bloat, vendor lock-in, or SaaS costs.
There are massive enterprise identity brokers (like Auth0 or Dex), and then there are reverse proxies (like oauth2-proxy). **toknd** is built to bridge the gap while remaining minimal and open-source. It provides the lightweight, developer-friendly infrastructure of a proxy, but with the specific "Token Vault" and **Multi-Tenancy** capabilities required for modern AI and microservice architectures—without the bloat, vendor lock-in, or SaaS costs.
## Use-Cases
- **Multi-Tenant SaaS Platforms:** If you are building a platform where *your* users need to connect their own GitHub or Google accounts, toknd handles the isolation. Use a unique `tenantId` for each of your customers to keep their credentials safe and separate.
- **The Ultimate AI Agent Wallet:** Equip your autonomous agents with a secure keychain. When your agent needs to hit a service backed by oauth, like GitHub or Notion API, it just asks toknd for a token. It gets a short-lived Bearer token instantly, keeping your permanent secrets completely isolated from dynamic AI environments.
- **Set It and Forget It:** toknd handles automated background refreshes. Your data ingestion pipelines, RAG syncs, and headless integration workers will never stall out due to a `401 Unauthorized` error again.
- **Microservice Centralization:** Stop implementing OAuth in every new service. Centralize your credentials so your microservices only need one internal API key to request valid access tokens for any configured provider.
@@ -24,6 +25,7 @@ There are massive enterprise identity brokers (like Auth0 or Dex), and then ther
## Features
- **Native Multi-Tenancy:** Isolate tokens for unlimited users/tenants using a single instance.
- **Drop-in Infrastructure:** Deploys in seconds via Docker (or Podman) Compose or just a simple Bun script.
- **Centralized Provider Management:** Native support for managing multiple OAuth2 providers (Google, GitHub, Trakt, etc.).
- **API Key Security:** Isolated and secure access to the broker via master API keys. Each instance can use its own key for isolation.
@@ -59,33 +61,12 @@ Open `.env`, define your own strong `API_KEY`, and map the ports (optional)
#### Option A: Docker Compose (Recommended)
The easiest way to get up and running. This spins up both toknd and a dedicated Redis instance.
You can run toknd by building the container locally or by pulling the pre-built image from the **GitHub Container Registry (GHCR)**.
##### Using Pre-built Image (GHCR)
Instead of building locally, you can pull the pre-built image. Update the `app` service in `docker-compose.yml` to pull the image:
```yaml
services:
app:
image: ghcr.io/ramvignesh-b/toknd:latest
# build: . # Comment or remove this line
```
Then start the services:
```bash
docker compose up -d
(or)
podman compose up -d
```
##### Building from Source
If you prefer to build the image locally:
```bash
docker compose up -d --build
(or)
podman compose up -d --build
```
- **Production**:
```bash
docker compose up -d --build
(or)
podman compose up -d --build
```
#### Option B: Bare Metal
@@ -110,12 +91,21 @@ toknd provides a built-in **Scalar API Reference** so you can explore and test e
- **Interactive UI**: [http://localhost:3000/api](http://localhost:3000/api) (or `/docs`)
- **OpenAPI Spec**: [http://localhost:3000/doc](http://localhost:3000/doc)
### The Golden Rule
All protected endpoints require your master API key in the Authorization header:
### Multi-Tenancy & The Golden Rule
All protected endpoints require your master API key and a **Tenant ID** for isolation:
1. **Authentication:** Use your API key in the `Authorization` header.
2. **Isolation:** Pass your unique tenant identifier in the `X-Tenant-ID` header.
```http
Authorization: Bearer <your_master_api_key>
X-Tenant-ID: <unique_user_or_org_id>
```
### Starting an OAuth Flow
To connect a new account for a specific tenant, redirect the user to:
`http://localhost:3000/v1/auth/{provider}/login?tenantId={your_tenant_id}`
### The Dashboard
REST API too complicated to use? No problem!
You absolutely don't have to manage everything via curl. Access the web dashboard to configure providers, trigger manual refreshes, and monitor token health:
+2 -2
View File
@@ -43,8 +43,8 @@ export class ConfigManager {
async deleteProviderConfig(provider: string): Promise<void> {
await this.redis.del(`config:${provider}`);
// Also clean up tokens
const tokenKeys = await this.redis.keys(`provider:${provider}:*`);
// Also clean up tokens across all tenants
const tokenKeys = await this.redis.keys(`tenant:*:provider:${provider}:*`);
if (tokenKeys.length > 0) {
await this.redis.del(...tokenKeys);
}
+13 -17
View File
@@ -7,41 +7,37 @@ export class TokenManager {
private provider: OAuthProvider,
) {}
async getAccessToken(providerName: string): Promise<string | null> {
const accessKey = `provider:${providerName}:access_token`;
async getAccessToken(tenantId: string, providerName: string): Promise<string | null> {
const accessKey = `tenant:${tenantId}:provider:${providerName}:access_token`;
const cached = await this.redis.get(accessKey);
if (cached) return cached;
const refreshKey = `provider:${providerName}:refresh_token`;
const refreshKey = `tenant:${tenantId}:provider:${providerName}:refresh_token`;
const refreshToken = await this.redis.get(refreshKey);
if (!refreshToken) return null;
const tokens = await this.provider.refreshToken(refreshToken);
await this.saveTokens(providerName, tokens);
await this.saveTokens(tenantId, providerName, tokens);
return tokens.accessToken;
}
async refreshAccessToken(providerName: string): Promise<string | null> {
const refreshKey = `provider:${providerName}:refresh_token`;
async refreshAccessToken(tenantId: string, providerName: string): Promise<string | null> {
const refreshKey = `tenant:${tenantId}:provider:${providerName}:refresh_token`;
const refreshToken = await this.redis.get(refreshKey);
if (!refreshToken) return null;
const tokens = await this.provider.refreshToken(refreshToken);
await this.saveTokens(providerName, tokens);
await this.saveTokens(tenantId, providerName, tokens);
return tokens.accessToken;
}
async saveTokens(providerName: string, tokens: TokenResponse) {
async saveTokens(tenantId: string, providerName: string, tokens: TokenResponse) {
const baseKey = `tenant:${tenantId}:provider:${providerName}`;
await this.redis.set(`${baseKey}:access_token`, tokens.accessToken, "EX", tokens.expiresIn);
await this.redis.set(`${baseKey}:refresh_token`, tokens.refreshToken);
await this.redis.set(`${baseKey}:last_updated`, new Date().toISOString());
await this.redis.set(
`provider:${providerName}:access_token`,
tokens.accessToken,
"EX",
tokens.expiresIn,
);
await this.redis.set(`provider:${providerName}:refresh_token`, tokens.refreshToken);
await this.redis.set(`provider:${providerName}:last_updated`, new Date().toISOString());
await this.redis.set(
`provider:${providerName}:expires_at`,
`${baseKey}:expires_at`,
new Date(Date.now() + tokens.expiresIn * 1000).toISOString(),
);
}
+2 -1
View File
@@ -6,7 +6,7 @@ import { prettyJSON } from "hono/pretty-json";
import { config } from "./config";
import { API_PREFIX, AUTH_PREFIX, DOCS_PREFIX } from "./constants";
import { redis } from "./core/RedisClient";
import { openApiSpec, securityScheme } from "./openapi";
import { openApiSpec, securityScheme, tenantIdScheme } from "./openapi";
import { apiRoutes } from "./routes/api";
import { authRoutes } from "./routes/auth";
import { configRoutes } from "./routes/config";
@@ -17,6 +17,7 @@ const app = new OpenAPIHono({ strict: false });
// OpenAPI specs
app.doc(`${DOCS_PREFIX}/openapi.json`, openApiSpec);
app.openAPIRegistry.registerComponent("securitySchemes", "API_KEY", securityScheme);
app.openAPIRegistry.registerComponent("securitySchemes", "TENANT_ID", tenantIdScheme);
// Scalar API Reference
app.get(
+9 -2
View File
@@ -6,7 +6,7 @@ export const openApiSpec = {
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.",
"A high-performance OAuth2 broker and token management service with multi-tenancy support. Designed to centralize provider configurations and automate token lifecycle management across distributed systems, securely isolated by Tenant IDs.",
},
tags: [
{
@@ -22,10 +22,17 @@ export const openApiSpec = {
description: "System-level OAuth2 handshake and callback processing.",
},
],
security: [{ API_KEY: [] }],
security: [{ API_KEY: [], TENANT_ID: [] }],
};
export const securityScheme = {
type: "http",
scheme: "bearer",
} as const;
export const tenantIdScheme = {
type: "apiKey",
in: "header",
name: "X-Tenant-ID",
description: "The unique identifier for the tenant (user or organization).",
} as const;
+29 -13
View File
@@ -54,8 +54,13 @@ const ErrorSchema = z
const statusRoute = createRoute({
method: "get",
path: "/status",
security: [{ API_KEY: [] }],
security: [{ API_KEY: [], TENANT_ID: [] }],
tags: ["Tokens"],
request: {
headers: z.object({
"x-tenant-id": z.string().openapi({ example: "my-tenant" }),
}),
},
responses: {
200: {
content: { "application/json": { schema: StatusResponseSchema } },
@@ -67,12 +72,15 @@ const statusRoute = createRoute({
const tokenRoute = createRoute({
method: "get",
path: "/token/{provider}",
security: [{ API_KEY: [] }],
security: [{ API_KEY: [], TENANT_ID: [] }],
tags: ["Tokens"],
request: {
params: z.object({
provider: z.string().openapi({ example: "trakt" }),
}),
headers: z.object({
"x-tenant-id": z.string().openapi({ example: "my-tenant" }),
}),
},
responses: {
200: {
@@ -89,12 +97,15 @@ const tokenRoute = createRoute({
const refreshRoute = createRoute({
method: "post",
path: "/refresh/{provider}",
security: [{ API_KEY: [] }],
security: [{ API_KEY: [], TENANT_ID: [] }],
tags: ["Tokens"],
request: {
params: z.object({
provider: z.string().openapi({ example: "trakt" }),
}),
headers: z.object({
"x-tenant-id": z.string().openapi({ example: "my-tenant" }),
}),
},
responses: {
200: {
@@ -112,15 +123,17 @@ const refreshRoute = createRoute({
apiRoutes.use("*", authMiddleware);
apiRoutes.openapi(statusRoute, async (c) => {
const tenantId = c.req.valid("header")["x-tenant-id"];
const configManager = new ConfigManager(redis);
const providers = await configManager.getAllProviders();
const status: z.infer<typeof StatusResponseSchema> = {};
for (const provider of Object.keys(providers)) {
const accessToken = await redis.get(`provider:${provider}:access_token`);
const refreshToken = await redis.get(`provider:${provider}:refresh_token`);
const lastUpdated = await redis.get(`provider:${provider}:last_updated`);
const expiresAt = await redis.get(`provider:${provider}:expires_at`);
const baseKey = `tenant:${tenantId}:provider:${provider}`;
const accessToken = await redis.get(`${baseKey}:access_token`);
const refreshToken = await redis.get(`${baseKey}:refresh_token`);
const lastUpdated = await redis.get(`${baseKey}:last_updated`);
const expiresAt = await redis.get(`${baseKey}:expires_at`);
status[provider] = { accessToken, refreshToken, lastUpdated, expiresAt };
}
@@ -129,6 +142,7 @@ apiRoutes.openapi(statusRoute, async (c) => {
apiRoutes.openapi(tokenRoute, async (c) => {
const providerName = c.req.valid("param").provider;
const tenantId = c.req.valid("header")["x-tenant-id"];
const configManager = new ConfigManager(redis);
const providerConfig = await configManager.getProviderConfig(providerName);
@@ -139,7 +153,7 @@ apiRoutes.openapi(tokenRoute, async (c) => {
const provider = new GenericProvider(providerName, providerConfig);
const tokenManager = new TokenManager(redis, provider);
const accessToken = await tokenManager.getAccessToken(providerName);
const accessToken = await tokenManager.getAccessToken(tenantId, providerName);
if (!accessToken) {
return c.json({ error: "No tokens found for provider" }, 404);
}
@@ -148,6 +162,7 @@ apiRoutes.openapi(tokenRoute, async (c) => {
apiRoutes.openapi(refreshRoute, async (c) => {
const providerName = c.req.valid("param").provider;
const tenantId = c.req.valid("header")["x-tenant-id"];
const configManager = new ConfigManager(redis);
const providerConfig = await configManager.getProviderConfig(providerName);
@@ -158,11 +173,12 @@ apiRoutes.openapi(refreshRoute, async (c) => {
const provider = new GenericProvider(providerName, providerConfig);
const tokenManager = new TokenManager(redis, provider);
await tokenManager.refreshAccessToken(providerName);
const accessToken = await redis.get(`provider:${providerName}:access_token`);
const refreshToken = await redis.get(`provider:${providerName}:refresh_token`);
const lastUpdated = await redis.get(`provider:${providerName}:last_updated`);
const expiresAt = await redis.get(`provider:${providerName}:expires_at`);
await tokenManager.refreshAccessToken(tenantId, providerName);
const baseKey = `tenant:${tenantId}:provider:${providerName}`;
const accessToken = await redis.get(`${baseKey}:access_token`);
const refreshToken = await redis.get(`${baseKey}:refresh_token`);
const lastUpdated = await redis.get(`${baseKey}:last_updated`);
const expiresAt = await redis.get(`${baseKey}:expires_at`);
return c.json(
{
+27 -10
View File
@@ -23,6 +23,9 @@ const loginRoute = createRoute({
params: z.object({
provider: z.string().openapi({ example: "trakt" }),
}),
query: z.object({
tenantId: z.string().openapi({ example: "my-tenant" }),
}),
},
responses: {
302: {
@@ -42,16 +45,13 @@ const callbackRoute = createRoute({
summary: "OAuth2 callback handler (Managed by System)",
request: {
query: z.object({
state: z
.string()
.openapi({ description: "The provider name (passed as state during login)" }),
state: z.string().openapi({ description: "Composite state: tenantId:providerName" }),
code: z.string().openapi({ description: "The authorization code from the provider" }),
}),
},
responses: {
200: {
description: "Success page indicating successful token exchange",
content: { "text/html": { schema: { type: "string" } } },
302: {
description: "Redirect to success page",
},
400: {
content: { "application/json": { schema: AuthErrorResponse } },
@@ -71,6 +71,7 @@ const callbackRoute = createRoute({
// Implementations
authRoutes.openapi(loginRoute, async (c) => {
const providerName = c.req.valid("param").provider;
const tenantId = c.req.valid("query").tenantId;
const configManager = new ConfigManager(redis);
const providerConfig = await configManager.getProviderConfig(providerName);
@@ -88,11 +89,27 @@ authRoutes.openapi(loginRoute, async (c) => {
const url = new URL(c.req.url);
const redirectUri = providerConfig.redirectUri || `${url.origin}/auth/callback`;
return c.redirect(provider.getAuthUrl(providerName, redirectUri));
// Pass both tenantId and providerName in state
const state = `${tenantId}:${providerName}`;
return c.redirect(provider.getAuthUrl(state, redirectUri));
});
authRoutes.openapi(callbackRoute, async (c) => {
const { state: providerName, code } = c.req.valid("query");
const { state, code } = c.req.valid("query");
// state is expected to be "tenantId:providerName"
const parts = state.split(":");
if (parts.length !== 2) {
return c.json(
{
error: "Invalid State",
message: "The state parameter is invalid.",
},
400,
);
}
const [tenantId, providerName] = parts;
const configManager = new ConfigManager(redis);
const providerConfig = await configManager.getProviderConfig(providerName);
@@ -115,9 +132,9 @@ authRoutes.openapi(callbackRoute, async (c) => {
try {
const tokens = await provider.exchangeCode(code, redirectUri);
await tokenManager.saveTokens(providerName, tokens);
await tokenManager.saveTokens(tenantId, providerName, tokens);
return c.redirect(`/app/success?provider=${providerName}`);
return c.redirect(`/app/success?provider=${providerName}&tenantId=${tenantId}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred.";
console.error(`[OAuth Error] ${errorMessage}`);
+494 -437
View File
@@ -63,498 +63,555 @@ export const Layout = (props: { title: string; children: Child; isUnlocked?: boo
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 class="flex flex-col min-h-screen">
<div class="navbar bg-base-100 shadow-sm px-4 md:px-8 border-b border-base-300 relative z-50">
<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>
</div>
<nav class="hidden md:flex items-center gap-1">
<div class="flex-none lg:hidden">
<label for="mobile-menu" class="btn btn-square btn-ghost">
<i class="ph-duotone ph-chart-donut text-3xl"></i>
</label>
</div>
<input type="checkbox" id="mobile-menu" class="peer hidden" />
<div class="fixed inset-y-0 right-0 w-80 bg-base-100 border-l border-base-300 shadow-2xl p-6 flex flex-col gap-6 transform translate-x-full peer-checked:translate-x-0 transition-transform z-50 lg:static lg:flex-none lg:w-auto lg:bg-transparent lg:border-none lg:shadow-none lg:p-0 lg:flex-row lg:items-center lg:gap-4 lg:translate-x-0 lg:z-auto">
<div class="flex justify-end lg:hidden">
<label for="mobile-menu" class="btn btn-ghost btn-square">
<i class="ph-bold ph-circle-dashed text-2xl"></i>
</label>
</div>
<a
href={DOCS_PREFIX}
target="_blank"
class="btn btn-ghost btn-sm text-base-content/60 hover:text-primary gap-2 px-3"
class="btn btn-ghost justify-start lg:justify-center lg:btn-sm text-base-content/60 hover:text-primary gap-3 lg:gap-2 w-full lg:w-auto 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{" "}
<span class="font-bold uppercase tracking-widest">
API Reference
<sup class="text-xxs 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="divider my-0 opacity-50 lg:hidden"></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">
<i class="ph-duotone ph-shield-plus text-2xl text-primary mb-1"></i>
<div class="w-1 h-6 bg-primary/50 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 before:text-left"
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>
</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 flex items-center gap-2">
Client ID
<span
class="tooltip tooltip-top before:text-left"
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 text-xs"></i>
</span>
</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 flex items-center gap-2">
Client Secret
<span
class="tooltip tooltip-top before:text-left"
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 ph-info opacity-50 cursor-help text-xs"></i>
</span>
</span>
</label>
<div class="relative">
<template x-if="!isUnlocked">
<div class="flex flex-col lg:flex-row gap-4 items-start lg:items-center w-full lg:w-auto">
<div class="join border border-base-200/50 bg-base-200/50 rounded-xl overflow-hidden focus-within:border-primary transition-colors w-full lg:w-auto">
<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="clientSecret"
x-model="form.clientSecret"
placeholder="OAuth client secret"
required
class="input input-bordered w-full focus:input-primary pr-12"
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-full lg:w-64 text-xs pr-10 font-mono"
/>
<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"
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-lg opacity-40' : 'ph-duotone ph-eye text-lg opacity-40'"></i>
<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>
</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 flex items-center gap-2">
Auth URL
<span
class="tooltip tooltip-top before:text-left"
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 text-xs"></i>
</span>
</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 flex items-center gap-2">
Token URL
<span
class="tooltip tooltip-top before:text-left"
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 text-xs"></i>
</span>
</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 flex items-center gap-2">
Redirect URI
<span
class="tooltip tooltip-top before:text-left"
data-tip="The provider will redirect the code to this URI. 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 text-xs"></i>
</span>
</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 flex items-center gap-2">
Scope
<span
class="tooltip tooltip-top before:text-left"
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 text-xs"></i>
</span>
</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
x-on:click="unlock(); document.getElementById('mobile-menu').checked = false;"
type="submit"
class="btn btn-primary w-full shadow-md"
class="btn btn-primary btn-sm join-item px-6"
x-bind:disabled="loading"
>
<i class="ph ph-plus-bold mr-1"></i>
Save Configuration
<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 lg:inline"
x-text="loading ? 'Unlocking...' : 'Unlock'"
></span>
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<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">
<i class="ph-duotone ph-shipping-container text-2xl text-primary"></i>
<div class="w-1 h-6 bg-primary/50 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-neutral"
x-bind:disabled="!isUnlocked || loading"
<template x-if="isUnlocked">
<div class="flex flex-col lg:flex-row items-start lg:items-center gap-4 w-full lg:w-auto">
<form
class="join border border-base-200/50 bg-base-200/50 rounded-xl overflow-hidden focus-within:border-primary transition-colors w-full lg:w-auto"
x-on:submit="$event.preventDefault(); if(isTenantLocked) { isTenantLocked = false; } else { isTenantLocked = true; fetchProviders(); document.getElementById('mobile-menu').checked = false; }"
>
<i x-bind:class="loading ? 'ph ph-arrows-clockwise animate-spin mr-1' : 'ph ph-arrows-clockwise mr-1'"></i>
Refresh List
<div class="join-item flex items-center px-4 bg-base-300 opacity-70">
<i class="ph-duotone ph-identification-badge text-xl text-primary"></i>
</div>
<input
type="text"
x-model="tenantId"
x-bind:readonly="isTenantLocked"
placeholder="Tenant ID"
class="input join-item input-sm bg-transparent border-none focus:outline-none flex-1 lg:flex-none lg:w-48 text-xs font-mono transition-opacity"
x-bind:class="isTenantLocked ? 'opacity-60 cursor-default' : ''"
/>
<button
type="submit"
class="btn btn-sm join-item px-4"
x-bind:class="isTenantLocked ? 'btn-neutral' : 'btn-secondary'"
x-bind:title="isTenantLocked ? 'Edit Tenant ID' : 'Apply Tenant ID'"
>
<i
class="text-lg"
x-bind:class="isTenantLocked ? 'ph-thin ph-pencil-simple' : 'ph-bold ph-check'"
></i>
</button>
</form>
<button
x-on:click="logout(); document.getElementById('mobile-menu').checked = false;"
type="button"
class="btn btn-ghost text-error hover:bg-error/10 gap-2 w-full lg:w-auto lg:btn-sm px-4 justify-start lg:justify-center"
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>
</div>
</template>
</div>
<div
class="fixed inset-0 bg-black/50 z-40 hidden peer-checked:block lg:hidden"
></div>
</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 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">
<i class="ph-duotone ph-shield-plus text-2xl text-primary mb-1"></i>
<div class="w-1 h-6 bg-primary/50 rounded-full"></div>
<h2 class="card-title text-xl font-semibold">Configure Provider</h2>
</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>
<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 before:text-left"
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>
</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>
<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 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 flex items-center gap-2">
Client ID
<span
class="tooltip tooltip-top before:text-left"
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 text-xs"></i>
</span>
</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 flex items-center gap-2">
Client Secret
<span
class="tooltip tooltip-top before:text-left"
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 ph-info opacity-50 cursor-help text-xs"></i>
</span>
</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 flex items-center gap-2">
Auth URL
<span
class="tooltip tooltip-top before:text-left"
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 text-xs"></i>
</span>
</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 flex items-center gap-2">
Token URL
<span
class="tooltip tooltip-top before:text-left"
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 text-xs"></i>
</span>
</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 flex items-center gap-2">
Redirect URI
<span
class="tooltip tooltip-top before:text-left"
data-tip="The provider will redirect the code to this URI. 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 text-xs"></i>
</span>
</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 flex items-center gap-2">
Scope
<span
class="tooltip tooltip-top before:text-left"
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 text-xs"></i>
</span>
</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">
<i class="ph-duotone ph-shipping-container text-2xl text-primary"></i>
<div class="w-1 h-6 bg-primary/50 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-neutral"
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
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-40 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="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 class="space-y-4">
<div x-data="{ show: false }">
<div class="text-xs uppercase font-semibold opacity-30 block mb-1">
Access Token
<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-40 transition-all duration-300"
title="Delete Provider"
>
<i class="ph-bold ph-trash text-lg"></i>
</button>
</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>
<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-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 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
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 x-show="provider.status.accessToken" class="grid grid-cols-2 gap-4 mb-5">
<div class="p-1 flex flex-col gap-0.5">
<span class="text-xxs font-bold opacity-20 uppercase tracking-wide">
Last Updated
</span>
<span
x-text="formatTime(provider.status.lastUpdated)"
class="text-xs text-secondary/75 font-bold font-mono tracking-wider opacity-60"
></span>
</div>
<div class="p-1 flex flex-col gap-0.5 items-end text-right">
<span class="text-xxs font-bold opacity-20 uppercase tracking-widest">
Expires In
</span>
<span
x-text="formatExpiry(provider.status.expiresAt)"
x-bind:class="isExpired(provider.status.expiresAt) ? 'text-error' : 'text-primary'"
class="text-xs font-bold font-mono tracking-wide"
></span>
</div>
</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-neutral 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-secondary w-full"
x-bind:disabled="loading || !provider.status.accessToken"
<div class="divider my-3 opacity-10"></div>
<div
x-show="provider.status.accessToken"
class="grid grid-cols-2 gap-4 mb-5"
>
<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 class="p-1 flex flex-col gap-0.5">
<span class="text-xxs font-bold opacity-20 uppercase tracking-wide">
Last Updated
</span>
<span
x-text="formatTime(provider.status.lastUpdated)"
class="text-xs text-secondary/75 font-bold font-mono tracking-wider opacity-60"
></span>
</div>
<div class="p-1 flex flex-col gap-0.5 items-end text-right">
<span class="text-xxs font-bold opacity-20 uppercase tracking-widest">
Expires In
</span>
<span
x-text="formatExpiry(provider.status.expiresAt)"
x-bind:class="isExpired(provider.status.expiresAt) ? 'text-error' : 'text-primary'"
class="text-xs font-bold font-mono tracking-wide"
></span>
</div>
</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?tenantId=' + encodeURIComponent(tenantId), '_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-neutral 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-secondary 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>
</div>
</template>
</template>
</div>
</div>
</div>
</div>
+1 -1
View File
@@ -1,2 +1,2 @@
export const API_VERSION = "v1";
export const APP_VERSION = "1.0";
export const APP_VERSION = "1.1";
+9 -1
View File
@@ -34,6 +34,8 @@ document.addEventListener("alpine:init", () => {
"dashboard",
({ initialIsUnlocked, apiPrefix, authPrefix, docsPrefix, apiVersion, appVersion }) => ({
apiKey: "",
tenantId: localStorage.getItem("toknd_tenant_id") || "default",
isTenantLocked: !!localStorage.getItem("toknd_tenant_id"),
isUnlocked: initialIsUnlocked,
apiPrefix,
authPrefix,
@@ -57,6 +59,9 @@ document.addEventListener("alpine:init", () => {
},
init() {
this.$watch("tenantId", (val) => {
localStorage.setItem("toknd_tenant_id", val);
});
if (this.isUnlocked) {
this.fetchProviders();
}
@@ -104,7 +109,9 @@ document.addEventListener("alpine:init", () => {
try {
const [configRes, statusRes] = await Promise.all([
fetch(`${this.apiPrefix}/config`),
fetch(`${this.apiPrefix}/status`),
fetch(`${this.apiPrefix}/status`, {
headers: { "x-tenant-id": this.tenantId },
}),
]);
if (configRes.status === 401 || statusRes.status === 401) {
@@ -180,6 +187,7 @@ document.addEventListener("alpine:init", () => {
try {
const res = await fetch(`${this.apiPrefix}/refresh/${name}`, {
method: "POST",
headers: { "x-tenant-id": this.tenantId },
});
if (res.status === 401) {
+30 -9
View File
@@ -1,18 +1,17 @@
// @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 storage: Record<string, string> = {};
const redis = {
set: mock((key, val) => {
set: mock((key: string, val: string) => {
storage[key] = val;
return Promise.resolve();
}),
get: mock((key) => Promise.resolve(storage[key] || null)),
get: mock((key: string) => Promise.resolve(storage[key] || null)),
};
const manager = new ConfigManager(redis);
const manager = new ConfigManager(redis as any);
const traktConfig = {
clientId: "trakt-client-id",
clientSecret: "trakt-client-secret",
@@ -24,14 +23,14 @@ describe("ConfigManager", () => {
await manager.setProviderConfig("trakt", traktConfig);
const retrieved = await manager.getProviderConfig("trakt");
expect(retrieved).toEqual(traktConfig);
expect(retrieved).toEqual(traktConfig as any);
expect(redis.set).toHaveBeenCalled();
});
it("should return all providers", async () => {
const redis = {
keys: mock(() => Promise.resolve(["config:trakt", "config:github"])),
get: mock((key) =>
get: mock((key: string) =>
Promise.resolve(
JSON.stringify({
clientId: `${key}-id`,
@@ -43,7 +42,7 @@ describe("ConfigManager", () => {
),
),
};
const manager = new ConfigManager(redis);
const manager = new ConfigManager(redis as any);
const providers = await manager.getAllProviders();
@@ -53,10 +52,32 @@ describe("ConfigManager", () => {
it("should return null for non-existent provider", async () => {
const redis = { get: mock(() => Promise.resolve(null)) };
const manager = new ConfigManager(redis);
const manager = new ConfigManager(redis as any);
const config = await manager.getProviderConfig("missing-provider");
expect(config).toBeNull();
});
it("should delete provider configuration and all tenant tokens", async () => {
const redis = {
del: mock(() => Promise.resolve()),
keys: mock(() =>
Promise.resolve([
"tenant:1:provider:trakt:access_token",
"tenant:2:provider:trakt:access_token",
]),
),
};
const manager = new ConfigManager(redis as any);
await manager.deleteProviderConfig("trakt");
expect(redis.del).toHaveBeenCalledWith("config:trakt");
expect(redis.keys).toHaveBeenCalledWith("tenant:*:provider:trakt:*");
expect(redis.del).toHaveBeenCalledWith(
"tenant:1:provider:trakt:access_token",
"tenant:2:provider:trakt:access_token",
);
});
});
+28 -12
View File
@@ -1,15 +1,17 @@
// @ts-nocheck
import { describe, expect, it, mock } from "bun:test";
import { TokenManager } from "../../src/core/TokenManager";
describe("TokenManager", () => {
const tenantId = "test-tenant";
it("should return token from redis if available", async () => {
const redis = { get: mock(() => Promise.resolve("active-access-token")) };
const manager = new TokenManager(redis, {});
const manager = new TokenManager(redis as any, {} as any);
const token = await manager.getAccessToken("trakt");
const token = await manager.getAccessToken(tenantId, "trakt");
expect(token).toBe("active-access-token");
expect(redis.get).toHaveBeenCalledWith(`tenant:${tenantId}:provider:trakt:access_token`);
});
it("should refresh token if access token is missing but refresh token exists", async () => {
@@ -26,19 +28,26 @@ describe("TokenManager", () => {
}),
),
};
const manager = new TokenManager(redis, provider);
const manager = new TokenManager(redis as any, provider as any);
const token = await manager.getAccessToken("trakt");
const token = await manager.getAccessToken(tenantId, "trakt");
expect(token).toBe("newly-refreshed-access-token");
expect(redis.set).toHaveBeenCalled();
expect(redis.get).toHaveBeenCalledWith(`tenant:${tenantId}:provider:trakt:access_token`);
expect(redis.get).toHaveBeenCalledWith(`tenant:${tenantId}:provider:trakt:refresh_token`);
expect(redis.set).toHaveBeenCalledWith(
`tenant:${tenantId}:provider:trakt:access_token`,
"newly-refreshed-access-token",
"EX",
3600,
);
});
it("should return null if no tokens are found", async () => {
const redis = { get: mock(() => Promise.resolve(null)) };
const manager = new TokenManager(redis, {});
const manager = new TokenManager(redis as any, {} as any);
const token = await manager.getAccessToken("trakt");
const token = await manager.getAccessToken(tenantId, "trakt");
expect(token).toBeNull();
});
@@ -57,19 +66,26 @@ describe("TokenManager", () => {
}),
),
};
const manager = new TokenManager(redis, provider);
const manager = new TokenManager(redis as any, provider as any);
const token = await manager.refreshAccessToken("trakt");
const token = await manager.refreshAccessToken(tenantId, "trakt");
expect(token).toBe("manually-refreshed-access-token");
expect(redis.get).toHaveBeenCalledWith(`tenant:${tenantId}:provider:trakt:refresh_token`);
expect(provider.refreshToken).toHaveBeenCalledWith("existing-refresh-token");
expect(redis.set).toHaveBeenCalledWith(
`tenant:${tenantId}:provider:trakt:access_token`,
"manually-refreshed-access-token",
"EX",
3600,
);
});
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 manager = new TokenManager(redis as any, {} as any);
const token = await manager.refreshAccessToken("trakt");
const token = await manager.refreshAccessToken(tenantId, "trakt");
expect(token).toBeNull();
});
+35 -60
View File
@@ -1,4 +1,3 @@
// @ts-nocheck
import { describe, expect, it, spyOn } from "bun:test";
import { API_PREFIX } from "../../src/constants";
import { redis } from "../../src/core/RedisClient";
@@ -13,6 +12,8 @@ describe("API Integration", () => {
scope: "public",
});
const tenantId = "test-tenant";
it("should return 401 if API Key is missing", async () => {
const res = await app.request(`${API_PREFIX}/status`);
@@ -30,7 +31,7 @@ describe("API Integration", () => {
});
it("should return 503 for health check if redis is down", async () => {
redis.status = "connecting";
(redis as any).status = "connecting";
const res = await app.request("/health");
const body = await res.json();
@@ -40,18 +41,31 @@ describe("API Integration", () => {
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) => {
it("should return 400 if X-Tenant-ID is missing", async () => {
const res = await app.request(`${API_PREFIX}/status`, {
headers: {
Authorization: "Bearer test-api-key",
},
});
expect(res.status).toBe(400);
});
it("should return 200 for status with valid API Key and X-Tenant-ID", async () => {
(redis.keys as any).mockReturnValue(Promise.resolve(["config:trakt"]));
(redis.get as any).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");
if (key.includes(`tenant:${tenantId}:provider:trakt:access_token`))
return Promise.resolve("current-access-token");
if (key.includes(`tenant:${tenantId}:provider:trakt:refresh_token`))
return Promise.resolve("current-refresh-token");
return Promise.resolve(null);
});
const res = await app.request(`${API_PREFIX}/status`, {
headers: {
Authorization: "Bearer test-api-key",
"X-Tenant-ID": tenantId,
},
});
@@ -59,76 +73,35 @@ describe("API Integration", () => {
const body = await res.json();
expect(body.trakt).toBeDefined();
expect(body.trakt.accessToken).toBe("current-access-token");
expect(redis.get).toHaveBeenCalledWith(`tenant:${tenantId}:provider:trakt: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 () => {
const res = await app.request(`${API_PREFIX}/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) => {
it("should return token for a configured provider with X-Tenant-ID", async () => {
(redis.get as any).mockImplementation((key) => {
if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig);
if (key.includes("access_token")) return Promise.resolve("trakt-active-token");
if (key.includes(`tenant:${tenantId}:provider:trakt:access_token`))
return Promise.resolve("trakt-active-token");
return Promise.resolve(null);
});
const res = await app.request(`${API_PREFIX}/token/trakt`, {
headers: {
Authorization: "Bearer test-api-key",
"X-Tenant-ID": tenantId,
},
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.access_token).toBe("trakt-active-token");
expect(redis.get).toHaveBeenCalledWith(`tenant:${tenantId}:provider:trakt:access_token`);
});
it("should return 404 if no access token is in redis for a valid provider", async () => {
redis.get.mockImplementation((key) => {
it("should successfully refresh a token with X-Tenant-ID", async () => {
(redis.get as any).mockImplementation((key) => {
if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig);
return Promise.resolve(null);
});
const res = await app.request(`${API_PREFIX}/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");
if (key.includes(`tenant:${tenantId}:provider:trakt:refresh_token`))
return Promise.resolve("old-refresh-token");
return Promise.resolve("new-access-token-from-refresh");
});
@@ -141,13 +114,14 @@ describe("API Integration", () => {
refresh_token: "new-refresh-token-from-fetch",
expires_in: 3600,
}),
}),
} as any),
);
const res = await app.request(`${API_PREFIX}/refresh/trakt`, {
method: "POST",
headers: {
Authorization: "Bearer test-api-key",
"X-Tenant-ID": tenantId,
},
});
@@ -155,5 +129,6 @@ describe("API Integration", () => {
const body = await res.json();
expect(body.success).toBe(true);
expect(body.status.accessToken).toBe("new-access-token-from-refresh");
expect(redis.get).toHaveBeenCalledWith(`tenant:${tenantId}:provider:trakt:refresh_token`);
});
});
+23 -13
View File
@@ -1,4 +1,3 @@
// @ts-nocheck
import { describe, expect, it, spyOn } from "bun:test";
import { AUTH_PREFIX } from "../../src/constants";
import { redis } from "../../src/core/RedisClient";
@@ -13,21 +12,25 @@ describe("Auth Integration", () => {
scope: "public",
});
it("should redirect to provider login", async () => {
redis.get.mockImplementation((key) => {
const tenantId = "test-tenant";
it("should redirect to provider login with tenantId in state", async () => {
(redis.get as any).mockImplementation((key: string) => {
if (key.includes("config:trakt")) return Promise.resolve(mockProviderConfig);
return Promise.resolve(null);
});
const res = await app.request(`${AUTH_PREFIX}/trakt/login`);
const res = await app.request(`${AUTH_PREFIX}/trakt/login?tenantId=${tenantId}`);
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");
const location = res.headers.get("Location") || "";
expect(location).toContain("trakt.tv/oauth/authorize");
expect(location).toContain("client_id=trakt-client-id");
expect(location).toContain(`state=${encodeURIComponent(`${tenantId}:trakt`)}`);
});
it("should handle callback and exchange code", async () => {
redis.get.mockImplementation((key) => {
it("should handle callback and exchange code using tenantId from state", async () => {
(redis.get as any).mockImplementation((key: string) => {
if (key.includes("config:trakt")) return Promise.resolve(mockProviderConfig);
return Promise.resolve(null);
});
@@ -41,19 +44,26 @@ describe("Auth Integration", () => {
refresh_token: "exchange-refresh-token",
expires_in: 3600,
}),
}),
} as any),
);
const res = await app.request(`${AUTH_PREFIX}/callback?state=trakt&code=temporary-auth-code`);
const res = await app.request(
`${AUTH_PREFIX}/callback?state=${tenantId}: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(res.headers.get("Location")).toBe(`/app/success?provider=trakt&tenantId=${tenantId}`);
expect(redis.set).toHaveBeenCalledWith(
`tenant:${tenantId}:provider:trakt:access_token`,
"exchange-access-token",
"EX",
3600,
);
expect(fetchSpy).toHaveBeenCalled();
});
it("should return 404 if provider not configured during login", async () => {
const res = await app.request(`${AUTH_PREFIX}/unknown-provider/login`);
const res = await app.request(`${AUTH_PREFIX}/unknown-provider/login?tenantId=${tenantId}`);
expect(res.status).toBe(404);
});
+7 -5
View File
@@ -1,4 +1,3 @@
// @ts-nocheck
import { describe, expect, it } from "bun:test";
import { API_PREFIX } from "../../src/constants";
import { redis } from "../../src/core/RedisClient";
@@ -6,8 +5,8 @@ import { app } from "../../src/index";
describe("Config Integration", () => {
it("should list all configured providers", async () => {
redis.keys.mockReturnValue(Promise.resolve(["config:trakt"]));
redis.get.mockImplementation(() =>
(redis.keys as any).mockReturnValue(Promise.resolve(["config:trakt"]));
(redis.get as any).mockImplementation(() =>
Promise.resolve(
JSON.stringify({
clientId: "trakt-client-id",
@@ -73,7 +72,8 @@ describe("Config Integration", () => {
expect(res.status).toBe(400);
});
it("should delete a provider configuration", async () => {
it("should delete a provider configuration and clean up all tenant tokens", async () => {
(redis.keys as any).mockReturnValue(Promise.resolve(["tenant:1:provider:trakt:token"]));
const res = await app.request(`${API_PREFIX}/config/trakt`, {
method: "DELETE",
headers: {
@@ -82,6 +82,8 @@ describe("Config Integration", () => {
});
expect(res.status).toBe(200);
expect(redis.del).toHaveBeenCalled();
expect(redis.del).toHaveBeenCalledWith("config:trakt");
expect(redis.keys).toHaveBeenCalledWith("tenant:*:provider:trakt:*");
expect(redis.del).toHaveBeenCalledWith("tenant:1:provider:trakt:token");
});
});
+5 -3
View File
@@ -1,4 +1,3 @@
// @ts-nocheck
import { describe, expect, it } from "bun:test";
import { API_PREFIX } from "../../src/constants";
import { redis } from "../../src/core/RedisClient";
@@ -23,12 +22,15 @@ describe("Dashboard & Common Integration", () => {
});
it("should return 500 for internal errors", async () => {
redis.keys.mockImplementationOnce(() => {
(redis.keys as any).mockImplementationOnce(() => {
throw new Error("Redis Crash");
});
const res = await app.request(`${API_PREFIX}/status`, {
headers: { Authorization: "Bearer test-api-key" },
headers: {
Authorization: "Bearer test-api-key",
"X-Tenant-ID": "test-tenant",
},
});
expect(res.status).toBe(500);