# 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: ```typescript 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: ```typescript 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: ```typescript 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: ```typescript 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** ```bash 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`: ```typescript 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; 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`: ```typescript 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 { 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 { 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** ```bash 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`: ```typescript 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; 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`: ```typescript 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** ```bash 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`: ```typescript 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; 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`: ```typescript 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(); 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** ```bash 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: ```typescript import { authRoutes } from "./routes/auth.ts"; ``` Add route registration after the other API routes: ```typescript app.route("/api/auth", authRoutes); ``` - [ ] **Step 2: Apply auth middleware to write endpoints** In `src/server/index.ts`, add import: ```typescript import { requireAuth } from "./middleware/auth.ts"; ``` Add middleware that protects all non-GET API requests (except auth routes): ```typescript // 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** ```bash 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`: ```typescript 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("/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("/api/auth/keys"), }); } export function useCreateApiKey() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (data: { name: string }) => apiPost("/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: ```typescript 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`: ```typescript import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api"; ``` - [ ] **Step 2: Create login page** Create `src/client/routes/login.tsx`: ```typescript 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 (

{isSetup ? "Create Account" : "Sign In"}

{isSetup && (

Create your admin account to manage your gear collection.

)}
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" />
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" />
{error && (

{error}

)}
); } ``` - [ ] **Step 3: Commit** ```bash 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: ```typescript import { useAuth, useLogout } from "../hooks/useAuth"; ``` Inside the `TotalsBar` component, add: ```typescript 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: ```tsx
{isAuthenticated ? ( ) : ( Sign in )}
``` - [ ] **Step 2: Hide FAB and edit actions when not authenticated** In `src/client/routes/__root.tsx`, add: ```typescript import { useAuth } from "../hooks/useAuth"; ``` Inside `RootLayout`, add: ```typescript const { data: auth } = useAuth(); const isAuthenticated = !!auth?.user; ``` Update the FAB visibility condition: ```typescript {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** ```bash 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: ```typescript import { useState } from "react"; import { useAuth, useChangePassword, useApiKeys, useCreateApiKey, useDeleteApiKey } from "../hooks/useAuth"; ``` Add a `ChangePasswordSection` component: ```typescript 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 (

Change Password

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" /> 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 && (

{message.text}

)}
); } ``` - [ ] **Step 2: Add API key management section** Add an `ApiKeySection` component: ```typescript function ApiKeySection() { const { data: keys } = useApiKeys(); const createKey = useCreateApiKey(); const deleteKey = useDeleteApiKey(); const [name, setName] = useState(""); const [newKey, setNewKey] = useState(null); async function handleCreate(e: React.FormEvent) { e.preventDefault(); const result = await createKey.mutateAsync({ name }); setNewKey(result.key); setName(""); } return (

API Keys

API keys allow programmatic access to GearBox (e.g., from Claude Desktop or scripts).

{newKey && (

Copy this key now — it won't be shown again:

{newKey}
)}
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" />
{keys && keys.length > 0 && (
{keys.map((key) => (
{key.name} {key.keyPrefix}...
))}
)}
); } ``` - [ ] **Step 3: Add sections to SettingsPage** In the `SettingsPage` component, add after the existing settings card, conditionally rendering the auth sections when logged in: ```tsx {auth?.user && (
)} ``` Add the auth query at the top of `SettingsPage`: ```typescript const { data: auth } = useAuth(); ``` - [ ] **Step 4: Commit** ```bash 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: 1. App loads — all pages viewable without login 2. "Sign in" link in top-right of TotalsBar 3. Click "Sign in" → shows setup form (first time) or login form 4. Create account → redirected to home, "Sign out" appears 5. FAB and edit actions visible when logged in 6. FAB hidden when logged out 7. Settings page shows password change + API keys when logged in 8. Create API key → key displayed once 9. POST/PUT/DELETE API calls return 401 without auth 10. GET API calls work without auth