diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts
index 90d94ca..55496d6 100644
--- a/src/middleware/auth.ts
+++ b/src/middleware/auth.ts
@@ -1,14 +1,22 @@
import type { Context, Next } from "hono";
+import { getCookie } from "hono/cookie";
import { config } from "../config";
export const authMiddleware = async (c: Context, next: Next) => {
const authHeader = c.req.header("Authorization");
+ const cookieToken = getCookie(c, "toknd_api_key");
- if (!authHeader?.startsWith("Bearer ")) {
- return c.json({ error: "Missing or invalid authorization header" }, 401);
+ let token: string | undefined;
+
+ if (authHeader?.startsWith("Bearer ")) {
+ token = authHeader.split(" ")[1];
+ } else if (cookieToken) {
+ token = cookieToken;
}
- const token = authHeader.split(" ")[1];
+ if (!token) {
+ return c.json({ error: "Missing or invalid authorization" }, 401);
+ }
if (token !== config.API_KEY) {
return c.json({ error: "Invalid API key" }, 403);
diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx
index 8bf833a..aa5bca3 100644
--- a/src/routes/dashboard.tsx
+++ b/src/routes/dashboard.tsx
@@ -1,11 +1,13 @@
/** @jsxImportSource hono/jsx */
import { Hono } from "hono";
+import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import { html } from "hono/html";
import type { Child } from "hono/jsx";
+import { config } from "../config";
const dashboardRoutes = new Hono({ strict: false });
-export const Layout = (props: { title: string; children: Child }) => (
+export const Layout = (props: { title: string; children: Child; isUnlocked?: boolean }) => (
<>
{html``}
@@ -37,7 +39,7 @@ export const Layout = (props: { title: string; children: Child }) => (
{props.children}
@@ -47,8 +49,8 @@ export const Layout = (props: { title: string; children: Child }) => (
>
);
-export const Dashboard = () => (
-
+export const Dashboard = (props: { isUnlocked: boolean }) => (
+
@@ -93,6 +95,14 @@ export const Dashboard = () => (
+
@@ -419,7 +429,7 @@ export const Dashboard = () => (
type="button"
x-on:click="forceRefresh(provider.name)"
class="btn btn-base w-full"
- x-bind:disabled="loading"
+ x-bind:disabled="loading || !provider.status.accessToken"
>
@@ -484,7 +494,28 @@ export const Success = (props: { provider: string }) => (
);
dashboardRoutes.get("/", async (c) => {
- return c.html();
+ const isUnlocked = getCookie(c, "toknd_api_key") === config.API_KEY;
+ return c.html();
+});
+
+dashboardRoutes.post("/unlock", async (c) => {
+ const { apiKey } = await c.req.json();
+ if (apiKey !== config.API_KEY) {
+ return c.json({ error: "Invalid API Key" }, 401);
+ }
+ setCookie(c, "toknd_api_key", apiKey, {
+ httpOnly: true,
+ secure: true,
+ sameSite: "Strict",
+ path: "/",
+ maxAge: 60 * 60 * 24 * 7, // 1 week
+ });
+ return c.json({ success: true });
+});
+
+dashboardRoutes.post("/logout", async (c) => {
+ deleteCookie(c, "toknd_api_key", { path: "/" });
+ return c.json({ success: true });
});
dashboardRoutes.get("/success", async (c) => {
diff --git a/src/views/dashboard.js b/src/views/dashboard.js
index 17bd765..7a3d7fe 100644
--- a/src/views/dashboard.js
+++ b/src/views/dashboard.js
@@ -10,9 +10,9 @@ document.addEventListener("alpine:init", () => {
return date.toLocaleDateString();
};
- window.Alpine.data("dashboard", () => ({
- apiKey: localStorage.getItem("toknd_api_key") || "",
- isUnlocked: false,
+ window.Alpine.data("dashboard", ({ initialIsUnlocked }) => ({
+ apiKey: "",
+ isUnlocked: initialIsUnlocked,
loading: false,
providers: [],
form: {
@@ -30,8 +30,8 @@ document.addEventListener("alpine:init", () => {
},
init() {
- if (this.apiKey) {
- this.unlock();
+ if (this.isUnlocked) {
+ this.fetchProviders();
}
},
@@ -39,14 +39,34 @@ document.addEventListener("alpine:init", () => {
if (!this.apiKey) return;
this.loading = true;
try {
- localStorage.setItem("toknd_api_key", this.apiKey);
- await this.fetchProviders();
+ const res = await fetch("/app/unlock", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ apiKey: this.apiKey }),
+ });
+
+ if (!res.ok) throw new Error("Invalid API Key");
+
this.isUnlocked = true;
+ await this.fetchProviders();
+ this.apiKey = ""; // Clear after success
} catch (err) {
- this.showNotification(`Failed to unlock: ${err.message}. Check your API Key`, "error");
- localStorage.removeItem("toknd_api_key");
+ this.showNotification(err.message, "error");
this.isUnlocked = false;
- this.apiKey = "";
+ } 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;
}
@@ -56,41 +76,53 @@ document.addEventListener("alpine:init", () => {
this.loading = true;
try {
const [configRes, statusRes] = await Promise.all([
- fetch("/api/config", { headers: { Authorization: `Bearer ${this.apiKey}` } }),
- fetch("/api/status", { headers: { Authorization: `Bearer ${this.apiKey}` } }),
+ fetch("/api/config"),
+ fetch("/api/status"),
]);
- if (!configRes.ok || !statusRes.ok) throw new Error("Unauthorized");
+ if (configRes.status === 401 || statusRes.status === 401) {
+ return this.handleSessionExpired();
+ }
- const config = await configRes.json();
- const status = await statusRes.json();
+ if (!configRes.ok || !statusRes.ok) throw new Error("Failed to fetch data");
- this.providers = Object.entries(config).map(([name, cfg]) => ({
- name,
- config: cfg,
- status: status[name] || { accessToken: null, refreshToken: null, lastUpdated: null },
- }));
+ const [config, status] = await Promise.all([configRes.json(), statusRes.json()]);
+ this.providers = this.mapProviders(config, status);
} catch (err) {
this.showNotification(err.message, "error");
- throw err;
} finally {
this.loading = false;
}
},
- async saveConfig(event) {
- if (event) event.preventDefault();
+ mapProviders(config, status) {
+ return Object.entries(config).map(([name, cfg]) => ({
+ name,
+ config: cfg,
+ status: status[name] || { accessToken: null, refreshToken: null, lastUpdated: null },
+ }));
+ },
+
+ handleSessionExpired() {
+ this.isUnlocked = false;
+ this.providers = [];
+ this.showNotification("Session expired", "error");
+ },
+
+ async saveConfig() {
this.loading = true;
try {
const res = await fetch(`/api/config/${this.form.providerName}`, {
method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${this.apiKey}`,
- },
+ headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form),
});
+ if (res.status === 401) {
+ this.isUnlocked = false;
+ throw new Error("Session expired");
+ }
+
if (!res.ok) throw new Error("Failed to save");
this.showNotification("Saved successfully");
@@ -116,9 +148,13 @@ document.addEventListener("alpine:init", () => {
try {
const res = await fetch(`/api/refresh/${name}`, {
method: "POST",
- headers: { Authorization: `Bearer ${this.apiKey}` },
});
+ if (res.status === 401) {
+ this.isUnlocked = false;
+ throw new Error("Session expired");
+ }
+
if (!res.ok) throw new Error("Refresh failed");
this.showNotification(`Refreshed ${name}`);
diff --git a/tests/integration/api.test.ts b/tests/integration/api.test.ts
index da11da8..3915b7e 100644
--- a/tests/integration/api.test.ts
+++ b/tests/integration/api.test.ts
@@ -24,7 +24,7 @@ describe("API Integration", () => {
expect(res.status).toBe(401);
const body = await res.json();
- expect(body).toEqual({ error: "Missing or invalid authorization header" });
+ expect(body).toEqual({ error: "Missing or invalid authorization" });
});
it("should return 200 for health check (no auth needed)", async () => {
@@ -67,6 +67,25 @@ describe("API Integration", () => {
expect(body.trakt.accessToken).toBe("current-access-token");
});
+ it("should return 200 for status with valid Cookie", async () => {
+ redis.keys.mockReturnValue(Promise.resolve(["config:trakt"]));
+ redis.get.mockImplementation((key) => {
+ if (key.includes("config")) return Promise.resolve(mockTraktConfig);
+ if (key.includes("access_token")) return Promise.resolve("current-access-token");
+ return Promise.resolve(null);
+ });
+
+ const res = await app.request("/api/status", {
+ headers: {
+ Cookie: "toknd_api_key=test-api-key",
+ },
+ });
+
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.trakt).toBeDefined();
+ });
+
it("should return 404 for unknown provider token", async () => {
const res = await app.request("/api/token/unconfigured-provider", {
headers: {
diff --git a/tests/integration/dashboard.test.ts b/tests/integration/dashboard.test.ts
index 507f952..1af0ed9 100644
--- a/tests/integration/dashboard.test.ts
+++ b/tests/integration/dashboard.test.ts
@@ -34,4 +34,25 @@ describe("Dashboard & Common Integration", () => {
const body = await res.json();
expect(body.error).toBe("Internal Server Error");
});
+
+ it("should set a cookie on successful unlock", async () => {
+ const res = await app.request("/app/unlock", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ apiKey: "test-api-key" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Set-Cookie")).toContain("toknd_api_key=test-api-key");
+ expect(res.headers.get("Set-Cookie")).toContain("HttpOnly");
+ });
+
+ it("should clear the cookie on logout", async () => {
+ const res = await app.request("/app/logout", {
+ method: "POST",
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Set-Cookie")).toContain("toknd_api_key=;");
+ });
});