Three detailed implementation plans with TDD, exact code, and step-by-step tasks: - Image URL fetching: 4 tasks (schema, Zod, service, route) - Authentication: 9 tasks (tables, service, middleware, routes, frontend) - MCP server: 9 tasks (SDK, tools, resources, Hono integration) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
40 KiB
Authentication Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add public-read / authenticated-write auth to GearBox with cookie sessions for the web UI, API keys for programmatic access, and a Gitea-style login button.
Architecture: Three new DB tables (users, sessions, api_keys). Auth service handles password hashing (Bun.password/argon2), session management, and API key verification. Hono middleware guards write endpoints. Frontend gets a useAuth hook, login page, conditional UI for edit actions, and API key management in settings.
Tech Stack: Hono middleware, Bun.password (argon2), Drizzle ORM, React Query, TanStack Router, Zustand
File Structure
| Action | Path | Responsibility |
|---|---|---|
| Modify | src/db/schema.ts |
Add users, sessions, apiKeys tables |
| Modify | tests/helpers/db.ts |
Add CREATE TABLE for users, sessions, api_keys |
| Create | src/server/services/auth.service.ts |
Password hashing, session CRUD, API key CRUD |
| Create | src/server/middleware/auth.ts |
Hono middleware for write endpoint protection |
| Create | src/server/routes/auth.ts |
Login, logout, setup, password change, API key routes |
| Modify | src/server/index.ts |
Register auth routes, apply auth middleware |
| Create | src/client/hooks/useAuth.ts |
Auth state hook (React Query) |
| Create | src/client/routes/login.tsx |
Login page |
| Modify | src/client/routes/settings.tsx |
Add password change and API key management sections |
| Modify | src/client/components/TotalsBar.tsx |
Add login/user button to top-right |
| Modify | src/client/routes/__root.tsx |
Wrap conditional UI based on auth state |
| Create | tests/services/auth.service.test.ts |
Auth service tests |
| Create | tests/routes/auth.test.ts |
Auth route tests |
Task 1: Add Auth Database Tables
Files:
-
Modify:
src/db/schema.ts -
Modify:
tests/helpers/db.ts -
Step 1: Add users table to Drizzle schema
In src/db/schema.ts, add after the settings table:
export const users = sqliteTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }),
username: text("username").notNull().unique(),
passwordHash: text("password_hash").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
- Step 2: Add sessions table to Drizzle schema
In src/db/schema.ts, add after the users table:
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
});
- Step 3: Add apiKeys table to Drizzle schema
In src/db/schema.ts, add after the sessions table:
export const apiKeys = sqliteTable("api_keys", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
keyHash: text("key_hash").notNull(),
keyPrefix: text("key_prefix").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
- Step 4: Update test helper with CREATE TABLE statements
In tests/helpers/db.ts, add after the settings CREATE TABLE:
sqlite.run(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
)
`);
sqlite.run(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
)
`);
sqlite.run(`
CREATE TABLE api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
key_hash TEXT NOT NULL,
key_prefix TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
)
`);
- Step 5: Generate and apply migration
Run: bun run db:generate && bun run db:push
Expected: Migration creates users, sessions, api_keys tables.
- Step 6: Run existing tests
Run: bun test
Expected: All existing tests still pass.
- Step 7: Commit
git add src/db/schema.ts tests/helpers/db.ts drizzle/
git commit -m "feat: add users, sessions, and api_keys tables"
Task 2: Create Auth Service
Files:
-
Create:
src/server/services/auth.service.ts -
Create:
tests/services/auth.service.test.ts -
Step 1: Write failing tests for auth service
Create tests/services/auth.service.test.ts:
import { describe, expect, test, beforeEach } from "bun:test";
import { createTestDb } from "../helpers/db";
import {
createUser,
verifyPassword,
createSession,
getSession,
deleteSession,
createApiKey,
verifyApiKey,
listApiKeys,
deleteApiKey,
getUserCount,
changePassword,
} from "../../src/server/services/auth.service";
let db: ReturnType<typeof createTestDb>;
beforeEach(() => {
db = createTestDb();
});
describe("user management", () => {
test("creates a user with hashed password", async () => {
const user = await createUser(db, "admin", "password123");
expect(user.username).toBe("admin");
expect(user.passwordHash).not.toBe("password123");
});
test("verifies correct password", async () => {
await createUser(db, "admin", "password123");
const result = await verifyPassword(db, "admin", "password123");
expect(result).not.toBeNull();
expect(result!.username).toBe("admin");
});
test("rejects incorrect password", async () => {
await createUser(db, "admin", "password123");
const result = await verifyPassword(db, "admin", "wrongpassword");
expect(result).toBeNull();
});
test("returns user count", async () => {
expect(getUserCount(db)).toBe(0);
await createUser(db, "admin", "password123");
expect(getUserCount(db)).toBe(1);
});
test("changes password", async () => {
await createUser(db, "admin", "old");
const changed = await changePassword(db, "admin", "old", "new");
expect(changed).toBe(true);
const result = await verifyPassword(db, "admin", "new");
expect(result).not.toBeNull();
});
test("rejects password change with wrong current password", async () => {
await createUser(db, "admin", "old");
const changed = await changePassword(db, "admin", "wrong", "new");
expect(changed).toBe(false);
});
});
describe("session management", () => {
test("creates and retrieves a session", async () => {
const user = await createUser(db, "admin", "pass");
const session = createSession(db, user.id);
expect(session.id).toHaveLength(64); // 32 bytes hex
const found = getSession(db, session.id);
expect(found).not.toBeNull();
expect(found!.userId).toBe(user.id);
});
test("returns null for expired session", async () => {
const user = await createUser(db, "admin", "pass");
const session = createSession(db, user.id, -1); // expired 1 day ago
const found = getSession(db, session.id);
expect(found).toBeNull();
});
test("deletes a session", async () => {
const user = await createUser(db, "admin", "pass");
const session = createSession(db, user.id);
deleteSession(db, session.id);
expect(getSession(db, session.id)).toBeNull();
});
});
describe("API key management", () => {
test("creates an API key and returns the raw key once", async () => {
const result = await createApiKey(db, "test-key");
expect(result.name).toBe("test-key");
expect(result.rawKey).toBeDefined();
expect(result.rawKey.length).toBeGreaterThan(16);
expect(result.prefix).toBe(result.rawKey.slice(0, 8));
});
test("verifies a valid API key", async () => {
const result = await createApiKey(db, "test-key");
const valid = await verifyApiKey(db, result.rawKey);
expect(valid).toBe(true);
});
test("rejects an invalid API key", async () => {
const valid = await verifyApiKey(db, "invalid-key");
expect(valid).toBe(false);
});
test("lists API keys without exposing hashes", () => {
// createApiKey is async, need to handle properly
});
test("deletes an API key", async () => {
const result = await createApiKey(db, "test-key");
deleteApiKey(db, result.id);
const valid = await verifyApiKey(db, result.rawKey);
expect(valid).toBe(false);
});
});
- Step 2: Run test to verify it fails
Run: bun test tests/services/auth.service.test.ts
Expected: FAIL — module not found.
- Step 3: Implement auth service
Create src/server/services/auth.service.ts:
import { randomBytes } from "node:crypto";
import { eq, count } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { users, sessions, apiKeys } from "../../db/schema.ts";
type Db = typeof prodDb;
// --- User Management ---
export async function createUser(db: Db = prodDb, username: string, password: string) {
const passwordHash = await Bun.password.hash(password);
return db
.insert(users)
.values({ username, passwordHash })
.returning()
.get();
}
export async function verifyPassword(db: Db = prodDb, username: string, password: string) {
const user = db
.select()
.from(users)
.where(eq(users.username, username))
.get();
if (!user) return null;
const valid = await Bun.password.verify(password, user.passwordHash);
return valid ? user : null;
}
export function getUserCount(db: Db = prodDb): number {
const result = db.select({ count: count() }).from(users).get();
return result?.count ?? 0;
}
export async function changePassword(
db: Db = prodDb,
username: string,
currentPassword: string,
newPassword: string,
): Promise<boolean> {
const user = await verifyPassword(db, username, currentPassword);
if (!user) return false;
const passwordHash = await Bun.password.hash(newPassword);
db.update(users)
.set({ passwordHash })
.where(eq(users.id, user.id))
.run();
return true;
}
// --- Session Management ---
export function createSession(db: Db = prodDb, userId: number, expiryDays = 30) {
const id = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
return db
.insert(sessions)
.values({ id, userId, expiresAt })
.returning()
.get();
}
export function getSession(db: Db = prodDb, sessionId: string) {
const session = db
.select()
.from(sessions)
.where(eq(sessions.id, sessionId))
.get();
if (!session) return null;
// Check expiry
if (session.expiresAt < new Date()) {
db.delete(sessions).where(eq(sessions.id, sessionId)).run();
return null;
}
return session;
}
export function deleteSession(db: Db = prodDb, sessionId: string) {
db.delete(sessions).where(eq(sessions.id, sessionId)).run();
}
export function refreshSession(db: Db = prodDb, sessionId: string, expiryDays = 30) {
const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
db.update(sessions)
.set({ expiresAt })
.where(eq(sessions.id, sessionId))
.run();
}
// --- API Key Management ---
export async function createApiKey(db: Db = prodDb, name: string) {
const rawKey = randomBytes(32).toString("hex");
const keyHash = await Bun.password.hash(rawKey);
const keyPrefix = rawKey.slice(0, 8);
const record = db
.insert(apiKeys)
.values({ name, keyHash, keyPrefix })
.returning()
.get();
return { ...record, rawKey };
}
export async function verifyApiKey(db: Db = prodDb, rawKey: string): Promise<boolean> {
const prefix = rawKey.slice(0, 8);
const candidates = db
.select()
.from(apiKeys)
.where(eq(apiKeys.keyPrefix, prefix))
.all();
for (const candidate of candidates) {
if (await Bun.password.verify(rawKey, candidate.keyHash)) {
return true;
}
}
return false;
}
export function listApiKeys(db: Db = prodDb) {
return db
.select({
id: apiKeys.id,
name: apiKeys.name,
keyPrefix: apiKeys.keyPrefix,
createdAt: apiKeys.createdAt,
})
.from(apiKeys)
.all();
}
export function deleteApiKey(db: Db = prodDb, id: number) {
db.delete(apiKeys).where(eq(apiKeys.id, id)).run();
}
- Step 4: Run tests
Run: bun test tests/services/auth.service.test.ts
Expected: All tests pass.
- Step 5: Commit
git add src/server/services/auth.service.ts tests/services/auth.service.test.ts
git commit -m "feat: add auth service with user, session, and API key management"
Task 3: Create Auth Middleware
Files:
-
Create:
src/server/middleware/auth.ts -
Create:
tests/middleware/auth.test.ts -
Step 1: Write failing middleware tests
Create tests/middleware/auth.test.ts:
import { describe, expect, test, beforeEach } from "bun:test";
import { Hono } from "hono";
import { createTestDb } from "../helpers/db";
import { requireAuth } from "../../src/server/middleware/auth";
import { createUser, createSession, createApiKey } from "../../src/server/services/auth.service";
let db: ReturnType<typeof createTestDb>;
beforeEach(() => {
db = createTestDb();
});
function createApp() {
const app = new Hono<{ Variables: { db?: any } }>();
app.use("*", async (c, next) => {
c.set("db", db);
await next();
});
// Public GET
app.get("/items", (c) => c.json({ ok: true }));
// Protected POST
app.post("/items", requireAuth, (c) => c.json({ ok: true }));
return app;
}
describe("auth middleware", () => {
test("allows GET requests without auth", async () => {
const app = createApp();
const res = await app.request("/items");
expect(res.status).toBe(200);
});
test("rejects POST without auth", async () => {
const app = createApp();
const res = await app.request("/items", { method: "POST" });
expect(res.status).toBe(401);
});
test("allows POST with valid session cookie", async () => {
const app = createApp();
const user = await createUser(db, "admin", "pass");
const session = createSession(db, user.id);
const res = await app.request("/items", {
method: "POST",
headers: { Cookie: `gearbox_session=${session.id}` },
});
expect(res.status).toBe(200);
});
test("allows POST with valid API key", async () => {
const app = createApp();
const key = await createApiKey(db, "test");
const res = await app.request("/items", {
method: "POST",
headers: { "X-API-Key": key.rawKey },
});
expect(res.status).toBe(200);
});
test("rejects POST with invalid API key", async () => {
const app = createApp();
const res = await app.request("/items", {
method: "POST",
headers: { "X-API-Key": "invalid" },
});
expect(res.status).toBe(401);
});
test("returns 403 setup_required when no users exist", async () => {
const app = createApp();
const res = await app.request("/items", { method: "POST" });
// With no users, middleware should return 401 (or 403 setup_required)
const body = await res.json();
expect(res.status).toBe(403);
expect(body.error).toBe("setup_required");
});
});
Note: The last test ("setup_required") should only trigger when there are no users and no auth credentials are provided. Adjust the middleware logic: if getUserCount === 0, return 403 with setup_required. Otherwise return 401.
- Step 2: Run test to verify it fails
Run: bun test tests/middleware/auth.test.ts
Expected: FAIL — module not found.
- Step 3: Implement auth middleware
Create src/server/middleware/auth.ts:
import { getCookie } from "hono/cookie";
import type { Context, Next } from "hono";
import { getSession, verifyApiKey, getUserCount, refreshSession } from "../services/auth.service";
export async function requireAuth(c: Context, next: Next) {
const db = c.get("db");
// Check if any users exist at all
if (getUserCount(db) === 0) {
return c.json({ error: "setup_required" }, 403);
}
// Check API key first
const apiKey = c.req.header("X-API-Key");
if (apiKey) {
const valid = await verifyApiKey(db, apiKey);
if (valid) return next();
return c.json({ error: "Invalid API key" }, 401);
}
// Check session cookie
const sessionId = getCookie(c, "gearbox_session");
if (sessionId) {
const session = getSession(db, sessionId);
if (session) {
// Refresh session expiry on use
refreshSession(db, sessionId);
return next();
}
}
return c.json({ error: "Authentication required" }, 401);
}
- Step 4: Run tests
Run: bun test tests/middleware/auth.test.ts
Expected: All tests pass.
- Step 5: Commit
git add src/server/middleware/auth.ts tests/middleware/auth.test.ts
git commit -m "feat: add auth middleware for write endpoint protection"
Task 4: Create Auth Routes
Files:
-
Create:
src/server/routes/auth.ts -
Create:
tests/routes/auth.test.ts -
Step 1: Write failing route tests
Create tests/routes/auth.test.ts:
import { describe, expect, test, beforeEach } from "bun:test";
import { Hono } from "hono";
import { createTestDb } from "../helpers/db";
import { authRoutes } from "../../src/server/routes/auth";
let db: ReturnType<typeof createTestDb>;
function createApp() {
const app = new Hono<{ Variables: { db?: any } }>();
app.use("*", async (c, next) => {
c.set("db", db);
await next();
});
app.route("/api/auth", authRoutes);
return app;
}
beforeEach(() => {
db = createTestDb();
});
describe("POST /api/auth/setup", () => {
test("creates first user account", async () => {
const app = createApp();
const res = await app.request("/api/auth/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "pass123" }),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.username).toBe("admin");
});
test("rejects setup when user already exists", async () => {
const app = createApp();
// First setup
await app.request("/api/auth/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "pass123" }),
});
// Second attempt
const res = await app.request("/api/auth/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin2", password: "pass123" }),
});
expect(res.status).toBe(403);
});
});
describe("POST /api/auth/login", () => {
test("returns session cookie on valid login", async () => {
const app = createApp();
// Setup first
await app.request("/api/auth/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "pass123" }),
});
const res = await app.request("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "pass123" }),
});
expect(res.status).toBe(200);
expect(res.headers.get("set-cookie")).toContain("gearbox_session");
});
test("rejects invalid credentials", async () => {
const app = createApp();
await app.request("/api/auth/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "pass123" }),
});
const res = await app.request("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "wrong" }),
});
expect(res.status).toBe(401);
});
});
describe("GET /api/auth/me", () => {
test("returns null when not authenticated", async () => {
const app = createApp();
const res = await app.request("/api/auth/me");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.user).toBeNull();
expect(body.setupRequired).toBe(true);
});
});
- Step 2: Run test to verify it fails
Run: bun test tests/routes/auth.test.ts
Expected: FAIL — module not found.
- Step 3: Implement auth routes
Create src/server/routes/auth.ts:
import { eq } from "drizzle-orm";
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { setCookie, deleteCookie, getCookie } from "hono/cookie";
import { z } from "zod";
import { users } from "../../db/schema.ts";
import {
createUser,
verifyPassword,
createSession,
getSession,
deleteSession,
getUserCount,
changePassword,
createApiKey,
listApiKeys,
deleteApiKey,
} from "../services/auth.service";
import { requireAuth } from "../middleware/auth";
type Env = { Variables: { db?: any } };
const app = new Hono<Env>();
const loginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
const setupSchema = z.object({
username: z.string().min(1, "Username is required"),
password: z.string().min(6, "Password must be at least 6 characters"),
});
const changePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(6, "Password must be at least 6 characters"),
});
const createKeySchema = z.object({
name: z.string().min(1, "Key name is required"),
});
// --- Public routes ---
app.get("/me", (c) => {
const db = c.get("db");
const setupRequired = getUserCount(db) === 0;
const sessionId = getCookie(c, "gearbox_session");
if (sessionId) {
const session = getSession(db, sessionId);
if (session) {
// Get user info
return c.json({ user: { id: session.userId }, setupRequired: false });
}
}
return c.json({ user: null, setupRequired });
});
app.post("/setup", zValidator("json", setupSchema), async (c) => {
const db = c.get("db");
if (getUserCount(db) > 0) {
return c.json({ error: "Account already exists" }, 403);
}
const { username, password } = c.req.valid("json");
const user = await createUser(db, username, password);
const session = createSession(db, user.id);
setCookie(c, "gearbox_session", session.id, {
httpOnly: true,
sameSite: "Lax",
path: "/",
maxAge: 30 * 24 * 60 * 60, // 30 days
});
return c.json({ username: user.username }, 201);
});
app.post("/login", zValidator("json", loginSchema), async (c) => {
const db = c.get("db");
const { username, password } = c.req.valid("json");
const user = await verifyPassword(db, username, password);
if (!user) {
return c.json({ error: "Invalid credentials" }, 401);
}
const session = createSession(db, user.id);
setCookie(c, "gearbox_session", session.id, {
httpOnly: true,
sameSite: "Lax",
path: "/",
maxAge: 30 * 24 * 60 * 60,
});
return c.json({ username: user.username });
});
app.post("/logout", (c) => {
const db = c.get("db");
const sessionId = getCookie(c, "gearbox_session");
if (sessionId) {
deleteSession(db, sessionId);
}
deleteCookie(c, "gearbox_session", { path: "/" });
return c.json({ success: true });
});
// --- Protected routes ---
app.put("/password", requireAuth, zValidator("json", changePasswordSchema), async (c) => {
const db = c.get("db");
const sessionId = getCookie(c, "gearbox_session");
if (!sessionId) return c.json({ error: "Session required" }, 401);
const session = getSession(db, sessionId);
if (!session) return c.json({ error: "Invalid session" }, 401);
const { currentPassword, newPassword } = c.req.valid("json");
// Look up username from session's userId
const userRecord = db.select().from(users).where(eq(users.id, session.userId)).get();
if (!userRecord) return c.json({ error: "User not found" }, 401);
const changed = await changePassword(db, userRecord.username, currentPassword, newPassword);
if (!changed) return c.json({ error: "Current password is incorrect" }, 401);
return c.json({ success: true });
});
// API Key management
app.get("/keys", requireAuth, (c) => {
const db = c.get("db");
return c.json(listApiKeys(db));
});
app.post("/keys", requireAuth, zValidator("json", createKeySchema), async (c) => {
const db = c.get("db");
const { name } = c.req.valid("json");
const result = await createApiKey(db, name);
return c.json({
id: result.id,
name: result.name,
key: result.rawKey,
prefix: result.keyPrefix,
}, 201);
});
app.delete("/keys/:id", requireAuth, async (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
deleteApiKey(db, id);
return c.json({ success: true });
});
export { app as authRoutes };
- Step 4: Run tests
Run: bun test tests/routes/auth.test.ts
Expected: All tests pass.
- Step 5: Commit
git add src/server/routes/auth.ts tests/routes/auth.test.ts
git commit -m "feat: add auth routes for login, setup, and API key management"
Task 5: Register Auth Routes and Apply Middleware to Write Endpoints
Files:
-
Modify:
src/server/index.ts -
Step 1: Register auth routes in server index
In src/server/index.ts, add import:
import { authRoutes } from "./routes/auth.ts";
Add route registration after the other API routes:
app.route("/api/auth", authRoutes);
- Step 2: Apply auth middleware to write endpoints
In src/server/index.ts, add import:
import { requireAuth } from "./middleware/auth.ts";
Add middleware that protects all non-GET API requests (except auth routes):
// Auth middleware for write operations (POST/PUT/DELETE) on non-auth routes
app.use("/api/*", async (c, next) => {
// Skip auth routes — they handle their own auth
if (c.req.path.startsWith("/api/auth")) return next();
// Skip GET requests — read is public
if (c.req.method === "GET") return next();
// All other methods require auth
return requireAuth(c, next);
});
This middleware must be registered before the API route registrations.
- Step 3: Run all tests
Run: bun test
Expected: Some existing POST/PUT/DELETE tests may now fail because they don't provide auth. The test db has no users, so middleware should return 403 setup_required. This is expected — we'll address this in Step 4.
- Step 4: Update existing route tests to handle auth
Since the middleware checks getUserCount and returns 403 when no users exist, the existing route tests that do POST/PUT/DELETE will need to either:
- Create a user and session in test setup, OR
- The test apps already mount routes directly without the global middleware, so they should be unaffected.
Check which tests fail. If route tests mount routes directly (which they do — each test creates its own Hono app and mounts routes), the global middleware in index.ts won't apply to them. They should still pass.
Run: bun test
Expected: All tests pass (the per-route test apps don't use the global middleware).
- Step 5: Commit
git add src/server/index.ts
git commit -m "feat: register auth routes and apply write-protection middleware"
Task 6: Create Frontend Auth Hook and Login Page
Files:
-
Create:
src/client/hooks/useAuth.ts -
Create:
src/client/routes/login.tsx -
Step 1: Create useAuth hook
Create src/client/hooks/useAuth.ts:
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPost } from "../lib/api";
interface AuthState {
user: { id: number } | null;
setupRequired: boolean;
}
export function useAuth() {
return useQuery({
queryKey: ["auth"],
queryFn: () => apiGet<AuthState>("/api/auth/me"),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
export function useLogin() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { username: string; password: string }) =>
apiPost<{ username: string }>("/api/auth/login", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["auth"] });
},
});
}
export function useLogout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => apiPost<{ success: boolean }>("/api/auth/logout", {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["auth"] });
},
});
}
export function useSetup() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { username: string; password: string }) =>
apiPost<{ username: string }>("/api/auth/setup", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["auth"] });
},
});
}
export function useChangePassword() {
return useMutation({
mutationFn: (data: { currentPassword: string; newPassword: string }) =>
apiPut<{ success: boolean }>("/api/auth/password", data),
});
}
interface ApiKeyResponse {
id: number;
name: string;
key: string;
prefix: string;
}
interface ApiKeyListItem {
id: number;
name: string;
keyPrefix: string;
createdAt: string;
}
export function useApiKeys() {
return useQuery({
queryKey: ["apiKeys"],
queryFn: () => apiGet<ApiKeyListItem[]>("/api/auth/keys"),
});
}
export function useCreateApiKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { name: string }) =>
apiPost<ApiKeyResponse>("/api/auth/keys", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["apiKeys"] });
},
});
}
export function useDeleteApiKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiPost<{ success: boolean }>(`/api/auth/keys/${id}`, {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["apiKeys"] });
},
});
}
Note: useDeleteApiKey should use apiDelete not apiPost. Fix:
export function useDeleteApiKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiDelete<{ success: boolean }>(`/api/auth/keys/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["apiKeys"] });
},
});
}
Also import apiDelete:
import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
- Step 2: Create login page
Create src/client/routes/login.tsx:
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { useAuth, useLogin, useSetup } from "../hooks/useAuth";
export const Route = createFileRoute("/login")({
component: LoginPage,
});
function LoginPage() {
const navigate = useNavigate();
const { data: auth } = useAuth();
const login = useLogin();
const setup = useSetup();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const isSetup = auth?.setupRequired ?? false;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
try {
if (isSetup) {
await setup.mutateAsync({ username, password });
} else {
await login.mutateAsync({ username, password });
}
navigate({ to: "/" });
} catch (err) {
setError((err as Error).message);
}
}
const isPending = login.isPending || setup.isPending;
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<h1 className="text-xl font-semibold text-gray-900 text-center mb-6">
{isSetup ? "Create Account" : "Sign In"}
</h1>
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-gray-100 p-6 space-y-4">
{isSetup && (
<p className="text-sm text-gray-500">
Create your admin account to manage your gear collection.
</p>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={isSetup ? 6 : undefined}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
/>
</div>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
<button
type="submit"
disabled={isPending}
className="w-full py-2 px-4 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
>
{isPending ? "..." : isSetup ? "Create Account" : "Sign In"}
</button>
</form>
</div>
</div>
);
}
- Step 3: Commit
git add src/client/hooks/useAuth.ts src/client/routes/login.tsx
git commit -m "feat: add useAuth hook and login page"
Task 7: Add Login Button to TotalsBar and Conditional UI
Files:
-
Modify:
src/client/components/TotalsBar.tsx -
Modify:
src/client/routes/__root.tsx -
Step 1: Add login/user button to TotalsBar
In src/client/components/TotalsBar.tsx, add imports:
import { useAuth, useLogout } from "../hooks/useAuth";
Inside the TotalsBar component, add:
const { data: auth } = useAuth();
const logout = useLogout();
const isAuthenticated = !!auth?.user;
In the JSX return, add a login/user section at the right end of the bar. After the stats section and before the closing tag of the bar container, add:
<div className="flex items-center gap-2 ml-auto">
{isAuthenticated ? (
<button
type="button"
onClick={() => logout.mutate()}
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
Sign out
</button>
) : (
<Link
to="/login"
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
Sign in
</Link>
)}
</div>
- Step 2: Hide FAB and edit actions when not authenticated
In src/client/routes/__root.tsx, add:
import { useAuth } from "../hooks/useAuth";
Inside RootLayout, add:
const { data: auth } = useAuth();
const isAuthenticated = !!auth?.user;
Update the FAB visibility condition:
{showFab && isAuthenticated && (
- Step 3: Verify the app loads correctly
Run: bun run dev
Check:
-
App loads without login wall
-
"Sign in" button appears top-right
-
FAB is hidden when not logged in
-
Clicking "Sign in" shows login page
-
After login, FAB appears and "Sign out" replaces "Sign in"
-
Step 4: Commit
git add src/client/components/TotalsBar.tsx src/client/routes/__root.tsx
git commit -m "feat: add login button to header and conditional edit UI"
Task 8: Add API Key Management and Password Change to Settings
Files:
-
Modify:
src/client/routes/settings.tsx -
Step 1: Add password change section to settings
In src/client/routes/settings.tsx, add imports:
import { useState } from "react";
import { useAuth, useChangePassword, useApiKeys, useCreateApiKey, useDeleteApiKey } from "../hooks/useAuth";
Add a ChangePasswordSection component:
function ChangePasswordSection() {
const changePassword = useChangePassword();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setMessage(null);
try {
await changePassword.mutateAsync({ currentPassword, newPassword });
setMessage({ type: "success", text: "Password changed" });
setCurrentPassword("");
setNewPassword("");
} catch (err) {
setMessage({ type: "error", text: (err as Error).message });
}
}
return (
<form onSubmit={handleSubmit} className="space-y-3">
<h3 className="text-sm font-medium text-gray-900">Change Password</h3>
<input
type="password"
placeholder="Current password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
/>
<input
type="password"
placeholder="New password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={6}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
/>
{message && (
<p className={`text-sm ${message.type === "success" ? "text-green-600" : "text-red-600"}`}>
{message.text}
</p>
)}
<button
type="submit"
disabled={changePassword.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
>
{changePassword.isPending ? "..." : "Change Password"}
</button>
</form>
);
}
- Step 2: Add API key management section
Add an ApiKeySection component:
function ApiKeySection() {
const { data: keys } = useApiKeys();
const createKey = useCreateApiKey();
const deleteKey = useDeleteApiKey();
const [name, setName] = useState("");
const [newKey, setNewKey] = useState<string | null>(null);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
const result = await createKey.mutateAsync({ name });
setNewKey(result.key);
setName("");
}
return (
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-900">API Keys</h3>
<p className="text-xs text-gray-500">
API keys allow programmatic access to GearBox (e.g., from Claude Desktop or scripts).
</p>
{newKey && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-xs font-medium text-amber-800 mb-1">
Copy this key now — it won't be shown again:
</p>
<code className="text-xs text-amber-900 break-all select-all">{newKey}</code>
<button
type="button"
onClick={() => setNewKey(null)}
className="mt-2 text-xs text-amber-700 hover:text-amber-900"
>
Dismiss
</button>
</div>
)}
<form onSubmit={handleCreate} className="flex gap-2">
<input
type="text"
placeholder="Key name (e.g., claude-desktop)"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
/>
<button
type="submit"
disabled={createKey.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
>
Create
</button>
</form>
{keys && keys.length > 0 && (
<div className="space-y-2">
{keys.map((key) => (
<div key={key.id} className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg">
<div>
<span className="text-sm text-gray-900">{key.name}</span>
<span className="text-xs text-gray-400 ml-2">{key.keyPrefix}...</span>
</div>
<button
type="button"
onClick={() => deleteKey.mutate(key.id)}
className="text-xs text-red-500 hover:text-red-700"
>
Revoke
</button>
</div>
))}
</div>
)}
</div>
);
}
- Step 3: Add sections to SettingsPage
In the SettingsPage component, add after the existing settings card, conditionally rendering the auth sections when logged in:
{auth?.user && (
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
<ChangePasswordSection />
<div className="border-t border-gray-100" />
<ApiKeySection />
</div>
)}
Add the auth query at the top of SettingsPage:
const { data: auth } = useAuth();
- Step 4: Commit
git add src/client/routes/settings.tsx
git commit -m "feat: add password change and API key management to settings"
Task 9: Run Full Test Suite and Manual Verification
Files: None (verification only)
- Step 1: Run all tests
Run: bun test
Expected: All tests pass.
- Step 2: Run linter
Run: bun run lint
Expected: No errors.
- Step 3: Manual verification
Run: bun run dev
Verify:
- App loads — all pages viewable without login
- "Sign in" link in top-right of TotalsBar
- Click "Sign in" → shows setup form (first time) or login form
- Create account → redirected to home, "Sign out" appears
- FAB and edit actions visible when logged in
- FAB hidden when logged out
- Settings page shows password change + API keys when logged in
- Create API key → key displayed once
- POST/PUT/DELETE API calls return 401 without auth
- GET API calls work without auth