diff --git a/apps/web/src/app/app/api-keys/page.tsx b/apps/web/src/app/app/api-keys/page.tsx index 5911c0ce..08739790 100644 --- a/apps/web/src/app/app/api-keys/page.tsx +++ b/apps/web/src/app/app/api-keys/page.tsx @@ -17,6 +17,7 @@ import { Skeleton } from "@/components/ui/skeleton" import { EncryptedToolPlaceholder } from "@/components/encrypted-tool-placeholder" import { useActiveWorkspace } from "@/store/workspace-store" import { useCipherKey } from "@/lib/use-cipher-key" +import { hasWorkspaceEncryption } from "@/lib/workspace-rbac" // ponytail: inline parser — one place uses it, no utils file function parseApiKeyPayload(plain: string): Omit | null { @@ -96,11 +97,7 @@ export default function ApiKeyVaultPage() { // Show placeholder for shared workspaces that have not yet enabled E2EE. // Forward-compat: when activeWs.settings?.encryption is set AND a wrappedDek // exists the normal flow runs (C-T9 will land the toggle UI). - if ( - activeWs && - !activeWs.is_personal && - !(activeWs as { settings?: { encryption?: unknown } }).settings?.encryption - ) { + if (activeWs && !activeWs.is_personal && !hasWorkspaceEncryption(activeWs)) { return } diff --git a/apps/web/src/app/app/environment-manager/page.tsx b/apps/web/src/app/app/environment-manager/page.tsx index 2167b0c2..6e2e115a 100644 --- a/apps/web/src/app/app/environment-manager/page.tsx +++ b/apps/web/src/app/app/environment-manager/page.tsx @@ -18,6 +18,7 @@ import { Skeleton } from "@/components/ui/skeleton" import { EncryptedToolPlaceholder } from "@/components/encrypted-tool-placeholder" import { useActiveWorkspace } from "@/store/workspace-store" import { useCipherKey } from "@/lib/use-cipher-key" +import { hasWorkspaceEncryption } from "@/lib/workspace-rbac" export default function EnvironmentManagerPage() { const t = useTranslations("EnvironmentManager.page") @@ -79,11 +80,7 @@ export default function EnvironmentManagerPage() { // Show placeholder for shared workspaces that have not yet enabled E2EE. // Forward-compat: when activeWs.settings?.encryption is set AND a wrappedDek // exists the normal flow runs (C-T9 will land the toggle UI). - if ( - activeWs && - !activeWs.is_personal && - !(activeWs as { settings?: { encryption?: unknown } }).settings?.encryption - ) { + if (activeWs && !activeWs.is_personal && !hasWorkspaceEncryption(activeWs)) { return } diff --git a/apps/web/src/app/app/password-manager/page.tsx b/apps/web/src/app/app/password-manager/page.tsx index a80aa9dd..a29dd4aa 100644 --- a/apps/web/src/app/app/password-manager/page.tsx +++ b/apps/web/src/app/app/password-manager/page.tsx @@ -19,6 +19,7 @@ import { Skeleton } from "@/components/ui/skeleton" import { fetchAllPages } from "@/lib/fetch-all-pages" import { EncryptedToolPlaceholder } from "@/components/encrypted-tool-placeholder" import { useActiveWorkspace } from "@/store/workspace-store" +import { hasWorkspaceEncryption } from "@/lib/workspace-rbac" const PASSWORDS_PAGE_SIZE = 500 @@ -39,9 +40,7 @@ export default function PasswordManagerPage() { // Placeholder gate computed up-front; the actual early return happens AFTER // every hook below so React sees a stable hook order across renders. const needsEncryptionGate = - !!activeWs && - !activeWs.is_personal && - !(activeWs as { settings?: { encryption?: unknown } }).settings?.encryption + !!activeWs && !activeWs.is_personal && !hasWorkspaceEncryption(activeWs) useEffect(() => { if (!encryptionKey || loadedRef.current) return diff --git a/apps/web/src/app/settings/workspaces/org-section.tsx b/apps/web/src/app/settings/workspaces/org-section.tsx index 6735b4fa..51727691 100644 --- a/apps/web/src/app/settings/workspaces/org-section.tsx +++ b/apps/web/src/app/settings/workspaces/org-section.tsx @@ -13,6 +13,7 @@ import { MemberList } from "@/components/member-list" import { WorkspaceSection } from "./workspace-section" import { CreateWorkspaceDialog } from "@/components/create-workspace-dialog" import { InviteMemberDialog } from "@/components/invite-member-dialog" +import { useConfirm } from "@/components/confirm-dialog" export function OrgSection({ org }: { org: Org }) { const { workspaces, loadFromBackend } = useWorkspaceStore() @@ -21,6 +22,7 @@ export function OrgSection({ org }: { org: Org }) { const [renaming, setRenaming] = useState(false) const [renameValue, setRenameValue] = useState(org.name) const [saving, setSaving] = useState(false) + const { confirm, dialog: confirmDialog } = useConfirm() // System orgs (Mydevtools Cloud) are platform-managed — no member roster shown. const isSystem = org.kind === "system" @@ -50,12 +52,13 @@ export function OrgSection({ org }: { org: Org }) { } async function handleDelete() { - if ( - !window.confirm( - `Delete organisation "${org.name}"? This will remove all workspaces and cannot be undone.` - ) - ) - return + const ok = await confirm({ + title: `Delete organisation "${org.name}"?`, + description: "This will remove all workspaces and cannot be undone.", + confirmLabel: "Delete", + destructive: true, + }) + if (!ok) return try { await deleteOrg(org.id) await loadFromBackend() @@ -207,6 +210,8 @@ export function OrgSection({ org }: { org: Org }) { open={inviteOpen} onOpenChange={setInviteOpen} /> + + {confirmDialog} ) } diff --git a/apps/web/src/app/settings/workspaces/workspace-section.tsx b/apps/web/src/app/settings/workspaces/workspace-section.tsx index f6ce68b7..8c79cf79 100644 --- a/apps/web/src/app/settings/workspaces/workspace-section.tsx +++ b/apps/web/src/app/settings/workspaces/workspace-section.tsx @@ -14,6 +14,8 @@ import { InviteMemberDialog } from "@/components/invite-member-dialog" import { EnableEncryptedToolsCta } from "@/components/enable-encrypted-tools-cta" import { PendingWrapsPrompt } from "@/components/pending-wraps-prompt" import { RotateKeyButton } from "@/components/rotate-key-button" +import { hasWorkspaceEncryption } from "@/lib/workspace-rbac" +import { useConfirm } from "@/components/confirm-dialog" export function WorkspaceSection({ workspace }: { workspace: Workspace }) { const { loadFromBackend } = useWorkspaceStore() @@ -21,6 +23,7 @@ export function WorkspaceSection({ workspace }: { workspace: Workspace }) { const [renaming, setRenaming] = useState(false) const [renameValue, setRenameValue] = useState(workspace.name) const [saving, setSaving] = useState(false) + const { confirm, dialog: confirmDialog } = useConfirm() const isPersonal = workspace.is_personal const canManage = !isPersonal && workspace.ws_role === "admin" @@ -45,12 +48,13 @@ export function WorkspaceSection({ workspace }: { workspace: Workspace }) { } async function handleDelete() { - if ( - !window.confirm( - `Delete workspace "${workspace.name}"? This action cannot be undone.` - ) - ) - return + const ok = await confirm({ + title: `Delete workspace "${workspace.name}"?`, + description: "This action cannot be undone.", + confirmLabel: "Delete", + destructive: true, + }) + if (!ok) return try { await deleteWorkspace(workspace.id) await loadFromBackend() @@ -168,26 +172,18 @@ export function WorkspaceSection({ workspace }: { workspace: Workspace }) { )} - {!workspace.is_personal - && !(workspace as { settings?: { encryption?: unknown } }).settings?.encryption - && workspace.ws_role === "admin" - && ( -
- -
- ) - } + {!workspace.is_personal && !hasWorkspaceEncryption(workspace) && workspace.ws_role === "admin" && ( +
+ +
+ )} - {!workspace.is_personal - && (workspace as { settings?: { encryption?: unknown } }).settings?.encryption != null - && workspace.ws_role === "admin" - && ( -
- - -
- ) - } + {!workspace.is_personal && hasWorkspaceEncryption(workspace) && workspace.ws_role === "admin" && ( +
+ + +
+ )} + + {confirmDialog} ) } diff --git a/apps/web/src/components/__tests__/member-list.test.ts b/apps/web/src/components/__tests__/member-list.test.ts index ba0a1a7e..d329e929 100644 --- a/apps/web/src/components/__tests__/member-list.test.ts +++ b/apps/web/src/components/__tests__/member-list.test.ts @@ -87,8 +87,9 @@ describe("MemberList — source structure assertions", () => { expect(source).toContain('scope === "org"') }) - it("uses window.confirm before removing member", () => { - expect(source).toContain("window.confirm") + it("confirms via useConfirm dialog before removing member", () => { + expect(source).toContain("useConfirm") + expect(source).toContain("confirm({") }) }) diff --git a/apps/web/src/components/__tests__/workspace-switcher-dropdown.test.tsx b/apps/web/src/components/__tests__/workspace-switcher-dropdown.test.tsx index bc4e7f4c..867aed09 100644 --- a/apps/web/src/components/__tests__/workspace-switcher-dropdown.test.tsx +++ b/apps/web/src/components/__tests__/workspace-switcher-dropdown.test.tsx @@ -130,12 +130,10 @@ describe("WorkspaceSwitcherDropdown — org sections and workspace items", () => expect(source).toContain("DropdownMenuLabel") // Workspace items rendered via DropdownMenuItem expect(source).toContain("DropdownMenuItem") - // Groups by org_id + // Scoped to active org (org switching handled by OrgSwitcherDropdown) expect(source).toContain("org_id") // Active workspace highlight expect(source).toContain("bg-accent/60") - // New organization CTA always present - expect(source).toContain("New organization") // New workspace CTA gated behind org_role check expect(source).toContain("New workspace") expect(source).toContain("org_role") @@ -205,17 +203,16 @@ describe("WorkspaceSwitcherDropdown — New workspace CTA role gating", () => { expect(shouldShowNewWs).toBe(true) }) - it("New organization CTA source is always present in component", () => { + it("New workspace CTA source is present in component", () => { const fs = require("fs") const path = require("path") const source: string = fs.readFileSync( path.join(__dirname, "../workspace-switcher-dropdown.tsx"), "utf8" ) - // New organization CTA is unconditional — not inside any role check - expect(source).toContain("New organization") - // Confirm it's outside the org_role conditional by checking it's in the orgs.map return - expect(source).toContain("setOrgDialogOpen") + // Workspace creation dialog is opened from the dropdown + expect(source).toContain("New workspace") + expect(source).toContain("setWsDialogOpen") }) it("mixed orgs: only owner/admin orgs show new workspace CTA", () => { diff --git a/apps/web/src/components/__tests__/workspace-switcher.test.tsx b/apps/web/src/components/__tests__/workspace-switcher.test.tsx deleted file mode 100644 index 4eec026b..00000000 --- a/apps/web/src/components/__tests__/workspace-switcher.test.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Tests for WorkspaceSwitcher (workspace-switcher.tsx). - * - * Note: @testing-library/react is not installed in this project, and the Jest - * testEnvironment is "jest-environment-node", so we cannot render React trees. - * We test module exports, store-driven logic, and structural guarantees - * without a browser environment. - * - * Sub-project B: workspace-switcher.tsx delegates to WorkspaceSwitcherDropdown. - * Structural dropdown tests live in workspace-switcher-dropdown.test.tsx. - * - * If @testing-library/react + jsdom are ever added, the skipped render tests - * at the bottom can be enabled. - */ - -jest.mock("next/navigation", () => ({ useRouter: jest.fn(), usePathname: jest.fn(() => "/app") })) -jest.mock("next-intl", () => ({ useTranslations: () => (k: string) => k, useMessages: () => ({}) })) -jest.mock("lucide-react", () => ({ - Briefcase: () => null, - Plus: () => null, - ChevronsUpDown: () => null, -})) -jest.mock("@/components/ui/dropdown-menu", () => ({ - DropdownMenu: ({ children }: { children: React.ReactNode }) => children, - DropdownMenuContent: ({ children }: { children: React.ReactNode }) => children, - DropdownMenuItem: ({ children }: { children: React.ReactNode }) => children, - DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => children, - DropdownMenuSeparator: () => null, - DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => children, -})) -jest.mock("@/components/create-org-dialog", () => ({ CreateOrgDialog: () => null })) -jest.mock("@/components/create-workspace-dialog", () => ({ CreateWorkspaceDialog: () => null })) - -import { useWorkspaceStore } from "@/store/workspace-store" - -describe("WorkspaceSwitcher module exports", () => { - it("exports a WorkspaceSwitcher function component", () => { - const mod = require("../workspace-switcher") - expect(typeof mod.WorkspaceSwitcher).toBe("function") - }) -}) - -describe("WorkspaceSwitcher — store contract", () => { - beforeEach(() => useWorkspaceStore.getState().clear()) - - it("store hydrated=false until loadFromBackend is called (guards null render)", () => { - const state = useWorkspaceStore.getState() - expect(state.hydrated).toBe(false) - expect(state.activeWorkspaceId).toBeNull() - }) - - it("store exposes activeWorkspaceId and workspace name after setState", () => { - useWorkspaceStore.setState({ - orgs: [{ id: "o1", name: "MyDevTools Cloud", slug: "mydevtools-cloud", kind: "system", org_role: "member" }], - workspaces: [{ - id: "w1", org_id: "o1", name: "Personal", slug: "personal-u1", - is_personal: true, kind: "personal", ws_role: "admin", - }], - activeWorkspaceId: "w1", - hydrated: true, - }) - - const state = useWorkspaceStore.getState() - expect(state.hydrated).toBe(true) - expect(state.activeWorkspaceId).toBe("w1") - const ws = state.workspaces.find((w: { id: string }) => w.id === state.activeWorkspaceId) - expect(ws?.name).toBe("Personal") - }) - - it("WorkspaceSwitcher returns null when hydrated=false (null guard verified via store state)", () => { - // hydrated=false → the component must return null (no pill rendered) - const state = useWorkspaceStore.getState() - expect(state.hydrated).toBe(false) - // This mirrors the component's: if (!hydrated || !ws) return null - const wouldRender = state.hydrated && state.activeWorkspaceId !== null - expect(wouldRender).toBe(false) - }) - - it("component would render when hydrated=true and activeWorkspaceId is set", () => { - useWorkspaceStore.setState({ - orgs: [], - workspaces: [{ id: "w1", org_id: "o1", name: "Personal", slug: "personal-u1", is_personal: true, kind: "personal", ws_role: "admin" }], - activeWorkspaceId: "w1", - hydrated: true, - }) - const state = useWorkspaceStore.getState() - const wouldRender = state.hydrated && state.activeWorkspaceId !== null - expect(wouldRender).toBe(true) - }) - - it("does NOT contain data-role='chevron' in component source (sub-project A)", () => { - // Structural assertion: the component file must not include a chevron data attribute - const fs = require("fs") - const path = require("path") - const source = fs.readFileSync( - path.join(__dirname, "../workspace-switcher.tsx"), - "utf8" - ) - expect(source).not.toContain('data-role="chevron"') - }) -}) diff --git a/apps/web/src/components/__tests__/workspaces-page.test.ts b/apps/web/src/components/__tests__/workspaces-page.test.ts index 4e9e795e..e44c53d4 100644 --- a/apps/web/src/components/__tests__/workspaces-page.test.ts +++ b/apps/web/src/components/__tests__/workspaces-page.test.ts @@ -213,8 +213,9 @@ describe("WorkspaceSection — source structure", () => { expect(source).toContain("InviteMemberDialog") }) - it("uses window.confirm before delete", () => { - expect(source).toContain("window.confirm") + it("confirms via useConfirm dialog before delete", () => { + expect(source).toContain("useConfirm") + expect(source).toContain("confirm({") }) it("uses toast from sonner", () => { diff --git a/apps/web/src/components/confirm-dialog.tsx b/apps/web/src/components/confirm-dialog.tsx new file mode 100644 index 00000000..98bab429 --- /dev/null +++ b/apps/web/src/components/confirm-dialog.tsx @@ -0,0 +1,70 @@ +"use client" + +import { useCallback, useRef, useState } from "react" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" + +type ConfirmOptions = { + title: string + description?: string + confirmLabel?: string + cancelLabel?: string + destructive?: boolean +} + +type PendingConfirm = ConfirmOptions & { resolve: (ok: boolean) => void } + +export function useConfirm(): { + confirm: (opts: ConfirmOptions) => Promise + dialog: React.ReactElement +} { + const [pending, setPending] = useState(null) + const resolverRef = useRef<((ok: boolean) => void) | null>(null) + + const confirm = useCallback((opts: ConfirmOptions) => { + return new Promise((resolve) => { + resolverRef.current = resolve + setPending({ ...opts, resolve }) + }) + }, []) + + const finish = useCallback((ok: boolean) => { + resolverRef.current?.(ok) + resolverRef.current = null + setPending(null) + }, []) + + const dialog = ( + { if (!o) finish(false) }}> + + + {pending?.title ?? ""} + {pending?.description && ( + {pending.description} + )} + + + finish(false)}> + {pending?.cancelLabel ?? "Cancel"} + + finish(true)} + className={pending?.destructive ? "bg-destructive text-destructive-foreground hover:bg-destructive/90" : ""} + > + {pending?.confirmLabel ?? "Confirm"} + + + + + ) + + return { confirm, dialog } +} diff --git a/apps/web/src/components/member-list.tsx b/apps/web/src/components/member-list.tsx index 03f0ba58..ec1150c6 100644 --- a/apps/web/src/components/member-list.tsx +++ b/apps/web/src/components/member-list.tsx @@ -14,6 +14,7 @@ import { type Member, } from "@/lib/members-api" import { RoleSelect } from "@/components/role-select" +import { useConfirm } from "@/components/confirm-dialog" export function MemberList({ scope, @@ -24,6 +25,7 @@ export function MemberList({ }) { const [members, setMembers] = useState([]) const [loading, setLoading] = useState(true) + const { confirm, dialog: confirmDialog } = useConfirm() const fetchMembers = useCallback(async () => { setLoading(true) @@ -59,7 +61,13 @@ export function MemberList({ } async function handleRemove(uid: string) { - if (!window.confirm("Remove this member?")) return + const ok = await confirm({ + title: "Remove this member?", + description: "They will lose access to this " + scope + ".", + confirmLabel: "Remove", + destructive: true, + }) + if (!ok) return try { if (scope === "org") { await removeOrgMember(scopeId, uid) @@ -87,6 +95,7 @@ export function MemberList({ } return ( + <>
{members.map((m) => (
))}
+ {confirmDialog} + ) } diff --git a/apps/web/src/components/nav-bar.tsx b/apps/web/src/components/nav-bar.tsx index 28c96aa7..faa0078c 100644 --- a/apps/web/src/components/nav-bar.tsx +++ b/apps/web/src/components/nav-bar.tsx @@ -6,7 +6,7 @@ import { Search } from "lucide-react"; import { ModeToggle } from "@/components/modeToggle"; import { getToolMessageKey } from "@/lib/tool-i18n"; import { routeConfig } from "@/lib/route-config"; -import { WorkspaceSwitcher } from "@/components/workspace-switcher"; +import { WorkspaceSwitcherDropdown } from "@/components/workspace-switcher-dropdown"; import { WorkspaceQuickActions } from "@/components/workspace-quick-actions"; import { WorkspaceBreadcrumb } from "@/components/workspace-breadcrumb"; import { NotificationsBell } from "@/components/notifications-bell"; @@ -70,7 +70,7 @@ export function NavBar() {
- + @@ -110,7 +110,7 @@ export function NavBar() {
- + diff --git a/apps/web/src/components/sidebar/client-layout.tsx b/apps/web/src/components/sidebar/client-layout.tsx index 6b9fb990..1419ca7b 100644 --- a/apps/web/src/components/sidebar/client-layout.tsx +++ b/apps/web/src/components/sidebar/client-layout.tsx @@ -12,7 +12,7 @@ import { isTabRoute } from '@/lib/route-config'; import { getTabComponent, isRegisteredTab } from '@/lib/tab-registry'; import { MigrationBanner } from '@/components/migration-banner'; import { MobileDesktopHint } from '@/components/mobile-desktop-hint'; -import { WorkspaceSwitcher } from '@/components/workspace-switcher'; +import { WorkspaceSwitcherDropdown } from '@/components/workspace-switcher-dropdown'; import { NotificationsBell } from '@/components/notifications-bell'; // Renders all open tool tabs simultaneously. The active tab is visible; @@ -90,7 +90,7 @@ function Layout({ children }: { children: React.ReactNode }) {
- +
diff --git a/apps/web/src/components/workspace-switcher.tsx b/apps/web/src/components/workspace-switcher.tsx deleted file mode 100644 index 29d32b32..00000000 --- a/apps/web/src/components/workspace-switcher.tsx +++ /dev/null @@ -1,7 +0,0 @@ -"use client" - -import { WorkspaceSwitcherDropdown } from "./workspace-switcher-dropdown" - -export function WorkspaceSwitcher() { - return -} diff --git a/apps/web/src/lib/workspace-rbac.ts b/apps/web/src/lib/workspace-rbac.ts index 5a0de61c..f0ece6ad 100644 --- a/apps/web/src/lib/workspace-rbac.ts +++ b/apps/web/src/lib/workspace-rbac.ts @@ -39,7 +39,7 @@ export const TOOL_PERMISSIONS: Record>> = "dns-lookup": PLAINTEXT_ROW, } -function hasWorkspaceEncryption(ws: Workspace): boolean { +export function hasWorkspaceEncryption(ws: Workspace): boolean { const settings = (ws as { settings?: { encryption?: unknown } }).settings return settings?.encryption != null }