14 Commits

Author SHA1 Message Date
ramvignesh-b fcaaa1d03c docs: add container registry instructions to README
CI / build (push) Successful in 24s
Publish Docker Image / Publish Docker Image (push) Failing after 20s
2026-05-21 14:07:49 +05:30
ramvignesh-b 77e992e14d feat: add GitHub Actions workflow to publish Docker image to GHCR
CI / build (push) Successful in 1m42s
Publish Docker Image / Publish Docker Image (push) Failing after 3m3s
2026-05-21 13:52:21 +05:30
ramvignesh-b a5a0616941 fix: define internal APP_PORT in docker-compose
CI / build (push) Successful in 6m25s
2026-05-13 02:01:06 +05:30
ramvignesh-b 75136ee6bd chore: add .worktrees to gitignore 2026-05-13 02:00:21 +05:30
ramvignesh-b 417104605f refactor: fix lint and formatting
CI / build (push) Successful in 23s
2026-05-12 11:06:15 +05:30
ramvignesh-b 0bcf13800e docs: update screenshot
CI / build (push) Failing after 13m24s
2026-05-12 11:02:52 +05:30
ramvignesh-b dbc9328bf5 docs: update README
CI / build (push) Failing after 23s
2026-05-12 10:54:03 +05:30
ramvignesh-b ed23999c50 feat: add tooltips to auth provider config fields 2026-05-12 10:26:01 +05:30
ramvignesh-b 30c9e9e308 style: update accent bar styling for sections 2026-05-12 10:10:30 +05:30
ramvignesh-b 748d308672 style: update button styles for registry
CI / build (push) Failing after 22s
2026-05-12 10:01:35 +05:30
ramvignesh-b 3e33ee4d61 style: update ui styles for expiry
CI / build (push) Failing after 27s
2026-05-12 09:30:26 +05:30
ramvignesh-b 074d48f536 feat: track and display access token expiration timestamps in dashboard and API responses
CI / build (push) Failing after 23s
2026-05-12 09:15:29 +05:30
ramvignesh-b eb11011017 feat: add favicon, manifest, and static asset serving support to auth server
CI / build (push) Successful in 24s
2026-05-12 05:48:06 +05:30
me 04267593fb docs: update api reference with versioning (#6)
CI / build (push) Successful in 24s
Co-authored-by: ramvignesh-b <ramvignesh-b@github.com>
Reviewed-on: #6
2026-05-12 00:17:08 +00:00
17 changed files with 315 additions and 66 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 286 KiB

+53
View File
@@ -0,0 +1,53 @@
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
+3
View File
@@ -19,3 +19,6 @@ docs/
# OS
.DS_Store
Thumbs.db
# Worktrees
.worktrees/
+93 -41
View File
@@ -1,82 +1,134 @@
# toknd — Auth Broker
# toknd — The Minimal Token Broker
![Dashboard Screenshot](.docs/screenshot.png)
**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.
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.
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
- Centralized management for multiple OAuth2 providers (Google, Trakt, GitHub, etc.).
- Automatic token refreshes.
- Secure and isolated API access via API key authentication.
- Web-based dashboard for configuration management.
- Docker Compose support for simplified deployment.
- High performance and low-latency powered by Bun and Redis.
- **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.
- **Web Dashboard:** Built-in clean ad modern UI for managing provider configurations and viewing live token statuses.
- **Blisteringly Fast:** Powered by Bun and Redis for ultra-low latency token retrieval.
## Tech Stack
- **Runtime**: [Bun](https://bun.sh/)
- **Web Framework**: [Hono](https://hono.dev/)
- **Data Store**: Redis
- **Styling**: Tailwind CSS & DaisyUI
- **Schema & Validation**: Zod
- **Data Store**: [Redis](https://redis.io/)
- **Styling**: [Tailwind CSS](https://tailwindcss.com/) & [DaisyUI](https://daisyui.com/)
- **Schema & Validation**: [Zod](https://zod.dev/)
- **Docs**: [Scalar](https://github.com/scalar/scalar)
---
## Getting Started
toknd can be deployed either as a containerized service or self-hosted directly on your hardware.
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.
### 1. Environment Setup
Clone the repository and create your environment file:
Clone the repository and set up your environment:
```bash
git clone https://git.ramvignesh.dev/toknd_auth.git
cd toknd
cp .env.example .env
```
Define a strong `API_KEY` and ensure `REDIS_URL` points to a valid Redis instance.
Open `.env`, define your own strong `API_KEY`, and map the ports (optional)
### 2. Choose Deployment Method
#### Option A: Containerized (Recommended)
This is the easiest way to get up and running, as it bundles the application and a Redis instance together.
#### Option A: Docker Compose (Recommended)
The easiest way to get up and running. This spins up both toknd and a dedicated Redis instance.
- **Development (with Hot-Reload)**:
```bash
podman compose up --build
```
- **Production**:
```bash
docker compose up -d --build
```
You can run toknd by building the container locally or by pulling the pre-built image from the **GitHub Container Registry (GHCR)**.
#### Option B: Self-Hosting (Bare Metal)
Ideal for lightweight deployments or custom environments where you already have Bun and Redis.
##### 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
```
#### Option B: Bare Metal
If you already have Bun and Redis running in your environment.
1. **Install Dependencies**:
```bash
bun install
```
2. **Start the Server**:
- **Development**: `bun run dev` (with hot-reload)
- **Production**: `bun run start`
*Note: Ensure your Redis server is running and accessible via the `REDIS_URL` in your `.env`.*
- **Development**: `bun run dev`
*Note: Make sure your Redis server is running and accessible via the `HOST` and `PORT` in your `.env`.*
---
## API Reference
## Usage & API Reference
toknd provides a built-in **Scalar API Reference** that allows you to explore and test all endpoints directly from your browser.
toknd provides a built-in **Scalar API Reference** so you can explore and test endpoints right from your browser.
- **Interactive UI**: [http://localhost:3000/api](http://localhost:3000/api) (or `/docs`)
- **OpenAPI Spec (JSON)**: [http://localhost:3000/doc](http://localhost:3000/doc)
- **OpenAPI Spec**: [http://localhost:3000/doc](http://localhost:3000/doc)
All protected endpoints require a Bearer token in the `Authorization` header:
`Authorization: Bearer <your_master_api_key>`
### The Golden Rule
All protected endpoints require your master API key in the Authorization header:
```http
Authorization: Bearer <your_master_api_key>
```
### Core Concepts
- **Token Brokerage**: Automated access token retrieval and background refreshes for all configured providers.
- **Provider Management**: Register and manage OAuth2 providers via the Dashboard or the configuration API.
### 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:
**`http://localhost:3000/app`**
## 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**.
*(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
+1
View File
@@ -8,6 +8,7 @@ services:
- REDIS_HOST=redis
- REDIS_PORT=6379
- API_KEY=${API_KEY}
- APP_PORT=3000
depends_on:
- redis
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+19
View File
@@ -0,0 +1,19 @@
{
"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"
}
+4
View File
@@ -40,5 +40,9 @@ export class TokenManager {
);
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`,
new Date(Date.now() + tokens.expiresIn * 1000).toISOString(),
);
}
}
+2
View File
@@ -34,6 +34,8 @@ app.use("*", prettyJSON());
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.route(AUTH_PREFIX, authRoutes);
app.route(`${API_PREFIX}/config`, configRoutes);
+12 -2
View File
@@ -15,6 +15,10 @@ const StatusResponseSchema = z
accessToken: z.string().nullable(),
refreshToken: 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");
@@ -32,6 +36,10 @@ const RefreshResponseSchema = z
accessToken: z.string().nullable(),
refreshToken: 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");
@@ -112,7 +120,8 @@ apiRoutes.openapi(statusRoute, async (c) => {
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`);
status[provider] = { accessToken, refreshToken, lastUpdated };
const expiresAt = await redis.get(`provider:${provider}:expires_at`);
status[provider] = { accessToken, refreshToken, lastUpdated, expiresAt };
}
return c.json(status, 200);
@@ -153,11 +162,12 @@ apiRoutes.openapi(refreshRoute, async (c) => {
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`);
return c.json(
{
success: true,
status: { accessToken, refreshToken, lastUpdated },
status: { accessToken, refreshToken, lastUpdated, expiresAt },
},
200,
);
+88 -17
View File
@@ -16,6 +16,7 @@ export const Layout = (props: { title: string; children: Child; isUnlocked?: boo
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link
@@ -35,6 +36,9 @@ export const Layout = (props: { title: string; children: Child; isUnlocked?: boo
.font-mono {
font-family: "DM Mono", monospace;
}
.text-xxs {
font-size: 10px;
}
`}
</style>
</head>
@@ -80,7 +84,7 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
<i class="ph-duotone ph-book-open text-lg"></i>
<span class="font-bold uppercase tracking-widest text-xs">
API Reference{" "}
<sup class="text-[8px] opacity-50 ml-0.5">
<sup class="text-xxs opacity-50 ml-0.5">
{API_VERSION}.{APP_VERSION}
</sup>
</span>
@@ -146,7 +150,8 @@ 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-body p-6">
<div class="flex items-center gap-2 mb-4">
<div class="w-2 h-6 bg-primary rounded-full"></div>
<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>
@@ -155,7 +160,10 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
<label htmlFor="providerName" class="label py-1">
<span class="label-text flex items-center gap-2">
Provider ID
<span class="tooltip tooltip-top" data-tip="Internal name for this service.">
<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>
@@ -176,7 +184,15 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
<div class="form-control">
<label htmlFor="clientId" class="label py-1">
<span class="label-text">Client ID</span>
<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"
@@ -189,7 +205,15 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
</div>
<div class="form-control" x-data="{ show: false }">
<label htmlFor="clientSecret" class="label py-1">
<span class="label-text">Client Secret</span>
<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
@@ -214,7 +238,15 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
<div class="form-control">
<label htmlFor="authUrl" class="label py-1">
<span class="label-text">Auth URL</span>
<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"
@@ -227,7 +259,15 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
</div>
<div class="form-control">
<label htmlFor="tokenUrl" class="label py-1">
<span class="label-text">Token URL</span>
<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"
@@ -240,7 +280,15 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
</div>
<div class="form-control">
<label htmlFor="redirectUri" class="label py-1">
<span class="label-text">Redirect URI</span>
<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
@@ -266,7 +314,15 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
</div>
<div class="form-control">
<label htmlFor="scope" class="label py-1">
<span class="label-text">Scope</span>
<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"
@@ -295,13 +351,14 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
<div class="card-body p-0">
<div class="p-6 pb-4 flex justify-between items-center bg-base-100">
<div class="flex items-center gap-2">
<div class="w-2 h-6 bg-primary rounded-full"></div>
<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-base"
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>
@@ -350,7 +407,7 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
<button
type="button"
x-on:click="deleteProvider(provider.name)"
class="btn btn-error btn-xs mt-1 opacity-0 group-hover:opacity-100 transition-all duration-300"
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>
@@ -444,13 +501,27 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
</div>
<div class="divider my-3 opacity-10"></div>
<div class="flex justify-between items-center mb-4">
<span class="text-xs font-semibold opacity-30 uppercase">Last Updated</span>
<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 font-medium opacity-60"
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">
@@ -464,7 +535,7 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
<button
type="button"
x-on:click="editProvider(provider)"
class="btn btn-secondary btn-sm"
class="btn btn-neutral btn-sm"
>
<i class="ph-bold ph-pencil-simple"></i> Edit
</button>
@@ -472,7 +543,7 @@ export const Dashboard = (props: { isUnlocked: boolean }) => (
<button
type="button"
x-on:click="forceRefresh(provider.name)"
class="btn btn-base w-full"
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>
+36 -2
View File
@@ -1,4 +1,12 @@
document.addEventListener("alpine:init", () => {
const formatDate = (date) => {
return date.toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
});
};
const formatTime = (timestamp) => {
if (!timestamp) return "Never";
const date = new Date(timestamp);
@@ -7,7 +15,19 @@ document.addEventListener("alpine:init", () => {
if (diff < 60) return "Just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return date.toLocaleDateString();
return formatDate(date);
};
const formatExpiry = (timestamp) => {
if (!timestamp) return "Expired";
const date = new Date(timestamp);
const diff = Math.floor((date - Date.now()) / 1000);
if (diff <= 0) return "Expired";
if (diff < 60) return `${diff}s`;
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${formatDate(date)}`;
};
window.Alpine.data(
@@ -106,7 +126,12 @@ document.addEventListener("alpine:init", () => {
return Object.entries(config).map(([name, cfg]) => ({
name,
config: cfg,
status: status[name] || { accessToken: null, refreshToken: null, lastUpdated: null },
status: status[name] || {
accessToken: null,
refreshToken: null,
lastUpdated: null,
expiresAt: null,
},
}));
},
@@ -234,6 +259,15 @@ document.addEventListener("alpine:init", () => {
formatTime(timestamp) {
return formatTime(timestamp);
},
formatExpiry(timestamp) {
return formatExpiry(timestamp);
},
isExpired(timestamp) {
if (!timestamp) return true;
return new Date(timestamp) < new Date();
},
}),
);
});