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
5 changes: 0 additions & 5 deletions apps/app/src/actions/policies/delete-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,7 @@ export const deletePolicyAction = authActionClient

// Revalidate paths to update UI
revalidatePath(`/${activeOrganizationId}/policies/all`);
revalidatePath(`/${activeOrganizationId}/policies`);
revalidateTag('policies');

return {
success: true,
};
} catch (error) {
console.error(error);
return {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
'use server';

import { authActionClient } from '@/actions/safe-action';
import { updatePolicy } from '@/jobs/tasks/onboarding/update-policy';
import { db } from '@db';
import { tasks } from '@trigger.dev/sdk';
import { z } from 'zod';

export const regeneratePolicyAction = authActionClient
.inputSchema(
z.object({
policyId: z.string().min(1),
}),
)
.metadata({
name: 'regenerate-policy',
track: {
event: 'regenerate-policy',
channel: 'server',
},
})
.action(async ({ parsedInput, ctx }) => {
const { policyId } = parsedInput;
const { session } = ctx;

if (!session?.activeOrganizationId) {
throw new Error('No active organization');
}

// Load frameworks associated to this organization via instances
const instances = await db.frameworkInstance.findMany({
where: { organizationId: session.activeOrganizationId },
include: {
framework: true,
},
});

const uniqueFrameworks = Array.from(
new Map(instances.map((fi) => [fi.framework.id, fi.framework])).values(),
).map((f) => ({
id: f.id,
name: f.name,
version: f.version,
description: f.description,
visible: f.visible,
createdAt: f.createdAt,
updatedAt: f.updatedAt,
}));

// Build contextHub string from context table Q&A
const contextEntries = await db.context.findMany({
where: { organizationId: session.activeOrganizationId },
orderBy: { createdAt: 'asc' },
});
const contextHub = contextEntries.map((c) => `${c.question}\n${c.answer}`).join('\n');

await tasks.trigger<typeof updatePolicy>('update-policy', {
organizationId: session.activeOrganizationId,
policyId,
contextHub,
frameworks: uniqueFrameworks,
});

// Revalidation handled by safe-action middleware using x-pathname header
return { success: true };
});
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,7 @@ export function PolicyDeleteDialog({ isOpen, onClose, policy }: PolicyDeleteDial

const deletePolicy = useAction(deletePolicyAction, {
onSuccess: () => {
toast.info('Policy deleted! Redirecting to policies list...');
onClose();
router.push(`/${policy.organizationId}/policies/all`);
},
onError: () => {
toast.error('Failed to delete policy.');
Expand All @@ -61,6 +59,11 @@ export function PolicyDeleteDialog({ isOpen, onClose, policy }: PolicyDeleteDial
id: policy.id,
entityId: policy.id,
});

setTimeout(() => {
router.replace(`/${policy.organizationId}/policies/all`);
}, 1000);
toast.info('Policy deleted! Redirecting to policies list...');
};

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
'use client';

import { regeneratePolicyAction } from '@/app/(app)/[orgId]/policies/[policyId]/actions/regenerate-policy';
import { Button } from '@comp/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@comp/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@comp/ui/dropdown-menu';
import { Icons } from '@comp/ui/icons';
import { useAction } from 'next-safe-action/hooks';
import { useState } from 'react';
import { toast } from 'sonner';

export function PolicyHeaderActions({ policyId }: { policyId: string }) {
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
// Delete flows through query param to existing dialog in PolicyOverview
const regenerate = useAction(regeneratePolicyAction, {
onSuccess: () => toast.success('Regeneration triggered. This may take a moment.'),
onError: () => toast.error('Failed to trigger policy regeneration'),
});

return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="m-0 size-auto p-2"
aria-label="Policy actions"
>
<Icons.Settings className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setIsConfirmOpen(true)}>
<Icons.AI className="mr-2 h-4 w-4" /> Regenerate policy
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
const url = new URL(window.location.href);
url.searchParams.set('policy-overview-sheet', 'true');
window.history.pushState({}, '', url.toString());
}}
>
<Icons.Edit className="mr-2 h-4 w-4" /> Edit policy
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
const url = new URL(window.location.href);
url.searchParams.set('archive-policy-sheet', 'true');
window.history.pushState({}, '', url.toString());
}}
>
<Icons.InboxCustomize className="mr-2 h-4 w-4" /> Archive / Restore
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
const url = new URL(window.location.href);
url.searchParams.set('delete-policy', 'true');
window.history.pushState({}, '', url.toString());
}}
className="text-destructive"
>
<Icons.Delete className="mr-2 h-4 w-4" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

<Dialog open={isConfirmOpen} onOpenChange={(open) => !open && setIsConfirmOpen(false)}>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>Regenerate Policy</DialogTitle>
<DialogDescription>
This will generate new policy content using your org context and frameworks and mark
it for review. Continue?
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setIsConfirmOpen(false)}
disabled={regenerate.status === 'executing'}
>
Cancel
</Button>
<Button
onClick={() => {
setIsConfirmOpen(false);
toast.info('Regenerating policy...');
regenerate.execute({ policyId });
}}
disabled={regenerate.status === 'executing'}
>
{regenerate.status === 'executing' ? 'Working…' : 'Confirm'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

{/* Delete confirmation handled by PolicyDeleteDialog via query param */}
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,16 @@ import { authClient } from '@/utils/auth-client';
import { Alert, AlertDescription, AlertTitle } from '@comp/ui/alert';
import { Button } from '@comp/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@comp/ui/dropdown-menu';
import { Icons } from '@comp/ui/icons';
import type { Member, Policy, User } from '@db';
import { Control } from '@db';
import { format } from 'date-fns';
import {
ArchiveIcon,
ArchiveRestoreIcon,
MoreVertical,
PencilIcon,
ShieldCheck,
ShieldX,
Trash2,
} from 'lucide-react';
import { ArchiveIcon, ArchiveRestoreIcon, ShieldCheck, ShieldX } from 'lucide-react';
import { useAction } from 'next-safe-action/hooks';
import { useQueryState } from 'nuqs';
import { useState } from 'react';
import { toast } from 'sonner';
import { regeneratePolicyAction } from '../actions/regenerate-policy';
import { PolicyActionDialog } from './PolicyActionDialog';
import { PolicyArchiveSheet } from './PolicyArchiveSheet';
import { PolicyControlMappings } from './PolicyControlMappings';
Expand Down Expand Up @@ -79,10 +66,8 @@ export function PolicyOverview({
// Dialog state for approval/denial actions
const [approveDialogOpen, setApproveDialogOpen] = useState(false);
const [denyDialogOpen, setDenyDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);

// Dropdown menu state
const [dropdownOpen, setDropdownOpen] = useState(false);
const [deleteOpenParam, setDeleteOpenParam] = useQueryState('delete-policy');
const [regenerateOpen, setRegenerateOpen] = useState(false);

// Handle approve with optional comment
const handleApprove = (comment?: string) => {
Expand Down Expand Up @@ -149,22 +134,26 @@ export function PolicyOverview({
</Alert>
)}
{policy?.isArchived && (
<Alert>
<div className="flex items-center gap-2">
<ArchiveIcon className="h-4 w-4" />
<div className="font-medium">{'This policy is archived'}</div>
<Alert className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2">
<ArchiveIcon className="mt-0.5 h-4 w-4" />
<div className="space-y-1">
<div className="font-medium">This policy is archived</div>
<AlertDescription>
Archived on {format(new Date(policy?.updatedAt ?? new Date()), 'PPP')}
</AlertDescription>
</div>
</div>
<div className="shrink-0">
<Button
size="sm"
variant="outline"
onClick={() => setArchiveOpen('true')}
className="gap-1"
>
<ArchiveRestoreIcon className="h-3 w-3" /> Restore
</Button>
</div>
<AlertDescription>
{policy?.isArchived && (
<>
{'Archived on'} {format(new Date(policy?.updatedAt ?? new Date()), 'PPP')}
</>
)}
</AlertDescription>
<Button size="sm" variant="outline" onClick={() => setArchiveOpen('true')}>
<ArchiveRestoreIcon className="h-3 w-3" />
{'Restore'}
</Button>
</Alert>
)}

Expand All @@ -176,55 +165,8 @@ export function PolicyOverview({
<Icons.Policies className="h-4 w-4" />
{policy?.name}
</div>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
disabled={isPendingApproval}
className="m-0 size-auto p-2 hover:bg-transparent"
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false);
setOpen('true');
}}
disabled={isPendingApproval}
>
<PencilIcon className="mr-2 h-4 w-4" />
{'Edit policy'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false);
setArchiveOpen('true');
}}
disabled={isPendingApproval}
>
{policy?.isArchived ? (
<ArchiveRestoreIcon className="mr-2 h-4 w-4" />
) : (
<ArchiveIcon className="mr-2 h-4 w-4" />
)}
{policy?.isArchived ? 'Restore policy' : 'Archive policy'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false);
setDeleteDialogOpen(true);
}}
disabled={isPendingApproval}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Redundant gear removed; actions moved to breadcrumb header */}
<div className="h-6" />
</div>
</CardTitle>
<CardDescription>{policy?.description}</CardDescription>
Expand Down Expand Up @@ -276,10 +218,24 @@ export function PolicyOverview({

{/* Delete Dialog */}
<PolicyDeleteDialog
isOpen={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
isOpen={Boolean(deleteOpenParam)}
onClose={() => setDeleteOpenParam(null)}
policy={policy}
/>
{/* Regenerate Dialog */}
<PolicyActionDialog
isOpen={regenerateOpen}
onClose={() => setRegenerateOpen(false)}
onConfirm={async () => {
if (!policy?.id) return;
await regeneratePolicyAction({ policyId: policy.id });
toast.info('Regeneration started');
}}
title="Regenerate Policy"
description="This will regenerate the policy content and mark it for review. Continue?"
confirmText="Regenerate"
confirmIcon={<Icons.AI className="h-4 w-4" />}
/>
</>
)}
</div>
Expand Down
Loading
Loading