Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d417d672c | |||
| 9d6df8a8df |
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 110 KiB |
@@ -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
|
|
||||||
@@ -19,6 +19,3 @@ docs/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Worktrees
|
|
||||||
.worktrees/
|
|
||||||
|
|||||||
@@ -1,134 +1,82 @@
|
|||||||
# toknd — The Minimal Token Broker
|
# toknd — Auth Broker
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Building integrations is fun until you have to manage the tokens. Suddenly, you're writing cron jobs to handle refresh cycles, dealing with expired sessions in the middle of background tasks, and copying OAuth secrets manually across multiple microservices.
|
**toknd** is a minimal, centralized authentication and token broker. Built with **Bun**, **Hono**, and **Redis**, it serves as a middleware layer that manages OAuth2 providers, token persistence, and automatic refreshes, allowing your applications to focus on their core logic.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
You just need to authenticate once. toknd manages the lifecycle of the tokens forever.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Use-Cases
|
|
||||||
- **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.
|
|
||||||
- **Secure Secret Isolation:** OAuth client IDs, secrets, and long-lived refresh tokens stay locked inside toknd's Redis vault, drastically reducing your attack surface. All you need to do is secure the store.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Drop-in Infrastructure:** Deploys in seconds via Docker (or Podman) Compose or just a simple Bun script.
|
- Centralized management for multiple OAuth2 providers (Google, Trakt, GitHub, etc.).
|
||||||
- **Centralized Provider Management:** Native support for managing multiple OAuth2 providers (Google, GitHub, Trakt, etc.).
|
- Automatic token refreshes.
|
||||||
- **API Key Security:** Isolated and secure access to the broker via master API keys. Each instance can use its own key for isolation.
|
- Secure and isolated API access via API key authentication.
|
||||||
- **Web Dashboard:** Built-in clean ad modern UI for managing provider configurations and viewing live token statuses.
|
- Web-based dashboard for configuration management.
|
||||||
- **Blisteringly Fast:** Powered by Bun and Redis for ultra-low latency token retrieval.
|
- Docker Compose support for simplified deployment.
|
||||||
|
- High performance and low-latency powered by Bun and Redis.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Runtime**: [Bun](https://bun.sh/)
|
- **Runtime**: [Bun](https://bun.sh/)
|
||||||
- **Web Framework**: [Hono](https://hono.dev/)
|
- **Web Framework**: [Hono](https://hono.dev/)
|
||||||
- **Data Store**: [Redis](https://redis.io/)
|
- **Data Store**: Redis
|
||||||
- **Styling**: [Tailwind CSS](https://tailwindcss.com/) & [DaisyUI](https://daisyui.com/)
|
- **Styling**: Tailwind CSS & DaisyUI
|
||||||
- **Schema & Validation**: [Zod](https://zod.dev/)
|
- **Schema & Validation**: Zod
|
||||||
- **Docs**: [Scalar](https://github.com/scalar/scalar)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
toknd is designed to be too easy to set-up. You can deploy it as a containerized service or host it directly on bare metal.
|
toknd can be deployed either as a containerized service or self-hosted directly on your hardware.
|
||||||
|
|
||||||
### 1. Environment Setup
|
### 1. Environment Setup
|
||||||
Clone the repository and set up your environment:
|
Clone the repository and create your environment file:
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.ramvignesh.dev/toknd_auth.git
|
|
||||||
cd toknd
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
Open `.env`, define your own strong `API_KEY`, and map the ports (optional)
|
Define a strong `API_KEY` and ensure `REDIS_URL` points to a valid Redis instance.
|
||||||
|
|
||||||
### 2. Choose Deployment Method
|
### 2. Choose Deployment Method
|
||||||
|
|
||||||
#### Option A: Docker Compose (Recommended)
|
#### Option A: Containerized (Recommended)
|
||||||
The easiest way to get up and running. This spins up both toknd and a dedicated Redis instance.
|
This is the easiest way to get up and running, as it bundles the application and a Redis instance together.
|
||||||
|
|
||||||
You can run toknd by building the container locally or by pulling the pre-built image from the **GitHub Container Registry (GHCR)**.
|
- **Development (with Hot-Reload)**:
|
||||||
|
```bash
|
||||||
|
podman compose up --build
|
||||||
|
```
|
||||||
|
- **Production**:
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
##### Using Pre-built Image (GHCR)
|
#### Option B: Self-Hosting (Bare Metal)
|
||||||
Instead of building locally, you can pull the pre-built image. Update the `app` service in `docker-compose.yml` to pull the image:
|
Ideal for lightweight deployments or custom environments where you already have Bun and Redis.
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#### Option B: Bare Metal
|
|
||||||
If you already have Bun and Redis running in your environment.
|
|
||||||
|
|
||||||
1. **Install Dependencies**:
|
1. **Install Dependencies**:
|
||||||
```bash
|
```bash
|
||||||
bun install
|
bun install
|
||||||
```
|
```
|
||||||
2. **Start the Server**:
|
2. **Start the Server**:
|
||||||
|
- **Development**: `bun run dev` (with hot-reload)
|
||||||
- **Production**: `bun run start`
|
- **Production**: `bun run start`
|
||||||
- **Development**: `bun run dev`
|
*Note: Ensure your Redis server is running and accessible via the `REDIS_URL` in your `.env`.*
|
||||||
|
|
||||||
*Note: Make sure your Redis server is running and accessible via the `HOST` and `PORT` in your `.env`.*
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage & API Reference
|
## API Reference
|
||||||
|
|
||||||
toknd provides a built-in **Scalar API Reference** so you can explore and test endpoints right from your browser.
|
toknd provides a built-in **Scalar API Reference** that allows you to explore and test all endpoints directly from your browser.
|
||||||
|
|
||||||
- **Interactive UI**: [http://localhost:3000/api](http://localhost:3000/api) (or `/docs`)
|
- **Interactive UI**: [http://localhost:3000/api](http://localhost:3000/api) (or `/docs`)
|
||||||
- **OpenAPI Spec**: [http://localhost:3000/doc](http://localhost:3000/doc)
|
- **OpenAPI Spec (JSON)**: [http://localhost:3000/doc](http://localhost:3000/doc)
|
||||||
|
|
||||||
### The Golden Rule
|
All protected endpoints require a Bearer token in the `Authorization` header:
|
||||||
All protected endpoints require your master API key in the Authorization header:
|
`Authorization: Bearer <your_master_api_key>`
|
||||||
```http
|
|
||||||
Authorization: Bearer <your_master_api_key>
|
|
||||||
```
|
|
||||||
|
|
||||||
### The Dashboard
|
### Core Concepts
|
||||||
REST API too complicated to use? No problem!
|
- **Token Brokerage**: Automated access token retrieval and background refreshes for all configured providers.
|
||||||
You absolutely don't have to manage everything via curl. Access the web dashboard to configure providers, trigger manual refreshes, and monitor token health:
|
- **Provider Management**: Register and manage OAuth2 providers via the Dashboard or the configuration API.
|
||||||
**`http://localhost:3000/app`**
|
|
||||||
|
|
||||||
*(Authenticate using your Master API Key).*
|
## Dashboard
|
||||||
|
Access the **toknd** dashboard at:
|
||||||
|
`http://localhost:3000/app`
|
||||||
|
|
||||||
|
The dashboard allows you to manage provider configurations, view live token statuses, and manually trigger refreshes. Authenticate using your **Master API Key**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
This is an open-source passion project built to solve a real headache in modern application architecture. Pull requests, issues, and feature requests (especially for new built-in OAuth providers!) are highly encouraged.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ services:
|
|||||||
- REDIS_HOST=redis
|
- REDIS_HOST=redis
|
||||||
- REDIS_PORT=6379
|
- REDIS_PORT=6379
|
||||||
- API_KEY=${API_KEY}
|
- API_KEY=${API_KEY}
|
||||||
- APP_PORT=3000
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 745 B |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "toknd_auth_broker",
|
|
||||||
"short_name": "toknd",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/android-chrome-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/android-chrome-512x512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"theme_color": "#bdff00",
|
|
||||||
"background_color": "#000",
|
|
||||||
"display": "standalone"
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
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,13 +40,4 @@ 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,9 +40,5 @@ export class TokenManager {
|
|||||||
);
|
);
|
||||||
await this.redis.set(`provider:${providerName}:refresh_token`, tokens.refreshToken);
|
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}:last_updated`, new Date().toISOString());
|
||||||
await this.redis.set(
|
|
||||||
`provider:${providerName}:expires_at`,
|
|
||||||
new Date(Date.now() + tokens.expiresIn * 1000).toISOString(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ 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";
|
||||||
@@ -15,31 +13,39 @@ import { dashboardRoutes } from "./routes/dashboard";
|
|||||||
const app = new OpenAPIHono({ strict: false });
|
const app = new OpenAPIHono({ strict: false });
|
||||||
|
|
||||||
// OpenAPI specs
|
// OpenAPI specs
|
||||||
app.doc(`${DOCS_PREFIX}/openapi.json`, openApiSpec);
|
app.doc("/doc", {
|
||||||
app.openAPIRegistry.registerComponent("securitySchemes", "API_KEY", securityScheme);
|
openapi: "3.0.0",
|
||||||
|
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(
|
||||||
DOCS_PREFIX,
|
"/api",
|
||||||
Scalar({
|
Scalar({
|
||||||
theme: "solarized",
|
theme: "solarized",
|
||||||
url: `${DOCS_PREFIX}/openapi.json`,
|
url: "/doc",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.get("/docs", (c) => c.redirect(DOCS_PREFIX));
|
app.get("/docs", (c) => c.redirect("/api"));
|
||||||
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.get("/favicon.ico", serveStatic({ path: "./public/favicon.ico" }));
|
|
||||||
app.use("/static/*", serveStatic({ root: "./public" }));
|
|
||||||
app.get("/app/dashboard.js", serveStatic({ path: "./src/views/dashboard.js" }));
|
app.get("/app/dashboard.js", serveStatic({ path: "./src/views/dashboard.js" }));
|
||||||
app.route(AUTH_PREFIX, authRoutes);
|
app.route("/auth", authRoutes);
|
||||||
app.route(`${API_PREFIX}/config`, configRoutes);
|
app.route("/api/config", configRoutes);
|
||||||
app.route(API_PREFIX, apiRoutes);
|
app.route("/api", apiRoutes);
|
||||||
app.route("/app", dashboardRoutes);
|
app.route("/app", dashboardRoutes);
|
||||||
|
|
||||||
app.notFound((c) => {
|
app.notFound((c) => {
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -15,10 +15,6 @@ const StatusResponseSchema = z
|
|||||||
accessToken: z.string().nullable(),
|
accessToken: z.string().nullable(),
|
||||||
refreshToken: z.string().nullable(),
|
refreshToken: z.string().nullable(),
|
||||||
lastUpdated: z.string().nullable(),
|
lastUpdated: z.string().nullable(),
|
||||||
expiresAt: z.string().nullable().openapi({
|
|
||||||
example: "2026-05-12T10:00:00.000Z",
|
|
||||||
description: "ISO timestamp of when the access token expires",
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.openapi("StatusResponse");
|
.openapi("StatusResponse");
|
||||||
@@ -36,10 +32,6 @@ const RefreshResponseSchema = z
|
|||||||
accessToken: z.string().nullable(),
|
accessToken: z.string().nullable(),
|
||||||
refreshToken: z.string().nullable(),
|
refreshToken: z.string().nullable(),
|
||||||
lastUpdated: z.string().nullable(),
|
lastUpdated: z.string().nullable(),
|
||||||
expiresAt: z.string().nullable().openapi({
|
|
||||||
example: "2026-05-12T10:00:00.000Z",
|
|
||||||
description: "ISO timestamp of when the access token expires",
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.openapi("RefreshResponse");
|
.openapi("RefreshResponse");
|
||||||
@@ -55,7 +47,6 @@ 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 } },
|
||||||
@@ -68,7 +59,6 @@ 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" }),
|
||||||
@@ -90,7 +80,6 @@ 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" }),
|
||||||
@@ -120,8 +109,7 @@ apiRoutes.openapi(statusRoute, async (c) => {
|
|||||||
const accessToken = await redis.get(`provider:${provider}:access_token`);
|
const accessToken = await redis.get(`provider:${provider}:access_token`);
|
||||||
const refreshToken = await redis.get(`provider:${provider}:refresh_token`);
|
const refreshToken = await redis.get(`provider:${provider}:refresh_token`);
|
||||||
const lastUpdated = await redis.get(`provider:${provider}:last_updated`);
|
const lastUpdated = await redis.get(`provider:${provider}:last_updated`);
|
||||||
const expiresAt = await redis.get(`provider:${provider}:expires_at`);
|
status[provider] = { accessToken, refreshToken, lastUpdated };
|
||||||
status[provider] = { accessToken, refreshToken, lastUpdated, expiresAt };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json(status, 200);
|
return c.json(status, 200);
|
||||||
@@ -162,12 +150,11 @@ apiRoutes.openapi(refreshRoute, async (c) => {
|
|||||||
const accessToken = await redis.get(`provider:${providerName}:access_token`);
|
const accessToken = await redis.get(`provider:${providerName}:access_token`);
|
||||||
const refreshToken = await redis.get(`provider:${providerName}:refresh_token`);
|
const refreshToken = await redis.get(`provider:${providerName}:refresh_token`);
|
||||||
const lastUpdated = await redis.get(`provider:${providerName}:last_updated`);
|
const lastUpdated = await redis.get(`provider:${providerName}:last_updated`);
|
||||||
const expiresAt = await redis.get(`provider:${providerName}:expires_at`);
|
|
||||||
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
status: { accessToken, refreshToken, lastUpdated, expiresAt },
|
status: { accessToken, refreshToken, lastUpdated },
|
||||||
},
|
},
|
||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ 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,8 +36,6 @@ 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
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ 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 } },
|
||||||
@@ -51,7 +50,6 @@ 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" }),
|
||||||
@@ -72,24 +70,6 @@ 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);
|
||||||
|
|
||||||
@@ -108,12 +88,4 @@ 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 };
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|||||||
import { html } from "hono/html";
|
import { html } from "hono/html";
|
||||||
import type { Child } from "hono/jsx";
|
import type { Child } from "hono/jsx";
|
||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
import { API_PREFIX, API_VERSION, APP_VERSION, AUTH_PREFIX, DOCS_PREFIX } from "../constants";
|
|
||||||
|
|
||||||
const dashboardRoutes = new Hono({ strict: false });
|
const dashboardRoutes = new Hono({ strict: false });
|
||||||
|
|
||||||
@@ -16,7 +15,6 @@ export const Layout = (props: { title: string; children: Child; isUnlocked?: boo
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{props.title}</title>
|
<title>{props.title}</title>
|
||||||
<link rel="icon" type="image/png" href="/favicon.ico" />
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
<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>
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||||
<link
|
<link
|
||||||
@@ -36,22 +34,12 @@ export const Layout = (props: { title: string; children: Child; isUnlocked?: boo
|
|||||||
.font-mono {
|
.font-mono {
|
||||||
font-family: "DM Mono", monospace;
|
font-family: "DM Mono", monospace;
|
||||||
}
|
}
|
||||||
.text-xxs {
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
class="bg-base-200/50 min-h-screen font-['DM_Sans',sans-serif] antialiased text-base-content tracking-tight"
|
class="bg-base-200/50 min-h-screen font-['DM_Sans',sans-serif] antialiased text-base-content tracking-tight"
|
||||||
x-data={`dashboard({
|
x-data={`dashboard({ initialIsUnlocked: ${props.isUnlocked || false} })`}
|
||||||
initialIsUnlocked: ${props.isUnlocked || false},
|
|
||||||
apiVersion: '${API_VERSION}',
|
|
||||||
appVersion: '${APP_VERSION}',
|
|
||||||
apiPrefix: '${API_PREFIX}',
|
|
||||||
authPrefix: '${AUTH_PREFIX}',
|
|
||||||
docsPrefix: '${DOCS_PREFIX}'
|
|
||||||
})`}
|
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
<script src="/app/dashboard.js"></script>
|
<script src="/app/dashboard.js"></script>
|
||||||
@@ -64,7 +52,7 @@ export const Layout = (props: { title: string; children: Child; isUnlocked?: boo
|
|||||||
export const Dashboard = (props: { isUnlocked: boolean }) => (
|
export const Dashboard = (props: { isUnlocked: boolean }) => (
|
||||||
<Layout title="toknd — Auth Broker Dashboard" isUnlocked={props.isUnlocked}>
|
<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="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-1">
|
||||||
<div class="flex items-center gap-2">
|
<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">
|
<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>
|
<i class="ph-duotone ph-fingerprint"></i>
|
||||||
@@ -73,75 +61,49 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
|
|||||||
toknd <span class="text-xs font-normal opacity-50 ml-1">auth broker</span>
|
toknd <span class="text-xs font-normal opacity-50 ml-1">auth broker</span>
|
||||||
</div>
|
</div>
|
||||||
</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-xxs opacity-50 ml-0.5">
|
|
||||||
{API_VERSION}.{APP_VERSION}
|
|
||||||
</sup>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-none hidden sm:flex">
|
<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 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">
|
||||||
<div class="join-item flex items-center px-4 bg-base-200">
|
<i class="ph-duotone ph-key text-secondary text-lg"></i>
|
||||||
<i class="ph-duotone ph-key text-secondary text-lg"></i>
|
</div>
|
||||||
</div>
|
<div class="relative flex-1" x-data="{ show: false }">
|
||||||
<div class="relative flex-1" x-data="{ show: false }">
|
<input
|
||||||
<input
|
x-bind:type="show ? 'text' : 'password'"
|
||||||
x-bind:type="show ? 'text' : 'password'"
|
id="apiKey"
|
||||||
id="apiKey"
|
name="apiKey"
|
||||||
name="apiKey"
|
x-model="apiKey"
|
||||||
x-model="apiKey"
|
aria-label="Master API Key"
|
||||||
aria-label="Master API Key"
|
placeholder="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"
|
||||||
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
|
<button
|
||||||
x-on:click="unlock()"
|
type="button"
|
||||||
type="submit"
|
x-on:click="show = !show"
|
||||||
class="btn btn-primary btn-sm join-item px-6"
|
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-square"
|
||||||
x-bind:disabled="loading"
|
|
||||||
>
|
>
|
||||||
<i class="ph-duotone ph-lock-key-open text-lg" x-show="!loading"></i>
|
<i x-bind:class="show ? 'ph-duotone ph-eye-slash text-base opacity-50' : 'ph-duotone ph-eye text-base opacity-50'"></i>
|
||||||
<span class="loading loading-spinner loading-xs" x-show="loading"></span>
|
|
||||||
<span
|
|
||||||
class="ml-1 hidden md:inline"
|
|
||||||
x-text="loading ? 'Unlocking...' : 'Unlock'"
|
|
||||||
></span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="isUnlocked">
|
|
||||||
<button
|
<button
|
||||||
x-on:click="logout()"
|
x-on:click="unlock()"
|
||||||
type="button"
|
type="submit"
|
||||||
class="btn btn-ghost btn-sm text-error hover:bg-error/10 gap-2 px-4"
|
class="btn btn-primary btn-sm join-item px-6"
|
||||||
x-bind:disabled="loading"
|
x-bind:disabled="loading"
|
||||||
>
|
>
|
||||||
<i class="ph-bold ph-power text-lg"></i>
|
<i class="ph-duotone ph-lock-key-open text-lg" x-show="!loading"></i>
|
||||||
<span class="font-bold uppercase tracking-wider text-xs">Logout</span>
|
<span class="loading loading-spinner loading-xs" x-show="loading"></span>
|
||||||
|
<span class="ml-1 hidden md:inline" x-text="loading ? 'Unlocking...' : 'Unlock'"></span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
<button
|
||||||
|
x-show="isUnlocked"
|
||||||
|
x-on:click="logout()"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm join-item text-error hover:bg-error/10"
|
||||||
|
>
|
||||||
|
<i class="ph-bold ph-power text-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -150,8 +112,7 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
|
|||||||
<div class="card bg-base-100 shadow-xl border border-base-300 lg:col-span-4 self-start">
|
<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="card-body p-6">
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<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-2 h-6 bg-primary rounded-full"></div>
|
||||||
<div class="w-1 h-6 bg-primary/50 rounded-full"></div>
|
|
||||||
<h2 class="card-title text-xl font-semibold">Configure Provider</h2>
|
<h2 class="card-title text-xl font-semibold">Configure Provider</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -160,10 +121,7 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
|
|||||||
<label htmlFor="providerName" class="label py-1">
|
<label htmlFor="providerName" class="label py-1">
|
||||||
<span class="label-text flex items-center gap-2">
|
<span class="label-text flex items-center gap-2">
|
||||||
Provider ID
|
Provider ID
|
||||||
<span
|
<span class="tooltip tooltip-top" data-tip="Internal name for this service.">
|
||||||
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>
|
<i class="ph ph-info opacity-50 cursor-help"></i>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -184,15 +142,7 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
|
|||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label htmlFor="clientId" class="label py-1">
|
<label htmlFor="clientId" class="label py-1">
|
||||||
<span class="label-text flex items-center gap-2">
|
<span class="label-text">Client ID</span>
|
||||||
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>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -205,15 +155,7 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-control" x-data="{ show: false }">
|
<div class="form-control" x-data="{ show: false }">
|
||||||
<label htmlFor="clientSecret" class="label py-1">
|
<label htmlFor="clientSecret" class="label py-1">
|
||||||
<span class="label-text flex items-center gap-2">
|
<span class="label-text">Client Secret</span>
|
||||||
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>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
@@ -238,15 +180,7 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
|
|||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label htmlFor="authUrl" class="label py-1">
|
<label htmlFor="authUrl" class="label py-1">
|
||||||
<span class="label-text flex items-center gap-2">
|
<span class="label-text">Auth URL</span>
|
||||||
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>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
@@ -259,15 +193,7 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label htmlFor="tokenUrl" class="label py-1">
|
<label htmlFor="tokenUrl" class="label py-1">
|
||||||
<span class="label-text flex items-center gap-2">
|
<span class="label-text">Token URL</span>
|
||||||
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>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
@@ -280,15 +206,7 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label htmlFor="redirectUri" class="label py-1">
|
<label htmlFor="redirectUri" class="label py-1">
|
||||||
<span class="label-text flex items-center gap-2">
|
<span class="label-text">Redirect URI</span>
|
||||||
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>
|
</label>
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<input
|
<input
|
||||||
@@ -314,15 +232,7 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label htmlFor="scope" class="label py-1">
|
<label htmlFor="scope" class="label py-1">
|
||||||
<span class="label-text flex items-center gap-2">
|
<span class="label-text">Scope</span>
|
||||||
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>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -351,14 +261,13 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
|
|||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="p-6 pb-4 flex justify-between items-center bg-base-100">
|
<div class="p-6 pb-4 flex justify-between items-center bg-base-100">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<i class="ph-duotone ph-shipping-container text-2xl text-primary"></i>
|
<div class="w-2 h-6 bg-primary rounded-full"></div>
|
||||||
<div class="w-1 h-6 bg-primary/50 rounded-full"></div>
|
|
||||||
<h2 class="card-title text-xl font-semibold">Provider Registry</h2>
|
<h2 class="card-title text-xl font-semibold">Provider Registry</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
x-on:click="fetchProviders()"
|
x-on:click="fetchProviders()"
|
||||||
class="btn btn-sm btn-neutral"
|
class="btn btn-sm btn-base"
|
||||||
x-bind:disabled="!isUnlocked || loading"
|
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>
|
<i x-bind:class="loading ? 'ph ph-arrows-clockwise animate-spin mr-1' : 'ph ph-arrows-clockwise mr-1'"></i>
|
||||||
@@ -399,20 +308,10 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
|
|||||||
<div class="card bg-base-200/50 border border-base-300 shadow-sm hover:shadow-md transition-all group">
|
<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="card-body p-5">
|
||||||
<div class="flex flex-col mb-4">
|
<div class="flex flex-col mb-4">
|
||||||
<div class="flex justify-between items-start">
|
<span
|
||||||
<span
|
x-text="provider.name"
|
||||||
x-text="provider.name"
|
class="text-lg font-black text-base-content/90 uppercase"
|
||||||
class="text-lg font-black text-base-content/90 uppercase"
|
></span>
|
||||||
></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
|
<span
|
||||||
x-text="provider.config.clientId"
|
x-text="provider.config.clientId"
|
||||||
x-bind:title="provider.config.clientId"
|
x-bind:title="provider.config.clientId"
|
||||||
@@ -501,33 +400,19 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider my-3 opacity-10"></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="flex justify-between items-center mb-4">
|
||||||
<div class="p-1 flex flex-col gap-0.5">
|
<span class="text-xs font-semibold opacity-30 uppercase">Last Updated</span>
|
||||||
<span class="text-xxs font-bold opacity-20 uppercase tracking-wide">
|
<span
|
||||||
Last Updated
|
x-text="formatTime(provider.status.lastUpdated)"
|
||||||
</span>
|
class="text-xs font-medium opacity-60"
|
||||||
<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>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
x-on:click={`window.open('${AUTH_PREFIX}/' + provider.name + '/login', '_blank')`}
|
x-on:click="window.open('/auth/' + provider.name + '/login', '_blank')"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
>
|
>
|
||||||
<i class="ph-bold ph-link"></i> Connect
|
<i class="ph-bold ph-link"></i> Connect
|
||||||
@@ -535,7 +420,7 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
x-on:click="editProvider(provider)"
|
x-on:click="editProvider(provider)"
|
||||||
class="btn btn-neutral btn-sm"
|
class="btn btn-secondary btn-sm"
|
||||||
>
|
>
|
||||||
<i class="ph-bold ph-pencil-simple"></i> Edit
|
<i class="ph-bold ph-pencil-simple"></i> Edit
|
||||||
</button>
|
</button>
|
||||||
@@ -543,7 +428,7 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
x-on:click="forceRefresh(provider.name)"
|
x-on:click="forceRefresh(provider.name)"
|
||||||
class="btn btn-secondary w-full"
|
class="btn btn-base w-full"
|
||||||
x-bind:disabled="loading || !provider.status.accessToken"
|
x-bind:disabled="loading || !provider.status.accessToken"
|
||||||
>
|
>
|
||||||
<i class="ph-bold ph-arrows-clockwise text-base mr-1"></i>
|
<i class="ph-bold ph-arrows-clockwise text-base mr-1"></i>
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
export const API_VERSION = "v1";
|
|
||||||
export const APP_VERSION = "1.0";
|
|
||||||
@@ -1,12 +1,4 @@
|
|||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
const formatDate = (date) => {
|
|
||||||
return date.toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (timestamp) => {
|
const formatTime = (timestamp) => {
|
||||||
if (!timestamp) return "Never";
|
if (!timestamp) return "Never";
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
@@ -15,259 +7,197 @@ document.addEventListener("alpine:init", () => {
|
|||||||
if (diff < 60) return "Just now";
|
if (diff < 60) return "Just now";
|
||||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
return formatDate(date);
|
return date.toLocaleDateString();
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatExpiry = (timestamp) => {
|
window.Alpine.data("dashboard", ({ initialIsUnlocked }) => ({
|
||||||
if (!timestamp) return "Expired";
|
apiKey: "",
|
||||||
const date = new Date(timestamp);
|
isUnlocked: initialIsUnlocked,
|
||||||
const diff = Math.floor((date - Date.now()) / 1000);
|
loading: false,
|
||||||
|
providers: [],
|
||||||
|
form: {
|
||||||
|
providerName: "",
|
||||||
|
clientId: "",
|
||||||
|
clientSecret: "",
|
||||||
|
authUrl: "",
|
||||||
|
tokenUrl: "",
|
||||||
|
scope: "public",
|
||||||
|
},
|
||||||
|
notification: {
|
||||||
|
show: false,
|
||||||
|
message: "",
|
||||||
|
type: "success",
|
||||||
|
},
|
||||||
|
|
||||||
if (diff <= 0) return "Expired";
|
init() {
|
||||||
if (diff < 60) return `${diff}s`;
|
if (this.isUnlocked) {
|
||||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
this.fetchProviders();
|
||||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
}
|
||||||
return `${formatDate(date)}`;
|
},
|
||||||
};
|
|
||||||
|
|
||||||
window.Alpine.data(
|
async unlock() {
|
||||||
"dashboard",
|
if (!this.apiKey) return;
|
||||||
({ initialIsUnlocked, apiPrefix, authPrefix, docsPrefix, apiVersion, appVersion }) => ({
|
this.loading = true;
|
||||||
apiKey: "",
|
try {
|
||||||
isUnlocked: initialIsUnlocked,
|
const res = await fetch("/app/unlock", {
|
||||||
apiPrefix,
|
method: "POST",
|
||||||
authPrefix,
|
headers: { "Content-Type": "application/json" },
|
||||||
docsPrefix,
|
body: JSON.stringify({ apiKey: this.apiKey }),
|
||||||
apiVersion,
|
});
|
||||||
appVersion,
|
|
||||||
loading: false,
|
|
||||||
providers: [],
|
|
||||||
form: {
|
|
||||||
providerName: "",
|
|
||||||
clientId: "",
|
|
||||||
clientSecret: "",
|
|
||||||
authUrl: "",
|
|
||||||
tokenUrl: "",
|
|
||||||
scope: "public",
|
|
||||||
},
|
|
||||||
notification: {
|
|
||||||
show: false,
|
|
||||||
message: "",
|
|
||||||
type: "success",
|
|
||||||
},
|
|
||||||
|
|
||||||
init() {
|
if (!res.ok) throw new Error("Invalid API Key");
|
||||||
if (this.isUnlocked) {
|
|
||||||
this.fetchProviders();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async unlock() {
|
this.isUnlocked = true;
|
||||||
if (!this.apiKey) return;
|
await this.fetchProviders();
|
||||||
this.loading = true;
|
this.apiKey = ""; // Clear after success
|
||||||
try {
|
} catch (err) {
|
||||||
const res = await fetch("/app/unlock", {
|
this.showNotification(err.message, "error");
|
||||||
method: "POST",
|
this.isUnlocked = false;
|
||||||
headers: { "Content-Type": "application/json" },
|
} finally {
|
||||||
body: JSON.stringify({ apiKey: this.apiKey }),
|
this.loading = false;
|
||||||
});
|
}
|
||||||
|
},
|
||||||
|
|
||||||
if (!res.ok) throw new Error("Invalid API Key");
|
async logout() {
|
||||||
|
this.loading = true;
|
||||||
this.isUnlocked = true;
|
try {
|
||||||
await this.fetchProviders();
|
await fetch("/app/logout", { method: "POST" });
|
||||||
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,
|
|
||||||
expiresAt: null,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
handleSessionExpired() {
|
|
||||||
this.isUnlocked = false;
|
this.isUnlocked = false;
|
||||||
this.providers = [];
|
this.providers = [];
|
||||||
this.showNotification("Session expired", "error");
|
this.showNotification("Logged out successfully");
|
||||||
},
|
} catch (err) {
|
||||||
|
this.showNotification(`Logout failed: ${err.message}`, "error");
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async saveConfig() {
|
async fetchProviders() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${this.apiPrefix}/config/${this.form.providerName}`, {
|
const [configRes, statusRes] = await Promise.all([
|
||||||
method: "POST",
|
fetch("/api/config"),
|
||||||
headers: { "Content-Type": "application/json" },
|
fetch("/api/status"),
|
||||||
body: JSON.stringify(this.form),
|
]);
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (configRes.status === 401 || statusRes.status === 401) {
|
||||||
this.isUnlocked = false;
|
return this.handleSessionExpired();
|
||||||
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) {
|
if (!configRes.ok || !statusRes.ok) throw new Error("Failed to fetch data");
|
||||||
this.loading = true;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${this.apiPrefix}/refresh/${name}`, {
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 401) {
|
const [config, status] = await Promise.all([configRes.json(), statusRes.json()]);
|
||||||
return this.handleSessionExpired();
|
this.providers = this.mapProviders(config, status);
|
||||||
}
|
} catch (err) {
|
||||||
|
this.showNotification(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
if (!res.ok) throw new Error("Refresh failed");
|
mapProviders(config, status) {
|
||||||
|
return Object.entries(config).map(([name, cfg]) => ({
|
||||||
|
name,
|
||||||
|
config: cfg,
|
||||||
|
status: status[name] || { accessToken: null, refreshToken: null, lastUpdated: null },
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
this.showNotification(`Refreshed ${name}`);
|
handleSessionExpired() {
|
||||||
await this.fetchProviders();
|
this.isUnlocked = false;
|
||||||
} catch (err) {
|
this.providers = [];
|
||||||
this.showNotification(err.message, "error");
|
this.showNotification("Session expired", "error");
|
||||||
} finally {
|
},
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteProvider(name) {
|
async saveConfig() {
|
||||||
if (
|
this.loading = true;
|
||||||
!confirm(
|
try {
|
||||||
`Are you sure you want to delete ${name}? This will also remove all associated tokens.`,
|
const res = await fetch(`/api/config/${this.form.providerName}`, {
|
||||||
)
|
method: "POST",
|
||||||
)
|
headers: { "Content-Type": "application/json" },
|
||||||
return;
|
body: JSON.stringify(this.form),
|
||||||
|
|
||||||
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") {
|
if (res.status === 401) {
|
||||||
this.notification = { show: true, message, type };
|
this.isUnlocked = false;
|
||||||
setTimeout(() => {
|
throw new Error("Session expired");
|
||||||
this.notification.show = false;
|
}
|
||||||
}, 3000);
|
|
||||||
},
|
|
||||||
|
|
||||||
formatTime(timestamp) {
|
if (!res.ok) throw new Error("Failed to save");
|
||||||
return formatTime(timestamp);
|
|
||||||
},
|
|
||||||
|
|
||||||
formatExpiry(timestamp) {
|
this.showNotification("Saved successfully");
|
||||||
return formatExpiry(timestamp);
|
await this.fetchProviders();
|
||||||
},
|
|
||||||
|
|
||||||
isExpired(timestamp) {
|
this.form = {
|
||||||
if (!timestamp) return true;
|
providerName: "",
|
||||||
return new Date(timestamp) < new Date();
|
clientId: "",
|
||||||
},
|
clientSecret: "",
|
||||||
}),
|
authUrl: "",
|
||||||
);
|
tokenUrl: "",
|
||||||
|
scope: "public",
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
this.showNotification(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async forceRefresh(name) {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/refresh/${name}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
this.isUnlocked = false;
|
||||||
|
throw new Error("Session expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Refresh failed");
|
||||||
|
|
||||||
|
this.showNotification(`Refreshed ${name}`);
|
||||||
|
await this.fetchProviders();
|
||||||
|
} catch (err) {
|
||||||
|
this.showNotification(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
editProvider(provider) {
|
||||||
|
this.form = {
|
||||||
|
providerName: provider.name,
|
||||||
|
clientId: provider.config.clientId,
|
||||||
|
clientSecret: provider.config.clientSecret,
|
||||||
|
authUrl: provider.config.authUrl,
|
||||||
|
tokenUrl: provider.config.tokenUrl,
|
||||||
|
scope: provider.config.scope,
|
||||||
|
};
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
},
|
||||||
|
|
||||||
|
getRedirectUri() {
|
||||||
|
return `${window.location.origin}/auth/${this.form.providerName || "{provider}"}/callback`;
|
||||||
|
},
|
||||||
|
|
||||||
|
copyToClipboard(text) {
|
||||||
|
if (!text) return;
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
this.showNotification("Copied");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
showNotification(message, type = "success") {
|
||||||
|
this.notification = { show: true, message, type };
|
||||||
|
setTimeout(() => {
|
||||||
|
this.notification.show = false;
|
||||||
|
}, 3000);
|
||||||
|
},
|
||||||
|
|
||||||
|
formatTime(timestamp) {
|
||||||
|
return formatTime(timestamp);
|
||||||
|
},
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { describe, expect, it, spyOn } from "bun:test";
|
import { afterEach, describe, expect, it, mock, 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",
|
||||||
@@ -14,7 +20,7 @@ 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_PREFIX}/status`);
|
const res = await app.request("/api/status");
|
||||||
|
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
@@ -49,7 +55,7 @@ describe("API Integration", () => {
|
|||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await app.request(`${API_PREFIX}/status`, {
|
const res = await app.request("/api/status", {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer test-api-key",
|
Authorization: "Bearer test-api-key",
|
||||||
},
|
},
|
||||||
@@ -69,7 +75,7 @@ describe("API Integration", () => {
|
|||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await app.request(`${API_PREFIX}/status`, {
|
const res = await app.request("/api/status", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: "toknd_api_key=test-api-key",
|
Cookie: "toknd_api_key=test-api-key",
|
||||||
},
|
},
|
||||||
@@ -81,7 +87,7 @@ describe("API Integration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should return 404 for unknown provider token", async () => {
|
it("should return 404 for unknown provider token", async () => {
|
||||||
const res = await app.request(`${API_PREFIX}/token/unconfigured-provider`, {
|
const res = await app.request("/api/token/unconfigured-provider", {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer test-api-key",
|
Authorization: "Bearer test-api-key",
|
||||||
},
|
},
|
||||||
@@ -97,7 +103,7 @@ describe("API Integration", () => {
|
|||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await app.request(`${API_PREFIX}/token/trakt`, {
|
const res = await app.request("/api/token/trakt", {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer test-api-key",
|
Authorization: "Bearer test-api-key",
|
||||||
},
|
},
|
||||||
@@ -114,7 +120,7 @@ describe("API Integration", () => {
|
|||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await app.request(`${API_PREFIX}/token/trakt`, {
|
const res = await app.request("/api/token/trakt", {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer test-api-key",
|
Authorization: "Bearer test-api-key",
|
||||||
},
|
},
|
||||||
@@ -144,7 +150,7 @@ describe("API Integration", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const res = await app.request(`${API_PREFIX}/refresh/trakt`, {
|
const res = await app.request("/api/refresh/trakt", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer test-api-key",
|
Authorization: "Bearer test-api-key",
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { describe, expect, it, spyOn } from "bun:test";
|
import { afterEach, describe, expect, it, mock, 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",
|
||||||
@@ -19,7 +23,7 @@ describe("Auth Integration", () => {
|
|||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await app.request(`${AUTH_PREFIX}/trakt/login`);
|
const res = await app.request("/auth/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");
|
||||||
@@ -44,7 +48,7 @@ describe("Auth Integration", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const res = await app.request(`${AUTH_PREFIX}/callback?state=trakt&code=temporary-auth-code`);
|
const res = await app.request("/auth/callback?state=trakt&code=temporary-auth-code");
|
||||||
|
|
||||||
expect(res.status).toBe(302);
|
expect(res.status).toBe(302);
|
||||||
expect(res.headers.get("Location")).toBe("/app/success?provider=trakt");
|
expect(res.headers.get("Location")).toBe("/app/success?provider=trakt");
|
||||||
@@ -53,13 +57,13 @@ describe("Auth Integration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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_PREFIX}/unknown-provider/login`);
|
const res = await app.request("/auth/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_PREFIX}/callback?code=some-code`);
|
const res = await app.request("/auth/callback?code=some-code");
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { describe, expect, it } from "bun:test";
|
import { afterEach, describe, expect, it, mock } 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(() =>
|
||||||
@@ -19,7 +25,7 @@ describe("Config Integration", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const res = await app.request(`${API_PREFIX}/config`, {
|
const res = await app.request("/api/config", {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer test-api-key",
|
Authorization: "Bearer test-api-key",
|
||||||
},
|
},
|
||||||
@@ -40,7 +46,7 @@ describe("Config Integration", () => {
|
|||||||
scope: "user:email",
|
scope: "user:email",
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await app.request(`${API_PREFIX}/config/github`, {
|
const res = await app.request("/api/config/github", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer test-api-key",
|
Authorization: "Bearer test-api-key",
|
||||||
@@ -61,7 +67,7 @@ describe("Config Integration", () => {
|
|||||||
clientId: "missing-other-required-fields",
|
clientId: "missing-other-required-fields",
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await app.request(`${API_PREFIX}/config/invalid`, {
|
const res = await app.request("/api/config/invalid", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer test-api-key",
|
Authorization: "Bearer test-api-key",
|
||||||
@@ -72,16 +78,4 @@ 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,6 +1,5 @@
|
|||||||
// @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";
|
||||||
|
|
||||||
@@ -27,7 +26,7 @@ describe("Dashboard & Common Integration", () => {
|
|||||||
throw new Error("Redis Crash");
|
throw new Error("Redis Crash");
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await app.request(`${API_PREFIX}/status`, {
|
const res = await app.request("/api/status", {
|
||||||
headers: { Authorization: "Bearer test-api-key" },
|
headers: { Authorization: "Bearer test-api-key" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,18 @@
|
|||||||
// @ts-nocheck
|
import { mock } from "bun:test";
|
||||||
|
|
||||||
|
// Global test setup to stub environment variables
|
||||||
process.env.API_KEY = "test-api-key";
|
process.env.API_KEY = "test-api-key";
|
||||||
process.env.REDIS_HOST = "localhost";
|
process.env.REDIS_HOST = "localhost";
|
||||||
process.env.REDIS_PORT = "6379";
|
process.env.REDIS_PORT = "6379";
|
||||||
process.env.APP_PORT = "3000";
|
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", () => ({
|
||||||
redis: {
|
redis: {
|
||||||
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([]));
|
|
||||||
});
|
|
||||||
|
|||||||