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>
1504 lines
40 KiB
Markdown
1504 lines
40 KiB
Markdown
# 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<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`:
|
|
|
|
```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<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**
|
|
|
|
```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<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`:
|
|
|
|
```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<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`:
|
|
|
|
```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<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**
|
|
|
|
```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<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:
|
|
|
|
```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 (
|
|
<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**
|
|
|
|
```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
|
|
<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:
|
|
|
|
```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 (
|
|
<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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```tsx
|
|
{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`:
|
|
|
|
```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
|