Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions apps/web/src/app/app/api-keys/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiKeyEntry, "id" | "createdAt" | "updatedAt"> | null {
Expand Down Expand Up @@ -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 <EncryptedToolPlaceholder toolName="API Key Vault" />
}

Expand Down
7 changes: 2 additions & 5 deletions apps/web/src/app/app/environment-manager/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 <EncryptedToolPlaceholder toolName="Environment Manager" />
}

Expand Down
5 changes: 2 additions & 3 deletions apps/web/src/app/app/password-manager/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
17 changes: 11 additions & 6 deletions apps/web/src/app/settings/workspaces/org-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -207,6 +210,8 @@ export function OrgSection({ org }: { org: Org }) {
open={inviteOpen}
onOpenChange={setInviteOpen}
/>

{confirmDialog}
</div>
)
}
48 changes: 23 additions & 25 deletions apps/web/src/app/settings/workspaces/workspace-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ 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()
const [inviteOpen, setInviteOpen] = useState(false)
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"
Expand All @@ -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()
Expand Down Expand Up @@ -168,33 +172,27 @@ export function WorkspaceSection({ workspace }: { workspace: Workspace }) {
</div>
)}

{!workspace.is_personal
&& !(workspace as { settings?: { encryption?: unknown } }).settings?.encryption
&& workspace.ws_role === "admin"
&& (
<div className="mt-4">
<EnableEncryptedToolsCta workspaceId={workspace.id} />
</div>
)
}
{!workspace.is_personal && !hasWorkspaceEncryption(workspace) && workspace.ws_role === "admin" && (
<div className="mt-4">
<EnableEncryptedToolsCta workspaceId={workspace.id} />
</div>
)}

{!workspace.is_personal
&& (workspace as { settings?: { encryption?: unknown } }).settings?.encryption != null
&& workspace.ws_role === "admin"
&& (
<div className="mt-4 space-y-3">
<PendingWrapsPrompt workspaceId={workspace.id} />
<RotateKeyButton workspaceId={workspace.id} />
</div>
)
}
{!workspace.is_personal && hasWorkspaceEncryption(workspace) && workspace.ws_role === "admin" && (
<div className="mt-4 space-y-3">
<PendingWrapsPrompt workspaceId={workspace.id} />
<RotateKeyButton workspaceId={workspace.id} />
</div>
)}

<InviteMemberDialog
scope="workspace"
scopeId={workspace.id}
open={inviteOpen}
onOpenChange={setInviteOpen}
/>

{confirmDialog}
</div>
)
}
5 changes: 3 additions & 2 deletions apps/web/src/components/__tests__/member-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({")
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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", () => {
Expand Down
101 changes: 0 additions & 101 deletions apps/web/src/components/__tests__/workspace-switcher.test.tsx

This file was deleted.

5 changes: 3 additions & 2 deletions apps/web/src/components/__tests__/workspaces-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading