# Settings Phase 1 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 5 admin features to the settings dialog: teams management, tool disabling, feature flags, temp file management, and custom branding/logo upload. **Architecture:** Extend the existing settings key-value store and settings dialog. New `teams` table for team CRUD. New API routes for teams and logo. Frontend filters tools based on settings. All changes build on existing patterns (Fastify routes, Drizzle ORM, React dialog with sidebar nav). **Tech Stack:** Fastify, Drizzle ORM (SQLite), React, Vite, Sharp, Vitest, Playwright **Spec:** `docs/superpowers/specs/2026-03-25-settings-phase1-design.md` --- ## File Map ### New Files | File | Purpose | |---|---| | `apps/api/drizzle/0005_add_teams_table.sql` | Migration: create teams table, seed Default, convert users.team | | `apps/api/drizzle/meta/0005_snapshot.json` | Drizzle migration snapshot | | `apps/api/src/routes/teams.ts` | Teams CRUD API routes | | `apps/api/src/routes/branding.ts` | Logo upload/serve/delete API routes | | `tests/api/teams.test.ts` | Teams API tests | | `tests/api/branding.test.ts` | Logo API tests | | `tests/api/settings-phase1.test.ts` | Tests for new settings keys (disabledTools, experimentalTools, tempFile, startupCleanup) | | `tests/api/cleanup.test.ts` | Cleanup system tests with DB-backed settings | | `tests/e2e/settings-teams.spec.ts` | E2E: Teams management UI | | `tests/e2e/settings-tools.spec.ts` | E2E: Tool disabling UI | | `tests/e2e/settings-system.spec.ts` | E2E: Feature flags, temp file, logo upload UI | ### Modified Files | File | What Changes | |---|---| | `packages/shared/src/types.ts` | Rename `alpha` to `experimental` on Tool interface | | `packages/shared/src/constants.ts` | Update any tools using `alpha` to use `experimental` | | `packages/shared/src/i18n/en.ts` | Add translation keys for teams, tools sections | | `apps/api/src/db/schema.ts` | Add `teams` table definition | | `apps/api/src/index.ts` | Register teams routes, branding routes, pass settings to tool registration | | `apps/api/src/routes/tools/index.ts` | Filter out disabled/experimental tools on startup | | `apps/api/src/lib/cleanup.ts` | Read tempFileMaxAgeHours from DB settings, respect startupCleanup | | `apps/api/src/plugins/auth.ts` | Add `/api/v1/settings/logo` to PUBLIC_PATHS, update register/update endpoints to use team IDs | | `apps/web/src/components/settings/settings-dialog.tsx` | Add Teams, Tools sections; add feature flags/temp/logo to System Settings; update People section team dropdown | | `apps/web/src/components/layout/tool-panel.tsx` | Filter disabled/experimental tools | | `apps/web/src/components/layout/app-layout.tsx` | Load custom logo | | `apps/web/src/components/tools/pipeline-builder.tsx` | Filter disabled/experimental tools from picker | | `apps/web/src/components/common/tool-card.tsx` | Rename `alpha` badge to `experimental` | | `apps/web/src/pages/fullscreen-grid-page.tsx` | Rename `alpha` badge, filter disabled/experimental tools | --- ## Task 1: Teams — Database & Migration **Files:** - Modify: `apps/api/src/db/schema.ts` - Create: `apps/api/drizzle/0005_add_teams_table.sql` - Modify: `apps/api/drizzle/meta/_journal.json` - [ ] **Step 1: Add teams table to Drizzle schema** In `apps/api/src/db/schema.ts`, add after the `users` table: ```typescript export const teams = sqliteTable("teams", { id: text("id").primaryKey(), name: text("name").notNull().unique(), createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()), }); ``` - [ ] **Step 2: Create migration SQL file** Create `apps/api/drizzle/0005_add_teams_table.sql`: ```sql -- Create teams table CREATE TABLE IF NOT EXISTS `teams` ( `id` text PRIMARY KEY NOT NULL, `name` text NOT NULL, `created_at` integer NOT NULL DEFAULT (unixepoch()) ); CREATE UNIQUE INDEX IF NOT EXISTS `teams_name_unique` ON `teams` (`name`); -- Seed Default team with a known UUID INSERT OR IGNORE INTO `teams` (`id`, `name`, `created_at`) VALUES ('default-team-00000000', 'Default', unixepoch()); -- Migrate existing users: for each distinct team value, create a team if it doesn't exist -- Then update users.team from the string name to the team ID -- Note: For fresh installs, all users have team='Default' which maps to 'default-team-00000000' -- For existing installs with custom team strings, we handle them here: INSERT OR IGNORE INTO `teams` (`id`, `name`, `created_at`) SELECT lower(hex(randomblob(16))), `team`, unixepoch() FROM `users` WHERE `team` != 'Default' GROUP BY `team`; -- Update users to reference team IDs instead of team names UPDATE `users` SET `team` = ( SELECT `id` FROM `teams` WHERE `teams`.`name` = `users`.`team` ) WHERE EXISTS ( SELECT 1 FROM `teams` WHERE `teams`.`name` = `users`.`team` ); ``` - [ ] **Step 3: Update the Drizzle journal** Add entry for migration 0005 to `apps/api/drizzle/meta/_journal.json`. Follow the pattern of existing entries (idx: 5, tag: "0005_add_teams_table"). Existing entries go up to idx 4. - [ ] **Step 4: Generate Drizzle snapshot** Create `apps/api/drizzle/meta/0005_snapshot.json` matching the updated schema. Can use `npx drizzle-kit generate` or manually create based on `0003_snapshot.json` pattern with the teams table added. - [ ] **Step 5: Verify migration runs** Run: `npm run dev` (or the dev command) briefly to verify the migration applies without errors. Check the database has the `teams` table with a "Default" row. - [ ] **Step 6: Commit** ```bash git add apps/api/src/db/schema.ts apps/api/drizzle/ git commit -m "feat(db): add teams table and migration" ``` --- ## Task 2: Teams — API Routes **Files:** - Create: `apps/api/src/routes/teams.ts` - Modify: `apps/api/src/index.ts` - Create: `tests/api/teams.test.ts` - [ ] **Step 1: Write failing tests for teams CRUD** Create `tests/api/teams.test.ts`: ```typescript import { describe, it, expect, beforeAll } from "vitest"; // Test helper: create a Fastify app instance with auth + teams routes // Use the same test setup pattern from existing tests describe("Teams API", () => { describe("GET /api/v1/teams", () => { it("returns list of teams with member counts", async () => { // Should return at least the Default team // Response shape: { teams: [{ id, name, memberCount, createdAt }] } }); it("requires authentication", async () => { // 401 without token }); }); describe("POST /api/v1/teams", () => { it("creates a new team", async () => { // Body: { name: "Engineering" } // Response: { team: { id, name, createdAt } } }); it("requires admin role", async () => { // 403 for non-admin }); it("rejects duplicate team names (case-insensitive)", async () => { // 409 for duplicate }); it("rejects empty or whitespace-only names", async () => { // 400 for validation error }); it("rejects names longer than 50 characters", async () => { // 400 for validation error }); it("trims whitespace from names", async () => { // " Marketing " -> "Marketing" }); }); describe("PUT /api/v1/teams/:id", () => { it("renames a team", async () => { // Body: { name: "New Name" } }); it("requires admin role", async () => {}); it("rejects rename to existing name", async () => {}); it("returns 404 for non-existent team", async () => {}); }); describe("DELETE /api/v1/teams/:id", () => { it("deletes an empty team", async () => {}); it("rejects deletion of team with members", async () => { // 409: "Cannot delete team with assigned members" }); it("rejects deletion of Default team", async () => { // 409: "Cannot delete the Default team" }); it("requires admin role", async () => {}); it("returns 404 for non-existent team", async () => {}); }); }); ``` Fill in each test with actual HTTP calls using the app's test harness. Follow existing test patterns from the codebase. - [ ] **Step 2: Run tests to verify they fail** Run: `npx vitest run tests/api/teams.test.ts` Expected: All tests FAIL (routes don't exist yet) - [ ] **Step 3: Implement teams routes** Create `apps/api/src/routes/teams.ts`: ```typescript import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import { eq, sql } from "drizzle-orm"; import { randomUUID } from "crypto"; import { db, schema } from "../db/index.js"; import { requireAuth, requireAdmin } from "../plugins/auth.js"; export async function teamsRoutes(app: FastifyInstance): Promise { // GET /api/v1/teams — List all teams with member counts app.get("/api/v1/teams", async (request: FastifyRequest, reply: FastifyReply) => { const user = requireAuth(request, reply); if (!user) return; const teams = db .select({ id: schema.teams.id, name: schema.teams.name, createdAt: schema.teams.createdAt, memberCount: sql`(SELECT COUNT(*) FROM users WHERE users.team = ${schema.teams.id})`, }) .from(schema.teams) .all(); return reply.send({ teams }); }); // POST /api/v1/teams — Create team app.post("/api/v1/teams", async (request: FastifyRequest, reply: FastifyReply) => { const admin = requireAdmin(request, reply); if (!admin) return; const body = request.body as { name?: string } | null; const name = body?.name?.trim(); if (!name || name.length === 0) { return reply.status(400).send({ error: "Team name is required", code: "VALIDATION_ERROR" }); } if (name.length > 50) { return reply.status(400).send({ error: "Team name must be 50 characters or less", code: "VALIDATION_ERROR" }); } // Case-insensitive uniqueness check const existing = db.select().from(schema.teams).all() .find(t => t.name.toLowerCase() === name.toLowerCase()); if (existing) { return reply.status(409).send({ error: "A team with this name already exists", code: "DUPLICATE" }); } const team = { id: randomUUID(), name, createdAt: new Date(), }; db.insert(schema.teams).values(team).run(); return reply.status(201).send({ team }); }); // PUT /api/v1/teams/:id — Rename team app.put("/api/v1/teams/:id", async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { const admin = requireAdmin(request, reply); if (!admin) return; const { id } = request.params; const body = request.body as { name?: string } | null; const name = body?.name?.trim(); if (!name || name.length === 0) { return reply.status(400).send({ error: "Team name is required", code: "VALIDATION_ERROR" }); } if (name.length > 50) { return reply.status(400).send({ error: "Team name must be 50 characters or less", code: "VALIDATION_ERROR" }); } const team = db.select().from(schema.teams).where(eq(schema.teams.id, id)).get(); if (!team) { return reply.status(404).send({ error: "Team not found", code: "NOT_FOUND" }); } const existing = db.select().from(schema.teams).all() .find(t => t.name.toLowerCase() === name.toLowerCase() && t.id !== id); if (existing) { return reply.status(409).send({ error: "A team with this name already exists", code: "DUPLICATE" }); } db.update(schema.teams).set({ name }).where(eq(schema.teams.id, id)).run(); return reply.send({ team: { ...team, name } }); }); // DELETE /api/v1/teams/:id — Delete team app.delete("/api/v1/teams/:id", async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { const admin = requireAdmin(request, reply); if (!admin) return; const { id } = request.params; const team = db.select().from(schema.teams).where(eq(schema.teams.id, id)).get(); if (!team) { return reply.status(404).send({ error: "Team not found", code: "NOT_FOUND" }); } if (team.name === "Default") { return reply.status(409).send({ error: "Cannot delete the Default team", code: "PROTECTED" }); } const memberCount = db.select({ count: sql`COUNT(*)` }) .from(schema.users) .where(eq(schema.users.team, id)) .get(); if (memberCount && memberCount.count > 0) { return reply.status(409).send({ error: "Cannot delete team with assigned members", code: "HAS_MEMBERS" }); } db.delete(schema.teams).where(eq(schema.teams.id, id)).run(); return reply.send({ ok: true }); }); app.log.info("Teams routes registered"); } ``` - [ ] **Step 4: Register teams routes in index.ts** In `apps/api/src/index.ts`, add import and registration: ```typescript import { teamsRoutes } from "./routes/teams.js"; // Register after settingsRoutes teamsRoutes(app); ``` - [ ] **Step 5: Run tests to verify they pass** Run: `npx vitest run tests/api/teams.test.ts` Expected: All tests PASS - [ ] **Step 6: Commit** ```bash git add apps/api/src/routes/teams.ts apps/api/src/index.ts tests/api/teams.test.ts git commit -m "feat(api): add teams CRUD routes with tests" ``` --- ## Task 2b: Update auth.ts — Team ID References **Files:** - Modify: `apps/api/src/plugins/auth.ts` After migration, `users.team` stores team UUIDs, not string names. The auth routes that create/update users must be updated. - [ ] **Step 1: Update register endpoint to use Default team ID** In `apps/api/src/plugins/auth.ts`, find the register endpoint (`POST /api/auth/register`). Where it sets `team`, look up the Default team ID instead of using the string `"Default"`: ```typescript // Before: team: body.team || "Default" // After: const defaultTeam = db.select().from(schema.teams).where(eq(schema.teams.name, "Default")).get(); const teamId = body.team || defaultTeam?.id || "default-team-00000000"; // Validate team exists if a specific team was provided if (body.team) { const teamExists = db.select().from(schema.teams).where(eq(schema.teams.id, body.team)).get(); if (!teamExists) { return reply.status(400).send({ error: "Team not found", code: "VALIDATION_ERROR" }); } } ``` - [ ] **Step 2: Update user update endpoint to validate team ID** In the `PUT /api/auth/users/:id` endpoint, validate that the team ID exists: ```typescript if (body.team) { const teamExists = db.select().from(schema.teams).where(eq(schema.teams.id, body.team)).get(); if (!teamExists) { return reply.status(400).send({ error: "Team not found", code: "VALIDATION_ERROR" }); } } ``` - [ ] **Step 3: Update People section team dropdown in settings-dialog.tsx** The People section currently shows team as free text. Update it to fetch from `GET /api/v1/teams` and render a ` updateSetting("tempFileMaxAgeHours", e.target.value)} className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24" min={1} /> updateSetting("startupCleanup", v ? "true" : "false")} /> ``` - [ ] **Step 3: Add logo upload area** After the "App Name" setting: ```typescript {/* Custom Logo */}
{/* Show current logo preview if exists */} {settings.customLogo === "true" && ( Logo )} {settings.customLogo === "true" && ( )}
``` Implement `handleLogoUpload` (FormData POST) and `handleLogoDelete` (DELETE request). - [ ] **Step 4: Show "restart required" banner for experimental toggle** When `enableExperimentalTools` changes and is saved, show the same restart banner as the Tools section. - [ ] **Step 5: Verify all controls work** Open settings > System Settings. Verify: - Feature flags toggle works - Temp file age input works - Startup cleanup toggle works - Logo upload shows preview - Logo remove works - Save persists all settings - [ ] **Step 6: Commit** ```bash git add apps/web/src/components/settings/settings-dialog.tsx git commit -m "feat(ui): add feature flags, temp file management, and logo upload to System Settings" ``` --- ## Task 11: Frontend — Filter Tools in Tool Panel, Pipeline Builder & Fullscreen Grid **Files:** - Modify: `apps/web/src/components/layout/tool-panel.tsx` - Modify: `apps/web/src/components/tools/pipeline-builder.tsx` - Modify: `apps/web/src/pages/fullscreen-grid-page.tsx` - [ ] **Step 1: Filter tools in tool-panel.tsx** Modify `tool-panel.tsx` to fetch settings and filter: ```typescript // Fetch settings on mount const [disabledTools, setDisabledTools] = useState([]); const [experimentalEnabled, setExperimentalEnabled] = useState(false); useEffect(() => { apiGet<{ settings: Record }>("/v1/settings") .then((data) => { setDisabledTools(data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : []); setExperimentalEnabled(data.settings.enableExperimentalTools === "true"); }) .catch(() => {}); }, []); // Filter tools const visibleTools = TOOLS.filter(t => { if (disabledTools.includes(t.id)) return false; if (t.experimental && !experimentalEnabled) return false; return true; }); ``` Use `visibleTools` instead of `TOOLS` for grouping and rendering. - [ ] **Step 2: Filter tools in pipeline-builder.tsx** Apply the same filter logic to the `PIPELINE_TOOLS` constant, making it dynamic: ```typescript const visiblePipelineTools = PIPELINE_TOOLS.filter(t => { if (disabledTools.includes(t.id)) return false; if (t.experimental && !experimentalEnabled) return false; return true; }); ``` - [ ] **Step 3: Filter tools in fullscreen-grid-page.tsx** Apply the same filtering logic to the fullscreen grid page. Fetch settings and filter `TOOLS` the same way as tool-panel and pipeline-builder. - [ ] **Step 4: Verify filtering works** Disable a tool via settings, refresh. Verify: - Tool is gone from tool panel - Tool is gone from fullscreen grid - Tool is gone from pipeline step picker - Direct URL to the tool still works (server hasn't restarted) - [ ] **Step 5: Commit** ```bash git add apps/web/src/components/layout/tool-panel.tsx apps/web/src/components/tools/pipeline-builder.tsx apps/web/src/pages/fullscreen-grid-page.tsx git commit -m "feat(ui): filter disabled and experimental tools from tool panel, fullscreen grid, and pipeline builder" ``` --- ## Task 12: Frontend — Custom Logo in Sidebar/Layout **Files:** - Modify: `apps/web/src/components/layout/app-layout.tsx` - [ ] **Step 1: Load and display custom logo** In `app-layout.tsx`, fetch the custom logo setting and conditionally render: ```typescript const [customLogo, setCustomLogo] = useState(false); useEffect(() => { apiGet<{ settings: Record }>("/v1/settings") .then((data) => { setCustomLogo(data.settings.customLogo === "true"); }) .catch(() => {}); }, []); // In the logo rendering spots (mobile header, mobile top bar): {customLogo ? ( Logo ) : ( Stirling Image )} ``` - [ ] **Step 2: Verify logo displays** Upload a logo via settings, refresh. Verify: - Custom logo appears in mobile header - Removing logo reverts to text branding - [ ] **Step 3: Commit** ```bash git add apps/web/src/components/layout/app-layout.tsx git commit -m "feat(ui): display custom logo in sidebar and mobile header" ``` --- ## Task 13: E2E Tests — Teams Management **Files:** - Create: `tests/e2e/settings-teams.spec.ts` - [ ] **Step 1: Write Playwright E2E tests for Teams** ```typescript import { test, expect } from "@playwright/test"; test.describe("Teams Management", () => { test.beforeEach(async ({ page }) => { // Login as admin // Navigate to settings > Teams }); test("shows Default team on load", async ({ page }) => { await expect(page.getByText("Default")).toBeVisible(); }); test("can create a new team", async ({ page }) => { await page.getByRole("button", { name: /create new team/i }).click(); await page.getByPlaceholder(/team name/i).fill("Engineering"); await page.getByRole("button", { name: /create/i }).click(); await expect(page.getByText("Engineering")).toBeVisible(); }); test("can rename a team", async ({ page }) => { // Create team, then rename it }); test("can delete an empty team", async ({ page }) => { // Create team, then delete it }); test("cannot delete Default team", async ({ page }) => { // Try to delete Default, verify error message }); test("cannot delete team with members", async ({ page }) => { // Default team has admin, try to delete, verify error }); }); ``` - [ ] **Step 2: Run E2E tests** Run: `npx playwright test tests/e2e/settings-teams.spec.ts` Expected: All PASS - [ ] **Step 3: Commit** ```bash git add tests/e2e/settings-teams.spec.ts git commit -m "test(e2e): add teams management E2E tests" ``` --- ## Task 14: E2E Tests — Tool Disabling **Files:** - Create: `tests/e2e/settings-tools.spec.ts` - [ ] **Step 1: Write Playwright E2E tests for Tool Disabling** ```typescript import { test, expect } from "@playwright/test"; test.describe("Tool Disabling", () => { test.beforeEach(async ({ page }) => { // Login as admin }); test("shows all tools with toggles in Tools settings", async ({ page }) => { // Navigate to settings > Tools // Verify tool list is populated // Verify each tool has a toggle }); test("can search tools", async ({ page }) => { // Type in search, verify filtered results }); test("disabling a tool hides it from tool panel after save", async ({ page }) => { // Navigate to settings > Tools // Disable "resize" tool // Save // Go to home page // Verify "Resize" is not in the tool panel }); test("re-enabling a tool shows it in tool panel after save", async ({ page }) => { // Re-enable the tool // Verify it reappears }); test("shows restart required banner after saving", async ({ page }) => { // Toggle a tool, save // Verify "restart required" message appears }); }); ``` - [ ] **Step 2: Run E2E tests** Run: `npx playwright test tests/e2e/settings-tools.spec.ts` Expected: All PASS - [ ] **Step 3: Commit** ```bash git add tests/e2e/settings-tools.spec.ts git commit -m "test(e2e): add tool disabling E2E tests" ``` --- ## Task 15: E2E Tests — System Settings (Feature Flags, Temp Files, Logo) **Files:** - Create: `tests/e2e/settings-system.spec.ts` - [ ] **Step 1: Write Playwright E2E tests** ```typescript import { test, expect } from "@playwright/test"; test.describe("System Settings - Feature Flags", () => { test("experimental tools toggle exists and is off by default", async ({ page }) => { // Navigate to settings > System Settings // Verify "Enable Experimental Tools" toggle exists and is unchecked }); test("enabling experimental tools shows experimental tools in panel", async ({ page }) => { // Toggle on, save // Go to home, verify experimental tools appear (if any are marked) }); }); test.describe("System Settings - File Management", () => { test("max file age input shows default value of 24", async ({ page }) => { // Verify input value is 24 }); test("can change max file age and save", async ({ page }) => { // Change to 48, save // Reload, verify value persists }); test("startup cleanup toggle is on by default", async ({ page }) => { // Verify toggle is checked }); }); test.describe("System Settings - Logo Upload", () => { test("shows upload area when no logo is set", async ({ page }) => { // Verify upload button exists // Verify no preview image }); test("can upload a logo and see preview", async ({ page }) => { // Upload a test PNG // Verify preview image appears // Verify "Remove" button appears }); test("can remove uploaded logo", async ({ page }) => { // Upload, then click Remove // Verify preview disappears }); test("uploaded logo appears in sidebar/header", async ({ page }) => { // Upload logo // Close settings // Check sidebar/header for logo image }); }); ``` - [ ] **Step 2: Run E2E tests** Run: `npx playwright test tests/e2e/settings-system.spec.ts` Expected: All PASS - [ ] **Step 3: Commit** ```bash git add tests/e2e/settings-system.spec.ts git commit -m "test(e2e): add system settings E2E tests for feature flags, temp files, and logo" ``` --- ## Task 16: Final Integration Verification - [ ] **Step 1: Run all unit/API tests** Run: `npx vitest run` Expected: All PASS - [ ] **Step 2: Run all E2E tests** Run: `npx playwright test` Expected: All PASS - [ ] **Step 3: Manual smoke test** Start dev server, verify: 1. Settings dialog has all new sections (Teams, Tools) 2. System Settings has feature flags, temp file, logo controls 3. Creating/renaming/deleting teams works 4. Disabling a tool hides it from the panel 5. Toggling experimental tools works 6. Uploading/removing logo works 7. All existing functionality still works (no regressions) - [ ] **Step 4: Final commit** ```bash git add -A git commit -m "feat: settings phase 1 — teams, tool disabling, feature flags, temp files, custom logo" ```