Skip to content

Commit c4b4c0d

Browse files
committed
feat: enhance policy management with regeneration and UI improvements
- Introduced `regeneratePolicyAction` to trigger policy regeneration based on organizational context and frameworks. - Added `PolicyHeaderActions` component for policy-specific actions, including regeneration and improved delete handling. - Updated `PolicyDeleteDialog` to provide a smoother user experience with a delayed redirect after deletion. - Refactored `PolicyOverview` to streamline UI interactions and integrate new regeneration functionality. - Enhanced logging and error handling in policy update processes for better traceability.
1 parent e6d6f47 commit c4b4c0d

File tree

10 files changed

+268
-131
lines changed

10 files changed

+268
-131
lines changed

apps/app/src/actions/policies/delete-policy.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,7 @@ export const deletePolicyAction = authActionClient
5353

5454
// Revalidate paths to update UI
5555
revalidatePath(`/${activeOrganizationId}/policies/all`);
56-
revalidatePath(`/${activeOrganizationId}/policies`);
5756
revalidateTag('policies');
58-
59-
return {
60-
success: true,
61-
};
6257
} catch (error) {
6358
console.error(error);
6459
return {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
'use server';
2+
3+
import { authActionClient } from '@/actions/safe-action';
4+
import { updatePolicy } from '@/jobs/tasks/onboarding/update-policy';
5+
import { db } from '@db';
6+
import { tasks } from '@trigger.dev/sdk';
7+
import { z } from 'zod';
8+
9+
export const regeneratePolicyAction = authActionClient
10+
.inputSchema(
11+
z.object({
12+
policyId: z.string().min(1),
13+
}),
14+
)
15+
.metadata({
16+
name: 'regenerate-policy',
17+
track: {
18+
event: 'regenerate-policy',
19+
channel: 'server',
20+
},
21+
})
22+
.action(async ({ parsedInput, ctx }) => {
23+
const { policyId } = parsedInput;
24+
const { session } = ctx;
25+
26+
if (!session?.activeOrganizationId) {
27+
throw new Error('No active organization');
28+
}
29+
30+
// Load frameworks associated to this organization via instances
31+
const instances = await db.frameworkInstance.findMany({
32+
where: { organizationId: session.activeOrganizationId },
33+
include: {
34+
framework: true,
35+
},
36+
});
37+
38+
const uniqueFrameworks = Array.from(
39+
new Map(instances.map((fi) => [fi.framework.id, fi.framework])).values(),
40+
).map((f) => ({
41+
id: f.id,
42+
name: f.name,
43+
version: f.version,
44+
description: f.description,
45+
visible: f.visible,
46+
createdAt: f.createdAt,
47+
updatedAt: f.updatedAt,
48+
}));
49+
50+
// Build contextHub string from context table Q&A
51+
const contextEntries = await db.context.findMany({
52+
where: { organizationId: session.activeOrganizationId },
53+
orderBy: { createdAt: 'asc' },
54+
});
55+
const contextHub = contextEntries.map((c) => `${c.question}\n${c.answer}`).join('\n');
56+
57+
await tasks.trigger<typeof updatePolicy>('update-policy', {
58+
organizationId: session.activeOrganizationId,
59+
policyId,
60+
contextHub,
61+
frameworks: uniqueFrameworks,
62+
});
63+
64+
// Revalidation handled by safe-action middleware using x-pathname header
65+
return { success: true };
66+
});

apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyDeleteDialog.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,7 @@ export function PolicyDeleteDialog({ isOpen, onClose, policy }: PolicyDeleteDial
4646

4747
const deletePolicy = useAction(deletePolicyAction, {
4848
onSuccess: () => {
49-
toast.info('Policy deleted! Redirecting to policies list...');
5049
onClose();
51-
router.push(`/${policy.organizationId}/policies/all`);
5250
},
5351
onError: () => {
5452
toast.error('Failed to delete policy.');
@@ -61,6 +59,11 @@ export function PolicyDeleteDialog({ isOpen, onClose, policy }: PolicyDeleteDial
6159
id: policy.id,
6260
entityId: policy.id,
6361
});
62+
63+
setTimeout(() => {
64+
router.replace(`/${policy.organizationId}/policies/all`);
65+
}, 1000);
66+
toast.info('Policy deleted! Redirecting to policies list...');
6467
};
6568

6669
return (
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
'use client';
2+
3+
import { regeneratePolicyAction } from '@/app/(app)/[orgId]/policies/[policyId]/actions/regenerate-policy';
4+
import { Button } from '@comp/ui/button';
5+
import {
6+
Dialog,
7+
DialogContent,
8+
DialogDescription,
9+
DialogFooter,
10+
DialogHeader,
11+
DialogTitle,
12+
} from '@comp/ui/dialog';
13+
import {
14+
DropdownMenu,
15+
DropdownMenuContent,
16+
DropdownMenuItem,
17+
DropdownMenuTrigger,
18+
} from '@comp/ui/dropdown-menu';
19+
import { Icons } from '@comp/ui/icons';
20+
import { useAction } from 'next-safe-action/hooks';
21+
import { useState } from 'react';
22+
import { toast } from 'sonner';
23+
24+
export function PolicyHeaderActions({ policyId }: { policyId: string }) {
25+
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
26+
// Delete flows through query param to existing dialog in PolicyOverview
27+
const regenerate = useAction(regeneratePolicyAction, {
28+
onSuccess: () => toast.success('Regeneration triggered. This may take a moment.'),
29+
onError: () => toast.error('Failed to trigger policy regeneration'),
30+
});
31+
32+
return (
33+
<>
34+
<DropdownMenu>
35+
<DropdownMenuTrigger asChild>
36+
<Button
37+
size="icon"
38+
variant="ghost"
39+
className="m-0 size-auto p-2"
40+
aria-label="Policy actions"
41+
>
42+
<Icons.Settings className="h-4 w-4" />
43+
</Button>
44+
</DropdownMenuTrigger>
45+
<DropdownMenuContent align="end">
46+
<DropdownMenuItem onClick={() => setIsConfirmOpen(true)}>
47+
<Icons.AI className="mr-2 h-4 w-4" /> Regenerate policy
48+
</DropdownMenuItem>
49+
<DropdownMenuItem
50+
onClick={() => {
51+
const url = new URL(window.location.href);
52+
url.searchParams.set('policy-overview-sheet', 'true');
53+
window.history.pushState({}, '', url.toString());
54+
}}
55+
>
56+
<Icons.Edit className="mr-2 h-4 w-4" /> Edit policy
57+
</DropdownMenuItem>
58+
<DropdownMenuItem
59+
onClick={() => {
60+
const url = new URL(window.location.href);
61+
url.searchParams.set('archive-policy-sheet', 'true');
62+
window.history.pushState({}, '', url.toString());
63+
}}
64+
>
65+
<Icons.InboxCustomize className="mr-2 h-4 w-4" /> Archive / Restore
66+
</DropdownMenuItem>
67+
<DropdownMenuItem
68+
onClick={() => {
69+
const url = new URL(window.location.href);
70+
url.searchParams.set('delete-policy', 'true');
71+
window.history.pushState({}, '', url.toString());
72+
}}
73+
className="text-destructive"
74+
>
75+
<Icons.Delete className="mr-2 h-4 w-4" /> Delete
76+
</DropdownMenuItem>
77+
</DropdownMenuContent>
78+
</DropdownMenu>
79+
80+
<Dialog open={isConfirmOpen} onOpenChange={(open) => !open && setIsConfirmOpen(false)}>
81+
<DialogContent className="sm:max-w-[420px]">
82+
<DialogHeader>
83+
<DialogTitle>Regenerate Policy</DialogTitle>
84+
<DialogDescription>
85+
This will generate new policy content using your org context and frameworks and mark
86+
it for review. Continue?
87+
</DialogDescription>
88+
</DialogHeader>
89+
<DialogFooter className="gap-2">
90+
<Button
91+
variant="outline"
92+
onClick={() => setIsConfirmOpen(false)}
93+
disabled={regenerate.status === 'executing'}
94+
>
95+
Cancel
96+
</Button>
97+
<Button
98+
onClick={() => {
99+
setIsConfirmOpen(false);
100+
toast.info('Regenerating policy...');
101+
regenerate.execute({ policyId });
102+
}}
103+
disabled={regenerate.status === 'executing'}
104+
>
105+
{regenerate.status === 'executing' ? 'Working…' : 'Confirm'}
106+
</Button>
107+
</DialogFooter>
108+
</DialogContent>
109+
</Dialog>
110+
111+
{/* Delete confirmation handled by PolicyDeleteDialog via query param */}
112+
</>
113+
);
114+
}

apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyOverview.tsx

Lines changed: 41 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,16 @@ import { authClient } from '@/utils/auth-client';
66
import { Alert, AlertDescription, AlertTitle } from '@comp/ui/alert';
77
import { Button } from '@comp/ui/button';
88
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card';
9-
import {
10-
DropdownMenu,
11-
DropdownMenuContent,
12-
DropdownMenuItem,
13-
DropdownMenuTrigger,
14-
} from '@comp/ui/dropdown-menu';
159
import { Icons } from '@comp/ui/icons';
1610
import type { Member, Policy, User } from '@db';
1711
import { Control } from '@db';
1812
import { format } from 'date-fns';
19-
import {
20-
ArchiveIcon,
21-
ArchiveRestoreIcon,
22-
MoreVertical,
23-
PencilIcon,
24-
ShieldCheck,
25-
ShieldX,
26-
Trash2,
27-
} from 'lucide-react';
13+
import { ArchiveIcon, ArchiveRestoreIcon, ShieldCheck, ShieldX } from 'lucide-react';
2814
import { useAction } from 'next-safe-action/hooks';
2915
import { useQueryState } from 'nuqs';
3016
import { useState } from 'react';
3117
import { toast } from 'sonner';
18+
import { regeneratePolicyAction } from '../actions/regenerate-policy';
3219
import { PolicyActionDialog } from './PolicyActionDialog';
3320
import { PolicyArchiveSheet } from './PolicyArchiveSheet';
3421
import { PolicyControlMappings } from './PolicyControlMappings';
@@ -79,10 +66,8 @@ export function PolicyOverview({
7966
// Dialog state for approval/denial actions
8067
const [approveDialogOpen, setApproveDialogOpen] = useState(false);
8168
const [denyDialogOpen, setDenyDialogOpen] = useState(false);
82-
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
83-
84-
// Dropdown menu state
85-
const [dropdownOpen, setDropdownOpen] = useState(false);
69+
const [deleteOpenParam, setDeleteOpenParam] = useQueryState('delete-policy');
70+
const [regenerateOpen, setRegenerateOpen] = useState(false);
8671

8772
// Handle approve with optional comment
8873
const handleApprove = (comment?: string) => {
@@ -149,22 +134,26 @@ export function PolicyOverview({
149134
</Alert>
150135
)}
151136
{policy?.isArchived && (
152-
<Alert>
153-
<div className="flex items-center gap-2">
154-
<ArchiveIcon className="h-4 w-4" />
155-
<div className="font-medium">{'This policy is archived'}</div>
137+
<Alert className="flex items-start justify-between gap-3">
138+
<div className="flex items-start gap-2">
139+
<ArchiveIcon className="mt-0.5 h-4 w-4" />
140+
<div className="space-y-1">
141+
<div className="font-medium">This policy is archived</div>
142+
<AlertDescription>
143+
Archived on {format(new Date(policy?.updatedAt ?? new Date()), 'PPP')}
144+
</AlertDescription>
145+
</div>
146+
</div>
147+
<div className="shrink-0">
148+
<Button
149+
size="sm"
150+
variant="outline"
151+
onClick={() => setArchiveOpen('true')}
152+
className="gap-1"
153+
>
154+
<ArchiveRestoreIcon className="h-3 w-3" /> Restore
155+
</Button>
156156
</div>
157-
<AlertDescription>
158-
{policy?.isArchived && (
159-
<>
160-
{'Archived on'} {format(new Date(policy?.updatedAt ?? new Date()), 'PPP')}
161-
</>
162-
)}
163-
</AlertDescription>
164-
<Button size="sm" variant="outline" onClick={() => setArchiveOpen('true')}>
165-
<ArchiveRestoreIcon className="h-3 w-3" />
166-
{'Restore'}
167-
</Button>
168157
</Alert>
169158
)}
170159

@@ -176,55 +165,8 @@ export function PolicyOverview({
176165
<Icons.Policies className="h-4 w-4" />
177166
{policy?.name}
178167
</div>
179-
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
180-
<DropdownMenuTrigger asChild>
181-
<Button
182-
size="icon"
183-
variant="ghost"
184-
disabled={isPendingApproval}
185-
className="m-0 size-auto p-2 hover:bg-transparent"
186-
>
187-
<MoreVertical className="h-4 w-4" />
188-
</Button>
189-
</DropdownMenuTrigger>
190-
<DropdownMenuContent align="end">
191-
<DropdownMenuItem
192-
onClick={() => {
193-
setDropdownOpen(false);
194-
setOpen('true');
195-
}}
196-
disabled={isPendingApproval}
197-
>
198-
<PencilIcon className="mr-2 h-4 w-4" />
199-
{'Edit policy'}
200-
</DropdownMenuItem>
201-
<DropdownMenuItem
202-
onClick={() => {
203-
setDropdownOpen(false);
204-
setArchiveOpen('true');
205-
}}
206-
disabled={isPendingApproval}
207-
>
208-
{policy?.isArchived ? (
209-
<ArchiveRestoreIcon className="mr-2 h-4 w-4" />
210-
) : (
211-
<ArchiveIcon className="mr-2 h-4 w-4" />
212-
)}
213-
{policy?.isArchived ? 'Restore policy' : 'Archive policy'}
214-
</DropdownMenuItem>
215-
<DropdownMenuItem
216-
onClick={() => {
217-
setDropdownOpen(false);
218-
setDeleteDialogOpen(true);
219-
}}
220-
disabled={isPendingApproval}
221-
className="text-destructive focus:text-destructive"
222-
>
223-
<Trash2 className="mr-2 h-4 w-4" />
224-
Delete
225-
</DropdownMenuItem>
226-
</DropdownMenuContent>
227-
</DropdownMenu>
168+
{/* Redundant gear removed; actions moved to breadcrumb header */}
169+
<div className="h-6" />
228170
</div>
229171
</CardTitle>
230172
<CardDescription>{policy?.description}</CardDescription>
@@ -276,10 +218,24 @@ export function PolicyOverview({
276218

277219
{/* Delete Dialog */}
278220
<PolicyDeleteDialog
279-
isOpen={deleteDialogOpen}
280-
onClose={() => setDeleteDialogOpen(false)}
221+
isOpen={Boolean(deleteOpenParam)}
222+
onClose={() => setDeleteOpenParam(null)}
281223
policy={policy}
282224
/>
225+
{/* Regenerate Dialog */}
226+
<PolicyActionDialog
227+
isOpen={regenerateOpen}
228+
onClose={() => setRegenerateOpen(false)}
229+
onConfirm={async () => {
230+
if (!policy?.id) return;
231+
await regeneratePolicyAction({ policyId: policy.id });
232+
toast.info('Regeneration started');
233+
}}
234+
title="Regenerate Policy"
235+
description="This will regenerate the policy content and mark it for review. Continue?"
236+
confirmText="Regenerate"
237+
confirmIcon={<Icons.AI className="h-4 w-4" />}
238+
/>
283239
</>
284240
)}
285241
</div>

0 commit comments

Comments
 (0)