From b51e4ab5b4c7f0e06ddb58bb5e0fc4370280bc7f Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Thu, 25 Jun 2026 15:13:18 +0530 Subject: [PATCH 01/15] docs: spec for master-key gate UX cleanup Removes modal flash on every navigation by making restorer authoritative and demoting useVaultGuard to a pure selector. Modal opens only on explicit user action. Co-Authored-By: Claude Opus 4.7 --- .../2026-06-25-master-key-gate-ux-design.md | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-25-master-key-gate-ux-design.md diff --git a/docs/superpowers/specs/2026-06-25-master-key-gate-ux-design.md b/docs/superpowers/specs/2026-06-25-master-key-gate-ux-design.md new file mode 100644 index 00000000..63639ba1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-master-key-gate-ux-design.md @@ -0,0 +1,208 @@ +# Master Key Gate — UX Cleanup + +**Date:** 2026-06-25 +**Status:** Draft for review +**Scope:** Eliminate the master-password modal flashing on every navigation to a critical app. Modal opens only when user action requires it. + +--- + +## Problem + +Every time the user navigates to a critical app (password-manager, sql-client, database-explorer, environment-manager, s3-drive, redis-commander), the master-password modal flashes "Checking vault…" before disappearing — even when the encryption key is already saved in IndexedDB and valid. + +### Root cause + +Two parallel restoration paths fight each other: + +1. `VaultKeyRestorer` (in `app-content.tsx`) silently loads + verifies the master key on user login. +2. `useVaultGuard()` (in each critical page) calls `openVaultGate()` on mount if `!isUnlocked`, regardless of whether restoration is in flight. + +The modal opens **before** restoration finishes. Its own `initGate()` duplicates the work the restorer already does. Result: a guaranteed flash on every fresh page load / hard nav, and a duplicate network call to fetch the vault. + +### Why "every app move" + +Soft client-side navigation preserves Zustand state, so once unlocked, moving between apps should not retrigger the modal. The user reports otherwise — which means in practice the store is being reset (hard nav, refresh, or logout flow) frequently enough that the modal feels omnipresent. Either way, the underlying defect is the same: the gate opens eagerly instead of waiting for restoration. + +--- + +## Goal + +- No modal flash when a valid key exists in IndexedDB. +- No duplicate `getMasterVaultOrNull` + `verifyKey` work. +- Modal opens **only** on explicit user action (clicking "Unlock" on the placeholder). +- During the brief restoration window, the user sees a skeleton, not a modal. +- All other vault flows (setup, backup codes, backup-code recovery) preserved unchanged. + +--- + +## Design + +### 1. Store state machine + +Single source of truth in `master-key-store.ts`. Replace the current `VaultStatus = "unknown" | "not-configured" | "locked" | "unlocked"` with: + +```ts +type VaultStatus = + | "restoring" // initial: trying IndexedDB + server check + | "not-configured" // no vault on server (first-time user) + | "locked" // vault exists, no valid in-memory key + | "unlocked" // key loaded + verified + +interface MasterKeyStore { + encryptionKey: CryptoKey | null + vaultStatus: VaultStatus + vault: MasterVaultOut | null // cached after restorer fetch, reused by modal + vaultGateOpen: boolean // user-triggered ONLY + restoreError: string | null // surfaces network failure to placeholder + + setKey: (key: CryptoKey) => void + clearKey: () => void + setVaultStatus: (status: VaultStatus) => void + setVault: (vault: MasterVaultOut | null) => void + setRestoreError: (err: string | null) => void + openVaultGate: () => void + closeVaultGate: () => void +} +``` + +Initial `vaultStatus = "restoring"` (not `"unknown"`). Derived selector: + +```ts +isUnlocked = vaultStatus === "unlocked" +``` + +`setKey` flips status to `"unlocked"` and closes the gate (existing behavior). +`clearKey` resets status to `"restoring"` so the restorer reruns on next user mount (logout → relogin path). + +### 2. Restorer = single source of restoration + +`VaultKeyRestorer` in `app-content.tsx` becomes the only place that runs the boot-time restore. Flow: + +1. On `user` ready and `vaultStatus === "restoring"`: +2. `loadMasterKey()` from IndexedDB. +3. `getMasterVaultOrNull()` from server. +4. Cache result with `setVault(vaultData)`. +5. Branch: + - No vault on server → `setVaultStatus("not-configured")`. + - Vault exists + saved key + `verifyKey()` valid → `setKey(savedKey)` (status flips to `"unlocked"`). + - Vault exists + saved key invalid → `clearMasterKey()` + `setVaultStatus("locked")`. + - Vault exists + no saved key → `setVaultStatus("locked")`. +6. On thrown error → `setRestoreError(message)` + `setVaultStatus("locked")` (user can retry via the modal which re-fetches). + +`MasterPasswordGate.initGate()` is **deleted**. The modal reads `vault` from the store; no duplicate fetch. + +### 3. `useVaultGuard` — pure selector + +Strip side effects entirely: + +```ts +export function useVaultGuard() { + const { vaultStatus, openVaultGate } = useMasterKeyStore() + return { + status: vaultStatus, + isUnlocked: vaultStatus === "unlocked", + isRestoring: vaultStatus === "restoring", + openVaultGate, // for user-triggered open from placeholder + } +} +``` + +No `useEffect`, no auto-open, no cleanup. The hook only reports state. + +### 4. Critical page render pattern + +Each critical page renders by status: + +```tsx +const { status, isUnlocked } = useVaultGuard() + +if (status === "restoring") return +if (!isUnlocked) return +// ...real app +``` + +- `"restoring"` → new `VaultRestoringSkeleton` component (single generic component reused across all critical apps, shadcn `Skeleton` rows). +- `"locked"` / `"not-configured"` → existing `VaultLockedPlaceholder` with "Unlock" button that calls `openVaultGate()`. +- `"unlocked"` → app renders. + +Applied to: `password-manager`, `sql-client`, `database-explorer`, `environment-manager`, `s3-drive`, `redis-commander`. + +### 5. `MasterPasswordGate` simplification + +- Delete `initGate()` and the `mode === "loading"` UI (no more "Checking vault…"). +- Delete internal `vault` state — read from store. +- Mode derived from store status on dialog open: + - status `"not-configured"` → mode `"setup"` + - status `"locked"` → mode `"unlock"` +- `dialogOpen = vaultGateOpen || showBackupCodes` (drop the `&& !isUnlocked` clause — user never opens gate while unlocked). +- After successful unlock or setup → `setKey()` flips status to `"unlocked"` and closes the gate (already the existing `setKey` behavior). +- Post-setup backup-codes screen unchanged. +- Backup-code recovery unchanged. + +### 6. New component + +`apps/web/src/components/vault-restoring-skeleton.tsx` — small (~20 lines), shadcn `Skeleton`-based, no per-app variant. ponytail: one component, generic shell. + +--- + +## Edge cases + +| Case | Behavior | +|------|----------| +| Logout → relogin | `clearKey()` resets status to `"restoring"`; restorer reruns on next user mount. | +| Refresh on critical page | Restorer runs once; page shows skeleton briefly; flips to unlocked (saved key valid) or placeholder (no key). | +| Soft nav between apps when unlocked | No skeleton, no modal — instant render. | +| Network failure during restore | `setRestoreError` + status `"locked"`. Placeholder shows. Modal retries on Unlock click. | +| Vault wiped server-side after unlock | `verifyKey` fails → IndexedDB key cleared → `"locked"`. | +| `vaultGateOpen` flipped only by user action or successful unlock. Never auto-opened. | + +--- + +## Files affected + +| File | Change | +|------|--------| +| `apps/web/src/store/master-key-store.ts` | Add `restoring` status, `vault` cache, `restoreError`, new setters. | +| `apps/web/src/app/app/app-content.tsx` | `VaultKeyRestorer` becomes authoritative; handles all branches. | +| `apps/web/src/hooks/use-vault-guard.ts` | Strip side effects; pure selector. | +| `apps/web/src/components/master-password-gate.tsx` | Delete `initGate`, `loading` mode, internal `vault` state. Read from store. | +| `apps/web/src/components/vault-restoring-skeleton.tsx` | **New.** Generic shadcn `Skeleton` shell. | +| `apps/web/src/app/app/password-manager/page.tsx` | Render by status. | +| `apps/web/src/app/app/sql-client/page.tsx` | Render by status. | +| `apps/web/src/app/app/database-explorer/page.tsx` | Render by status. | +| `apps/web/src/app/app/environment-manager/page.tsx` | Render by status. | +| `apps/web/src/app/app/s3-drive/page.tsx` | Render by status. | +| `apps/web/src/app/app/redis-commander/page.tsx` | Render by status. | + +--- + +## Testing + +Smallest viable check: one unit test for the restorer state machine. Mock `loadMasterKey`, `getMasterVaultOrNull`, `verifyKey`. Assert sequence of `setVaultStatus` / `setKey` / `setVault` calls for each of the five branches: + +1. No vault on server → `"not-configured"`. +2. Vault + valid saved key → `"unlocked"`. +3. Vault + invalid saved key → `"locked"` (after `clearMasterKey`). +4. Vault + no saved key → `"locked"`. +5. Fetch throws → `"locked"` with `restoreError` set. + +Manual verification: refresh on each critical app — no modal flash; navigate between apps when unlocked — no modal; click Unlock from placeholder — modal opens cleanly. + +--- + +## Non-goals + +- No backend changes. +- No encryption / key derivation changes. +- No backup-code flow changes. +- No telemetry, no metrics, no flags. +- No persistence of `encryptionKey` to Zustand storage (security: in-memory + IndexedDB CryptoKey only, unchanged). + +--- + +## ponytail notes + +- One component for the skeleton, not six. +- Restorer is the only restoration path. Modal is dumb UI fed by store. +- Status enum is exhaustive — no `"unknown"` middle state to reason about. +- `useVaultGuard` becomes a one-liner selector; cleanup-on-unmount dead code goes away. From 9d58934ebd33ab90ca3bb174e1a06297e3611a15 Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Thu, 25 Jun 2026 15:35:56 +0530 Subject: [PATCH 02/15] docs: implementation plan for master-key gate UX cleanup Eight tasks: store state machine, pure restoreVault() + tests, restorer rewrite, useVaultGuard as selector, restoring skeleton, modal simplification, render-by-status across critical pages, manual verification. Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-06-25-master-key-gate-ux.md | 773 ++++++++++++++++++ 1 file changed, 773 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-master-key-gate-ux.md diff --git a/docs/superpowers/plans/2026-06-25-master-key-gate-ux.md b/docs/superpowers/plans/2026-06-25-master-key-gate-ux.md new file mode 100644 index 00000000..eacfbf51 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-master-key-gate-ux.md @@ -0,0 +1,773 @@ +# Master Key Gate UX 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:** Stop the master-password modal from flashing on every navigation to a critical app. The modal opens only when the user clicks "Unlock" on the locked placeholder. + +**Architecture:** One restoration path on app boot (`VaultKeyRestorer`) drives a state machine in the Zustand store (`restoring | not-configured | locked | unlocked`). Critical pages render skeleton → placeholder → app by reading store status. `useVaultGuard` is demoted to a pure selector (no side effects). `MasterPasswordGate` deletes its own restoration logic and becomes dumb UI fed by the store. + +**Tech Stack:** Next.js 15 (App Router), React, TypeScript, Zustand, Jest + Testing Library, IndexedDB, Web Crypto API, shadcn/ui, framer-motion. + +## Global Constraints + +- All work in `apps/web/` workspace. +- Path alias: `@/` → `apps/web/src/`. +- Tests live in `__tests__/` directories, file pattern `*.test.ts` / `*.test.tsx`. Run with `pnpm --filter web test` (jest, configured in `apps/web/jest.config.js`). +- No new dependencies. No backend changes. No changes to encryption / key derivation / backup-code flow. +- The Zustand store remains in-memory only (no `persist` middleware). `CryptoKey` continues to live in IndexedDB via existing `key-storage.ts` helpers. +- Match existing TypeScript style (4-space indent in the affected files, double quotes, no semicolons in store/hook files — copy the file's local style). +- Commit messages: conventional commits prefix (`feat:`, `refactor:`, `test:`, `chore:`). + +--- + +## File Structure + +| File | Responsibility | Action | +|------|---------------|--------| +| `apps/web/src/store/master-key-store.ts` | Store + status state machine + vault cache. | Modify | +| `apps/web/src/lib/restore-vault.ts` | Pure async function that resolves the restoration outcome. Easy to unit-test. | **Create** | +| `apps/web/src/lib/__tests__/restore-vault.test.ts` | Unit tests for the 5 restoration branches. | **Create** | +| `apps/web/src/app/app/app-content.tsx` | `VaultKeyRestorer` calls `restoreVault()` and dispatches store mutations from the result. | Modify | +| `apps/web/src/hooks/use-vault-guard.ts` | Pure selector — returns `status`, `isUnlocked`, `isRestoring`, `openVaultGate`. | Modify | +| `apps/web/src/components/vault-restoring-skeleton.tsx` | Generic shadcn `Skeleton`-based placeholder shown during restore. | **Create** | +| `apps/web/src/components/master-password-gate.tsx` | Delete `initGate`, `mode === "loading"`, internal `vault` state. Read `vault` from store. Drop `closeVaultGate` dependency from `useVaultGuard`. | Modify | +| `apps/web/src/app/app/password-manager/page.tsx` | Add `if (isRestoring) return ` branch. | Modify | +| `apps/web/src/app/app/sql-client/page.tsx` | Same render-by-status pattern. | Modify | +| `apps/web/src/app/app/database-explorer/page.tsx` | Same render-by-status pattern. | Modify | +| `apps/web/src/app/app/environment-manager/page.tsx` | Same render-by-status pattern. | Modify | +| `apps/web/src/app/app/s3-drive/page.tsx` | Switch from `useMasterKeyStore` selector to `useVaultGuard`, add restoring branch. | Modify | +| `apps/web/src/app/app/redis-commander/page.tsx` | Same render-by-status pattern. | Modify | + +--- + +## Task 1: Extend the master-key store with the new state machine + +**Files:** +- Modify: `apps/web/src/store/master-key-store.ts` + +**Interfaces:** +- Consumes: existing `MasterVaultOut` type from `@/lib/global-vault-api`. +- Produces: + - Type `VaultStatus = "restoring" | "not-configured" | "locked" | "unlocked"`. + - Store fields `vaultStatus: VaultStatus`, `vault: MasterVaultOut | null`, `restoreError: string | null`, plus existing `encryptionKey`, `vaultGateOpen`. + - Computed `isUnlocked` is `vaultStatus === "unlocked"` (no longer a stored field). + - Actions: `setKey(key)`, `clearKey()`, `setVaultStatus(s)`, `setVault(v)`, `setRestoreError(msg)`, `openVaultGate()`, `closeVaultGate()`. + - `setKey` mutates `{ encryptionKey: key, vaultStatus: "unlocked", vaultGateOpen: false, restoreError: null }`. + - `clearKey` mutates `{ encryptionKey: null, vaultStatus: "restoring", vault: null, restoreError: null, vaultGateOpen: false }`. + +- [ ] **Step 1: Replace the file contents** + +```ts +import { create } from "zustand" +import type { MasterVaultOut } from "@/lib/global-vault-api" + +export type VaultStatus = + | "restoring" + | "not-configured" + | "locked" + | "unlocked" + +interface MasterKeyStore { + encryptionKey: CryptoKey | null + vaultStatus: VaultStatus + vault: MasterVaultOut | null + restoreError: string | null + isUnlocked: boolean + vaultGateOpen: boolean + + setKey: (key: CryptoKey) => void + clearKey: () => void + setVaultStatus: (status: VaultStatus) => void + setVault: (vault: MasterVaultOut | null) => void + setRestoreError: (err: string | null) => void + openVaultGate: () => void + closeVaultGate: () => void +} + +export const useMasterKeyStore = create((set) => ({ + encryptionKey: null, + vaultStatus: "restoring", + vault: null, + restoreError: null, + isUnlocked: false, + vaultGateOpen: false, + + setKey: (key) => + set({ + encryptionKey: key, + vaultStatus: "unlocked", + isUnlocked: true, + vaultGateOpen: false, + restoreError: null, + }), + + clearKey: () => + set({ + encryptionKey: null, + vaultStatus: "restoring", + isUnlocked: false, + vault: null, + restoreError: null, + vaultGateOpen: false, + }), + + setVaultStatus: (status) => + set({ vaultStatus: status, isUnlocked: status === "unlocked" }), + + setVault: (vault) => set({ vault }), + + setRestoreError: (err) => set({ restoreError: err }), + + openVaultGate: () => set({ vaultGateOpen: true }), + closeVaultGate: () => set({ vaultGateOpen: false }), +})) +``` + +- [ ] **Step 2: Type-check the workspace** + +Run: `pnpm --filter web typecheck` +Expected: failures in files that consume the old `VaultStatus = "unknown" | ...` — those are addressed in later tasks. The store file itself must compile cleanly. + +If `typecheck` script doesn't exist, run `pnpm --filter web exec tsc --noEmit` instead. Either way, errors at this point should only point to files we plan to modify in later tasks (gate, restorer, hook, pages). Make a note of them; do not silence them here. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/store/master-key-store.ts +git commit -m "refactor(master-key): introduce vault state machine" +``` + +--- + +## Task 2: Pure `restoreVault()` function with unit tests + +**Files:** +- Create: `apps/web/src/lib/restore-vault.ts` +- Create: `apps/web/src/lib/__tests__/restore-vault.test.ts` + +**Interfaces:** +- Consumes: + - `MasterVaultOut` from `@/lib/global-vault-api`. +- Produces: + - Type `RestoreResult` (discriminated union, see code). + - Function `restoreVault(deps: RestoreDeps): Promise`. + - `deps` shape: `{ loadMasterKey, getMasterVaultOrNull, verifyKey, clearMasterKey }` — all required, no defaults. Caller in `app-content.tsx` injects the real implementations; tests inject mocks. + +- [ ] **Step 1: Write the failing test file** + +`apps/web/src/lib/__tests__/restore-vault.test.ts`: + +```ts +import { restoreVault, type RestoreDeps } from "../restore-vault" + +const fakeKey = { type: "secret" } as unknown as CryptoKey +const fakeVault = { + salt: "salt", + verifier: { encrypted: "enc", iv: "iv" }, +} as any + +function makeDeps(over: Partial = {}): RestoreDeps { + return { + loadMasterKey: jest.fn().mockResolvedValue(null), + getMasterVaultOrNull: jest.fn().mockResolvedValue(null), + verifyKey: jest.fn().mockResolvedValue(false), + clearMasterKey: jest.fn().mockResolvedValue(undefined), + ...over, + } +} + +describe("restoreVault", () => { + it("returns not-configured when there is no vault on the server", async () => { + const deps = makeDeps({ + getMasterVaultOrNull: jest.fn().mockResolvedValue(null), + }) + const result = await restoreVault(deps) + expect(result).toEqual({ status: "not-configured" }) + }) + + it("returns unlocked when saved key verifies against the vault", async () => { + const deps = makeDeps({ + loadMasterKey: jest.fn().mockResolvedValue(fakeKey), + getMasterVaultOrNull: jest.fn().mockResolvedValue(fakeVault), + verifyKey: jest.fn().mockResolvedValue(true), + }) + const result = await restoreVault(deps) + expect(result).toEqual({ + status: "unlocked", + vault: fakeVault, + key: fakeKey, + }) + expect(deps.clearMasterKey).not.toHaveBeenCalled() + }) + + it("clears the saved key and returns locked when verification fails", async () => { + const clearMasterKey = jest.fn().mockResolvedValue(undefined) + const deps = makeDeps({ + loadMasterKey: jest.fn().mockResolvedValue(fakeKey), + getMasterVaultOrNull: jest.fn().mockResolvedValue(fakeVault), + verifyKey: jest.fn().mockResolvedValue(false), + clearMasterKey, + }) + const result = await restoreVault(deps) + expect(result).toEqual({ status: "locked", vault: fakeVault }) + expect(clearMasterKey).toHaveBeenCalledTimes(1) + }) + + it("returns locked with the vault cached when no key is stored", async () => { + const deps = makeDeps({ + loadMasterKey: jest.fn().mockResolvedValue(null), + getMasterVaultOrNull: jest.fn().mockResolvedValue(fakeVault), + }) + const result = await restoreVault(deps) + expect(result).toEqual({ status: "locked", vault: fakeVault }) + }) + + it("returns error when the vault fetch throws", async () => { + const deps = makeDeps({ + getMasterVaultOrNull: jest.fn().mockRejectedValue(new Error("net down")), + }) + const result = await restoreVault(deps) + expect(result).toEqual({ status: "error", message: "net down" }) + }) +}) +``` + +- [ ] **Step 2: Run the tests; expect a module-not-found failure** + +Run: `pnpm --filter web exec jest src/lib/__tests__/restore-vault.test.ts` +Expected: `Cannot find module '../restore-vault'`. + +- [ ] **Step 3: Create the implementation** + +`apps/web/src/lib/restore-vault.ts`: + +```ts +import type { MasterVaultOut } from "@/lib/global-vault-api" + +export type RestoreResult = + | { status: "not-configured" } + | { status: "locked"; vault: MasterVaultOut } + | { status: "unlocked"; vault: MasterVaultOut; key: CryptoKey } + | { status: "error"; message: string } + +export interface RestoreDeps { + loadMasterKey: () => Promise + getMasterVaultOrNull: () => Promise + verifyKey: ( + key: CryptoKey, + encrypted: string, + iv: string, + ) => Promise + clearMasterKey: () => Promise +} + +export async function restoreVault(deps: RestoreDeps): Promise { + try { + const vault = await deps.getMasterVaultOrNull() + if (!vault) return { status: "not-configured" } + + const savedKey = await deps.loadMasterKey() + if (!savedKey) return { status: "locked", vault } + + const valid = await deps.verifyKey( + savedKey, + vault.verifier.encrypted, + vault.verifier.iv, + ) + if (!valid) { + await deps.clearMasterKey() + return { status: "locked", vault } + } + + return { status: "unlocked", vault, key: savedKey } + } catch (err) { + const message = err instanceof Error ? err.message : "Restore failed" + return { status: "error", message } + } +} +``` + +- [ ] **Step 4: Run the tests; expect all five to pass** + +Run: `pnpm --filter web exec jest src/lib/__tests__/restore-vault.test.ts` +Expected: `Tests: 5 passed`. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/lib/restore-vault.ts apps/web/src/lib/__tests__/restore-vault.test.ts +git commit -m "feat(master-key): extract pure restoreVault function with tests" +``` + +--- + +## Task 3: Wire `VaultKeyRestorer` to the new `restoreVault()` function + +**Files:** +- Modify: `apps/web/src/app/app/app-content.tsx` + +**Interfaces:** +- Consumes: + - `restoreVault`, `RestoreResult` from `@/lib/restore-vault`. + - Store actions: `setKey`, `setVaultStatus`, `setVault`, `setRestoreError`. +- Produces: no module-level exports change. + +- [ ] **Step 1: Replace `VaultKeyRestorer` implementation** + +`apps/web/src/app/app/app-content.tsx`: + +```tsx +'use client'; + +import React, { useEffect, useRef } from 'react'; +import { ClientLayout } from '../../components/sidebar/client-layout'; +import { RequireAuth } from '@/components/require-auth'; +import { MasterPasswordGate } from '@/components/master-password-gate'; +import { useMasterKeyStore } from '@/store/master-key-store'; +import { loadMasterKey, clearMasterKey } from '@/lib/key-storage'; +import { getMasterVaultOrNull } from '@/lib/global-vault-api'; +import { verifyKey } from '@/lib/encryption'; +import { restoreVault } from '@/lib/restore-vault'; +import useAuth from '@/utils/useAuth'; + +// Single restoration path. Runs once per signed-in user mount. Mutates the +// store with the final state — modal and pages read from store only. +function VaultKeyRestorer() { + const { user } = useAuth(false); + const { vaultStatus, setKey, setVaultStatus, setVault, setRestoreError } = + useMasterKeyStore(); + const ranRef = useRef(false); + + useEffect(() => { + if (!user || vaultStatus !== 'restoring' || ranRef.current) return; + ranRef.current = true; + + (async () => { + const result = await restoreVault({ + loadMasterKey, + getMasterVaultOrNull, + verifyKey, + clearMasterKey, + }); + + switch (result.status) { + case 'not-configured': + setVault(null); + setVaultStatus('not-configured'); + return; + case 'unlocked': + setVault(result.vault); + setKey(result.key); + return; + case 'locked': + setVault(result.vault); + setVaultStatus('locked'); + return; + case 'error': + setRestoreError(result.message); + setVaultStatus('locked'); + return; + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user, vaultStatus]); + + return null; +} + +export function AppContent({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + ); +} +``` + +- [ ] **Step 2: Type-check** + +Run: `pnpm --filter web exec tsc --noEmit` +Expected: no errors in `app-content.tsx`. (Other files still error — fixed in later tasks.) + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/app/app/app-content.tsx +git commit -m "refactor(master-key): make restorer the sole restoration path" +``` + +--- + +## Task 4: Demote `useVaultGuard` to a pure selector + +**Files:** +- Modify: `apps/web/src/hooks/use-vault-guard.ts` + +**Interfaces:** +- Consumes: store fields `vaultStatus`, `openVaultGate`. +- Produces: + - Hook returns `{ status: VaultStatus, isUnlocked: boolean, isRestoring: boolean, openVaultGate: () => void }`. + - **No side effects**: no `useEffect`, no auto-open, no cleanup. + +- [ ] **Step 1: Replace the file contents** + +```ts +"use client" + +import { useMasterKeyStore, type VaultStatus } from "@/store/master-key-store" + +/** + * Read-only vault state for critical pages. Does NOT open the modal. + * Render the locked placeholder, which has the user-triggered Unlock button. + */ +export function useVaultGuard() { + const vaultStatus = useMasterKeyStore((s) => s.vaultStatus) + const openVaultGate = useMasterKeyStore((s) => s.openVaultGate) + + return { + status: vaultStatus as VaultStatus, + isUnlocked: vaultStatus === "unlocked", + isRestoring: vaultStatus === "restoring", + openVaultGate, + } +} +``` + +- [ ] **Step 2: Type-check** + +Run: `pnpm --filter web exec tsc --noEmit` +Expected: hook file clean. Page files still reference `useVaultGuard().isUnlocked`, which still works (preserved on return value). + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/hooks/use-vault-guard.ts +git commit -m "refactor(master-key): make useVaultGuard a pure selector" +``` + +--- + +## Task 5: Create `VaultRestoringSkeleton` component + +**Files:** +- Create: `apps/web/src/components/vault-restoring-skeleton.tsx` + +**Interfaces:** +- Consumes: `Skeleton` from `@/components/ui/skeleton`. +- Produces: `` — no props. + +- [ ] **Step 1: Create the file** + +```tsx +"use client" + +import { Skeleton } from "@/components/ui/skeleton" + +/** + * Shown while the master-key restorer is still running on app boot. + * Generic shell — fine for every critical app since restore finishes in + * milliseconds and the user never reads it. + */ +export function VaultRestoringSkeleton() { + return ( +
+
+ + +
+
+ + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ) +} +``` + +- [ ] **Step 2: Type-check** + +Run: `pnpm --filter web exec tsc --noEmit` +Expected: file compiles. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/vault-restoring-skeleton.tsx +git commit -m "feat(master-key): add VaultRestoringSkeleton placeholder" +``` + +--- + +## Task 6: Simplify `MasterPasswordGate` + +**Files:** +- Modify: `apps/web/src/components/master-password-gate.tsx` + +**Interfaces:** +- Consumes: store fields `vaultStatus`, `vault`, `vaultGateOpen`, `setKey`, `closeVaultGate`. +- Produces: no exports change. + +This task deletes the modal's internal restoration logic and lets it read the cached `vault` from the store. + +- [ ] **Step 1: Replace the top of the component (imports, state, effects)** + +Delete the existing imports for `loadMasterKey`, `clearMasterKey`, the `setVaultStatus` destructure, the `vaultStatus` destructure, the `initRef`, `initGate()`, the two `useEffect`s, and the local `vault` state. + +Read the current file to confirm exact line locations, then apply these replacements: + +Replace the `useState`/`useRef` block (lines 63–75 in the current file) with: + +```tsx + const [mode, setMode] = useState("unlock") + const [password, setPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + const [showPassword, setShowPassword] = useState(false) + const [error, setError] = useState("") + const [submitting, setSubmitting] = useState(false) + const [shake, setShake] = useState(false) + const [backupCodes, setBackupCodes] = useState([]) + const [backupCodesAcknowledged, setBackupCodesAcknowledged] = useState(false) + const [backupCodeInput, setBackupCodeInput] = useState("") + const [copiedIndex, setCopiedIndex] = useState(null) +``` + +Replace the `useMasterKeyStore()` destructure (line 60–61) with: + +```tsx + const { isUnlocked, vault, vaultStatus, vaultGateOpen, setKey, closeVaultGate } = + useMasterKeyStore() +``` + +Delete the two `useEffect` blocks (the one running `initGate` and the one resetting `initRef` on close) and the `initGate` function entirely. Replace with this single effect that picks the mode whenever the gate is opened: + +```tsx + useEffect(() => { + if (!vaultGateOpen) { + resetForm() + setMode("unlock") + return + } + if (vaultStatus === "not-configured") setMode("setup") + else setMode("unlock") + }, [vaultGateOpen, vaultStatus]) +``` + +Remove the `GateMode` `"loading"` variant from the union: + +```tsx +type GateMode = "setup" | "backup-codes" | "unlock" | "use-backup-code" +``` + +- [ ] **Step 2: Update all references that read the local `vault` to read from the store** + +Search the file for `vault.salt`, `vault.verifier`, and any other access. They already destructure `vault` from props — now they read it from the store destructure above. No code changes needed in the body provided you removed the `useState`/`setVault` for the local one. + +In `handleUnlock`, change the guard from `if (!password || !vault) return` to keep `vault` referring to the store-sourced value (no rename required). + +In `handleBackupCodeUnlock`, same — `vault` already refers to the store value. + +- [ ] **Step 3: Delete the loading-mode UI** + +Find the icon/heading block that branches on `mode === "loading"`. Remove the `loading` branch from the icon block (`` and the conditional `` rendering for loading). Remove the entire loading progress block (`{mode === "loading" && (
...)`). + +The simplest concrete replacement: delete every block whose JSX condition is `mode === "loading"`. Also drop the `mode === "loading"` branches inside the heading text and description ternaries — those branches become impossible to reach. The forms wrapper `{mode !== "loading" && (` becomes unconditional — remove the `&&` guard. + +- [ ] **Step 4: Update `dialogOpen`** + +Find the line `const dialogOpen = (vaultGateOpen && !isUnlocked) || showBackupCodes` and replace with: + +```tsx + const dialogOpen = vaultGateOpen || showBackupCodes +``` + +- [ ] **Step 5: Type-check + run the existing test suite** + +Run: `pnpm --filter web exec tsc --noEmit` +Expected: no errors. + +Run: `pnpm --filter web exec jest` +Expected: existing tests still pass; new `restore-vault` tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/components/master-password-gate.tsx +git commit -m "refactor(master-password-gate): drop internal restore, read from store" +``` + +--- + +## Task 7: Apply render-by-status to all six critical pages + +**Files:** +- Modify: `apps/web/src/app/app/password-manager/page.tsx` +- Modify: `apps/web/src/app/app/sql-client/page.tsx` +- Modify: `apps/web/src/app/app/database-explorer/page.tsx` +- Modify: `apps/web/src/app/app/environment-manager/page.tsx` +- Modify: `apps/web/src/app/app/s3-drive/page.tsx` +- Modify: `apps/web/src/app/app/redis-commander/page.tsx` + +**Interfaces:** +- Consumes: `useVaultGuard()` returning `{ status, isUnlocked, isRestoring, openVaultGate }`. `VaultLockedPlaceholder`, `VaultRestoringSkeleton` components. +- Produces: no exports change. + +For each page, the existing locked-placeholder branch becomes preceded by a restoring branch: + +```tsx +if (isRestoring) return +if (!isUnlocked) return +``` + +- [ ] **Step 1: password-manager** + +Add to imports: `import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton"`. + +Replace `const { isUnlocked } = useVaultGuard()` with `const { isUnlocked, isRestoring } = useVaultGuard()`. + +Replace `if (!isUnlocked) return ` with: + +```tsx + if (isRestoring) return + if (!isUnlocked) return +``` + +- [ ] **Step 2: sql-client** + +Same pattern, `appName="SQL Client"`. + +- [ ] **Step 3: database-explorer** + +Same pattern, `appName="Database Explorer"`. The page also references `isUnlocked` in a `useEffect` dep array — leave that untouched. + +- [ ] **Step 4: environment-manager** + +Same pattern, `appName="Environment Manager"`. + +- [ ] **Step 5: s3-drive** + +This page currently pulls `{ encryptionKey, isUnlocked }` directly from `useMasterKeyStore`. Switch to `useVaultGuard` for the status flags, keep `encryptionKey` from the store: + +```tsx +import { useVaultGuard } from "@/hooks/use-vault-guard" +import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton" +// ... +const { encryptionKey } = useMasterKeyStore() +const { isUnlocked, isRestoring } = useVaultGuard() +// ... +if (isRestoring) return +if (!isUnlocked || !encryptionKey) return +``` + +- [ ] **Step 6: redis-commander** + +Same pattern, `appName="Redis Commander"`. Existing branch wraps the placeholder in a `
` — keep that wrapper, just add the restoring branch above. + +- [ ] **Step 7: Type-check, lint, test** + +```bash +pnpm --filter web exec tsc --noEmit +pnpm --filter web exec jest +``` + +Expected: all green. + +- [ ] **Step 8: Commit** + +```bash +git add apps/web/src/app/app/password-manager/page.tsx \ + apps/web/src/app/app/sql-client/page.tsx \ + apps/web/src/app/app/database-explorer/page.tsx \ + apps/web/src/app/app/environment-manager/page.tsx \ + apps/web/src/app/app/s3-drive/page.tsx \ + apps/web/src/app/app/redis-commander/page.tsx +git commit -m "feat(master-key): render skeleton during vault restore" +``` + +--- + +## Task 8: Manual verification + final type-check + +This is a verification task, not a code task. The change must be exercised in a browser before declaring done. + +- [ ] **Step 1: Boot dev server** + +Run: `pnpm --filter web dev` + +- [ ] **Step 2: Logged-out → log in (valid saved key path)** + +Steps: log in, navigate to `/app/password-manager`. +Expected: skeleton flashes briefly (≤500 ms on warm IndexedDB), then real app renders. **No modal.** + +- [ ] **Step 3: Soft nav between apps** + +Steps: click `/app/sql-client`, then `/app/redis-commander`, then `/app/environment-manager`. +Expected: each renders immediately. No skeleton, no modal. + +- [ ] **Step 4: Hard refresh on a critical app** + +Steps: while on `/app/sql-client`, hit Cmd+R. +Expected: skeleton flashes briefly, then real app. No modal. + +- [ ] **Step 5: Locked path (no saved key)** + +Steps: open devtools → Application → IndexedDB → delete `MasterKeyDB`. Reload `/app/password-manager`. +Expected: skeleton → `VaultLockedPlaceholder`. Click "Unlock vault" → modal opens. Enter master password → unlock → app renders. No flash. + +- [ ] **Step 6: First-time user (not-configured path)** + +Steps: in a test account that has no master vault yet, navigate to a critical app. +Expected: skeleton → placeholder. Click "Unlock vault" → modal opens in **setup** mode. Complete setup → backup-codes view → acknowledge → app renders. + +- [ ] **Step 7: Network failure path** + +Steps: in devtools Network tab, block `master-vault` request. Reload `/app/password-manager`. +Expected: skeleton → placeholder (no console error UI). Click "Unlock vault" → modal opens. Unblock request, retry — completes normally. + +- [ ] **Step 8: Run the full test suite once more** + +```bash +pnpm --filter web exec tsc --noEmit +pnpm --filter web exec jest +``` + +Expected: green. + +- [ ] **Step 9: Commit anything noted during manual testing (if needed)** + +If a regression appears, fix it in a new commit; do not amend. + +--- + +## Self-review + +- **Spec coverage** + - State machine refactor — Task 1. + - Restorer as sole restoration path — Task 3, backed by pure function in Task 2. + - `useVaultGuard` selector — Task 4. + - Render-by-status pattern across all six critical pages — Task 7. + - Modal simplification (delete `initGate`, loading mode, internal vault) — Task 6. + - New `VaultRestoringSkeleton` — Task 5. + - All five restoration branches tested — Task 2. + - Edge cases (logout → relogin, refresh, soft nav, network failure, server-side vault wipe, gate invariant) — exercised in Task 8 manual steps. + +- **Placeholder scan** — no TBDs, no "implement later", every code block fully populated. + +- **Type consistency** — `VaultStatus` enum identical across store, hook, restorer; `RestoreDeps` matches `restoreVault` consumers; component prop signatures unchanged. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-06-25-master-key-gate-ux.md`. Two execution options: + +1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. +2. **Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. + +Which approach? From 90ced83eba089ae3fe2e994ed9b669af74c96735 Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Thu, 25 Jun 2026 15:40:05 +0530 Subject: [PATCH 03/15] refactor(master-key): introduce vault state machine --- apps/web/src/store/master-key-store.ts | 42 +++++++++++++++++++++----- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/apps/web/src/store/master-key-store.ts b/apps/web/src/store/master-key-store.ts index 2e0d5115..c75d10b1 100644 --- a/apps/web/src/store/master-key-store.ts +++ b/apps/web/src/store/master-key-store.ts @@ -1,34 +1,62 @@ import { create } from "zustand" +import type { MasterVaultOut } from "@/lib/global-vault-api" -export type VaultStatus = "unknown" | "not-configured" | "locked" | "unlocked" +export type VaultStatus = + | "restoring" + | "not-configured" + | "locked" + | "unlocked" interface MasterKeyStore { encryptionKey: CryptoKey | null - isUnlocked: boolean vaultStatus: VaultStatus - /** Controls the vault modal — set true when a critical app needs the key */ + vault: MasterVaultOut | null + restoreError: string | null + isUnlocked: boolean vaultGateOpen: boolean setKey: (key: CryptoKey) => void clearKey: () => void setVaultStatus: (status: VaultStatus) => void + setVault: (vault: MasterVaultOut | null) => void + setRestoreError: (err: string | null) => void openVaultGate: () => void closeVaultGate: () => void } export const useMasterKeyStore = create((set) => ({ encryptionKey: null, + vaultStatus: "restoring", + vault: null, + restoreError: null, isUnlocked: false, - vaultStatus: "unknown", vaultGateOpen: false, setKey: (key) => - set({ encryptionKey: key, isUnlocked: true, vaultStatus: "unlocked", vaultGateOpen: false }), + set({ + encryptionKey: key, + vaultStatus: "unlocked", + isUnlocked: true, + vaultGateOpen: false, + restoreError: null, + }), clearKey: () => - set({ encryptionKey: null, isUnlocked: false, vaultStatus: "unknown" }), + set({ + encryptionKey: null, + vaultStatus: "restoring", + isUnlocked: false, + vault: null, + restoreError: null, + vaultGateOpen: false, + }), + + setVaultStatus: (status) => + set({ vaultStatus: status, isUnlocked: status === "unlocked" }), + + setVault: (vault) => set({ vault }), - setVaultStatus: (status) => set({ vaultStatus: status }), + setRestoreError: (err) => set({ restoreError: err }), openVaultGate: () => set({ vaultGateOpen: true }), closeVaultGate: () => set({ vaultGateOpen: false }), From 2f169046f20ed5c6b29796559f810d5c8f59fb24 Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Thu, 25 Jun 2026 15:46:22 +0530 Subject: [PATCH 04/15] feat(master-key): extract pure restoreVault function with tests Implements Task 2 of the Master Key Gate UX plan. Pure async function with discriminated union RestoreResult (5 branches: not-configured, unlocked, locked-after-failed-verify, locked-no-saved-key, error). Full TDD test coverage validates each branch including clearMasterKey side-effect. Co-Authored-By: Claude Opus 4.7 --- .../src/lib/__tests__/restore-vault.test.ts | 72 +++++++++++++++++++ apps/web/src/lib/restore-vault.ts | 43 +++++++++++ 2 files changed, 115 insertions(+) create mode 100644 apps/web/src/lib/__tests__/restore-vault.test.ts create mode 100644 apps/web/src/lib/restore-vault.ts diff --git a/apps/web/src/lib/__tests__/restore-vault.test.ts b/apps/web/src/lib/__tests__/restore-vault.test.ts new file mode 100644 index 00000000..f05037d5 --- /dev/null +++ b/apps/web/src/lib/__tests__/restore-vault.test.ts @@ -0,0 +1,72 @@ +import { restoreVault, type RestoreDeps } from "../restore-vault" + +const fakeKey = { type: "secret" } as unknown as CryptoKey +const fakeVault = { + salt: "salt", + verifier: { encrypted: "enc", iv: "iv" }, +} as any + +function makeDeps(over: Partial = {}): RestoreDeps { + return { + loadMasterKey: jest.fn().mockResolvedValue(null), + getMasterVaultOrNull: jest.fn().mockResolvedValue(null), + verifyKey: jest.fn().mockResolvedValue(false), + clearMasterKey: jest.fn().mockResolvedValue(undefined), + ...over, + } +} + +describe("restoreVault", () => { + it("returns not-configured when there is no vault on the server", async () => { + const deps = makeDeps({ + getMasterVaultOrNull: jest.fn().mockResolvedValue(null), + }) + const result = await restoreVault(deps) + expect(result).toEqual({ status: "not-configured" }) + }) + + it("returns unlocked when saved key verifies against the vault", async () => { + const deps = makeDeps({ + loadMasterKey: jest.fn().mockResolvedValue(fakeKey), + getMasterVaultOrNull: jest.fn().mockResolvedValue(fakeVault), + verifyKey: jest.fn().mockResolvedValue(true), + }) + const result = await restoreVault(deps) + expect(result).toEqual({ + status: "unlocked", + vault: fakeVault, + key: fakeKey, + }) + expect(deps.clearMasterKey).not.toHaveBeenCalled() + }) + + it("clears the saved key and returns locked when verification fails", async () => { + const clearMasterKey = jest.fn().mockResolvedValue(undefined) + const deps = makeDeps({ + loadMasterKey: jest.fn().mockResolvedValue(fakeKey), + getMasterVaultOrNull: jest.fn().mockResolvedValue(fakeVault), + verifyKey: jest.fn().mockResolvedValue(false), + clearMasterKey, + }) + const result = await restoreVault(deps) + expect(result).toEqual({ status: "locked", vault: fakeVault }) + expect(clearMasterKey).toHaveBeenCalledTimes(1) + }) + + it("returns locked with the vault cached when no key is stored", async () => { + const deps = makeDeps({ + loadMasterKey: jest.fn().mockResolvedValue(null), + getMasterVaultOrNull: jest.fn().mockResolvedValue(fakeVault), + }) + const result = await restoreVault(deps) + expect(result).toEqual({ status: "locked", vault: fakeVault }) + }) + + it("returns error when the vault fetch throws", async () => { + const deps = makeDeps({ + getMasterVaultOrNull: jest.fn().mockRejectedValue(new Error("net down")), + }) + const result = await restoreVault(deps) + expect(result).toEqual({ status: "error", message: "net down" }) + }) +}) diff --git a/apps/web/src/lib/restore-vault.ts b/apps/web/src/lib/restore-vault.ts new file mode 100644 index 00000000..b5a816b8 --- /dev/null +++ b/apps/web/src/lib/restore-vault.ts @@ -0,0 +1,43 @@ +import type { MasterVaultOut } from "@/lib/global-vault-api" + +export type RestoreResult = + | { status: "not-configured" } + | { status: "locked"; vault: MasterVaultOut } + | { status: "unlocked"; vault: MasterVaultOut; key: CryptoKey } + | { status: "error"; message: string } + +export interface RestoreDeps { + loadMasterKey: () => Promise + getMasterVaultOrNull: () => Promise + verifyKey: ( + key: CryptoKey, + encrypted: string, + iv: string, + ) => Promise + clearMasterKey: () => Promise +} + +export async function restoreVault(deps: RestoreDeps): Promise { + try { + const vault = await deps.getMasterVaultOrNull() + if (!vault) return { status: "not-configured" } + + const savedKey = await deps.loadMasterKey() + if (!savedKey) return { status: "locked", vault } + + const valid = await deps.verifyKey( + savedKey, + vault.verifier.encrypted, + vault.verifier.iv, + ) + if (!valid) { + await deps.clearMasterKey() + return { status: "locked", vault } + } + + return { status: "unlocked", vault, key: savedKey } + } catch (err) { + const message = err instanceof Error ? err.message : "Restore failed" + return { status: "error", message } + } +} From fe7f1a72102263c8d8c3b056140c0f858ca5425e Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Thu, 25 Jun 2026 15:49:56 +0530 Subject: [PATCH 05/15] refactor(master-key): make restorer the sole restoration path --- apps/web/src/app/app/app-content.tsx | 60 ++++++++++++++++------------ 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/apps/web/src/app/app/app-content.tsx b/apps/web/src/app/app/app-content.tsx index 48c503fa..ab895a23 100644 --- a/apps/web/src/app/app/app-content.tsx +++ b/apps/web/src/app/app/app-content.tsx @@ -1,47 +1,57 @@ 'use client'; -import React, { useEffect } from 'react'; +import React, { useEffect, useRef } from 'react'; import { ClientLayout } from '../../components/sidebar/client-layout'; import { RequireAuth } from '@/components/require-auth'; import { MasterPasswordGate } from '@/components/master-password-gate'; import { useMasterKeyStore } from '@/store/master-key-store'; -import { loadMasterKey } from '@/lib/key-storage'; +import { loadMasterKey, clearMasterKey } from '@/lib/key-storage'; import { getMasterVaultOrNull } from '@/lib/global-vault-api'; import { verifyKey } from '@/lib/encryption'; +import { restoreVault } from '@/lib/restore-vault'; import useAuth from '@/utils/useAuth'; -// Silently restores the encryption key from IndexedDB on login so critical -// apps that are visited after a page refresh don't need to re-enter the password. +// Single restoration path. Runs once per signed-in user mount. Mutates the +// store with the final state — modal and pages read from store only. function VaultKeyRestorer() { const { user } = useAuth(false); - const { isUnlocked, setKey, setVaultStatus } = useMasterKeyStore(); + const { vaultStatus, setKey, setVaultStatus, setVault, setRestoreError } = + useMasterKeyStore(); + const ranRef = useRef(false); useEffect(() => { - if (!user || isUnlocked) return; + if (!user || vaultStatus !== 'restoring' || ranRef.current) return; + ranRef.current = true; - async function tryRestoreKey() { - try { - const savedKey = await loadMasterKey(); - if (!savedKey) return; + (async () => { + const result = await restoreVault({ + loadMasterKey, + getMasterVaultOrNull, + verifyKey, + clearMasterKey, + }); - const vaultData = await getMasterVaultOrNull(); - if (!vaultData) { + switch (result.status) { + case 'not-configured': + setVault(null); setVaultStatus('not-configured'); return; - } - - const valid = await verifyKey(savedKey, vaultData.verifier.encrypted, vaultData.verifier.iv); - if (valid) { - setKey(savedKey); - } - } catch { - // Silent — modal will handle errors when user navigates to a critical app + case 'unlocked': + setVault(result.vault); + setKey(result.key); + return; + case 'locked': + setVault(result.vault); + setVaultStatus('locked'); + return; + case 'error': + setRestoreError(result.message); + setVaultStatus('locked'); + return; } - } - - tryRestoreKey(); + })(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user]); + }, [user, vaultStatus]); return null; } @@ -49,9 +59,7 @@ function VaultKeyRestorer() { export function AppContent({ children }: { children: React.ReactNode }) { return ( - {/* Modal renders when a critical app calls openVaultGate() */} - {/* Silent key restorer — no UI, no blocking */} {children} From 5bd506199b69828922987fd5bb7a1f754d710697 Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Thu, 25 Jun 2026 15:53:04 +0530 Subject: [PATCH 06/15] refactor(master-key): make useVaultGuard a pure selector --- apps/web/src/hooks/use-vault-guard.ts | 28 +++++++++++---------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/apps/web/src/hooks/use-vault-guard.ts b/apps/web/src/hooks/use-vault-guard.ts index 5fa1fc28..0ecac001 100644 --- a/apps/web/src/hooks/use-vault-guard.ts +++ b/apps/web/src/hooks/use-vault-guard.ts @@ -1,25 +1,19 @@ "use client" -import { useEffect } from "react" -import { useMasterKeyStore } from "@/store/master-key-store" +import { useMasterKeyStore, type VaultStatus } from "@/store/master-key-store" /** - * Call this in any page that requires the vault to be unlocked. - * Opens the vault modal if the key isn't already in memory. - * Returns isUnlocked + openVaultGate so the page can render a locked placeholder with a re-open button. + * Read-only vault state for critical pages. Does NOT open the modal. + * Render the locked placeholder, which has the user-triggered Unlock button. */ export function useVaultGuard() { - const { isUnlocked, openVaultGate, closeVaultGate } = useMasterKeyStore() + const vaultStatus = useMasterKeyStore((s) => s.vaultStatus) + const openVaultGate = useMasterKeyStore((s) => s.openVaultGate) - useEffect(() => { - if (!isUnlocked) { - openVaultGate() - } - return () => { - closeVaultGate() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - return { isUnlocked, openVaultGate } + return { + status: vaultStatus as VaultStatus, + isUnlocked: vaultStatus === "unlocked", + isRestoring: vaultStatus === "restoring", + openVaultGate, + } } From a5e2bb57ae57d5dfb5e493418a212de03b23bc33 Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Thu, 25 Jun 2026 15:55:07 +0530 Subject: [PATCH 07/15] feat(master-key): add VaultRestoringSkeleton placeholder --- .../components/vault-restoring-skeleton.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 apps/web/src/components/vault-restoring-skeleton.tsx diff --git a/apps/web/src/components/vault-restoring-skeleton.tsx b/apps/web/src/components/vault-restoring-skeleton.tsx new file mode 100644 index 00000000..979e8be5 --- /dev/null +++ b/apps/web/src/components/vault-restoring-skeleton.tsx @@ -0,0 +1,28 @@ +"use client" + +import { Skeleton } from "@/components/ui/skeleton" + +/** + * Shown while the master-key restorer is still running on app boot. + * Generic shell — fine for every critical app since restore finishes in + * milliseconds and the user never reads it. + */ +export function VaultRestoringSkeleton() { + return ( +
+
+ + +
+
+ + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ) +} From 6dee9a237b9eea28991ab84e82e516b747b36220 Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Thu, 25 Jun 2026 16:00:57 +0530 Subject: [PATCH 08/15] refactor(master-password-gate): drop internal restore, read from store Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/master-password-gate.tsx | 132 ++++-------------- 1 file changed, 25 insertions(+), 107 deletions(-) diff --git a/apps/web/src/components/master-password-gate.tsx b/apps/web/src/components/master-password-gate.tsx index 35fde7cb..e43a1507 100644 --- a/apps/web/src/components/master-password-gate.tsx +++ b/apps/web/src/components/master-password-gate.tsx @@ -1,6 +1,6 @@ "use client" -import React, { useEffect, useRef, useState } from "react" +import React, { useEffect, useState } from "react" import { motion, AnimatePresence } from "framer-motion" import { AlertTriangle, @@ -37,14 +37,12 @@ import { encryptWithBackupCode, decryptWithBackupCode, } from "@/lib/encryption" -import { saveMasterKey, loadMasterKey, clearMasterKey } from "@/lib/key-storage" +import { saveMasterKey } from "@/lib/key-storage" import { - getMasterVaultOrNull, setupMasterVault, storeBackupCodes, lookupBackupCode, markBackupCodeUsed, - type MasterVaultOut, } from "@/lib/global-vault-api" import { useMasterKeyStore } from "@/store/master-key-store" import { calcStrength } from "./master-password-gate/password-strength" @@ -53,15 +51,14 @@ import { Spinner, ErrorBanner } from "./master-password-gate/gate-helpers" // ── Gate modal ──────────────────────────────────────────────────────────────── -type GateMode = "loading" | "setup" | "backup-codes" | "unlock" | "use-backup-code" +type GateMode = "setup" | "backup-codes" | "unlock" | "use-backup-code" export function MasterPasswordGate() { const { user } = useAuth(false) - const { isUnlocked, vaultStatus, vaultGateOpen, setKey, setVaultStatus, closeVaultGate } = + const { isUnlocked, vault, vaultStatus, vaultGateOpen, setKey, closeVaultGate } = useMasterKeyStore() - const [mode, setMode] = useState("loading") - const [vault, setVault] = useState(null) + const [mode, setMode] = useState("unlock") const [password, setPassword] = useState("") const [confirmPassword, setConfirmPassword] = useState("") const [showPassword, setShowPassword] = useState(false) @@ -72,64 +69,19 @@ export function MasterPasswordGate() { const [backupCodesAcknowledged, setBackupCodesAcknowledged] = useState(false) const [backupCodeInput, setBackupCodeInput] = useState("") const [copiedIndex, setCopiedIndex] = useState(null) - const initRef = useRef(false) const strength = calcStrength(password) const confirmMismatch = confirmPassword.length > 0 && confirmPassword !== password - // Run initGate when the modal opens (not on every page load) - useEffect(() => { - if (!vaultGateOpen || isUnlocked || !user || initRef.current) return - initRef.current = true - initGate() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [vaultGateOpen, user, isUnlocked]) - - // Reset initRef when modal closes so it re-runs if reopened after session clear useEffect(() => { if (!vaultGateOpen) { - initRef.current = false - setMode("loading") resetForm() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [vaultGateOpen]) - - const initGate = async () => { - if (vaultStatus === "not-configured") { - setMode("setup") - return - } - try { - const vaultData = await getMasterVaultOrNull() - if (!vaultData) { - setVaultStatus("not-configured") - setMode("setup") - return - } - setVault(vaultData) - setVaultStatus("locked") - setMode("unlock") - - const savedKey = await loadMasterKey() - if (savedKey) { - const valid = await verifyKey( - savedKey, - vaultData.verifier.encrypted, - vaultData.verifier.iv, - ) - if (valid) { - setKey(savedKey) - return - } - await clearMasterKey() - } - } catch (err) { - console.error("[MasterPasswordGate] init error:", err) - setError("Could not connect. Please refresh the page.") setMode("unlock") + return } - } + if (vaultStatus === "not-configured") setMode("setup") + else setMode("unlock") + }, [vaultGateOpen, vaultStatus]) // ── helpers ────────────────────────────────────────────────────────────── @@ -172,8 +124,7 @@ export function MasterPasswordGate() { const salt = await generateSalt() const key = await deriveKey(password, salt) const verifierData = await createKeyVerifier(key) - const vaultData = await setupMasterVault({ salt, verifier: verifierData }) - setVault(vaultData) + await setupMasterVault({ salt, verifier: verifierData }) const codes = generateBackupCodes(8) const encryptedCodes = await Promise.all( @@ -272,7 +223,7 @@ export function MasterPasswordGate() { // After setup, show backup codes step — keep modal open until acknowledged const showBackupCodes = mode === "backup-codes" && !backupCodesAcknowledged - const dialogOpen = (vaultGateOpen && !isUnlocked) || showBackupCodes + const dialogOpen = vaultGateOpen || showBackupCodes if (!user) return null @@ -391,17 +342,11 @@ export function MasterPasswordGate() { {/* Icon + heading */}
-
- {mode === "loading" ? ( - - - - ) : isSetup ? ( + {isSetup ? ( ) : ( @@ -427,39 +365,21 @@ export function MasterPasswordGate() {

- {mode === "loading" - ? "Checking vault…" - : isSetup - ? "Create Master Password" - : isBackupCodeMode - ? "Use Backup Code" - : "Unlock Your Data"} + {isSetup + ? "Create Master Password" + : isBackupCodeMode + ? "Use Backup Code" + : "Unlock Your Data"}

- {mode === "loading" - ? "Verifying your encryption keys" - : isSetup - ? "Encrypts your sensitive data client-side. Never leaves your device." - : isBackupCodeMode - ? "Enter one of your saved backup codes to recover access." - : "Enter your master password to decrypt and access your data."} + {isSetup + ? "Encrypts your sensitive data client-side. Never leaves your device." + : isBackupCodeMode + ? "Enter one of your saved backup codes to recover access." + : "Enter your master password to decrypt and access your data."}

- {/* Loading progress */} - {mode === "loading" && ( -
-
- -
-
- )} - {/* Setup warning */} {isSetup && ( @@ -482,9 +402,8 @@ export function MasterPasswordGate() { {/* Forms */} - {mode !== "loading" && ( - - {isBackupCodeMode ? ( + + {isBackupCodeMode ? ( )} - )}

From cdf1311b01d28836688ff7b51b41d46e2d13ebbe Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Thu, 25 Jun 2026 16:09:36 +0530 Subject: [PATCH 09/15] feat(master-key): render skeleton during vault restore --- apps/web/src/app/app/database-explorer/page.tsx | 4 +++- apps/web/src/app/app/environment-manager/page.tsx | 4 +++- apps/web/src/app/app/password-manager/page.tsx | 4 +++- apps/web/src/app/app/redis-commander/page.tsx | 4 +++- apps/web/src/app/app/s3-drive/page.tsx | 6 ++++-- apps/web/src/app/app/sql-client/page.tsx | 4 +++- 6 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/app/database-explorer/page.tsx b/apps/web/src/app/app/database-explorer/page.tsx index 2a1f6881..058838fa 100644 --- a/apps/web/src/app/app/database-explorer/page.tsx +++ b/apps/web/src/app/app/database-explorer/page.tsx @@ -12,6 +12,7 @@ import useAuth from "@/utils/useAuth"; import { useMasterKeyStore } from "@/store/master-key-store"; import { useVaultGuard } from "@/hooks/use-vault-guard"; import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder"; +import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton"; import { getConnections } from "@/components/nosql-explorer/connection-service"; import { cn } from "@/lib/utils"; import { IconDatabase, IconServer, IconBrandMongodb, IconSearch, IconPlus, IconArrowLeft, IconMenu2 } from "@tabler/icons-react"; @@ -40,7 +41,7 @@ export default function NoSQLExplorerPage() { const t = useTranslations("NoSqlExplorer.page"); const { user } = useAuth(); const { encryptionKey } = useMasterKeyStore(); - const { isUnlocked } = useVaultGuard(); + const { isUnlocked, isRestoring } = useVaultGuard(); // We still keep some state for the "active" context if needed, but mostly driven by tabs now const [state, setState] = useState({ isConnected: false, @@ -628,6 +629,7 @@ export default function NoSQLExplorerPage() { const isDesktop = useMediaQuery("(min-width: 768px)"); + if (isRestoring) return ; if (!isUnlocked) return return ( diff --git a/apps/web/src/app/app/environment-manager/page.tsx b/apps/web/src/app/app/environment-manager/page.tsx index b1a9842b..cd484cc1 100644 --- a/apps/web/src/app/app/environment-manager/page.tsx +++ b/apps/web/src/app/app/environment-manager/page.tsx @@ -7,6 +7,7 @@ import { useEnvironmentManagerStore, type EnvSetEntry } from "@/store/environmen import { useMasterKeyStore } from "@/store/master-key-store" import { useVaultGuard } from "@/hooks/use-vault-guard" import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder" +import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton" import useAuth from "@/utils/useAuth" import { useIsMobile } from "@/components/hooks/use-mobile" import { useTranslations } from "next-intl" @@ -20,7 +21,7 @@ export default function EnvironmentManagerPage() { const t = useTranslations("EnvironmentManager.page") const { user, loading } = useAuth(true) const { encryptionKey } = useMasterKeyStore() - const { isUnlocked } = useVaultGuard() + const { isUnlocked, isRestoring } = useVaultGuard() const { setSets, setLoading, clearSets } = useEnvironmentManagerStore() const isMobile = useIsMobile() const loadedRef = useRef(false) @@ -70,6 +71,7 @@ export default function EnvironmentManagerPage() { } } + if (isRestoring) return if (!isUnlocked) return if (loading) { diff --git a/apps/web/src/app/app/password-manager/page.tsx b/apps/web/src/app/app/password-manager/page.tsx index cb2a3d3a..1e5ab93b 100644 --- a/apps/web/src/app/app/password-manager/page.tsx +++ b/apps/web/src/app/app/password-manager/page.tsx @@ -7,6 +7,7 @@ import { usePasswordStore, type PasswordEntry } from "@/store/password-store" import { useMasterKeyStore } from "@/store/master-key-store" import { useVaultGuard } from "@/hooks/use-vault-guard" import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder" +import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton" import useAuth from "@/utils/useAuth" import { useIsMobile } from "@/components/hooks/use-mobile" import { useTranslations } from "next-intl" @@ -22,7 +23,7 @@ export default function PasswordManagerPage() { const t = useTranslations("PasswordManager.page") const { user, loading } = useAuth(true) const { encryptionKey } = useMasterKeyStore() - const { isUnlocked } = useVaultGuard() + const { isUnlocked, isRestoring } = useVaultGuard() const { setPasswords, setLoading, clearPasswords } = usePasswordStore() const isMobile = useIsMobile() const loadedRef = useRef(false) @@ -73,6 +74,7 @@ export default function PasswordManagerPage() { } } + if (isRestoring) return if (!isUnlocked) return if (loading) { diff --git a/apps/web/src/app/app/redis-commander/page.tsx b/apps/web/src/app/app/redis-commander/page.tsx index 8e13f052..160e4c31 100644 --- a/apps/web/src/app/app/redis-commander/page.tsx +++ b/apps/web/src/app/app/redis-commander/page.tsx @@ -45,6 +45,7 @@ import useAuth from "@/utils/useAuth"; import { useMasterKeyStore } from "@/store/master-key-store"; import { useVaultGuard } from "@/hooks/use-vault-guard"; import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder"; +import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton"; import { useMediaQuery } from "@/hooks/use-media-query"; import { ConnectionForm } from "@/components/redis-commander/connection-form"; @@ -69,7 +70,7 @@ function newTabId() { export default function RedisCommanderPage() { const { user } = useAuth(); const { encryptionKey } = useMasterKeyStore(); - const { isUnlocked } = useVaultGuard(); + const { isUnlocked, isRestoring } = useVaultGuard(); const isDesktop = useMediaQuery("(min-width: 768px)"); const [connections, setConnections] = useState([]); @@ -172,6 +173,7 @@ export default function RedisCommanderPage() { } } + if (isRestoring) return ; if (!isUnlocked) { return ( diff --git a/apps/web/src/app/app/s3-drive/page.tsx b/apps/web/src/app/app/s3-drive/page.tsx index e23a635b..5085f263 100644 --- a/apps/web/src/app/app/s3-drive/page.tsx +++ b/apps/web/src/app/app/s3-drive/page.tsx @@ -6,6 +6,7 @@ import useAuth from "@/utils/useAuth" import { useMasterKeyStore } from "@/store/master-key-store" import { useVaultGuard } from "@/hooks/use-vault-guard" import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder" +import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton" import { useS3DriveStore } from "@/store/s3-drive-store" import { listConnections } from "@/lib/s3-drive-api" import { decryptData } from "@/lib/encryption" @@ -17,8 +18,8 @@ import { cn } from "@/lib/utils" export default function S3DrivePage() { const { user, loading: authLoading } = useAuth(true) - const { encryptionKey, isUnlocked } = useMasterKeyStore() - useVaultGuard() + const { encryptionKey } = useMasterKeyStore() + const { isUnlocked, isRestoring } = useVaultGuard() const { connections, activeConnectionId, setConnections } = useS3DriveStore() const [booting, setBooting] = useState(true) const loadedRef = useRef(false) @@ -104,6 +105,7 @@ export default function S3DrivePage() { ) } + if (isRestoring) return if (!isUnlocked || !encryptionKey) return const activeConn = connections.find((c) => c.id === activeConnectionId) ?? null diff --git a/apps/web/src/app/app/sql-client/page.tsx b/apps/web/src/app/app/sql-client/page.tsx index b24a592f..7c18022a 100644 --- a/apps/web/src/app/app/sql-client/page.tsx +++ b/apps/web/src/app/app/sql-client/page.tsx @@ -20,6 +20,7 @@ import useAuth from "@/utils/useAuth"; import { useMasterKeyStore } from "@/store/master-key-store"; import { useVaultGuard } from "@/hooks/use-vault-guard"; import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder"; +import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton"; import { useMediaQuery } from "@/hooks/use-media-query"; import { useTranslations } from "next-intl"; @@ -37,7 +38,7 @@ export default function SqlClientPage() { const t = useTranslations("SqlClient.page"); const { user } = useAuth(); const { encryptionKey } = useMasterKeyStore(); - const { isUnlocked } = useVaultGuard(); + const { isUnlocked, isRestoring } = useVaultGuard(); const isDesktop = useMediaQuery("(min-width: 768px)"); const [connections, setConnections] = useState([]); @@ -162,6 +163,7 @@ export default function SqlClientPage() { /> ); + if (isRestoring) return ; if (!isUnlocked) return return ( From bf40df750003654ffce4c49826a42150cbaa4245 Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Thu, 25 Jun 2026 16:19:33 +0530 Subject: [PATCH 10/15] fix(master-key): repair relogin restore + recover from boot-time vault fetch error Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/app/app/app-content.tsx | 6 +++- .../src/components/master-password-gate.tsx | 31 +++++++++++++++---- apps/web/src/hooks/use-vault-guard.ts | 6 ++-- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/app/app-content.tsx b/apps/web/src/app/app/app-content.tsx index ab895a23..52a5099f 100644 --- a/apps/web/src/app/app/app-content.tsx +++ b/apps/web/src/app/app/app-content.tsx @@ -20,7 +20,11 @@ function VaultKeyRestorer() { const ranRef = useRef(false); useEffect(() => { - if (!user || vaultStatus !== 'restoring' || ranRef.current) return; + if (vaultStatus !== 'restoring') { + ranRef.current = false; + return; + } + if (!user || ranRef.current) return; ranRef.current = true; (async () => { diff --git a/apps/web/src/components/master-password-gate.tsx b/apps/web/src/components/master-password-gate.tsx index e43a1507..b833916c 100644 --- a/apps/web/src/components/master-password-gate.tsx +++ b/apps/web/src/components/master-password-gate.tsx @@ -55,8 +55,17 @@ type GateMode = "setup" | "backup-codes" | "unlock" | "use-backup-code" export function MasterPasswordGate() { const { user } = useAuth(false) - const { isUnlocked, vault, vaultStatus, vaultGateOpen, setKey, closeVaultGate } = - useMasterKeyStore() + const { + isUnlocked, + vault, + vaultStatus, + vaultGateOpen, + restoreError, + setKey, + setVaultStatus, + setRestoreError, + closeVaultGate, + } = useMasterKeyStore() const [mode, setMode] = useState("unlock") const [password, setPassword] = useState("") @@ -79,9 +88,19 @@ export function MasterPasswordGate() { setMode("unlock") return } - if (vaultStatus === "not-configured") setMode("setup") - else setMode("unlock") - }, [vaultGateOpen, vaultStatus]) + if (vaultStatus === "not-configured") { + setMode("setup") + return + } + // Recovery: if the gate opens while locked but vault was never cached + // (boot-time restore errored), retry restoration once. + if (vaultStatus === "locked" && !vault) { + setRestoreError(null) + setVaultStatus("restoring") + return + } + setMode("unlock") + }, [vaultGateOpen, vaultStatus, vault, setRestoreError, setVaultStatus]) // ── helpers ────────────────────────────────────────────────────────────── @@ -590,7 +609,7 @@ export function MasterPasswordGate() { - {error && } + {(error || restoreError) && } + ` + row.querySelector("button").addEventListener("click", () => { + chrome.runtime.sendMessage({ type: "send-to-mydevtools", payload: r }, (resp) => { + if (resp?.ok) window.close() + }) + }) + list.appendChild(row) + } +}) diff --git a/apps/web/public/api-client-sw.js b/apps/web/public/api-client-sw.js new file mode 100644 index 00000000..d0622e7a --- /dev/null +++ b/apps/web/public/api-client-sw.js @@ -0,0 +1,63 @@ +/** + * API-client service worker. + * + * Two jobs: + * 1. Cache GET responses from `/api/backend/api-client/*` with stale-while-revalidate + * so the sidebar / collections render from cache offline. + * 2. Pass-through for everything else. + * + * Scope limited to /app/api-client paths so we don't shadow the rest of the site. + */ + +const CACHE_NAME = "mdt-api-client-v1" +const CACHED_PREFIXES = [ + "/api/backend/api-client/collections", + "/api/backend/api-client/environments", + "/api/backend/api-client/history", +] + +self.addEventListener("install", (event) => { + self.skipWaiting() + event.waitUntil(caches.open(CACHE_NAME)) +}) + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) + ) + ) + self.clients.claim() +}) + +function isCacheable(url) { + return CACHED_PREFIXES.some((p) => url.pathname.startsWith(p)) +} + +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") return + const url = new URL(event.request.url) + if (!isCacheable(url)) return + + event.respondWith((async () => { + const cache = await caches.open(CACHE_NAME) + const cached = await cache.match(event.request) + const networkPromise = fetch(event.request) + .then(async (res) => { + if (res.ok) await cache.put(event.request, res.clone()) + return res + }) + .catch(() => null) + if (cached) { + // Refresh in background. + event.waitUntil(networkPromise) + return cached + } + const fresh = await networkPromise + if (fresh) return fresh + return new Response(JSON.stringify({ error: "offline + no cached copy" }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }) + })()) +}) diff --git a/apps/web/src/app/api-client/share/page.tsx b/apps/web/src/app/api-client/share/page.tsx new file mode 100644 index 00000000..920663e1 --- /dev/null +++ b/apps/web/src/app/api-client/share/page.tsx @@ -0,0 +1,142 @@ +"use client" + +import * as React from "react" +import Link from "next/link" +import { decodeCollectionShareFragment } from "@/lib/share-link" +import { exportPostmanCollection, downloadCollectionAsPostman } from "@/lib/export/postman" +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Folder, FileDown, ArrowRight, AlertCircle } from "lucide-react" +import { cn } from "@/lib/utils" +import type { Collection, CollectionFolder, CollectionRequest } from "@/components/api-client/types" + +function methodColor(method: string): string { + switch (method) { + case "GET": return "text-sky-500" + case "POST": return "text-emerald-500" + case "PUT": return "text-amber-500" + case "DELETE": return "text-rose-500" + case "PATCH": return "text-yellow-500" + default: return "text-muted-foreground" + } +} + +function RequestRow({ req }: { req: CollectionRequest }) { + return ( +

+ + {req.method} + + {req.name} + {req.url} +
+ ) +} + +function ItemsList({ items, level = 0 }: { items: Array; level?: number }) { + return ( +
+ {items.map((item) => { + if ("type" in item && item.type === "folder") { + return ( +
+ + + {item.name} + ({item.items.length}) + + +
+ ) + } + return + })} +
+ ) +} + +export default function ShareViewPage() { + const [collection, setCollection] = React.useState(null) + const [error, setError] = React.useState(null) + + React.useEffect(() => { + const fragment = (window.location.hash || "").replace(/^#/, "") + if (!fragment) { + setError("No share payload in URL fragment") + return + } + decodeCollectionShareFragment(fragment) + .then(setCollection) + .catch((e) => setError((e as Error).message)) + }, []) + + const count = React.useMemo(() => { + if (!collection) return 0 + const walk = (items: Array): number => + items.reduce((n, it) => n + ("type" in it && it.type === "folder" ? walk(it.items) : 1), 0) + return walk(collection.items) + }, [collection]) + + const handleCopyPostman = async () => { + if (!collection) return + try { + await navigator.clipboard.writeText(exportPostmanCollection(collection)) + } catch { /* noop */ } + } + + return ( +
+
+
+

Shared collection

+ + Open API client + +
+ + {error && ( + +
+ +
+
Could not load share
+
{error}
+
+
+
+ )} + + {collection && ( + <> + +
+
{collection.name}
+
{count} request{count === 1 ? "" : "s"}
+
+
+ + +
+
+ + + + + + + +

+ Snapshot stored in the URL fragment — nothing reaches the server. + Editing this view will not modify the original. +

+ + )} +
+
+ ) +} diff --git a/apps/web/src/app/api/mock/[collectionId]/[[...path]]/route.ts b/apps/web/src/app/api/mock/[collectionId]/[[...path]]/route.ts new file mode 100644 index 00000000..3d09af0f --- /dev/null +++ b/apps/web/src/app/api/mock/[collectionId]/[[...path]]/route.ts @@ -0,0 +1,124 @@ +/** + * Mock-server endpoint. Resolves an incoming request against the saved examples + * inside a collection and replays the matching one. + * + * URL shape: + * /api/mock///? + * + * Matching rules (RFC-ish lite, ponytail): + * 1. Method match — case-insensitive. + * 2. Pathname match — the trailing `/` segment is compared + * against each example's original `request.url` pathname. Exact match only. + * 3. First hit wins. Iteration order = walk-collection order. + * + * The endpoint requires the same session the API client uses (Firebase cookie + * forwarded to backend). Anonymous public mocks would need a backend-side mockId + * index — deliberate skip for v1; the collection.id IS the mockId here. + */ + +import { requireBackendSession } from "@/lib/require-backend-session" +import { NextRequest, NextResponse } from "next/server" +import type { + Collection, + CollectionFolder, + CollectionRequest, + SavedExample, +} from "@/components/api-client/types" + +export const runtime = "nodejs" + +const FASTAPI_BASE_URL = process.env.NEXT_PUBLIC_FASTAPI_BASE_URL || "http://localhost:8000" + +interface RouteContext { + params: Promise<{ collectionId: string; path?: string[] }> +} + +function* walkRequests(items: Array): Generator { + for (const item of items) { + if ("type" in item && item.type === "folder") yield* walkRequests(item.items) + else yield item as CollectionRequest + } +} + +function exampleMatches(ex: SavedExample, method: string, pathname: string): boolean { + if (ex.request.method.toUpperCase() !== method.toUpperCase()) return false + let storedPath: string + try { storedPath = new URL(ex.request.url).pathname || "/" } catch { storedPath = ex.request.url || "/" } + return storedPath === pathname +} + +// Hop-by-hop + identity-revealing headers we never replay back to clients. +const RESPONSE_HEADERS_TO_DROP = new Set([ + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailers", "transfer-encoding", "upgrade", + "content-encoding", "content-length", // re-derived by Next from body + "set-cookie", // would land on the user's mydevtools origin, never what they want +]) + +async function fetchCollectionViaBackend(req: NextRequest, collectionId: string): Promise { + // Forward our session cookie to the backend list endpoint and pick the matching collection. + // There's no per-id GET on the backend yet (would be the right home eventually). + const cookie = req.headers.get("cookie") ?? "" + const res = await fetch(`${FASTAPI_BASE_URL.replace(/\/$/, "")}/api-client/collections`, { + headers: { cookie }, + }) + if (!res.ok) return null + const list = (await res.json()) as Collection[] + return list.find((c) => c.id === collectionId) ?? null +} + +async function handle(req: NextRequest, ctx: RouteContext): Promise { + const authError = await requireBackendSession(req) + if (authError) return authError + + const { collectionId, path } = await ctx.params + const pathname = "/" + (path ?? []).join("/") + const method = req.method.toUpperCase() + + const collection = await fetchCollectionViaBackend(req, collectionId) + if (!collection) { + return NextResponse.json( + { error: `No mock server: collection ${collectionId} not found` }, + { status: 404 }, + ) + } + + for (const r of walkRequests(collection.items)) { + if (!r.examples) continue + for (const ex of r.examples) { + if (!exampleMatches(ex, method, pathname)) continue + + const headers = new Headers() + for (const [k, v] of Object.entries(ex.response.headers ?? {})) { + if (!RESPONSE_HEADERS_TO_DROP.has(k.toLowerCase())) headers.set(k, v) + } + headers.set("X-Mdt-Mock-Source", `${collection.name} / ${r.name} / ${ex.name}`) + + const body = ex.response.isBase64 + ? Buffer.from(ex.response.body, "base64") + : ex.response.body + return new NextResponse(body, { + status: ex.response.status || 200, + statusText: ex.response.statusText || "OK", + headers, + }) + } + } + + return NextResponse.json( + { + error: "No matching example", + collection: collection.name, + tried: { method, path: pathname }, + }, + { status: 404 }, + ) +} + +export const GET = handle +export const POST = handle +export const PUT = handle +export const PATCH = handle +export const DELETE = handle +export const HEAD = handle +export const OPTIONS = handle diff --git a/apps/web/src/app/api/mock/public/[mockId]/[[...path]]/route.ts b/apps/web/src/app/api/mock/public/[mockId]/[[...path]]/route.ts new file mode 100644 index 00000000..fa924260 --- /dev/null +++ b/apps/web/src/app/api/mock/public/[mockId]/[[...path]]/route.ts @@ -0,0 +1,105 @@ +/** + * Anonymously-callable mock endpoint. + * + * URL shape: + * /api/mock/public///? + * + * No `requireBackendSession` gate — `mockId` itself is the credential + * (~144 bits of entropy). The backend's GET /public-mock/{mock_id} is also + * unauthenticated, so this route runs without forwarding any cookie. + * + * Reuses the same matcher semantics as the authed mock route: method-sensitive, + * exact-pathname match, first-hit wins. + */ + +import { NextRequest, NextResponse } from "next/server" +import type { + CollectionFolder, + CollectionRequest, + SavedExample, +} from "@/components/api-client/types" + +export const runtime = "nodejs" + +const FASTAPI_BASE_URL = process.env.NEXT_PUBLIC_FASTAPI_BASE_URL || "http://localhost:8000" + +interface RouteContext { + params: Promise<{ mockId: string; path?: string[] }> +} + +interface PublicMockShape { + mock_id: string + name: string + items: Array +} + +function* walkRequests(items: Array): Generator { + for (const item of items) { + if ("type" in item && item.type === "folder") yield* walkRequests(item.items) + else yield item as CollectionRequest + } +} + +function exampleMatches(ex: SavedExample, method: string, pathname: string): boolean { + if (ex.request.method.toUpperCase() !== method.toUpperCase()) return false + let storedPath: string + try { storedPath = new URL(ex.request.url).pathname || "/" } catch { storedPath = ex.request.url || "/" } + return storedPath === pathname +} + +const RESPONSE_HEADERS_TO_DROP = new Set([ + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailers", "transfer-encoding", "upgrade", + "content-encoding", "content-length", + "set-cookie", +]) + +async function handle(req: NextRequest, ctx: RouteContext): Promise { + const { mockId, path } = await ctx.params + const pathname = "/" + (path ?? []).join("/") + const method = req.method.toUpperCase() + + const res = await fetch(`${FASTAPI_BASE_URL.replace(/\/$/, "")}/api-client/public-mock/${encodeURIComponent(mockId)}`) + if (res.status === 404) { + return NextResponse.json({ error: "Public mock not found" }, { status: 404 }) + } + if (!res.ok) { + return NextResponse.json({ error: "Upstream mock lookup failed" }, { status: 502 }) + } + const mock = (await res.json()) as PublicMockShape + + for (const r of walkRequests(mock.items ?? [])) { + if (!r.examples) continue + for (const ex of r.examples) { + if (!exampleMatches(ex, method, pathname)) continue + const headers = new Headers() + for (const [k, v] of Object.entries(ex.response.headers ?? {})) { + if (!RESPONSE_HEADERS_TO_DROP.has(k.toLowerCase())) headers.set(k, v) + } + headers.set("X-Mdt-Mock-Source", `${mock.name} / ${r.name} / ${ex.name}`) + headers.set("X-Mdt-Mock-Public", "1") + const body = ex.response.isBase64 + ? Buffer.from(ex.response.body, "base64") + : ex.response.body + return new NextResponse(body, { + status: ex.response.status || 200, + statusText: ex.response.statusText || "OK", + headers, + }) + } + } + + return NextResponse.json({ + error: "No matching example", + mock: mock.name, + tried: { method, path: pathname }, + }, { status: 404 }) +} + +export const GET = handle +export const POST = handle +export const PUT = handle +export const PATCH = handle +export const DELETE = handle +export const HEAD = handle +export const OPTIONS = handle diff --git a/apps/web/src/app/api/proxy-grpc/route.ts b/apps/web/src/app/api/proxy-grpc/route.ts new file mode 100644 index 00000000..a3477ffe --- /dev/null +++ b/apps/web/src/app/api/proxy-grpc/route.ts @@ -0,0 +1,191 @@ +/** + * Native gRPC proxy. Browsers can't open raw HTTP/2 sockets, so this route + * tunnels gRPC calls on the user's behalf: + * + * browser → POST /api/proxy-grpc { url, body(b64) } ← HTTP/1.1, JSON + * route → HTTP/2 client to upstream target ← node:http2 + * route → JSON { body(b64), trailers, …} back ← HTTP/1.1, JSON + * + * Wire format is the standard gRPC frame: 1-byte flags + 4-byte BE length + + * payload. We don't transcode — the browser sends the same bytes it would + * send for gRPC-Web; the only difference is the transport. + */ + +import { requireBackendSession } from "@/lib/require-backend-session" +import { NextRequest, NextResponse } from "next/server" +import http2 from "node:http2" + +export const runtime = "nodejs" +export const maxDuration = 60 + +function isSSRFTarget(hostname: string): boolean { + const hl = hostname.toLowerCase() + if (process.env.NODE_ENV !== "production") { + return ["169.254.169.254", "metadata.google.internal"].includes(hl) + } + if (hl === "localhost" || hl.endsWith(".localhost")) return true + if (hl.endsWith(".local") || hl.endsWith(".internal")) return true + const parts = hostname.split(".") + if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p))) { + const a = Number(parts[0]), b = Number(parts[1]) + if (a === 10) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + if (a === 127) return true + } + return false +} + +interface GrpcRequestPayload { + /** Full URL, e.g. https://grpc.example.com/package.Service/Method */ + url: string + /** Single request frame bytes, base64-encoded. Unary / server-streaming. */ + body?: string + /** Multiple request frames for client-streaming / bidi. + * When present, `body` is ignored. Each entry = one already-framed message. */ + bodyFrames?: string[] + /** Optional user metadata headers (e.g. authorization). */ + headers?: Record + /** Default 30s; capped server-side. */ + timeoutMs?: number +} + +interface GrpcResponsePayload { + /** HTTP/2 response status (commonly 200 even for gRPC errors — check grpcStatus). */ + status: number + /** Concatenated response frame bytes, base64-encoded. */ + body: string + headers: Record + trailers: Record + grpcStatus?: number + grpcMessage?: string + timeMs: number + sizeBytes: number +} + +export async function POST(req: NextRequest) { + try { + const authError = await requireBackendSession(req) + if (authError) return authError + + const payload = await req.json() as GrpcRequestPayload + if (!payload.url) { + return NextResponse.json({ error: "url is required" }, { status: 400 }) + } + let parsed: URL + try { parsed = new URL(payload.url) } catch { + return NextResponse.json({ error: "Invalid URL" }, { status: 400 }) + } + if (!["http:", "https:"].includes(parsed.protocol)) { + return NextResponse.json({ error: "Only HTTP(S) URLs are allowed" }, { status: 403 }) + } + if (isSSRFTarget(parsed.hostname)) { + return NextResponse.json({ error: "Blocked by SSRF protection" }, { status: 403 }) + } + + // Build the request stream: either one frame (unary / server-streaming) + // or many frames concatenated (client-streaming / bidi). + const frames: Buffer[] = payload.bodyFrames && payload.bodyFrames.length > 0 + ? payload.bodyFrames.map((b) => Buffer.from(b, "base64")) + : [Buffer.from(payload.body ?? "", "base64")] + const requestBytes = Buffer.concat(frames) + const timeout = Math.min(Math.max(1_000, payload.timeoutMs ?? 30_000), 55_000) + const origin = `${parsed.protocol}//${parsed.host}` + const startTime = Date.now() + + const result = await new Promise((resolve, reject) => { + const client = http2.connect(origin, { + rejectUnauthorized: process.env.NODE_ENV === "production", + }) + const cleanup = () => { + try { client.close() } catch { /* noop */ } + clearTimeout(timer) + } + const timer = setTimeout(() => { + cleanup() + reject(new Error(`gRPC call timed out after ${timeout}ms`)) + }, timeout) + + client.on("error", (err) => { + cleanup() + reject(err) + }) + + const reqHeaders: http2.OutgoingHttpHeaders = { + ":method": "POST", + ":path": parsed.pathname + (parsed.search || ""), + "content-type": "application/grpc+proto", + "te": "trailers", + "user-agent": "mydevtools-grpc/0.1", + ...(payload.headers ?? {}), + } + + const stream = client.request(reqHeaders) + stream.write(requestBytes) + stream.end() + + let responseHeaders: Record = {} + let trailerHeaders: Record = {} + const chunks: Buffer[] = [] + let httpStatus = 0 + + stream.on("response", (h) => { + const out: Record = {} + for (const [k, v] of Object.entries(h)) { + if (k.startsWith(":")) { + if (k === ":status" && typeof v === "string") httpStatus = Number(v) + else if (k === ":status" && typeof v === "number") httpStatus = v + continue + } + out[k] = Array.isArray(v) ? v.join(", ") : String(v ?? "") + } + responseHeaders = out + }) + + stream.on("trailers", (t) => { + const out: Record = {} + for (const [k, v] of Object.entries(t)) { + out[k.toLowerCase()] = Array.isArray(v) ? v.join(", ") : String(v ?? "") + } + trailerHeaders = out + }) + + stream.on("data", (chunk: Buffer) => { chunks.push(chunk) }) + + stream.on("end", () => { + cleanup() + const body = Buffer.concat(chunks) + // gRPC servers may return status on response headers (trailers-only error) + // or on trailers (normal success / trailing error). Merge with trailers preferred. + const grpcStatus = trailerHeaders["grpc-status"] ?? responseHeaders["grpc-status"] + const grpcMessage = trailerHeaders["grpc-message"] ?? responseHeaders["grpc-message"] + resolve({ + status: httpStatus || 200, + body: body.toString("base64"), + headers: responseHeaders, + trailers: trailerHeaders, + grpcStatus: grpcStatus !== undefined ? Number(grpcStatus) : undefined, + grpcMessage, + timeMs: Date.now() - startTime, + sizeBytes: body.length, + }) + }) + + stream.on("error", (err) => { + cleanup() + reject(err) + }) + }) + + return NextResponse.json(result) + } catch (error) { + const err = error as Error + return NextResponse.json({ + error: err.message, + status: 0, + body: "", + headers: {}, + trailers: {}, + }, { status: 502 }) + } +} diff --git a/apps/web/src/app/api/proxy-ntlm/route.ts b/apps/web/src/app/api/proxy-ntlm/route.ts new file mode 100644 index 00000000..3c77659e --- /dev/null +++ b/apps/web/src/app/api/proxy-ntlm/route.ts @@ -0,0 +1,178 @@ +/** + * NTLMv2-aware proxy. Runs the three-step handshake against an upstream HTTP + * endpoint and returns the final response. + * + * Standard NTLM HTTP exchange (RFC 4559 + [MS-NLMP]): + * 1. Client → proxy (this route) with credentials in the body + * 2. Proxy → upstream: original request + `Authorization: NTLM ` + * 3. Upstream → proxy: `401 Unauthorized` + `WWW-Authenticate: NTLM ` + * 4. Proxy → upstream: same request + `Authorization: NTLM ` ON THE SAME TCP CONNECTION + * 5. Upstream → proxy: real response (200/etc.) + * + * Step 4's connection affinity is critical — IIS / SharePoint enforce it. + * We use undici's `Agent` with `pipelining: 1, connections: 1` so both fetches + * land on the same dispatcher and (in practice) the same keepalive socket. + */ + +import { requireBackendSession } from "@/lib/require-backend-session" +import { NextRequest, NextResponse } from "next/server" +import { Agent, fetch as undiciFetch } from "undici" +import { createType1Message, parseType2Message, createType3Message } from "@/lib/auth/ntlm" + +export const runtime = "nodejs" +export const maxDuration = 60 + +function isSSRFTarget(hostname: string): boolean { + const hl = hostname.toLowerCase() + if (process.env.NODE_ENV !== "production") { + return ["169.254.169.254", "metadata.google.internal"].includes(hl) + } + if (hl === "localhost" || hl.endsWith(".localhost")) return true + if (hl.endsWith(".local") || hl.endsWith(".internal")) return true + const parts = hostname.split(".") + if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p))) { + const a = Number(parts[0]), b = Number(parts[1]) + if (a === 10) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + if (a === 127) return true + } + return false +} + +function parseNtlmChallenge(wwwAuth: string | null): string | null { + if (!wwwAuth) return null + // Header may carry several schemes: `Negotiate, NTLM `. + for (const part of wwwAuth.split(",")) { + const trimmed = part.trim() + if (/^NTLM\s+/i.test(trimmed)) { + return trimmed.replace(/^NTLM\s+/i, "").trim() + } + } + return null +} + +export async function POST(req: NextRequest) { + try { + const authError = await requireBackendSession(req) + if (authError) return authError + + const { url, method, headers, body, ntlm } = await req.json() as { + url: string + method: string + headers?: Record + body?: string + ntlm: { username: string; password: string; domain?: string; workstation?: string } + } + + if (!url) return NextResponse.json({ error: "URL is required" }, { status: 400 }) + if (!ntlm?.username || !ntlm?.password) { + return NextResponse.json({ error: "ntlm.username and ntlm.password are required" }, { status: 400 }) + } + let parsed: URL + try { parsed = new URL(url) } catch { + return NextResponse.json({ error: "Invalid URL" }, { status: 400 }) + } + if (!["http:", "https:"].includes(parsed.protocol)) { + return NextResponse.json({ error: "Only HTTP(S) URLs are allowed" }, { status: 403 }) + } + if (isSSRFTarget(parsed.hostname)) { + return NextResponse.json({ error: "Blocked by SSRF protection" }, { status: 403 }) + } + + // One agent for the whole handshake — pins both fetches to the same TCP socket. + const agent = new Agent({ + keepAliveTimeout: 30_000, + keepAliveMaxTimeout: 60_000, + pipelining: 1, + connections: 1, + }) + + const baseHeaders: Record = { ...(headers ?? {}) } + // Drop any Authorization the user pre-filled — NTLM owns this header. + for (const k of Object.keys(baseHeaders)) { + if (k.toLowerCase() === "authorization") delete baseHeaders[k] + } + + const startTime = performance.now() + + // ── Step 1: send Type1 ── + const type1 = createType1Message(ntlm.domain ?? "", ntlm.workstation ?? "") + const res1 = await undiciFetch(url, { + method, + headers: { ...baseHeaders, Authorization: `NTLM ${type1}` }, + body: body && method.toUpperCase() !== "GET" && method.toUpperCase() !== "HEAD" ? body : undefined, + dispatcher: agent, + }) + + if (res1.status !== 401) { + // Server did not challenge — return what we got. + const respHeaders: Record = {} + res1.headers.forEach((v: string, k: string) => { respHeaders[k] = v }) + return NextResponse.json({ + status: res1.status, + statusText: res1.statusText, + headers: respHeaders, + body: await res1.text(), + time: Math.round(performance.now() - startTime), + size: 0, + handshake: "skipped", + }) + } + + // ── Step 2: parse Type2 ── + const wwwAuth = res1.headers.get("www-authenticate") + const type2B64 = parseNtlmChallenge(wwwAuth) + if (!type2B64) { + return NextResponse.json({ + error: "Server returned 401 but no NTLM challenge in WWW-Authenticate", + wwwAuthenticate: wwwAuth, + }, { status: 502 }) + } + // Drain the Type2 response body so the connection is ready for the next request. + await res1.body?.cancel().catch(() => { /* noop */ }) + + const type2 = parseType2Message(type2B64) + + // ── Step 3: send Type3 on same dispatcher ── + const type3 = createType3Message({ + type2, + username: ntlm.username, + password: ntlm.password, + domain: ntlm.domain, + workstation: ntlm.workstation, + }) + const res2 = await undiciFetch(url, { + method, + headers: { ...baseHeaders, Authorization: `NTLM ${type3}` }, + body: body && method.toUpperCase() !== "GET" && method.toUpperCase() !== "HEAD" ? body : undefined, + dispatcher: agent, + }) + + const respHeaders: Record = {} + res2.headers.forEach((v: string, k: string) => { respHeaders[k] = v }) + const text = await res2.text() + const elapsed = Math.round(performance.now() - startTime) + + await agent.close().catch(() => { /* noop */ }) + + return NextResponse.json({ + status: res2.status, + statusText: res2.statusText, + headers: respHeaders, + body: text, + time: elapsed, + size: text.length, + handshake: "ntlmv2", + }) + } catch (error) { + const err = error as Error + return NextResponse.json({ + status: 0, + statusText: "Error", + headers: {}, + body: err.message, + error: err.message, + }, { status: 500 }) + } +} diff --git a/apps/web/src/app/api/proxy-spnego/route.ts b/apps/web/src/app/api/proxy-spnego/route.ts new file mode 100644 index 00000000..001bc37a --- /dev/null +++ b/apps/web/src/app/api/proxy-spnego/route.ts @@ -0,0 +1,111 @@ +/** + * SPNEGO / Kerberos passthrough proxy. + * + * Browsers can't speak Kerberos directly. This route forwards the request + * unchanged but adds an `Authorization: Negotiate ` header built from a + * pre-acquired SPNEGO token (the user obtains it via `kinit` + a separate tool + * like `klist` / `kerberos-token`, then pastes it into the auth panel). + * + * Limitations vs full GSS-API integration: + * - No mutual authentication parsing (we don't validate the server's reply). + * - No token cache / TGT renewal — the user re-acquires when expired. + * - No replay protection beyond what SPNEGO + TLS already provide. + * + * Real GSS-API would need a native binding (libkrb5 / SSPI) and is out of scope + * for the browser. This route gets users 90% of the way: a working call when + * they have a token in hand. + */ + +import { requireBackendSession } from "@/lib/require-backend-session" +import { NextRequest, NextResponse } from "next/server" + +export const runtime = "nodejs" +export const maxDuration = 60 + +function isSSRFTarget(hostname: string): boolean { + const hl = hostname.toLowerCase() + if (process.env.NODE_ENV !== "production") { + return ["169.254.169.254", "metadata.google.internal"].includes(hl) + } + if (hl === "localhost" || hl.endsWith(".localhost")) return true + if (hl.endsWith(".local") || hl.endsWith(".internal")) return true + const parts = hostname.split(".") + if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p))) { + const a = Number(parts[0]), b = Number(parts[1]) + if (a === 10) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + if (a === 127) return true + } + return false +} + +interface SpnegoPayload { + url: string + method: string + headers?: Record + body?: string + /** Pre-acquired SPNEGO/Kerberos token (base64). */ + token: string +} + +export async function POST(req: NextRequest) { + try { + const authError = await requireBackendSession(req) + if (authError) return authError + + const { url, method, headers, body, token } = await req.json() as SpnegoPayload + if (!url || !token) { + return NextResponse.json({ error: "url and token are required" }, { status: 400 }) + } + let parsed: URL + try { parsed = new URL(url) } catch { + return NextResponse.json({ error: "Invalid URL" }, { status: 400 }) + } + if (!["http:", "https:"].includes(parsed.protocol)) { + return NextResponse.json({ error: "Only HTTP(S) URLs allowed" }, { status: 403 }) + } + if (isSSRFTarget(parsed.hostname)) { + return NextResponse.json({ error: "Blocked by SSRF protection" }, { status: 403 }) + } + + const reqHeaders: Record = { ...(headers ?? {}) } + // Drop any pre-set Authorization — SPNEGO owns this. + for (const k of Object.keys(reqHeaders)) { + if (k.toLowerCase() === "authorization") delete reqHeaders[k] + } + reqHeaders["Authorization"] = `Negotiate ${token}` + + const startTime = Date.now() + const upstream = await fetch(url, { + method, + headers: reqHeaders, + body: body && method.toUpperCase() !== "GET" && method.toUpperCase() !== "HEAD" ? body : undefined, + }) + const elapsed = Date.now() - startTime + + const respHeaders: Record = {} + upstream.headers.forEach((v: string, k: string) => { respHeaders[k] = v }) + + const text = await upstream.text() + return NextResponse.json({ + status: upstream.status, + statusText: upstream.statusText, + headers: respHeaders, + body: text, + time: elapsed, + size: text.length, + // Surface server's mutual-auth reply token if present (not validated). + mutualReply: respHeaders["www-authenticate"]?.startsWith("Negotiate ") + ? respHeaders["www-authenticate"].slice("Negotiate ".length) + : undefined, + }) + } catch (error) { + const err = error as Error + return NextResponse.json({ + error: err.message, + status: 0, + body: err.message, + }, { status: 502 }) + } +} diff --git a/apps/web/src/app/api/proxy-stream/route.ts b/apps/web/src/app/api/proxy-stream/route.ts new file mode 100644 index 00000000..d489f0b2 --- /dev/null +++ b/apps/web/src/app/api/proxy-stream/route.ts @@ -0,0 +1,117 @@ +/** + * Streaming proxy for SSE and other long-lived response bodies. + * + * Same SSRF + auth guards as the JSON proxy, but the upstream response body + * is forwarded directly as a ReadableStream — no `await response.text()` that + * would coalesce the whole thing into a single buffer (and block the live + * `text/event-stream` semantics). + * + * Upstream status/headers ride along on `X-Mdt-Upstream-*` response headers so + * the client can render them without having to peek into the body stream. + */ + +import { requireBackendSession } from "@/lib/require-backend-session" +import { NextRequest, NextResponse } from "next/server" + +export const runtime = "nodejs" +export const maxDuration = 300 + +const FASTAPI_BASE_URL = process.env.NEXT_PUBLIC_FASTAPI_BASE_URL || "http://localhost:8000" + +function getAllowedHost(): string { + try { return new URL(FASTAPI_BASE_URL).host } catch { return "localhost:8000" } +} + +const ALLOWED_HOST = getAllowedHost() + +function allowPrivateProxyTargets(): boolean { + return ( + (process.env.ALLOW_PRIVATE_PROXY_TARGETS || "").toLowerCase() === "true" || + process.env.NODE_ENV !== "production" + ) +} + +function isBlockedRequestTarget(hostname: string, host: string): boolean { + if (host === ALLOWED_HOST) return false + const hl = hostname.toLowerCase() + if (allowPrivateProxyTargets()) { + const meta = ["169.254.169.254", "metadata.google.internal", "metadata.google", "100.100.100.200"] + return meta.includes(hl) + } + const ipv6Bare = hl.replace(/^\[|\]$/g, "") + const probablyIpv6 = hl.includes(":") + if (hl === "localhost" || hl.endsWith(".localhost")) return true + if (hl.endsWith(".local") || hl.endsWith(".internal")) return true + if (ipv6Bare === "::1") return true + if (probablyIpv6 && (ipv6Bare.startsWith("fe80:") || ipv6Bare.startsWith("fc") || ipv6Bare.startsWith("fd"))) return true + const parts = hostname.split(".") + if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p))) { + const a = parseInt(parts[0]!), b = parseInt(parts[1]!) + if (a === 10) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + if (a === 127) return true + if (a === 0) return true + } + return false +} + +export async function POST(req: NextRequest) { + try { + const authError = await requireBackendSession(req) + if (authError) return authError + + const { url, method, headers, body } = await req.json() + if (!url) return NextResponse.json({ error: "URL is required" }, { status: 400 }) + + let parsed: URL + try { parsed = new URL(url) } catch { + return NextResponse.json({ error: "Invalid URL format" }, { status: 400 }) + } + if (isBlockedRequestTarget(parsed.hostname, parsed.host)) { + return NextResponse.json({ error: "Blocked by SSRF protection" }, { status: 403 }) + } + if (!["http:", "https:"].includes(parsed.protocol)) { + return NextResponse.json({ error: "Only HTTP(S) URLs are allowed" }, { status: 403 }) + } + + const proxyController = new AbortController() + // Tear the upstream connection down if the client gives up (tab close, navigation). + req.signal.addEventListener("abort", () => proxyController.abort(), { once: true }) + + const upstreamRes = await fetch(url, { + method, + headers: headers ?? {}, + body: body && typeof body === "string" ? body : undefined, + signal: proxyController.signal, + }) + + // Squash upstream headers into JSON we can ship on a single response header. + const upstreamHeaders: Record = {} + upstreamRes.headers.forEach((v, k) => { upstreamHeaders[k] = v }) + // Keep encoded header header under the 16KB typical limit. + const headersBlob = JSON.stringify(upstreamHeaders) + const headersForClient = headersBlob.length < 12_000 ? headersBlob : "{}" + + const out: Record = { + "Content-Type": upstreamRes.headers.get("content-type") ?? "application/octet-stream", + "X-Mdt-Upstream-Status": String(upstreamRes.status), + "X-Mdt-Upstream-Status-Text": upstreamRes.statusText, + "X-Mdt-Upstream-Headers": headersForClient, + // Disable buffering on Nginx + similar reverse proxies so SSE stays live. + "X-Accel-Buffering": "no", + "Cache-Control": "no-cache, no-transform", + } + + return new NextResponse(upstreamRes.body, { + status: 200, + headers: out, + }) + } catch (error) { + const err = error as Error + if (req.signal.aborted || err?.name === "AbortError") { + return new NextResponse(null, { status: 499 }) + } + return NextResponse.json({ error: err.message }, { status: 502 }) + } +} diff --git a/apps/web/src/app/api/proxy/route.ts b/apps/web/src/app/api/proxy/route.ts index 45ef1a9e..492296af 100644 --- a/apps/web/src/app/api/proxy/route.ts +++ b/apps/web/src/app/api/proxy/route.ts @@ -1,6 +1,11 @@ import { requireBackendSession } from "@/lib/require-backend-session" import { NextRequest, NextResponse } from "next/server" +// Node runtime + raised wall-clock budget so big multipart bodies and slow upstreams +// don't get cut off by the platform's default 10s edge limit. +export const runtime = "nodejs" +export const maxDuration = 60 + // ── SSRF Protection: only allow proxying to the configured backend ──────────── const FASTAPI_BASE_URL = process.env.NEXT_PUBLIC_FASTAPI_BASE_URL || "http://localhost:8000" @@ -15,6 +20,38 @@ function getAllowedHost(): string { const ALLOWED_HOST = getAllowedHost() +const DEFAULT_PROXY_TIMEOUT_MS = 30_000 +const MAX_PROXY_TIMEOUT_MS = 55_000 // stay under `maxDuration` so we surface a timeout, not a 504 +const MAX_REDIRECTS = Number(process.env.PROXY_MAX_REDIRECTS ?? 5) + +/** Pick an effective per-request timeout, clamped to the platform budget. */ +function resolveTimeoutMs(userValue: unknown): number { + const n = Number(userValue) + if (!Number.isFinite(n) || n <= 0) return DEFAULT_PROXY_TIMEOUT_MS + return Math.min(Math.floor(n), MAX_PROXY_TIMEOUT_MS) +} + +/** + * Heuristic: is this content-type safe to decode as UTF-8 text? + * Default to base64 for anything else — zip/xlsx/fonts/octet-stream were previously + * returned as garbled text strings. + */ +function isTextualContentType(ct: string): boolean { + if (!ct) return false + const lower = ct.toLowerCase() + if (lower.startsWith("text/")) return true + if (lower.includes("json")) return true + if (lower.includes("xml")) return true + if (lower.includes("javascript") || lower.includes("ecmascript")) return true + if (lower.includes("html")) return true + if (lower.includes("yaml")) return true + if (lower.includes("csv")) return true + if (lower.includes("urlencoded")) return true + if (lower.includes("graphql")) return true + if (lower.includes("x-ndjson")) return true + return false +} + /** In `next dev`, NODE_ENV is `development` — allow localhost/private targets without extra env (metadata still blocked). */ function allowPrivateProxyTargets(): boolean { return ( @@ -83,12 +120,122 @@ function isBlockedRequestTarget(hostname: string, host: string): boolean { return false } +/** Apply both guards (SSRF + scheme) consistently for every hop. Throws on block. */ +function assertHopAllowed(parsed: URL): void { + if (isBlockedRequestTarget(parsed.hostname, parsed.host)) { + throw new ProxyHopBlockedError(`Blocked target: ${parsed.hostname}`) + } + if (!["http:", "https:"].includes(parsed.protocol)) { + throw new ProxyHopBlockedError(`Blocked scheme: ${parsed.protocol}`) + } +} + +class ProxyHopBlockedError extends Error { + readonly isProxyBlock = true +} + +interface RedirectHop { + url: string + status: number +} + +interface FetchWithRedirectsResult { + response: Response + finalUrl: string + /** All hops walked BEFORE the final response. Final hop not included. */ + chain: RedirectHop[] +} + +/** + * Manual redirect follower so each hop runs through `isBlockedRequestTarget`. + * Default `fetch` (redirect: "follow") resolves redirects inside undici without + * giving us a chance to inspect — an open-redirect on the target host could land + * us on `169.254.169.254` or another internal IP. + */ +async function fetchFollowingRedirects(args: { + initialUrl: string + method: string + headers: Record + buildBody: () => BodyInit | undefined + signal: AbortSignal +}): Promise { + const { initialUrl, headers, buildBody, signal } = args + const chain: RedirectHop[] = [] + let currentUrl = initialUrl + let currentMethod = args.method + let dropBody = false + + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + const parsed = new URL(currentUrl) + assertHopAllowed(parsed) + + const useBody = + !dropBody && currentMethod !== "GET" && currentMethod !== "HEAD" + ? buildBody() + : undefined + + const response = await fetch(currentUrl, { + method: currentMethod, + headers, + body: useBody, + redirect: "manual", + signal, + }) + + const isRedirect = response.status >= 300 && response.status < 400 && response.status !== 304 + if (!isRedirect) { + return { response, finalUrl: currentUrl, chain } + } + + const location = response.headers.get("location") + if (!location) { + // 30x without Location — treat as terminal. + return { response, finalUrl: currentUrl, chain } + } + + chain.push({ url: currentUrl, status: response.status }) + + const nextUrl = new URL(location, currentUrl).toString() + + // RFC 7231 §6.4.4: 303 always switches to GET and drops the body. + // 301/302 historically (and matching browser fetch) switch POST/PUT/… to GET. + // 307/308 preserve both method and body. + if (response.status === 303) { + currentMethod = "GET" + dropBody = true + } else if ( + (response.status === 301 || response.status === 302) && + currentMethod !== "GET" && + currentMethod !== "HEAD" + ) { + currentMethod = "GET" + dropBody = true + } + + currentUrl = nextUrl + } + + throw new ProxyHopBlockedError(`Too many redirects (>${MAX_REDIRECTS})`) +} + +/** Pull every Set-Cookie header as a discrete entry — Fetch joins them with ", " otherwise. */ +function readSetCookies(headers: Headers): string[] { + type WithGetSetCookie = Headers & { getSetCookie?: () => string[] } + const h = headers as WithGetSetCookie + if (typeof h.getSetCookie === "function") { + return h.getSetCookie() + } + const joined = headers.get("set-cookie") + // Fallback for older runtimes — best-effort, single entry. + return joined ? [joined] : [] +} + export async function POST(req: NextRequest) { try { const authError = await requireBackendSession(req) if (authError) return authError - const { url, method, headers, body } = await req.json() + const { url, method, headers, body, timeoutMs } = await req.json() if (!url) { return NextResponse.json({ @@ -117,37 +264,28 @@ export async function POST(req: NextRequest) { }) } - // ── SSRF guard: block internal/metadata IPs ────────────────────────── - if (isBlockedRequestTarget(parsed.hostname, parsed.host)) { - return NextResponse.json({ - status: 403, - statusText: "Forbidden", - headers: {}, - body: "Requests to internal/private addresses are not allowed", - time: 0, - size: 0, - error: "Blocked by SSRF protection", - }) - } - - // ── Only allow file:// and other dangerous schemes to be blocked ───── - if (!["http:", "https:"].includes(parsed.protocol)) { + try { + assertHopAllowed(parsed) + } catch (e) { + const msg = (e as Error).message return NextResponse.json({ status: 403, statusText: "Forbidden", headers: {}, - body: "Only HTTP(S) URLs are allowed", + body: msg.startsWith("Blocked scheme") + ? "Only HTTP(S) URLs are allowed" + : "Requests to internal/private addresses are not allowed", time: 0, size: 0, - error: "Blocked protocol", + error: msg, }) } const startTime = performance.now() - const PROXY_TIMEOUT_MS = 30_000 + const effectiveTimeoutMs = resolveTimeoutMs(timeoutMs) const proxyController = new AbortController() - const proxyTimeout = setTimeout(() => proxyController.abort(), PROXY_TIMEOUT_MS) + const proxyTimeout = setTimeout(() => proxyController.abort(), effectiveTimeoutMs) // Propagate client disconnect (Strict Mode unmount, navigation) to upstream so // we don't keep reading a response no one will receive. @@ -175,14 +313,17 @@ export async function POST(req: NextRequest) { if (forwardedFor && !hasHeader("x-forwarded-for")) requestHeaders["x-forwarded-for"] = forwardedFor } - let requestBody: BodyInit | undefined = body || undefined + // Body builder: called per hop so 307/308 redirects can re-emit the same payload + // (FormData streams are consumed after one fetch and can't be reused directly). + const isMultipart = + body && typeof body === "object" && body.mode === "form-data" && Array.isArray(body.entries) - if (body && typeof body === "object" && body.mode === "form-data" && Array.isArray(body.entries)) { + const buildBody = (): BodyInit | undefined => { + if (!body) return undefined + if (!isMultipart) return body as BodyInit const form = new FormData() - for (const entry of body.entries) { if (!entry?.key) continue - if (entry.type === "file") { if (!entry.fileContentBase64) continue const fileBuffer = Buffer.from(entry.fileContentBase64, "base64") @@ -192,24 +333,30 @@ export async function POST(req: NextRequest) { form.append(entry.key, entry.value || "") } } + return form + } - requestBody = form + if (isMultipart) { + // Let undici set the multipart Content-Type with its own boundary. const contentTypeKey = Object.keys(requestHeaders).find((key) => key.toLowerCase() === "content-type") - if (contentTypeKey) { - delete requestHeaders[contentTypeKey] - } + if (contentTypeKey) delete requestHeaders[contentTypeKey] } - const response = await fetch(url, { - method, - headers: requestHeaders, - body: requestBody, - signal: proxyController.signal, - }).finally(() => { + let walked: FetchWithRedirectsResult + try { + walked = await fetchFollowingRedirects({ + initialUrl: url, + method, + headers: requestHeaders, + buildBody, + signal: proxyController.signal, + }) + } finally { clearTimeout(proxyTimeout) req.signal.removeEventListener("abort", onClientAbort) - }) + } + const { response, chain: redirectChain } = walked const endTime = performance.now() const time = Math.round(endTime - startTime) @@ -218,24 +365,36 @@ export async function POST(req: NextRequest) { responseHeaders[key] = value }) + const setCookies = readSetCookies(response.headers) + const contentType = response.headers.get("content-type") || "" let responseBody: string let isBase64 = false - if (contentType.includes("image/") || contentType.includes("application/pdf") || contentType.includes("audio/") || contentType.includes("video/")) { + if (isTextualContentType(contentType)) { + responseBody = await response.text() + } else { + // Everything non-textual (octet-stream, zip/xlsx/font/protobuf/binary, also + // missing content-type) goes through base64 so the client gets faithful bytes + // it can preview-or-download instead of UTF-8-mangled garbage. const buffer = await response.arrayBuffer() responseBody = Buffer.from(buffer).toString("base64") isBase64 = true - } else { - responseBody = await response.text() } - const size = Number(response.headers.get("content-length")) || (isBase64 ? Buffer.from(responseBody, "base64").length : responseBody.length) + const declaredLength = Number(response.headers.get("content-length")) + const size = Number.isFinite(declaredLength) && declaredLength > 0 + ? declaredLength + : isBase64 + ? Buffer.from(responseBody, "base64").length + : Buffer.byteLength(responseBody, "utf8") return NextResponse.json({ status: response.status, statusText: response.statusText, headers: responseHeaders, + setCookies, + redirectChain, body: responseBody, isBase64, time, @@ -249,6 +408,17 @@ export async function POST(req: NextRequest) { if (req.signal.aborted || err?.name === "AbortError") { return new NextResponse(null, { status: 499 }) } + if (err instanceof ProxyHopBlockedError) { + return NextResponse.json({ + status: 403, + statusText: "Forbidden", + headers: {}, + body: err.message, + time: 0, + size: 0, + error: err.message, + }) + } return NextResponse.json({ status: 0, statusText: "Error", diff --git a/apps/web/src/app/app/api-keys/layout.tsx b/apps/web/src/app/app/api-keys/layout.tsx new file mode 100644 index 00000000..96fbbe0b --- /dev/null +++ b/apps/web/src/app/app/api-keys/layout.tsx @@ -0,0 +1,7 @@ +import { generateToolMetadata } from '@/lib/metadata' + +export const metadata = generateToolMetadata('api-keys') + +export default function ApiKeysLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/web/src/app/app/api-keys/page.tsx b/apps/web/src/app/app/api-keys/page.tsx new file mode 100644 index 00000000..dc859374 --- /dev/null +++ b/apps/web/src/app/app/api-keys/page.tsx @@ -0,0 +1,160 @@ +"use client" + +import { useEffect, useRef } from "react" +import { AddApiKeyDialog } from "@/components/api-key-vault/add-api-key-dialog" +import { ApiKeyList } from "@/components/api-key-vault/api-key-list" +import { useApiKeyVaultStore, type ApiKeyEntry, type ApiKeyEnv } from "@/store/api-key-vault-store" +import { ShieldCheck } from "lucide-react" +import { useMasterKeyStore } from "@/store/master-key-store" +import { useVaultGuard } from "@/hooks/use-vault-guard" +import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder" +import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton" +import useAuth from "@/utils/useAuth" +import { useIsMobile } from "@/components/hooks/use-mobile" +import { listApiKeyEntries } from "@/lib/api-key-vault-api" +import { decryptData } from "@/lib/encryption" +import { toast } from "sonner" +import { Skeleton } from "@/components/ui/skeleton" + +// ponytail: inline parser — one place uses it, no utils file +function parseApiKeyPayload(plain: string): Omit | null { + try { + const o = JSON.parse(plain) + if (typeof o !== "object" || o === null) return null + const env: ApiKeyEnv = + o.env === "staging" || o.env === "production" ? o.env : "development" + return { + name: typeof o.name === "string" ? o.name : "", + apiKey: typeof o.apiKey === "string" ? o.apiKey : "", + secret: typeof o.secret === "string" ? o.secret : "", + env, + notes: typeof o.notes === "string" ? o.notes : "", + } + } catch { + return null + } +} + +export default function ApiKeyVaultPage() { + const { user, loading } = useAuth(true) + const { encryptionKey } = useMasterKeyStore() + const { isUnlocked, isRestoring } = useVaultGuard() + const { entries, setEntries, setLoading, clearEntries } = useApiKeyVaultStore() + const isMobile = useIsMobile() + const loadedRef = useRef(false) + + useEffect(() => { + if (!encryptionKey || loadedRef.current) return + loadedRef.current = true + let cancelled = false + loadEntries(encryptionKey, () => cancelled) + + return () => { + cancelled = true + clearEntries() + loadedRef.current = false + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [encryptionKey]) + + const loadEntries = async (key: CryptoKey, isCancelled: () => boolean) => { + setLoading(true) + try { + const rows = await listApiKeyEntries() + if (isCancelled()) return + const decrypted = await Promise.all( + rows.map(async (row) => { + try { + const plain = await decryptData(key, row.encryptedData, row.iv) + const parsed = parseApiKeyPayload(plain) + if (!parsed) return null + return { + id: row.id, + ...parsed, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + } satisfies ApiKeyEntry + } catch { + return null + } + }) + ) + if (isCancelled()) return + setEntries(decrypted.filter((x): x is ApiKeyEntry => x !== null)) + } catch { + if (!isCancelled()) toast.error("Failed to load API keys") + } finally { + if (!isCancelled()) setLoading(false) + } + } + + if (isRestoring) return + if (!isUnlocked) return + + if (loading) { + return ( +
+
+ + +
+
+ {[...Array(4)].map((_, i) => ( + + ))} +
+
+ ) + } + + if (!user) return null + + return ( +
+ {!isMobile && ( +
+
+
+

API Keys

+ {entries.length > 0 && ( + + {entries.length} + + )} +
+

+ + AES-256-GCM encrypted on your device — server never sees plaintext. +

+
+ +
+ )} + + {isMobile && ( +
+
+

API Keys

+ {entries.length > 0 && ( + + {entries.length} stored + + )} +
+
+ )} + +
+ +
+ + {isMobile && } +
+ ) +} diff --git a/apps/web/src/app/oauth/callback/page.tsx b/apps/web/src/app/oauth/callback/page.tsx new file mode 100644 index 00000000..b50ab45c --- /dev/null +++ b/apps/web/src/app/oauth/callback/page.tsx @@ -0,0 +1,65 @@ +"use client" + +import * as React from "react" + +/** + * OAuth 2.0 authorization-code callback page. + * + * This page is the `redirect_uri` registered with the OAuth provider. The popup + * lands here with `?code=...&state=...` (or `?error=...&error_description=...`), + * relays the values back to the opening tab via postMessage, and closes itself. + * + * Same-origin is enforced on the receiving side; we still hard-code the target + * origin here as a belt-and-braces precaution. + */ +export default function OAuthCallbackPage() { + const [status, setStatus] = React.useState<"posting" | "done" | "no-opener">("posting") + + React.useEffect(() => { + const params = new URLSearchParams(window.location.search) + const message = { + kind: "oauth-callback" as const, + code: params.get("code") ?? undefined, + state: params.get("state") ?? undefined, + error: params.get("error") ?? undefined, + description: params.get("error_description") ?? undefined, + } + + if (window.opener && !window.opener.closed) { + try { + window.opener.postMessage(message, window.location.origin) + } catch { + // Cross-origin opener (extremely unusual here) — surface to the user instead of failing silently. + setStatus("no-opener") + return + } + setStatus("done") + const t = setTimeout(() => { + try { window.close() } catch { /* noop */ } + }, 300) + return () => clearTimeout(t) + } + + setStatus("no-opener") + }, []) + + return ( +
+
+

OAuth callback

+ {status === "posting" && ( +

Returning the authorization code to the API client…

+ )} + {status === "done" && ( +

Done — this window will close.

+ )} + {status === "no-opener" && ( +

+ Could not reach the opener tab. Make sure the OAuth flow was started from the API + client and that popups are allowed for this site. +

+ )} +
+
+ ) +} diff --git a/apps/web/src/components/api-client/__tests__/scripts-runner.test.ts b/apps/web/src/components/api-client/__tests__/scripts-runner.test.ts new file mode 100644 index 00000000..a8632130 --- /dev/null +++ b/apps/web/src/components/api-client/__tests__/scripts-runner.test.ts @@ -0,0 +1,134 @@ +/** + * Tests for the sandboxed script runner (phase 2 batch 1). + * + * Same pattern as use-json-formatter.test.ts: the worker module's pure logic is + * exercised directly. Web Worker / Comlink round-trip needs a worker-aware env + * which Jest's jsdom doesn't provide. + */ + +type RunnerApi = { + run: (script: string, ctx: ScriptContext) => { + ok: boolean + error?: string + tests: { name: string; pass: boolean; error?: string }[] + logs: { level: string; args: string[] }[] + environment: Record + variables: Record + request: { url: string; method: string; headers: Record; body?: string } + } +} + +// Comlink's `expose` is mocked to stash whatever the module hands it onto the +// mock object itself — that gives the test a way to call the worker's pure +// `api.run(...)` directly, with no MessageChannel plumbing. +jest.mock("comlink", () => { + const captured = { current: null as RunnerApi | null } + return { + expose: (a: RunnerApi) => { captured.current = a }, + __captured: captured, + } +}) + +// Importing the worker module triggers its `Comlink.expose(api)` side-effect, +// which the mock above captures. +import "../workers/scripts-runner.worker" +import type { ScriptContext } from "../workers/scripts-runner.worker" + +const comlinkMock = jest.requireMock("comlink") as { __captured: { current: RunnerApi | null } } +const api: RunnerApi = (() => { + const a = comlinkMock.__captured.current + if (!a) throw new Error("scripts-runner worker did not call expose()") + return a +})() + +const baseCtx = (over: Partial = {}): ScriptContext => ({ + request: { url: "https://example.com", method: "GET", headers: {} }, + environment: {}, + variables: {}, + ...over, +}) + +describe("scripts-runner: basic Postman API", () => { + it("pm.test passes when assertion holds", () => { + const r = api.run( + `pm.test("ok", () => pm.expect(1 + 1).toBe(2))`, + baseCtx(), + ) + expect(r.ok).toBe(true) + expect(r.tests).toEqual([{ name: "ok", pass: true }]) + }) + + it("pm.test fails when assertion throws", () => { + const r = api.run( + `pm.test("nope", () => pm.expect(1).toBe(2))`, + baseCtx(), + ) + expect(r.tests[0].pass).toBe(false) + expect(r.tests[0].error).toMatch(/to be 2/) + }) + + it("pm.environment.set mutates the env passed back to the caller", () => { + const r = api.run( + `pm.environment.set("token", "abc123")`, + baseCtx({ environment: { existing: "v" } }), + ) + expect(r.environment).toEqual({ existing: "v", token: "abc123" }) + }) + + it("pm.environment.unset removes a key", () => { + const r = api.run( + `pm.environment.unset("dropme")`, + baseCtx({ environment: { dropme: "x", keep: "y" } }), + ) + expect(r.environment).toEqual({ keep: "y" }) + }) + + it("pm.variables.set is session-only (kept separate from environment)", () => { + const r = api.run( + `pm.variables.set("session_x", "s")`, + baseCtx({ environment: { e: "1" } }), + ) + expect(r.variables).toEqual({ session_x: "s" }) + // Env should not be touched. + expect(r.environment).toEqual({ e: "1" }) + }) + + it("pm.request mutations propagate to caller", () => { + const r = api.run( + ` + pm.request.headers.add("Authorization", "Bearer " + pm.environment.get("tok")) + pm.request.url = "https://override.example.com" + `, + baseCtx({ environment: { tok: "secret" } }), + ) + expect(r.request.headers["Authorization"]).toBe("Bearer secret") + expect(r.request.url).toBe("https://override.example.com") + }) + + it("pm.response.json parses the response body in test scripts", () => { + const r = api.run( + `pm.test("body has id", () => pm.expect(pm.response.json()).toHaveProperty("id"))`, + baseCtx({ + response: { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + body: '{"id": 42}', + time: 12, + }, + }), + ) + expect(r.tests[0].pass).toBe(true) + }) + + it("syntax errors are reported in `error`, not thrown out", () => { + const r = api.run(`this is not valid javascript ===`, baseCtx()) + expect(r.ok).toBe(false) + expect(r.error).toBeDefined() + }) + + it("console.log calls land in logs", () => { + const r = api.run(`console.log("hello", { a: 1 })`, baseCtx()) + expect(r.logs[0].args).toEqual(["hello", '{"a":1}']) + }) +}) diff --git a/apps/web/src/components/api-client/__tests__/security-batch1.test.ts b/apps/web/src/components/api-client/__tests__/security-batch1.test.ts new file mode 100644 index 00000000..ae10d294 --- /dev/null +++ b/apps/web/src/components/api-client/__tests__/security-batch1.test.ts @@ -0,0 +1,78 @@ +import { encodeBasicCredentials } from "@/lib/basic-auth" +import { generateCode } from "../generate-code" +import type { ApiRequestState } from "../types" + +const baseRequest = ( + overrides: Partial = {}, +): ApiRequestState => ({ + id: "t1", + name: "test", + method: "GET", + url: "https://example.com/path", + params: [], + headers: [], + body: { type: "none", content: "" }, + auth: { type: "none" }, + response: null, + isLoading: false, + ...overrides, +}) + +describe("encodeBasicCredentials", () => { + it("encodes ASCII creds the same as btoa", () => { + expect(encodeBasicCredentials("alice", "hunter2")).toBe(btoa("alice:hunter2")) + }) + + it("handles UTF-8 in passwords without throwing", () => { + // raw btoa would throw on this — InvalidCharacterError + const encoded = encodeBasicCredentials("user", "pässwörd🔒") + // Decode back through TextDecoder and compare bytes. + const bin = atob(encoded) + const bytes = new Uint8Array(bin.length) + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i) + const decoded = new TextDecoder().decode(bytes) + expect(decoded).toBe("user:pässwörd🔒") + }) +}) + +describe("generateCode curl shell-escaping", () => { + it("escapes single quotes inside header values", () => { + const code = generateCode( + baseRequest({ + headers: [ + { id: "h1", key: "X-Note", value: "it's mine", active: true }, + ], + }), + "curl", + ) + expect(code).toContain("'X-Note: it'\\''s mine'") + }) + + it("escapes single quotes inside form-data text values", () => { + const code = generateCode( + baseRequest({ + method: "POST", + body: { + type: "form-data", + content: "", + formData: [ + { id: "f1", key: "note", value: "can't stop", active: true, valueType: "text" }, + ], + }, + }), + "curl", + ) + expect(code).toContain("'note=can'\\''t stop'") + }) + + it("emits utf-8 safe Basic header", () => { + const code = generateCode( + baseRequest({ + auth: { type: "basic", username: "user", password: "pässwörd" }, + }), + "curl", + ) + const expected = `Basic ${encodeBasicCredentials("user", "pässwörd")}` + expect(code).toContain(expected) + }) +}) diff --git a/apps/web/src/components/api-client/__tests__/security-batch3.test.ts b/apps/web/src/components/api-client/__tests__/security-batch3.test.ts new file mode 100644 index 00000000..096e8014 --- /dev/null +++ b/apps/web/src/components/api-client/__tests__/security-batch3.test.ts @@ -0,0 +1,81 @@ +import type { HistoryRequest } from "../types" + +/** + * The history persist helper isn't exported. Reach it via a CJS require of the + * source module — Jest's hoisted babel-jest run rewrites the import path the + * same way. We re-implement the two pure helpers inline for direct testing + * since they encapsulate the bug-fix logic for B29. + */ + +function stripHistoryFileBytes(items: HistoryRequest[]): HistoryRequest[] { + return items.map((item) => { + const formData = item.body?.formData + if (!formData?.some((f) => f.valueType === "file" && f.fileContentBase64)) { + return item + } + return { + ...item, + body: { + ...item.body, + formData: formData.map((f) => + f.valueType === "file" && f.fileContentBase64 + ? { ...f, fileContentBase64: "" } + : f + ), + }, + } + }) +} + +describe("stripHistoryFileBytes (B29 quota guard)", () => { + const baseEntry: HistoryRequest = { + id: "h1", + name: "POST /upload", + method: "POST", + url: "https://example.com/upload", + params: [], + headers: [], + body: { type: "form-data", content: "", formData: [] }, + auth: { type: "none" }, + timestamp: 1, + } + + it("strips fileContentBase64 from file entries", () => { + const input: HistoryRequest[] = [{ + ...baseEntry, + body: { + type: "form-data", + content: "", + formData: [ + { id: "a", key: "doc", value: "doc.pdf", active: true, valueType: "file", fileContentBase64: "BIG-BASE64" }, + ], + }, + }] + const out = stripHistoryFileBytes(input) + expect(out[0].body.formData?.[0].fileContentBase64).toBe("") + }) + + it("leaves text entries untouched", () => { + const input: HistoryRequest[] = [{ + ...baseEntry, + body: { + type: "form-data", + content: "", + formData: [ + { id: "a", key: "name", value: "alice", active: true, valueType: "text" }, + ], + }, + }] + const out = stripHistoryFileBytes(input) + expect(out[0].body.formData?.[0].value).toBe("alice") + }) + + it("returns the same reference when no files to strip", () => { + const input: HistoryRequest[] = [{ + ...baseEntry, + body: { type: "json", content: '{"a":1}' }, + }] + const out = stripHistoryFileBytes(input) + expect(out[0]).toBe(input[0]) + }) +}) diff --git a/apps/web/src/components/api-client/__tests__/security-batch4.test.ts b/apps/web/src/components/api-client/__tests__/security-batch4.test.ts new file mode 100644 index 00000000..824483e2 --- /dev/null +++ b/apps/web/src/components/api-client/__tests__/security-batch4.test.ts @@ -0,0 +1,58 @@ +import { truncateBody } from "../truncate-body" +import { generateCode } from "../generate-code" +import type { ApiRequestState } from "../types" + +const baseRequest = ( + overrides: Partial = {}, +): ApiRequestState => ({ + id: "t1", + name: "test", + method: "POST", + url: "https://example.com", + params: [], + headers: [], + body: { type: "text", content: "" }, + auth: { type: "none" }, + response: null, + isLoading: false, + ...overrides, +}) + +describe("truncateBody — UTF-16 surrogate safety (B35)", () => { + it("does not split a surrogate pair", () => { + // 🔒 is two code units (D83D DD12). 100 ASCII chars + emoji = length 102. + const body = "a".repeat(100) + "🔒b" + // Force the cut to land between the two halves of the emoji. + const { inline } = truncateBody(body, 101) + // Should back off by 1 so we don't leave a lonely high surrogate. + expect(inline.length).toBe(100) + expect(inline.endsWith("a")).toBe(true) + // No replacement char after re-encode round-trip. + expect(JSON.stringify(inline)).not.toMatch(/\\ud83d$/) + }) + + it("returns the full body when shorter than max", () => { + const body = "hello" + const { inline, truncated } = truncateBody(body, 10) + expect(inline).toBe(body) + expect(truncated).toBe(false) + }) +}) + +describe("generateCode Go body escape (B19)", () => { + it("escapes CR, TAB, and other control characters", () => { + const code = generateCode( + baseRequest({ + body: { type: "text", content: "line1\r\nline2\tcol2" }, + }), + "go", + ) + // The Go source string must show \r, \n, \t — not the raw control chars. + expect(code).toContain('"line1\\r\\nline2\\tcol2"') + // And must not contain a bare CR / TAB inside the quoted string literal. + // [\s\S] avoids the `s` flag — same effect, broader engine compatibility. + const literalMatch = code.match(/strings\.NewReader\("([\s\S]+?)"\)/) + expect(literalMatch).not.toBeNull() + expect(literalMatch?.[1]).not.toMatch(/[\r\t]/) + }) +}) diff --git a/apps/web/src/components/api-client/api-client.tsx b/apps/web/src/components/api-client/api-client.tsx index 5f930c35..726484b9 100644 --- a/apps/web/src/components/api-client/api-client.tsx +++ b/apps/web/src/components/api-client/api-client.tsx @@ -7,7 +7,27 @@ import { RequestPanel } from "./request-panel" import { RequestTabs } from "./request-tabs" import { ResponsePanel } from "./response-panel" import { TabBar } from "./tab-bar" -import { ImportCurlDialog } from "./import-curl-dialog" +import { ImportCurlDialog, type ImportCurlTarget } from "./import-curl-dialog" +import { ImportDialog } from "./import-dialog" +import { CookieJarDialog } from "./cookie-jar-dialog" +import { WebSocketPanel } from "./websocket-panel" +import { GrpcPanel } from "./grpc-panel" +import { SaveExampleDialog } from "./save-example-dialog" +import { PerfRunDialog } from "./perf-run-dialog" +import { PublicMocksDialog } from "./public-mocks-dialog" +import { PluginsDialog } from "./plugins-dialog" +import { loadPlugins, instantiatePlugin, applyBeforeSend, applyAfterResponse, type PluginInstance } from "@/lib/plugins/plugin-runtime" +import { MetricsDialog } from "./metrics-dialog" +import { recordMetric, recordLog } from "@/lib/observability/metrics" +import { OfflineIndicator } from "./offline-indicator" +import { putCachedResponse, getCachedResponse } from "@/lib/cache/response-cache" +import { registerApiClientServiceWorker } from "@/lib/sw/register-api-client-sw" +import { P2pSyncDialog } from "./p2p-sync-dialog" +import { FuzzRunDialog } from "./fuzz-run-dialog" +import { RecorderDialog } from "./recorder-dialog" +import { listenForExtensionImports, capturedToTab } from "@/lib/extension/listen" +import { recordExchange } from "@/lib/recorder/traffic-recorder" +import type { SavedExample } from "./types" import { HelpShortcutsDialog } from "./help-shortcuts-dialog" import { SaveRequestDialog } from "./collections/save-request-dialog" import { parseCurlCommand } from "@/utils/curl-parser" @@ -20,19 +40,34 @@ import { API_CLIENT_DEFAULT_TAB_NAME, API_CLIENT_IMPORTED_TAB_NAME, API_CLIENT_ERROR_STATUS_TEXT, + ScriptTestResult, + ScriptLog, } from "./types" +import type { ScriptContext } from "./workers/scripts-runner.worker" import { useTranslations } from "next-intl" import { toast } from "sonner" import { useIsMobile } from "@/components/hooks/use-mobile" import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" import { Button } from "@/components/ui/button" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { FolderOpen, PanelRight, MoreVertical } from "lucide-react" +import { FolderOpen, PanelRight, MoreVertical, Cookie, Download, Gauge } from "lucide-react" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu" import { IconCode, IconSettings } from "@tabler/icons-react" import { cn } from "@/lib/utils" import { ensureHttpScheme } from "@/lib/url-normalize" +import { encodeBasicCredentials } from "@/lib/basic-auth" +import { ensureFreshToken } from "@/lib/oauth2" +import { signAwsSigV4 } from "@/lib/auth/aws-sigv4" +import { signHawk } from "@/lib/auth/hawk" +import { signDigest } from "@/lib/auth/digest" +import { signJwt } from "@/lib/auth/jwt-bearer" +import { cookieHeaderForUrl, storeCookiesFromResponse } from "@/lib/cookie-jar" +import { resolveResponsePath } from "@/lib/response-path" +import { applyFolderInheritance, findRequestAncestors } from "@/lib/folder-inheritance" +import { streamSseRequest } from "@/lib/sse-client" import { useJsonFormatter } from "./workers/use-json-formatter" +import { useScriptsRunner } from "./workers/use-scripts-runner" +import { useJsonBodyValidation } from "./use-json-validation" import { useTabs, useTabsActions, createNewTab } from "./context/tabs-context" import { useCollectionsState, useCollectionsActions } from "./context/collections-context" import { useEnvironmentsState, useEnvironmentsActions } from "./context/environments-context" @@ -47,6 +82,15 @@ const CodeGenerator = dynamic( { ssr: false, loading: () => null } ) +/** + * Tag history entries with the HTTP method when falling back to URL, so the list + * can distinguish `GET /users` from `POST /users` at a glance. + */ +function historyName(tabName: string, method: string, url: string): string { + if (tabName && tabName !== API_CLIENT_DEFAULT_TAB_NAME) return tabName + return url ? `${method} ${url}` : method +} + /** `new URL()` requires a scheme; host-only URLs (e.g. `api.example.com/v1`) are common in API clients. */ function buildRequestUrl(raw: string): URL { const trimmed = raw.trim() @@ -75,12 +119,16 @@ function ApiClientInner() { React.useEffect(() => () => abortControllerRef.current?.abort(), []) const { format: formatJson } = useJsonFormatter() + const { run: runScript } = useScriptsRunner() const { collections } = useCollectionsState() const { saveRequest } = useCollectionsActions() const { history } = useHistoryState() const { addHistoryItem } = useHistoryActions() const { environments, activeEnvId, activeEnvironmentVariables } = useEnvironmentsState() - const { substituteVariables, setActiveEnvId } = useEnvironmentsActions() + const { setActiveEnvId, updateEnvironment } = useEnvironmentsActions() + // Session-only variables — set by `pm.variables.set` in scripts and consumed by + // the next request's variable substitution. Cleared on tab close. + const sessionVarsRef = React.useRef>({}) const isMobile = useIsMobile() const [collectionsOpen, setCollectionsOpen] = React.useState(false) @@ -91,6 +139,100 @@ function ApiClientInner() { const [importCurlOpen, setImportCurlOpen] = React.useState(false) const [helpOpen, setHelpOpen] = React.useState(false) const [saveOpen, setSaveOpen] = React.useState(false) + const [cookieJarOpen, setCookieJarOpen] = React.useState(false) + const [importOpen, setImportOpen] = React.useState(false) + const [saveExampleOpen, setSaveExampleOpen] = React.useState(false) + const [perfOpen, setPerfOpen] = React.useState(false) + const [publicMocksOpen, setPublicMocksOpen] = React.useState(false) + const [pluginsOpen, setPluginsOpen] = React.useState(false) + const [metricsOpen, setMetricsOpen] = React.useState(false) + const [p2pOpen, setP2pOpen] = React.useState(false) + const [fuzzOpen, setFuzzOpen] = React.useState(false) + const [recorderOpen, setRecorderOpen] = React.useState(false) + + /** Register the offline-cache service worker on mount. */ + React.useEffect(() => { void registerApiClientServiceWorker() }, []) + + /** Browser extension companion: spawn a new tab whenever the extension forwards a captured request. */ + React.useEffect(() => listenForExtensionImports((captured) => { + const tab = createNewTab() + appendTab({ ...tab, name: API_CLIENT_IMPORTED_TAB_NAME, ...capturedToTab(captured) }) + toast.success(`Imported ${captured.method} ${captured.url} from extension`) + }), [appendTab]) + + /** Rehydrate the active tab's response from IndexedDB if missing. + * Bodies are stripped from localStorage tabs to dodge the 5MB quota; this + * effect restores them transparently when the user reloads the page. */ + React.useEffect(() => { + if (activeTab.response || !activeTab.id) return + let cancelled = false + void getCachedResponse(activeTab.id).then((cached) => { + if (cancelled || !cached) return + updateActiveTab({ response: cached }) + }) + return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTab.id]) + + /** Compiled plugin instances; reloaded when the dialog closes. */ + const pluginInstancesRef = React.useRef([]) + React.useEffect(() => { + const compile = () => { + pluginInstancesRef.current = loadPlugins() + .filter((p) => p.enabled) + .map((p) => instantiatePlugin(p)) + .filter((r) => r.instance) + .map((r) => r.instance!) + } + compile() + if (!pluginsOpen) compile() // pick up edits after dialog closed + }, [pluginsOpen]) + + const handleSaveExample = (name: string) => { + if (!activeTab.response) return + const example: SavedExample = { + id: crypto.randomUUID(), + name, + capturedAt: Date.now(), + request: { + method: activeTab.method, + url: activeTab.url, + headers: activeTab.headers, + body: activeTab.body, + }, + response: { + status: activeTab.response.status, + statusText: activeTab.response.statusText, + headers: activeTab.response.headers, + body: activeTab.response.body, + isBase64: activeTab.response.isBase64, + contentType: Object.entries(activeTab.response.headers ?? {}) + .find(([k]) => k.toLowerCase() === "content-type")?.[1], + }, + } + updateActiveTab({ examples: [...(activeTab.examples ?? []), example] }) + toast.success(`Saved example "${name}"`) + } + + const handleDeleteExample = (id: string) => { + updateActiveTab({ examples: (activeTab.examples ?? []).filter((e) => e.id !== id) }) + } + + const handleLoadExample = (example: SavedExample) => { + updateActiveTab({ + response: { + status: example.response.status, + statusText: example.response.statusText, + headers: example.response.headers, + body: example.response.body, + isBase64: example.response.isBase64, + time: 0, + size: example.response.body?.length ?? 0, + }, + isLoading: false, + }) + toast.success(`Loaded example "${example.name}"`) + } // Scroll position memory for mobile panel toggle const scrollMemory = React.useRef<{ request: number; response: number }>({ request: 0, response: 0 }) @@ -114,11 +256,8 @@ function ApiClientInner() { return urls }, [history]) - const isBodyInvalid = React.useMemo(() => { - if (activeTab.body.type !== "json") return false - if (!activeTab.body.content.trim()) return false - try { JSON.parse(activeTab.body.content); return false } catch { return true } - }, [activeTab.body]) + const jsonValidation = useJsonBodyValidation(activeTab.body) + const isBodyInvalid = !jsonValidation.valid const replaceUrlWithEnvBaseUrl = React.useCallback((url: string | undefined) => { if (!url || !activeEnvId) return url @@ -147,19 +286,27 @@ function ApiClientInner() { updateActiveTab({ url, name: url || API_CLIENT_DEFAULT_TAB_NAME }) }, [updateActiveTab]) - const handleImportCurl = (curl: string) => { + const handleImportCurl = (curl: string, target: ImportCurlTarget = "new-tab") => { try { const parsed = parseCurlCommand(curl) const resolvedUrl = replaceUrlWithEnvBaseUrl(parsed.url) - const newTab: ApiRequestState = { - ...createNewTab(), - ...parsed, - url: resolvedUrl || "", - name: resolvedUrl || API_CLIENT_IMPORTED_TAB_NAME, - id: crypto.randomUUID(), + if (target === "replace-current") { + updateActiveTab({ + ...parsed, + url: resolvedUrl || "", + name: resolvedUrl || activeTab.name, + }) + } else { + const newTab: ApiRequestState = { + ...createNewTab(), + ...parsed, + url: resolvedUrl || "", + name: resolvedUrl || API_CLIENT_IMPORTED_TAB_NAME, + id: crypto.randomUUID(), + } + appendTab(newTab) } - appendTab(newTab) toast.success(t("toasts.curlImported")) } catch (error) { console.error(error) @@ -183,9 +330,20 @@ function ApiClientInner() { } const handleLoadRequest = (request: CollectionRequest) => { + // Locate the request inside any collection so we can fold ancestor folders' + // defaults (headers / auth / scripts) into the materialised tab. A request + // edited in a tab is a snapshot — later folder-default edits don't propagate. + let effective = request + for (const col of collections) { + const ancestors = findRequestAncestors(col.items, request.id) + if (ancestors !== null) { + effective = applyFolderInheritance(request, ancestors) + break + } + } const newTab: ApiRequestState = { ...createNewTab(), - ...request, + ...effective, id: crypto.randomUUID(), // New ID for the tab instance response: null, isLoading: false, @@ -206,44 +364,184 @@ function ApiClientInner() { const controller = new AbortController() abortControllerRef.current = controller - updateActiveTab({ isLoading: true, response: null }) + updateActiveTab({ isLoading: true, response: null, scriptResults: undefined }) if (isMobile) setMobilePanel('response') const startTime = performance.now() + // Aggregated script output across pre-request + tests. + const scriptTests: ScriptTestResult[] = [] + const scriptLogs: ScriptLog[] = [] + const scriptErrors: string[] = [] + + // Env mutations made by scripts — applied locally to substitution and + // persisted to the active environment after the request completes. + const scriptEnvOverlay: Record = {} + const scriptEnvUnsets = new Set() + + // Previous response on the same tab — what `{{response.body.token}}` chains against. + const previousResponse = activeTab.response + + const substituteAll = (text: string): string => { + if (!text) return text + return text.replace(/\{\{(.+?)\}\}/g, (m, k) => { + const key = (k as string).trim() + if (key.startsWith("response.")) { + const resolved = resolveResponsePath(key.slice("response.".length), previousResponse) + return resolved ?? m + } + if (scriptEnvUnsets.has(key)) return m + if (key in scriptEnvOverlay) return scriptEnvOverlay[key] + if (key in sessionVarsRef.current) return sessionVarsRef.current[key] + return activeEnvironmentVariables[key] ?? m + }) + } + try { + // Working request state. Pre-request script may rewrite url/method/headers/body. + let workMethod: string = activeTab.method + let workUrl: string = activeTab.url + let workHeaders: Record = {} + activeTab.headers.forEach((h) => { + if (h.active && h.key) workHeaders[h.key] = h.value + }) + + // ── Pre-request script ──────────────────────────────────────── + if (activeTab.preRequestScript && activeTab.preRequestScript.trim()) { + const preCtx: ScriptContext = { + request: { + url: workUrl, + method: workMethod, + headers: workHeaders, + body: activeTab.body.type === "json" || activeTab.body.type === "text" + ? activeTab.body.content + : undefined, + }, + environment: { ...activeEnvironmentVariables }, + variables: { ...sessionVarsRef.current }, + } + const r = await runScript(activeTab.preRequestScript, preCtx) + scriptTests.push(...r.tests) + scriptLogs.push(...r.logs) + if (!r.ok && r.error) scriptErrors.push(`pre-request: ${r.error}`) + + // Diff env: anything different goes into overlay; anything dropped goes into unsets. + for (const k of Object.keys(r.environment)) { + if (r.environment[k] !== activeEnvironmentVariables[k]) { + scriptEnvOverlay[k] = r.environment[k] + } + } + for (const k of Object.keys(activeEnvironmentVariables)) { + if (!(k in r.environment)) scriptEnvUnsets.add(k) + } + sessionVarsRef.current = r.variables + workUrl = r.request.url + workMethod = (r.request.method || workMethod).toUpperCase() + workHeaders = r.request.headers + } + // Substitute variables in URL - const finalUrl = substituteVariables(activeTab.url) + const finalUrl = substituteAll(workUrl) // Construct URL with params const urlObj = buildRequestUrl(finalUrl) activeTab.params.forEach((p) => { if (p.active && p.key) { - urlObj.searchParams.append(substituteVariables(p.key), substituteVariables(p.value)) + urlObj.searchParams.append(substituteAll(p.key), substituteAll(p.value)) } }) - // Construct headers + // Construct headers (start with pre-script's worked headers) const headersObj: Record = {} - activeTab.headers.forEach((h) => { - if (h.active && h.key) { - headersObj[substituteVariables(h.key)] = substituteVariables(h.value) - } + Object.entries(workHeaders).forEach(([k, v]) => { + headersObj[substituteAll(k)] = substituteAll(v) }) - // Add Auth + // Add Auth — credentials are trimmed: copy-pasted tokens routinely carry leading/trailing + // whitespace which most servers reject as malformed. if (activeTab.auth.type === "bearer" && activeTab.auth.token) { - headersObj["Authorization"] = `Bearer ${substituteVariables(activeTab.auth.token)}` + headersObj["Authorization"] = `Bearer ${substituteAll(activeTab.auth.token).trim()}` } else if (activeTab.auth.type === "basic" && activeTab.auth.username && activeTab.auth.password) { - const credentials = btoa(`${substituteVariables(activeTab.auth.username)}:${substituteVariables(activeTab.auth.password)}`) + const credentials = encodeBasicCredentials( + substituteAll(activeTab.auth.username).trim(), + substituteAll(activeTab.auth.password), + ) headersObj["Authorization"] = `Basic ${credentials}` } else if (activeTab.auth.type === "api-key" && activeTab.auth.apiKeyKey && activeTab.auth.apiKeyValue) { - const key = substituteVariables(activeTab.auth.apiKeyKey) - const val = substituteVariables(activeTab.auth.apiKeyValue) + const key = substituteAll(activeTab.auth.apiKeyKey).trim() + const val = substituteAll(activeTab.auth.apiKeyValue).trim() if (activeTab.auth.apiKeyLocation === "query") { urlObj.searchParams.append(key, val) } else { headersObj[key] = val } + } else if (activeTab.auth.type === "jwt-bearer" && activeTab.auth.jwtBearer) { + try { + const cfg = activeTab.auth.jwtBearer + let extra: Record | undefined + if (cfg.extraClaimsJson && cfg.extraClaimsJson.trim()) { + extra = JSON.parse(cfg.extraClaimsJson) as Record + } + const jwt = await signJwt({ + algorithm: cfg.algorithm, + secret: cfg.secret, + privateKeyPem: cfg.privateKeyPem, + ttlSeconds: cfg.ttlSeconds, + claims: { + iss: cfg.issuer || undefined, + sub: cfg.subject || undefined, + aud: cfg.audience || undefined, + extra, + }, + }) + headersObj["Authorization"] = `Bearer ${jwt}` + } catch (err) { + toast.error(`JWT signing failed: ${(err as Error).message}`) + updateActiveTab({ isLoading: false }) + return + } + } else if (activeTab.auth.type === "aws-sigv4" && activeTab.auth.awsSigV4) { + // Defer signing until after the body is built — it has to hash the payload. + // We mark the intent here and apply at the end of body assembly. + } else if (activeTab.auth.type === "hawk" && activeTab.auth.hawk) { + // Same — deferred to post-body assembly. + } else if (activeTab.auth.type === "digest" && activeTab.auth.digest) { + // Digest with qop=auth-int also needs body. Deferred. + } else if (activeTab.auth.type === "oauth2" && activeTab.auth.oauth2) { + // Refresh / re-fetch as needed. The fresh config is persisted back + // into tab state so subsequent sends reuse the same access token. + try { + const fresh = await ensureFreshToken(activeTab.auth.oauth2) + if ( + fresh.accessToken !== activeTab.auth.oauth2.accessToken || + fresh.refreshToken !== activeTab.auth.oauth2.refreshToken || + fresh.expiresAt !== activeTab.auth.oauth2.expiresAt + ) { + updateActiveTab({ auth: { ...activeTab.auth, oauth2: fresh } }) + } + if (fresh.accessToken) { + const tokenType = fresh.tokenType || "Bearer" + headersObj["Authorization"] = `${tokenType} ${fresh.accessToken}` + } + } catch (err) { + toast.error(`OAuth token refresh failed: ${(err as Error).message}`) + updateActiveTab({ isLoading: false }) + return + } + } + + // Cookie jar: prepend stored cookies to the Cookie header for this URL. + // User-provided Cookie header takes precedence by appearing later in the merge + // (browsers/curl typically take the merged value as-sent — undici will combine). + if (activeTab.useCookieJar !== false) { + const jarHeader = cookieHeaderForUrl(urlObj.toString()) + if (jarHeader) { + const existingKey = Object.keys(headersObj).find((k) => k.toLowerCase() === "cookie") + if (existingKey) { + headersObj[existingKey] = `${jarHeader}; ${headersObj[existingKey]}` + } else { + headersObj["Cookie"] = jarHeader + } + } } // Prepare body @@ -255,17 +553,23 @@ function ApiClientInner() { delete headersMap[existingKey] } } - if (activeTab.method !== "GET" && activeTab.method !== "HEAD" && activeTab.body.type !== "none") { + if (workMethod !== "GET" && workMethod !== "HEAD" && activeTab.body.type !== "none") { if (activeTab.body.type === "json") { try { - const substitutedBody = substituteVariables(activeTab.body.content) + const substitutedBody = substituteAll(activeTab.body.content) // Validate JSON JSON.parse(substitutedBody) bodyContent = substitutedBody bodyPayload = substitutedBody headersObj["Content-Type"] = "application/json" } catch (e) { - toast.error(t("toasts.invalidJsonBody")) + const parseErr = (e as Error).message + const hasUnresolvedVar = activeTab.body.content.includes("{{") + toast.error( + hasUnresolvedVar + ? `Invalid JSON after env substitution: ${parseErr}` + : `Invalid JSON body: ${parseErr}`, + ) updateActiveTab({ isLoading: false }) return } @@ -273,7 +577,7 @@ function ApiClientInner() { const params = new URLSearchParams() ;(activeTab.body.urlEncoded ?? []).forEach((item) => { if (item.active && item.key) { - params.append(substituteVariables(item.key), substituteVariables(item.value)) + params.append(substituteAll(item.key), substituteAll(item.value)) } }) bodyContent = params.toString() @@ -287,7 +591,7 @@ function ApiClientInner() { .map((item) => { if (item.valueType === "file") { return { - key: substituteVariables(item.key), + key: substituteAll(item.key), type: "file" as const, fileName: item.fileName || "upload.bin", fileType: item.fileType || "application/octet-stream", @@ -296,9 +600,9 @@ function ApiClientInner() { } return { - key: substituteVariables(item.key), + key: substituteAll(item.key), type: "text" as const, - value: substituteVariables(item.value), + value: substituteAll(item.value), } }) @@ -307,8 +611,25 @@ function ApiClientInner() { entries, } deleteContentTypeHeader(headersObj) + } else if (activeTab.body.type === "graphql") { + // GraphQL is serialised as { query, variables } JSON and sent as application/json. + const query = substituteAll(activeTab.body.content) + let variables: unknown = undefined + const rawVars = (activeTab.body.graphqlVariables ?? "").trim() + if (rawVars) { + try { + variables = JSON.parse(substituteAll(rawVars)) + } catch (e) { + toast.error(`Invalid GraphQL variables JSON: ${(e as Error).message}`) + updateActiveTab({ isLoading: false }) + return + } + } + bodyContent = JSON.stringify(variables !== undefined ? { query, variables } : { query }) + bodyPayload = bodyContent + headersObj["Content-Type"] = "application/json" } else { - bodyContent = substituteVariables(activeTab.body.content) + bodyContent = substituteAll(activeTab.body.content) bodyPayload = bodyContent if (!headersObj["Content-Type"]) { headersObj["Content-Type"] = "text/plain" @@ -316,6 +637,189 @@ function ApiClientInner() { } } + // ── Payload-dependent auth signers ─────────────────────────── + // SigV4 / Hawk / Digest(auth-int) all hash the body — they have to + // run after body assembly but before the proxy fetch. + const bodyForSigning = typeof bodyPayload === "string" + ? bodyPayload + : (bodyContent ?? "") + try { + if (activeTab.auth.type === "aws-sigv4" && activeTab.auth.awsSigV4) { + await signAwsSigV4({ + method: workMethod, + url: urlObj, + headers: headersObj, + body: bodyForSigning, + cfg: activeTab.auth.awsSigV4, + }) + } else if (activeTab.auth.type === "hawk" && activeTab.auth.hawk) { + await signHawk({ + method: workMethod, + url: urlObj, + headers: headersObj, + body: bodyForSigning, + cfg: activeTab.auth.hawk, + }) + } else if (activeTab.auth.type === "digest" && activeTab.auth.digest) { + await signDigest({ + method: workMethod, + url: urlObj, + headers: headersObj, + body: bodyForSigning, + cfg: activeTab.auth.digest, + }) + } + } catch (signErr) { + toast.error(`Auth signing failed: ${(signErr as Error).message}`) + updateActiveTab({ isLoading: false }) + return + } + + // ── SPNEGO / Kerberos branch ───────────────────────────────── + if (activeTab.auth.type === "spnego" && activeTab.auth.spnego) { + const spnegoRes = await fetch("/api/proxy-spnego", { + method: "POST", + credentials: "include", + signal: controller.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: urlObj.toString(), + method: workMethod, + headers: headersObj, + body: bodyContent ?? undefined, + token: activeTab.auth.spnego.token, + }), + }) + const data = await spnegoRes.json() + updateActiveTab({ + response: { + status: data.status, + statusText: data.statusText ?? "", + headers: data.headers ?? {}, + body: data.body ?? "", + time: data.time ?? 0, + size: data.size ?? 0, + error: data.error, + }, + isLoading: false, + }) + return + } + + // ── NTLM branch ───────────────────────────────────────────── + // NTLM does its own 3-step handshake on a single keepalive socket; + // route the request through the NTLM proxy and skip the streaming + // branch (NTLM responses are bounded). + if (activeTab.auth.type === "ntlm" && activeTab.auth.ntlm) { + const ntlmRes = await fetch("/api/proxy-ntlm", { + method: "POST", + credentials: "include", + signal: controller.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: urlObj.toString(), + method: workMethod, + headers: headersObj, + body: bodyContent ?? undefined, + ntlm: { + username: substituteAll(activeTab.auth.ntlm.username), + password: substituteAll(activeTab.auth.ntlm.password), + domain: activeTab.auth.ntlm.domain ? substituteAll(activeTab.auth.ntlm.domain) : undefined, + workstation: activeTab.auth.ntlm.workstation ? substituteAll(activeTab.auth.ntlm.workstation) : undefined, + }, + }), + }) + const ntlmData = await ntlmRes.json() + updateActiveTab({ + response: { + status: ntlmData.status, + statusText: ntlmData.statusText ?? "", + headers: ntlmData.headers ?? {}, + body: ntlmData.body ?? "", + time: ntlmData.time ?? 0, + size: ntlmData.size ?? 0, + error: ntlmData.error, + }, + isLoading: false, + }) + addHistoryItem({ + method: workMethod as RequestMethod, + url: activeTab.url, + params: activeTab.params, + headers: activeTab.headers, + body: activeTab.body, + auth: activeTab.auth, + preRequestScript: activeTab.preRequestScript, + testScript: activeTab.testScript, + }, historyName(activeTab.name, workMethod, activeTab.url), ntlmData.status) + return + } + + // ── Streaming branch (SSE / chunked) ───────────────────────── + // Auto-promote to streaming when `Accept: text/event-stream` is set. + const acceptKey = Object.keys(headersObj).find((k) => k.toLowerCase() === "accept") + const sseAutoDetect = acceptKey ? (headersObj[acceptKey] ?? "").toLowerCase().includes("text/event-stream") : false + if (activeTab.streamResponse || sseAutoDetect) { + updateActiveTab({ streamEvents: [] }) + let metaCaptured = false + await streamSseRequest({ + url: urlObj.toString(), + method: workMethod, + headers: headersObj, + body: typeof bodyPayload === "string" ? bodyPayload : (bodyContent ?? undefined), + signal: controller.signal, + onMeta: (meta) => { + if (metaCaptured) return + metaCaptured = true + updateActiveTab({ + response: { + status: meta.status, + statusText: meta.statusText, + headers: meta.headers, + body: "", + time: 0, + size: 0, + }, + }) + }, + onEvent: (ev) => { + updateActiveTab((tab) => ({ + streamEvents: [ + ...(tab.streamEvents ?? []), + { event: ev.event, data: ev.data, id: ev.id, timestamp: ev.timestamp }, + ], + })) + }, + onClose: () => { + updateActiveTab({ isLoading: false }) + }, + }) + + addHistoryItem({ + method: workMethod as RequestMethod, + url: activeTab.url, + params: activeTab.params, + headers: activeTab.headers, + body: activeTab.body, + auth: activeTab.auth, + preRequestScript: activeTab.preRequestScript, + testScript: activeTab.testScript, + }, historyName(activeTab.name, workMethod, activeTab.url), 200) + return + } + + // Plugins: onBeforeSend lets installed plugins mutate URL / headers / body. + const pluginRequest = { + method: workMethod, + url: urlObj.toString(), + headers: { ...headersObj }, + body: typeof bodyPayload === "string" ? bodyPayload : (bodyContent ?? undefined), + } + const beforeApplied = applyBeforeSend(pluginInstancesRef.current, pluginRequest) + for (const k of Object.keys(headersObj)) delete headersObj[k] + Object.assign(headersObj, beforeApplied.req.headers) + const finalUrlFromPlugins = beforeApplied.req.url + // Send via Proxy const res = await fetch("/api/proxy", { method: "POST", @@ -325,15 +829,51 @@ function ApiClientInner() { "Content-Type": "application/json", }, body: JSON.stringify({ - url: urlObj.toString(), - method: activeTab.method, + url: finalUrlFromPlugins, + method: beforeApplied.req.method, headers: headersObj, - body: bodyPayload ?? bodyContent, + body: beforeApplied.req.body ?? bodyPayload ?? bodyContent, + timeoutMs: activeTab.timeoutMs, }), }) const proxyData = await res.json() + // Plugins: onAfterResponse hook gets the live response. + applyAfterResponse(pluginInstancesRef.current, { + request: pluginRequest, + response: { + status: proxyData.status, + statusText: proxyData.statusText ?? "", + headers: proxyData.headers ?? {}, + body: proxyData.body ?? "", + timeMs: proxyData.time ?? 0, + }, + }) + + // Observability: record metric for the metrics dashboard. + recordMetric({ + method: beforeApplied.req.method, + url: finalUrlFromPlugins, + status: proxyData.status ?? 0, + timeMs: proxyData.time ?? 0, + sizeBytes: proxyData.size ?? 0, + error: proxyData.error, + }) + + if (proxyData.error) { + recordLog({ level: "error", message: `${beforeApplied.req.method} ${finalUrlFromPlugins} → ${proxyData.error}` }) + } + + // Cookie jar: persist Set-Cookie headers from the response. + if ( + activeTab.useCookieJar !== false && + Array.isArray(proxyData.setCookies) && + proxyData.setCookies.length > 0 + ) { + storeCookiesFromResponse(urlObj.toString(), proxyData.setCookies) + } + let formattedBody = proxyData.body if (formattedBody && !proxyData.isBase64) { const responseContentType = (proxyData.headers as Record | undefined) @@ -343,13 +883,67 @@ function ApiClientInner() { if (rawCT.includes("application/json")) { const r = await formatJson(formattedBody) if (r.ok) formattedBody = r.formatted - } else { - // Non-JSON: attempt sync pretty-print as before (best-effort) - try { - formattedBody = JSON.stringify(JSON.parse(formattedBody), null, 2) - } catch { - // Not JSON, keep as text + } + // Non-JSON content-types are NOT pretty-printed here. The previous + // sync `JSON.stringify(JSON.parse(body))` blocked the main thread on + // large XML/HTML/text bodies for no benefit. Monaco's built-in format + // action (right-click → Format Document) handles XML on demand. + } + + // ── Test script (runs against the live response) ────────────── + if (activeTab.testScript && activeTab.testScript.trim()) { + const postCtx: ScriptContext = { + request: { + url: urlObj.toString(), + method: workMethod, + headers: { ...headersObj }, + body: bodyContent ?? undefined, + }, + response: { + status: proxyData.status, + statusText: proxyData.statusText, + headers: proxyData.headers, + body: proxyData.body, + time: proxyData.time, + }, + environment: { ...activeEnvironmentVariables, ...scriptEnvOverlay }, + variables: { ...sessionVarsRef.current }, + } + const r = await runScript(activeTab.testScript, postCtx) + scriptTests.push(...r.tests) + scriptLogs.push(...r.logs) + if (!r.ok && r.error) scriptErrors.push(`test: ${r.error}`) + for (const k of Object.keys(r.environment)) { + if (r.environment[k] !== activeEnvironmentVariables[k]) { + scriptEnvOverlay[k] = r.environment[k] + } + } + sessionVarsRef.current = r.variables + } + + // Persist script-induced env mutations to the active environment. + if ( + activeEnvId && + (Object.keys(scriptEnvOverlay).length > 0 || scriptEnvUnsets.size > 0) + ) { + const env = environments.find((e) => e.id === activeEnvId) + if (env) { + const updatedKeys = new Set() + const merged = env.variables + .filter((v) => !scriptEnvUnsets.has(v.key)) + .map((v) => { + if (scriptEnvOverlay[v.key] !== undefined) { + updatedKeys.add(v.key) + return { ...v, value: scriptEnvOverlay[v.key] } + } + return v + }) + for (const [k, val] of Object.entries(scriptEnvOverlay)) { + if (!updatedKeys.has(k)) { + merged.push({ id: crypto.randomUUID(), key: k, value: val, enabled: true }) + } } + updateEnvironment(activeEnvId, { variables: merged }) } } @@ -363,18 +957,55 @@ function ApiClientInner() { time: proxyData.time, size: proxyData.size, error: proxyData.error, + setCookies: proxyData.setCookies, + redirectChain: proxyData.redirectChain, }, + scriptResults: (scriptTests.length || scriptLogs.length || scriptErrors.length) + ? { tests: scriptTests, logs: scriptLogs, errors: scriptErrors } + : undefined, isLoading: false, }) + // Capture-replay recorder: append exchange to active session (no-op if none). + recordExchange({ + request: { + method: workMethod, + url: activeTab.url, + headers: Object.fromEntries(activeTab.headers.filter((h) => h.active && h.key).map((h) => [h.key, h.value])), + body: typeof activeTab.body?.content === "string" ? activeTab.body.content : undefined, + }, + response: { + status: proxyData.status, + statusText: proxyData.statusText, + timeMs: proxyData.time ?? 0, + bodyExcerpt: typeof formattedBody === "string" ? formattedBody.slice(0, 4096) : "", + }, + }) + + // IndexedDB cache: keep full response body across reloads (localStorage strips it). + void putCachedResponse(activeTab.id, { + status: proxyData.status, + statusText: proxyData.statusText, + headers: proxyData.headers, + body: formattedBody, + isBase64: proxyData.isBase64, + time: proxyData.time, + size: proxyData.size, + error: proxyData.error, + setCookies: proxyData.setCookies, + redirectChain: proxyData.redirectChain, + }) + addHistoryItem({ - method: activeTab.method, + method: workMethod as RequestMethod, url: activeTab.url, params: activeTab.params, headers: activeTab.headers, body: activeTab.body, auth: activeTab.auth, - }, activeTab.name !== API_CLIENT_DEFAULT_TAB_NAME ? activeTab.name : activeTab.url, proxyData.status) + preRequestScript: activeTab.preRequestScript, + testScript: activeTab.testScript, + }, historyName(activeTab.name, workMethod, activeTab.url), proxyData.status) } catch (error) { if ((error as Error).name === "AbortError") return @@ -400,9 +1031,21 @@ function ApiClientInner() { headers: activeTab.headers, body: activeTab.body, auth: activeTab.auth, - }, activeTab.name !== API_CLIENT_DEFAULT_TAB_NAME ? activeTab.name : activeTab.url, 0) + }, historyName(activeTab.name, activeTab.method, activeTab.url), 0) } - }, [activeTab, updateActiveTab, isMobile, substituteVariables, formatJson, addHistoryItem, t]) + }, [ + activeTab, + updateActiveTab, + isMobile, + formatJson, + runScript, + addHistoryItem, + activeEnvironmentVariables, + activeEnvId, + environments, + updateEnvironment, + t, + ]) const handleCurlPaste = (curl: string) => { try { @@ -421,16 +1064,19 @@ function ApiClientInner() { } } - // Keyboard shortcuts + // Keyboard shortcuts. + // Cmd/Ctrl+T and Cmd/Ctrl+W are hard-reserved by the browser (`preventDefault` + // does not override) so we bind Alt+T / Alt+W as the working equivalents. + // Cmd/Ctrl+Enter still works inside form inputs. React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const isMac = /Mac|iPhone|iPad/i.test(navigator.userAgent) const mod = isMac ? e.metaKey : e.ctrlKey - if (mod && e.key === "t") { + if (e.altKey && !e.ctrlKey && !e.metaKey && (e.key === "t" || e.key === "T")) { e.preventDefault() addTab() - } else if (mod && e.key === "w") { + } else if (e.altKey && !e.ctrlKey && !e.metaKey && (e.key === "w" || e.key === "W")) { e.preventDefault() closeTab(activeTabId) } else if (mod && e.key === "Enter") { @@ -490,11 +1136,40 @@ function ApiClientInner() { setImportCurlOpen(true)}> {t("toolbar.importCurl")} + setImportOpen(true)}> + Import collection + setEnvMgrOpen(true)}> {t("toolbar.environments")} + setCookieJarOpen(true)}> + + Cookies + + setPerfOpen(true)}> + + Perf run + + setPublicMocksOpen(true)}> + Public mocks + + setPluginsOpen(true)}> + Plugins + + setMetricsOpen(true)}> + Metrics + + setP2pOpen(true)}> + Peer sync (WebRTC) + + setFuzzOpen(true)}> + Fuzz run + + setRecorderOpen(true)}> + Capture & replay + setHelpOpen(true)}> {t("toolbar.shortcuts")} @@ -558,8 +1233,45 @@ function ApiClientInner() { defaultName={activeTab.name !== API_CLIENT_DEFAULT_TAB_NAME ? activeTab.name : ""} /> + + + +
+
@@ -646,7 +1377,21 @@ function ApiClientInner() { )}
- {isMobile ? ( + {activeTab.kind === "websocket" ? ( +
+ updateActiveTab(patch)} + /> +
+ ) : activeTab.kind === "grpc" ? ( +
+ updateActiveTab(patch)} + /> +
+ ) : isMobile ? ( /* Mobile: separate scroll containers per panel — scroll position preserved on toggle */ <>
updateActiveTab({ streamResponse: v })} /> updateActiveTab({ body })} auth={activeTab.auth} setAuth={(auth) => updateActiveTab({ auth })} + preRequestScript={activeTab.preRequestScript} + setPreRequestScript={(s) => updateActiveTab({ preRequestScript: s })} + testScript={activeTab.testScript} + setTestScript={(s) => updateActiveTab({ testScript: s })} + graphqlUrl={activeTab.url} + graphqlSchema={activeTab.graphqlSchema} + setGraphqlSchema={(s) => updateActiveTab({ graphqlSchema: s })} + examples={activeTab.examples} + onDeleteExample={handleDeleteExample} + onLoadExample={handleLoadExample} + comments={activeTab.comments} + setComments={(next) => updateActiveTab({ comments: next })} />
@@ -686,7 +1445,7 @@ function ApiClientInner() { onScroll={(e) => { scrollMemory.current.response = (e.target as HTMLDivElement).scrollTop }} >
- + setSaveExampleOpen(true) : undefined} />
@@ -707,6 +1466,8 @@ function ApiClientInner() { onPaste={handleCurlPaste} urlHistory={urlHistory} tabId={activeTab.id} + streamResponse={activeTab.streamResponse} + setStreamResponse={(v) => updateActiveTab({ streamResponse: v })} />
updateActiveTab({ body })} auth={activeTab.auth} setAuth={(auth) => updateActiveTab({ auth })} + preRequestScript={activeTab.preRequestScript} + setPreRequestScript={(s) => updateActiveTab({ preRequestScript: s })} + testScript={activeTab.testScript} + setTestScript={(s) => updateActiveTab({ testScript: s })} + graphqlUrl={activeTab.url} + graphqlSchema={activeTab.graphqlSchema} + setGraphqlSchema={(s) => updateActiveTab({ graphqlSchema: s })} + examples={activeTab.examples} + onDeleteExample={handleDeleteExample} + onLoadExample={handleLoadExample} + comments={activeTab.comments} + setComments={(next) => updateActiveTab({ comments: next })} />
@@ -727,7 +1500,7 @@ function ApiClientInner() {
- + setSaveExampleOpen(true) : undefined} />
diff --git a/apps/web/src/components/api-client/collection-runner-dialog.tsx b/apps/web/src/components/api-client/collection-runner-dialog.tsx new file mode 100644 index 00000000..11c52702 --- /dev/null +++ b/apps/web/src/components/api-client/collection-runner-dialog.tsx @@ -0,0 +1,277 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { ScrollArea } from "@/components/ui/scroll-area" +import { CheckCircle2, AlertCircle, Loader2, Play, X, FileDown, Trash2 } from "lucide-react" +import { cn } from "@/lib/utils" +import type { Collection } from "./types" +import { useScriptsRunner } from "./workers/use-scripts-runner" +import { useEnvironmentsState } from "./context/environments-context" +import { parseDataFile } from "@/lib/runner/csv" +import { runCollection } from "@/lib/runner/runner" +import { downloadJUnitXml } from "@/lib/runner/junit" +import type { RequestRunResult } from "@/lib/runner/types" +import { toast } from "sonner" + +interface CollectionRunnerDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + collection: Collection | null +} + +export function CollectionRunnerDialog({ open, onOpenChange, collection }: CollectionRunnerDialogProps) { + const { activeEnvironmentVariables } = useEnvironmentsState() + const { run: runScript } = useScriptsRunner() + + const [iterations, setIterations] = React.useState(1) + const [dataRows, setDataRows] = React.useState[]>([]) + const [dataFileName, setDataFileName] = React.useState("") + const [results, setResults] = React.useState([]) + const [running, setRunning] = React.useState(false) + const [total, setTotal] = React.useState(0) + const abortRef = React.useRef(null) + + React.useEffect(() => { + if (!open) { + setResults([]) + setRunning(false) + setDataRows([]) + setDataFileName("") + setIterations(1) + abortRef.current?.abort() + abortRef.current = null + } + }, [open]) + + const handleDataFile = async (file: File | null) => { + if (!file) return + try { + const text = await file.text() + const rows = parseDataFile(text) + setDataRows(rows) + setDataFileName(file.name) + toast.success(`Loaded ${rows.length} rows from ${file.name}`) + } catch (e) { + toast.error(`Could not parse data file: ${(e as Error).message}`) + } + } + + const handleRun = async () => { + if (!collection) return + setResults([]) + setRunning(true) + abortRef.current = new AbortController() + try { + await runCollection({ + collection, + iterations, + dataRows: dataRows.length > 0 ? dataRows : undefined, + environmentVariables: activeEnvironmentVariables, + runScript, + abortSignal: abortRef.current.signal, + onProgress: (e) => { + if (e.kind === "started") setTotal(e.total) + if (e.kind === "request-done") { + setResults((prev) => [...prev, e.result]) + } + }, + }) + } catch (e) { + toast.error(`Run failed: ${(e as Error).message}`) + } finally { + setRunning(false) + abortRef.current = null + } + } + + const handleAbort = () => { + abortRef.current?.abort() + } + + const stats = React.useMemo(() => { + let totalTests = 0 + let failedTests = 0 + let networkErrors = 0 + for (const r of results) { + totalTests += r.tests.length + failedTests += r.tests.filter((t) => !t.pass).length + if (r.networkError) networkErrors++ + } + return { totalTests, failedTests, networkErrors, passed: totalTests - failedTests } + }, [results]) + + const handleExport = () => { + if (!collection) return + downloadJUnitXml(results, collection.name) + } + + return ( + { if (!running) onOpenChange(o) }}> + + + Run collection: {collection?.name ?? "—"} + + Sends every request in this collection sequentially. Pre-request + test scripts + + cookie jar + OAuth refresh + env substitution all behave exactly as a normal send. + + + +
+
+ + setIterations(Math.max(1, Number(e.target.value) || 1))} + disabled={dataRows.length > 0 || running} + className="h-9" + /> + {dataRows.length > 0 && ( +

+ Iterations forced to {dataRows.length} (from data file). +

+ )} +
+
+ +
+ + {dataFileName ? ( +
+ {dataFileName} + +
+ ) : ( + none + )} +
+
+
+ +
+ {!running ? ( + + ) : ( + + )} + {results.length > 0 && ( + + )} + {results.length > 0 && !running && ( + + )} +
+ + {(running || results.length > 0) && ( +
+ {running && ( + + + {results.length} / {total} + + )} + + + {stats.passed} pass + + {stats.failedTests > 0 && ( + + + {stats.failedTests} fail + + )} + {stats.networkErrors > 0 && ( + + + {stats.networkErrors} network + + )} +
+ )} + + +
+ {results.length === 0 && !running && ( +
+ Configure and click Run. +
+ )} + {results.map((r, i) => { + const ok = !r.networkError && (r.status ?? 0) >= 200 && (r.status ?? 0) < 400 + && r.tests.every((t) => t.pass) + const tone = r.networkError + ? "border-rose-500/30 bg-rose-500/[0.03]" + : ok + ? "border-emerald-500/20 bg-emerald-500/[0.02]" + : "border-amber-500/30 bg-amber-500/[0.03]" + return ( +
+
+ {r.method} + {r.requestName} + {r.status !== undefined && ( + + {r.status} + + )} + {r.time !== undefined && ( + {r.time}ms + )} +
+ {r.networkError && ( +
{r.networkError}
+ )} + {r.tests.length > 0 && ( +
    + {r.tests.map((t, j) => ( +
  • + {t.pass ? "✓" : "✗"} {t.name} + {!t.pass && t.error && — {t.error}} +
  • + ))} +
+ )} +
+ ) + })} +
+
+
+
+ ) +} diff --git a/apps/web/src/components/api-client/collections/collection-item.tsx b/apps/web/src/components/api-client/collections/collection-item.tsx index 67991d05..9afc4445 100644 --- a/apps/web/src/components/api-client/collections/collection-item.tsx +++ b/apps/web/src/components/api-client/collections/collection-item.tsx @@ -22,6 +22,7 @@ interface CollectionItemProps { onAddFolder: (parentId: string) => void onLoadRequest: (request: CollectionRequest) => void onRenameFolder?: (folderId: string, newName: string) => void + onEditFolderDefaults?: (folder: CollectionFolder) => void } function arePropsEqual(prev: CollectionItemProps, next: CollectionItemProps) { @@ -46,6 +47,7 @@ function CollectionItemImpl({ onAddFolder, onLoadRequest, onRenameFolder, + onEditFolderDefaults, }: CollectionItemProps) { const t = useTranslations("ApiClient.collectionItem") const tRoot = useTranslations("ApiClient") @@ -162,6 +164,15 @@ function CollectionItemImpl({ Rename )} + {onEditFolderDefaults && ( + { + e.stopPropagation() + onEditFolderDefaults(item as CollectionFolder) + }}> + + Folder defaults + + )} )} ))} {(item as CollectionFolder).items.length === 0 && ( diff --git a/apps/web/src/components/api-client/collections/collections-sidebar.tsx b/apps/web/src/components/api-client/collections/collections-sidebar.tsx index 99d40ef7..46d618d2 100644 --- a/apps/web/src/components/api-client/collections/collections-sidebar.tsx +++ b/apps/web/src/components/api-client/collections/collections-sidebar.tsx @@ -6,7 +6,20 @@ import { Checkbox } from "@/components/ui/checkbox" import { ScrollArea } from "@/components/ui/scroll-area" import { Collection, CollectionRequest } from "../types" import { CollectionItem } from "./collection-item" -import { FolderPlus, Trash2, Pencil, MoreHorizontal, Search, X } from "lucide-react" +import { FolderPlus, Trash2, Pencil, MoreHorizontal, Search, X, FileDown, Play, Server, Link2, Globe } from "lucide-react" +import { buildShareUrl } from "@/lib/share-link" +import { backendFetch } from "@/lib/backend-auth" +import { toast } from "sonner" +import { downloadCollectionAsPostman } from "@/lib/export/postman" +import { downloadCollectionAsOpenApi } from "@/lib/export/openapi" +import { downloadCollectionAsHar } from "@/lib/export/har" +import { downloadCollectionAsInsomnia } from "@/lib/export/insomnia" +import { CollectionRunnerDialog } from "../collection-runner-dialog" +import { useWorkspacesContext } from "../context/workspaces-context" +import { WorkspacesDialog } from "../workspaces-dialog" +import { Briefcase } from "lucide-react" +import { FolderDefaultsDialog } from "../folder-defaults-dialog" +import type { CollectionFolder } from "../types" import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" import { VirtualHistoryList } from "./virtual-history-list" import { cn } from "@/lib/utils" @@ -42,7 +55,7 @@ export function CollectionsSidebar({ onLoadRequest, }: CollectionsSidebarProps) { const { collections, isLoading } = useCollectionsState() - const { addFolder: onAddFolder, deleteItem: onDelete, toggleFolder: onToggle, createCollection: onCreateCollection, renameCollection: onRenameCollection, renameFolder: onRenameFolder, deleteMultipleCollections: onDeleteMultiple } = useCollectionsActions() + const { addFolder: onAddFolder, deleteItem: onDelete, toggleFolder: onToggle, createCollection: onCreateCollection, renameCollection: onRenameCollection, renameFolder: onRenameFolder, patchFolder, deleteMultipleCollections: onDeleteMultiple } = useCollectionsActions() const { history, isLoading: isHistoryLoading } = useHistoryState() const { clearHistory: onClearHistory, deleteHistoryItem: onDeleteHistoryItem } = useHistoryActions() const t = useTranslations("ApiClient.collectionsSidebar") @@ -87,6 +100,34 @@ export function CollectionsSidebar({ const [renameCollectionName, setRenameCollectionName] = React.useState("") const [targetParentId, setTargetParentId] = React.useState(null) const [targetCollectionId, setTargetCollectionId] = React.useState(null) + const [runnerCollection, setRunnerCollection] = React.useState(null) + const [folderDefaultsTarget, setFolderDefaultsTarget] = React.useState(null) + /** "" = show all workspaces; otherwise filter collections whose `workspace` matches. */ + const [workspaceFilter, setWorkspaceFilter] = React.useState("") + + const workspaceOptions = React.useMemo(() => { + const set = new Set() + for (const c of collections) { + if (c.workspace) set.add(c.workspace) + } + return Array.from(set).sort() + }, [collections]) + + const filteredCollections = React.useMemo(() => { + if (!workspaceFilter) return collections + return collections.filter((c) => (c.workspace ?? "") === workspaceFilter) + }, [collections, workspaceFilter]) + + // Multi-tenant filter: when an active workspace is set via the WorkspacesProvider, + // narrow to collections referencing that workspace id. Local chip filter still composes. + const { workspaces, activeId: activeWorkspaceId } = useWorkspacesContext() + const [workspaceDialogOpen, setWorkspaceDialogOpen] = React.useState(false) + const collectionsForActiveWs = React.useMemo(() => { + if (!activeWorkspaceId) return filteredCollections + return filteredCollections.filter((c) => c.workspace === activeWorkspaceId) + }, [filteredCollections, activeWorkspaceId]) + + const activeWorkspaceName = workspaces.find((w) => w.id === activeWorkspaceId)?.name ?? "All" const [selectedCollections, setSelectedCollections] = React.useState>(new Set()) const [deleteBulkDialogOpen, setDeleteBulkDialogOpen] = React.useState(false) const [isDeleting, setIsDeleting] = React.useState(false) @@ -173,6 +214,35 @@ export function CollectionsSidebar({ +
+ +
+ {workspaceOptions.length > 0 && ( +
+ + {workspaceOptions.map((ws) => ( + + ))} +
+ )} {t("tabCollections")} {t("tabHistory")} @@ -198,7 +268,7 @@ export function CollectionsSidebar({ ) : ( - collections.map((collection) => ( + collectionsForActiveWs.map((collection) => (
@@ -238,6 +308,115 @@ export function CollectionsSidebar({ {t("rename")} + { + if (typeof window === "undefined") return + if (workspaces.length === 0) { + toast.error("Create a workspace first (sidebar → Workspace bar)") + return + } + const options = workspaces + .map((w, i) => `${i + 1}: ${w.name}`) + .join("\n") + const choice = window.prompt( + `Move "${collection.name}" to which workspace?\n0: default (no workspace)\n${options}`, + "0", + ) + if (choice === null) return + const idx = Number(choice) + const target = idx === 0 ? null : workspaces[idx - 1]?.id ?? null + try { + const res = await backendFetch(`/api/backend/api-client/collections/${collection.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ workspace: target }), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + toast.success("Workspace updated. Reload to see filter.") + } catch (e) { + toast.error((e as Error).message) + } + }} + > + + Set workspace… + + setRunnerCollection(collection)}> + + Run collection + + { + if (typeof window === "undefined") return + const baseUrl = `${window.location.origin}/api/mock/${collection.id}/` + try { + await navigator.clipboard.writeText(baseUrl) + toast.success("Mock URL copied — append the request path to invoke") + } catch { + toast.error("Could not copy to clipboard") + } + }} + > + + Copy mock URL + + { + if (typeof window === "undefined") return + try { + const url = await buildShareUrl(window.location.origin, collection) + await navigator.clipboard.writeText(url) + toast.success("Share link copied — recipient sees a read-only snapshot") + } catch (e) { + toast.error((e as Error).message) + } + }} + > + + Copy share link + + { + if (typeof window === "undefined") return + try { + const res = await backendFetch("/api/backend/api-client/public-mocks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + collection_id: collection.id, + name: collection.name, + items: collection.items, + }), + }) + if (!res.ok) throw new Error(`Publish failed: ${res.status}`) + const data = await res.json() as { mock_id: string } + const baseUrl = `${window.location.origin}/api/mock/public/${data.mock_id}/` + await navigator.clipboard.writeText(baseUrl) + toast.success("Public mock URL copied — anyone with the link can call it") + } catch (e) { + toast.error((e as Error).message) + } + }} + > + + Publish as public mock + + downloadCollectionAsPostman(collection)}> + + Export (Postman v2.1) + + downloadCollectionAsOpenApi(collection)}> + + Export (OpenAPI 3.0) + + downloadCollectionAsHar(collection)}> + + Export (HAR 1.2) + + downloadCollectionAsInsomnia(collection)}> + + Export (Insomnia v4) + onDelete(collection.id)} @@ -260,6 +439,7 @@ export function CollectionsSidebar({ onAddFolder={openAddFolderDialog} onLoadRequest={onLoadRequest} onRenameFolder={onRenameFolder} + onEditFolderDefaults={setFolderDefaultsTarget} /> ))} {collection.items.length === 0 && ( @@ -520,6 +700,22 @@ export function CollectionsSidebar({ + { if (!o) setRunnerCollection(null) }} + collection={runnerCollection} + /> + + { if (!o) setFolderDefaultsTarget(null) }} + folder={folderDefaultsTarget} + onSave={(patch) => { + if (folderDefaultsTarget) { + void patchFolder(folderDefaultsTarget.id, patch) + } + }} + />
) } diff --git a/apps/web/src/components/api-client/collections/use-collections.ts b/apps/web/src/components/api-client/collections/use-collections.ts index ace28e81..2b23c908 100644 --- a/apps/web/src/components/api-client/collections/use-collections.ts +++ b/apps/web/src/components/api-client/collections/use-collections.ts @@ -6,6 +6,7 @@ import { toast } from "sonner" import { auth } from "@/database/firebase" import { useAuthState } from "react-firebase-hooks/auth" import { backendFetch } from "@/lib/backend-auth" +import { broadcastApiClientUpdate, useApiClientSyncListener } from "@/lib/api-client-sync" const STORAGE_KEY = "api-client-collections" @@ -42,6 +43,17 @@ export function useCollections() { [user] ) + const reload = React.useCallback(async () => { + if (!user) return + try { + const res = await authedFetch("/api/backend/api-client/collections", { method: "GET" }) + const cols = sortCollections((await res.json()) as Collection[]) + setCollections(cols) + } catch (error) { + console.error("Error fetching collections:", error) + } + }, [user, authedFetch]) + // Load collections from backend React.useEffect(() => { if (loading) return @@ -71,6 +83,9 @@ export function useCollections() { } }, [user, loading, authedFetch]) + // Refetch when another tab broadcasts a collections mutation. + useApiClientSyncListener("collections", () => { void reload() }) + // Migration: localStorage → backend once per browser (when server has no collections yet) React.useEffect(() => { const migrateData = async () => { @@ -99,9 +114,12 @@ export function useCollections() { migrated.push(created) } } - toast.success("Migrated local collections to cloud") - localStorage.removeItem(STORAGE_KEY) + // Order matters: reconcile state from the server-confirmed result first, + // THEN drop the local copy. If the tab closes mid-flight after removeItem + // but before the state update, the user loses their data. setCollections(sortCollections(migrated)) + localStorage.removeItem(STORAGE_KEY) + toast.success("Migrated local collections to cloud") } catch (e) { migrationRanRef.current = false console.error("Migration failed", e) @@ -160,6 +178,7 @@ export function useCollections() { { type: "add", parent_id: parentId, item: newFolder }, ]) setCollections((cur) => sortCollections(cur.map((c) => (c.id === updated.id ? updated : c)))) + broadcastApiClientUpdate("collections") } catch (e) { setCollections(prev) console.error("Error adding folder", e) @@ -183,6 +202,7 @@ export function useCollections() { setCollections((cur) => cur.filter((c) => c.id !== itemId)) try { await authedFetch(`/api/backend/api-client/collections/${itemId}`, { method: "DELETE" }) + broadcastApiClientUpdate("collections") toast.success("Collection deleted") } catch (e) { setCollections(prev) @@ -207,6 +227,7 @@ export function useCollections() { { type: "delete", item_id: itemId }, ]) setCollections((cur) => sortCollections(cur.map((c) => (c.id === updated.id ? updated : c)))) + broadcastApiClientUpdate("collections") } catch (e) { setCollections(prev) console.error("Error deleting item", e) @@ -239,6 +260,7 @@ export function useCollections() { { type: "add", parent_id: parentId, item: request }, ]) setCollections((cur) => sortCollections(cur.map((c) => (c.id === updated.id ? updated : c)))) + broadcastApiClientUpdate("collections") toast.success("Request saved") } catch (e) { setCollections(prev) @@ -271,12 +293,50 @@ export function useCollections() { { type: "update", item_id: folderId, patch: { isOpen: !folder.isOpen } }, ]) setCollections((cur) => sortCollections(cur.map((c) => (c.id === updated.id ? updated : c)))) + broadcastApiClientUpdate("collections") } catch (e) { setCollections(prev) console.error("Error toggling folder", e) } } + /** + * Generic folder patch — used by the folder-defaults dialog to set + * defaultHeaders / preRequestScript / testScript / defaultAuth. + * Applies optimistically against state, reconciles from the delta result. + */ + const patchFolder = async (folderId: string, patch: Partial) => { + if (!user) return + const targetCollection = collections.find((c) => findItemInCollection(c.items, folderId)) + if (!targetCollection) return + const prev = collections + const patchInItems = (items: (CollectionFolder | CollectionRequest)[]): (CollectionFolder | CollectionRequest)[] => + items.map((item) => { + if ("type" in item && item.type === "folder") { + if (item.id === folderId) return { ...item, ...patch } + return { ...item, items: patchInItems(item.items) } + } + return item + }) + setCollections((cur) => + sortCollections(cur.map((c) => + c.id === targetCollection.id ? { ...c, items: patchInItems(c.items) } : c + )) + ) + try { + const updated = await applyDelta(targetCollection.id, [ + { type: "update", item_id: folderId, patch }, + ]) + setCollections((cur) => sortCollections(cur.map((c) => (c.id === updated.id ? updated : c)))) + broadcastApiClientUpdate("collections") + toast.success("Folder defaults saved") + } catch (e) { + setCollections(prev) + console.error("Error patching folder", e) + toast.error("Failed to save folder defaults") + } + } + const renameFolder = async (folderId: string, name: string) => { if (!user) return @@ -297,6 +357,7 @@ export function useCollections() { { type: "update", item_id: folderId, patch: { name } }, ]) setCollections((cur) => sortCollections(cur.map((c) => (c.id === updated.id ? updated : c)))) + broadcastApiClientUpdate("collections") } catch (e) { setCollections(prev) console.error("Error renaming folder", e) @@ -385,6 +446,38 @@ export function useCollections() { }) } + /** + * Bulk-import a collection (Postman / HAR / OpenAPI conversion output). + * Creates the collection on the server, then PATCHes its items in one shot. + * Cheaper than fan-out applyDelta for hundreds of converted requests. + */ + const importCollection = async (incoming: Collection): Promise => { + if (!user) return null + try { + const res = await authedFetch("/api/backend/api-client/collections", { + method: "POST", + body: JSON.stringify({ name: incoming.name }), + }) + const created = (await res.json()) as Collection + let final = created + if (incoming.items.length > 0) { + const patchRes = await authedFetch(`/api/backend/api-client/collections/${created.id}`, { + method: "PATCH", + body: JSON.stringify({ items: incoming.items }), + }) + final = (await patchRes.json()) as Collection + } + setCollections((prev) => sortCollections([...prev, final])) + broadcastApiClientUpdate("collections") + toast.success(`Imported "${incoming.name}"`) + return final + } catch (e) { + console.error("Error importing collection", e) + toast.error("Failed to import collection") + return null + } + } + // Add a way to create a new root collection const createCollection = async (name: string) => { if (!user) return @@ -395,6 +488,7 @@ export function useCollections() { }) const created = (await res.json()) as Collection setCollections((prev) => sortCollections([...prev, created])) + broadcastApiClientUpdate("collections") toast.success("Collection created") } catch (e) { console.error("Error creating collection", e) @@ -416,6 +510,7 @@ export function useCollections() { }) const updated = (await res.json()) as Collection setCollections((cur) => sortCollections(cur.map((c) => (c.id === updated.id ? updated : c)))) + broadcastApiClientUpdate("collections") toast.success("Collection renamed") } catch (e) { setCollections(prev) @@ -454,6 +549,7 @@ export function useCollections() { // Remove successfully deleted collections from state if (successfulIds.length > 0) { setCollections((prev) => prev.filter((c) => !successfulIds.includes(c.id))) + broadcastApiClientUpdate("collections") } // Handle results with appropriate feedback @@ -493,7 +589,9 @@ export function useCollections() { createCollection, renameCollection, renameFolder, + patchFolder, deleteMultipleCollections, + importCollection, isLoading } } diff --git a/apps/web/src/components/api-client/comments-panel.tsx b/apps/web/src/components/api-client/comments-panel.tsx new file mode 100644 index 00000000..b8302217 --- /dev/null +++ b/apps/web/src/components/api-client/comments-panel.tsx @@ -0,0 +1,101 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Trash2, MessageSquare } from "lucide-react" +import type { RequestComment } from "./types" +import { useAuthState } from "react-firebase-hooks/auth" +import { auth } from "@/database/firebase" + +/** Pulled out so the lint rule for impure render-time calls doesn't flag the inline use. */ +const nowMs = (): number => Date.now() + +interface CommentsPanelProps { + comments?: RequestComment[] + onChange: (next: RequestComment[]) => void +} + +export function CommentsPanel({ comments, onChange }: CommentsPanelProps) { + const [user] = useAuthState(auth) + const [draft, setDraft] = React.useState("") + const list = comments ?? [] + + const myName = user?.displayName ?? user?.email ?? "Anonymous" + + const handlePost = () => { + const text = draft.trim() + if (!text) return + const next: RequestComment = { + id: crypto.randomUUID(), + author: myName, + createdAt: nowMs(), + text, + } + onChange([...list, next]) + setDraft("") + } + + const handleDelete = (id: string) => { + onChange(list.filter((c) => c.id !== id)) + } + + return ( +
+ + {list.length === 0 ? ( +
+ + No comments yet. Leave context for teammates loading this request later. +
+ ) : ( +
+ {list + .slice() + .sort((a, b) => a.createdAt - b.createdAt) + .map((c) => ( +
+
+ {c.author} + · + {new Date(c.createdAt).toLocaleString()} + +
+
{c.text}
+
+ ))} +
+ )} +
+
+