diff --git a/app/(app)/search/PolicyExplorerClient.tsx b/app/(app)/search/PolicyExplorerClient.tsx new file mode 100644 index 0000000..afffef9 --- /dev/null +++ b/app/(app)/search/PolicyExplorerClient.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useState } from 'react'; +import { PolicySearchContainer } from '@/components/policy-explorer/PolicySearchContainer'; +import { PolicyDetailSheet } from '@/components/policy-explorer/PolicyDetailSheet'; +import type { PolicySettingSearchResult } from '@/lib/actions/policySettings'; + +interface PolicyExplorerClientProps { + initialPolicies: PolicySettingSearchResult[]; +} + +export function PolicyExplorerClient({ + initialPolicies, +}: PolicyExplorerClientProps) { + const [selectedPolicy, setSelectedPolicy] = useState(null); + const [sheetOpen, setSheetOpen] = useState(false); + + const handlePolicyClick = (policy: PolicySettingSearchResult) => { + setSelectedPolicy(policy); + setSheetOpen(true); + }; + + return ( + <> + + + + + ); +} diff --git a/app/(app)/search/page.tsx b/app/(app)/search/page.tsx index cdf0fb1..f81a624 100644 --- a/app/(app)/search/page.tsx +++ b/app/(app)/search/page.tsx @@ -1,78 +1,17 @@ -'use client'; - -import { useState, useTransition, useCallback } from 'react'; -import { SearchInput } from '@/components/search/SearchInput'; -import { ResultsTable } from '@/components/search/ResultsTable'; -import { EmptyState } from '@/components/search/EmptyState'; import { SyncButton } from '@/components/search/SyncButton'; -import { - searchPolicySettings, - seedMyTenantData, - type PolicySettingSearchResult, -} from '@/lib/actions/policySettings'; +import { PolicyExplorerClient } from './PolicyExplorerClient'; +import { getRecentPolicySettings } from '@/lib/actions/policySettings'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { toast } from 'sonner'; -import { Database } from 'lucide-react'; -import { useRouter } from 'next/navigation'; +import { Metadata } from 'next'; -export default function SearchPage() { - const router = useRouter(); - const [results, setResults] = useState([]); - const [searchTerm, setSearchTerm] = useState(''); - const [hasSearched, setHasSearched] = useState(false); - const [isPending, startTransition] = useTransition(); - const [isSeeding, startSeedTransition] = useTransition(); +export const metadata: Metadata = { + title: 'Policy Explorer | TenantPilot', + description: 'Browse and search Microsoft Intune policy settings with detailed views and filtering', +}; - const handleSearch = useCallback((query: string) => { - setSearchTerm(query); - - if (query.length === 0) { - setResults([]); - setHasSearched(false); - return; - } - - if (query.length < 2) { - return; - } - - startTransition(async () => { - try { - const result = await searchPolicySettings(query); - - if (result.success) { - setResults(result.data ?? []); - setHasSearched(true); - } else { - toast.error(result.error ?? 'Search failed'); - setResults([]); - setHasSearched(true); - } - } catch (error) { - toast.error('An unexpected error occurred'); - setResults([]); - setHasSearched(true); - } - }); - }, []); - - const handleSeedData = () => { - startSeedTransition(async () => { - try { - const result = await seedMyTenantData(); - - if (result.success) { - toast.success(result.message ?? 'Test data created successfully'); - router.refresh(); - } else { - toast.error(result.error ?? 'Failed to seed data'); - } - } catch (error) { - toast.error('An unexpected error occurred'); - } - }); - }; +export default async function SearchPage() { + // Fetch initial 50 newest policies on server + const initialData = await getRecentPolicySettings(50); return (
@@ -81,73 +20,18 @@ export default function SearchPage() {
- Global Policy Search + Policy Explorer - Search across all your Intune policy settings by keyword + Browse and search Intune policy settings
-
- - - {isPending && ( -
-
-
- - Searching... - -
-
- )} - - {!isPending && hasSearched && ( - <> - {results.length > 0 ? ( -
-

- Found {results.length} result{results.length !== 1 ? 's' : ''} -

- -
- ) : ( - - )} - - )} - - {!hasSearched && !isPending && ( - - )} -
+ - - {/* Seed Data Button - Development Helper */} -
- -
); diff --git a/components/policy-explorer/PolicyDetailSheet.tsx b/components/policy-explorer/PolicyDetailSheet.tsx new file mode 100644 index 0000000..1b25831 --- /dev/null +++ b/components/policy-explorer/PolicyDetailSheet.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import type { PolicySettingSearchResult } from '@/lib/actions/policySettings'; +import { formatDistanceToNow } from 'date-fns'; +import { de } from 'date-fns/locale'; + +interface PolicyDetailSheetProps { + policy: PolicySettingSearchResult | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function isJsonString(str: string): boolean { + if (!str || typeof str !== 'string') return false; + const trimmed = str.trim(); + return trimmed.startsWith('{') || trimmed.startsWith('['); +} + +function formatJson(value: string): string { + try { + const parsed = JSON.parse(value); + return JSON.stringify(parsed, null, 2); + } catch { + return value; + } +} + +export function PolicyDetailSheet({ + policy, + open, + onOpenChange, +}: PolicyDetailSheetProps) { + if (!policy) return null; + + const isJson = isJsonString(policy.settingValue); + const displayValue = isJson + ? formatJson(policy.settingValue) + : policy.settingValue; + + return ( + + + + {policy.settingName} + + Policy Setting Details + + + +
+ {/* Policy Name */} +
+

+ Policy Name +

+

{policy.policyName}

+
+ + {/* Policy Type */} +
+

+ Policy Type +

+

+ {policy.policyType.replace(/([A-Z])/g, ' $1').trim()} +

+
+ + {/* Setting Name */} +
+

+ Setting Name +

+

{policy.settingName}

+
+ + {/* Setting Value */} +
+

+ Setting Value +

+ {isJson ? ( +
+                {displayValue}
+              
+ ) : ( +

+ {displayValue} +

+ )} +
+ + {/* Last Synced */} +
+

+ Last Synced +

+

+ {formatDistanceToNow(new Date(policy.lastSyncedAt), { + addSuffix: true, + locale: de, + })} +

+

+ {new Date(policy.lastSyncedAt).toLocaleString('de-DE')} +

+
+
+
+
+ ); +} diff --git a/components/policy-explorer/PolicySearchContainer.tsx b/components/policy-explorer/PolicySearchContainer.tsx new file mode 100644 index 0000000..e6e18ad --- /dev/null +++ b/components/policy-explorer/PolicySearchContainer.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { PolicyTable } from './PolicyTable'; +import { SearchInput } from '@/components/search/SearchInput'; +import { EmptyState } from '@/components/search/EmptyState'; +import type { PolicySettingSearchResult } from '@/lib/actions/policySettings'; +import { searchPolicySettings } from '@/lib/actions/policySettings'; +import { toast } from 'sonner'; + +interface PolicySearchContainerProps { + initialPolicies: PolicySettingSearchResult[]; + onPolicyClick: (policy: PolicySettingSearchResult) => void; +} + +export function PolicySearchContainer({ + initialPolicies, + onPolicyClick, +}: PolicySearchContainerProps) { + const [policies, setPolicies] = useState(initialPolicies); + const [searchTerm, setSearchTerm] = useState(''); + const [hasSearched, setHasSearched] = useState(false); + const [isPending, startTransition] = useTransition(); + + const handleSearch = (query: string) => { + setSearchTerm(query); + + if (query.length === 0) { + // Reset to initial policies when search is cleared + setPolicies(initialPolicies); + setHasSearched(false); + return; + } + + if (query.length < 2) { + return; + } + + startTransition(async () => { + try { + const result = await searchPolicySettings(query); + + if (result.success) { + setPolicies(result.data ?? []); + setHasSearched(true); + } else { + toast.error(result.error ?? 'Search failed'); + setPolicies([]); + setHasSearched(true); + } + } catch (error) { + toast.error('An unexpected error occurred'); + setPolicies([]); + setHasSearched(true); + } + }); + }; + + return ( +
+ + + {policies.length === 0 && hasSearched && ( + + )} + + {policies.length === 0 && !hasSearched && initialPolicies.length === 0 && ( +
+

Keine Policies gefunden - Starten Sie einen Sync

+
+ )} + + {policies.length > 0 && ( + + )} +
+ ); +} diff --git a/components/policy-explorer/PolicyTable.tsx b/components/policy-explorer/PolicyTable.tsx new file mode 100644 index 0000000..832626e --- /dev/null +++ b/components/policy-explorer/PolicyTable.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import type { PolicySettingSearchResult } from '@/lib/actions/policySettings'; +import { getPolicyBadgeConfig } from '@/lib/utils/policyBadges'; +import { formatDistanceToNow } from 'date-fns'; +import { de } from 'date-fns/locale'; + +interface PolicyTableProps { + policies: PolicySettingSearchResult[]; + onRowClick: (policy: PolicySettingSearchResult) => void; +} + +export function PolicyTable({ policies, onRowClick }: PolicyTableProps) { + if (policies.length === 0) { + return null; + } + + return ( + + +
+ + + + Setting Name + Setting Value + Policy Name + Policy Type + Last Synced + + + + {policies.map((policy) => ( + onRowClick(policy)} + className="cursor-pointer hover:bg-accent" + > + + {policy.settingName} + + + {policy.settingValue} + + {policy.policyName} + + {(() => { + const badgeConfig = getPolicyBadgeConfig(policy.policyType); + return ( + + {badgeConfig.label} + + ); + })()} + + + {formatDistanceToNow(new Date(policy.lastSyncedAt), { + addSuffix: true, + locale: de, + })} + + + ))} + +
+
+
+
+ ); +} diff --git a/components/search/EmptyState.tsx b/components/search/EmptyState.tsx index 9b03f01..513553a 100644 --- a/components/search/EmptyState.tsx +++ b/components/search/EmptyState.tsx @@ -17,7 +17,7 @@ export function EmptyState({ searchTerm }: EmptyStateProps) {

) : (

- Enter a search term to find policy settings + No policy settings available. Trigger a sync to import policies from Intune.

)} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..f000e3e --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx new file mode 100644 index 0000000..a37f17b --- /dev/null +++ b/components/ui/sheet.tsx @@ -0,0 +1,140 @@ +"use client" + +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + } +) + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/config/nav.ts b/config/nav.ts index db2d0ec..76be78e 100644 --- a/config/nav.ts +++ b/config/nav.ts @@ -8,8 +8,7 @@ type AdditionalLinks = { export const defaultLinks: SidebarLink[] = [ { href: "/dashboard", title: "Home", icon: HomeIcon }, - { href: "/search", title: "Search", icon: Search }, - { href: "/settings-overview", title: "All Settings", icon: Database }, + { href: "/search", title: "Policy Explorer", icon: Search }, { href: "/account", title: "Account", icon: User }, { href: "/settings", title: "Settings", icon: Cog }, ]; diff --git a/lib/actions/policySettings.ts b/lib/actions/policySettings.ts index 04bb064..1488e13 100644 --- a/lib/actions/policySettings.ts +++ b/lib/actions/policySettings.ts @@ -2,7 +2,7 @@ import { db, policySettings, type PolicySetting } from '@/lib/db'; import { getUserAuth } from '@/lib/auth/utils'; -import { eq, ilike, or, desc, and } from 'drizzle-orm'; +import { eq, ilike, or, desc, and, ne, isNotNull } from 'drizzle-orm'; import { env } from '@/lib/env.mjs'; export interface PolicySettingSearchResult { @@ -49,10 +49,12 @@ export interface AllSettingsResult { * 3. Including explicit WHERE tenantId = ? in ALL queries * * @param searchTerm - Search query (min 2 characters) + * @param limit - Maximum number of results (default 100, max 200) * @returns Search results filtered by user's tenant */ export async function searchPolicySettings( - searchTerm: string + searchTerm: string, + limit: number = 100 ): Promise { try { const { session } = await getUserAuth(); @@ -76,7 +78,10 @@ export async function searchPolicySettings( const sanitizedSearchTerm = searchTerm.slice(0, 200); const searchPattern = `%${sanitizedSearchTerm}%`; - // T017: Explicit WHERE clause filters by tenantId FIRST for security + // Enforce maximum limit + const safeLimit = Math.min(Math.max(1, limit), 200); + + // Explicit WHERE clause filters by tenantId FIRST for security + null filtering const results = await db .select({ id: policySettings.id, @@ -90,6 +95,9 @@ export async function searchPolicySettings( .where( and( eq(policySettings.tenantId, tenantId), // CRITICAL: Tenant isolation + ne(policySettings.settingValue, 'null'), // Filter out string "null" + ne(policySettings.settingValue, ''), // Filter out empty strings + isNotNull(policySettings.settingValue), // Filter out NULL values or( ilike(policySettings.settingName, searchPattern), ilike(policySettings.settingValue, searchPattern) @@ -97,7 +105,7 @@ export async function searchPolicySettings( ) ) .orderBy(policySettings.settingName) - .limit(100); + .limit(safeLimit); return { success: true, @@ -166,11 +174,11 @@ export async function getPolicySettingById( * * **Security**: Enforces tenant isolation with explicit WHERE tenantId filter * - * @param limit - Maximum number of results (1-100, default 20) + * @param limit - Maximum number of results (1-100, default 50) * @returns Recent policy settings for user's tenant */ export async function getRecentPolicySettings( - limit: number = 20 + limit: number = 50 ): Promise { try { const { session } = await getUserAuth(); diff --git a/lib/utils/policyBadges.ts b/lib/utils/policyBadges.ts new file mode 100644 index 0000000..324bdac --- /dev/null +++ b/lib/utils/policyBadges.ts @@ -0,0 +1,56 @@ +/** + * Policy Type Badge Configuration + * Maps Intune policy types to Shadcn Badge variants and colors + */ + +export type PolicyBadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline'; + +interface PolicyBadgeConfig { + variant: PolicyBadgeVariant; + label: string; +} + +/** + * Maps policy type to badge configuration + * Based on Microsoft Intune policy categories + */ +export function getPolicyBadgeConfig(policyType: string): PolicyBadgeConfig { + const type = policyType.toLowerCase(); + + // Security & Protection + if (type.includes('security') || type.includes('defender') || type.includes('threat')) { + return { variant: 'destructive', label: formatPolicyType(policyType) }; + } + + // Compliance & Conditional Access + if (type.includes('compliance') || type.includes('conditional')) { + return { variant: 'default', label: formatPolicyType(policyType) }; + } + + // Configuration Profiles + if (type.includes('configuration') || type.includes('profile') || type.includes('settings')) { + return { variant: 'secondary', label: formatPolicyType(policyType) }; + } + + // App Management + if (type.includes('app') || type.includes('application')) { + return { variant: 'outline', label: formatPolicyType(policyType) }; + } + + // Default for unknown types + return { variant: 'secondary', label: formatPolicyType(policyType) }; +} + +/** + * Formats policy type string for display + * Converts camelCase/PascalCase to readable format + */ +function formatPolicyType(policyType: string): string { + return policyType + .replace(/([A-Z])/g, ' $1') // Add space before capital letters + .trim() + .replace(/\s+/g, ' ') // Collapse multiple spaces + .split(' ') + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); +} diff --git a/package-lock.json b/package-lock.json index eb368e8..7d89cf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@auth/drizzle-adapter": "^1.11.1", "@paralleldrive/cuid2": "^3.0.4", "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-slot": "^1.2.4", @@ -2542,6 +2543,98 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", diff --git a/package.json b/package.json index 60e50be..a79eec9 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@auth/drizzle-adapter": "^1.11.1", "@paralleldrive/cuid2": "^3.0.4", "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-slot": "^1.2.4", diff --git a/specs/003-policy-explorer-ux/tasks.md b/specs/003-policy-explorer-ux/tasks.md index f700d70..e6c4940 100644 --- a/specs/003-policy-explorer-ux/tasks.md +++ b/specs/003-policy-explorer-ux/tasks.md @@ -21,9 +21,9 @@ **Purpose**: Verify prerequisites and install missing Shadcn UI components -- [ ] T001 [P] Verify Shadcn Sheet component exists at `components/ui/sheet.tsx`, install if missing via `npx shadcn@latest add sheet` -- [ ] T002 [P] Verify Shadcn Badge component exists at `components/ui/badge.tsx`, install if missing via `npx shadcn@latest add badge` -- [ ] T003 Create new directory `components/policy-explorer/` for new feature components +- [X] T001 [P] Verify Shadcn Sheet component exists at `components/ui/sheet.tsx`, install if missing via `npx shadcn@latest add sheet` +- [X] T002 [P] Verify Shadcn Badge component exists at `components/ui/badge.tsx`, install if missing via `npx shadcn@latest add badge` +- [X] T003 Create new directory `components/policy-explorer/` for new feature components --- @@ -35,11 +35,11 @@ ### Backend Server Actions -- [ ] T004 Add `getRecentPolicySettings(limit?: number)` Server Action in `lib/actions/policySettings.ts` - returns 50 newest policies sorted by lastSyncedAt DESC, filtered by tenantId and excluding null values -- [ ] T005 Extend `searchPolicySettings()` Server Action in `lib/actions/policySettings.ts` - add optional `limit` parameter (default 100, max 200) -- [ ] T006 Add null-value filtering logic to Server Actions - filter out entries where `settingValue === "null"` or `settingValue === null` (backend approach for performance) -- [ ] T007 Add type exports for `PolicySettingSearchResult` interface if not already exported from `lib/actions/policySettings.ts` -- [ ] T008 Test Server Actions manually - verify tenant isolation, null filtering, and limit parameters work correctly +- [X] T004 Add `getRecentPolicySettings(limit?: number)` Server Action in `lib/actions/policySettings.ts` - returns 50 newest policies sorted by lastSyncedAt DESC, filtered by tenantId and excluding null values +- [X] T005 Extend `searchPolicySettings()` Server Action in `lib/actions/policySettings.ts` - add optional `limit` parameter (default 100, max 200) +- [X] T006 Add null-value filtering logic to Server Actions - filter out entries where `settingValue === "null"` or `settingValue === null` (backend approach for performance) +- [X] T007 Add type exports for `PolicySettingSearchResult` interface if not already exported from `lib/actions/policySettings.ts` +- [X] T008 Test Server Actions manually - verify tenant isolation, null filtering, and limit parameters work correctly **Checkpoint**: Backend ready - all Server Actions functional and tested @@ -53,14 +53,14 @@ ### Implementation for User Story 1 -- [ ] T009 [P] [US1] Create `PolicyTable.tsx` component in `components/policy-explorer/PolicyTable.tsx` - display policies in table format with columns: Setting Name, Setting Value, Policy Name, Policy Type, Last Synced -- [ ] T010 [P] [US1] Add row click handler prop to `PolicyTable` component - accepts `onRowClick: (policy: PolicySettingSearchResult) => void` -- [ ] T011 [P] [US1] Add hover styles to table rows in `PolicyTable` - `hover:bg-accent cursor-pointer` classes -- [ ] T012 [P] [US1] Create `PolicySearchContainer.tsx` client component in `components/policy-explorer/PolicySearchContainer.tsx` - manages search state, accepts `initialPolicies` prop -- [ ] T013 [US1] Refactor `app/(app)/search/page.tsx` to Server Component pattern - call `getRecentPolicySettings(50)` in Server Component, pass data to `PolicySearchContainer` -- [ ] T014 [US1] Update page CardTitle to "Policy Explorer" and CardDescription in `app/(app)/search/page.tsx` -- [ ] T015 [US1] Add empty state handling - if no policies returned, show message "Keine Policies gefunden - Starten Sie einen Sync" -- [ ] T016 [US1] Keep existing `SyncButton` component in CardHeader of `app/(app)/search/page.tsx` +- [X] T009 [P] [US1] Create `PolicyTable.tsx` component in `components/policy-explorer/PolicyTable.tsx` - display policies in table format with columns: Setting Name, Setting Value, Policy Name, Policy Type, Last Synced +- [X] T010 [P] [US1] Add row click handler prop to `PolicyTable` component - accepts `onRowClick: (policy: PolicySettingSearchResult) => void` +- [X] T011 [P] [US1] Add hover styles to table rows in `PolicyTable` - `hover:bg-accent cursor-pointer` classes +- [X] T012 [P] [US1] Create `PolicySearchContainer.tsx` client component in `components/policy-explorer/PolicySearchContainer.tsx` - manages search state, accepts `initialPolicies` prop +- [X] T013 [US1] Refactor `app/(app)/search/page.tsx` to Server Component pattern - call `getRecentPolicySettings(50)` in Server Component, pass data to `PolicySearchContainer` +- [X] T014 [US1] Update page CardTitle to "Policy Explorer" and CardDescription in `app/(app)/search/page.tsx` +- [X] T015 [US1] Add empty state handling - if no policies returned, show message "Keine Policies gefunden - Starten Sie einen Sync" +- [X] T016 [US1] Keep existing `SyncButton` component in CardHeader of `app/(app)/search/page.tsx` **Checkpoint**: Page loads with 50 newest policies, no null values, empty state works @@ -74,15 +74,15 @@ ### Implementation for User Story 2 -- [ ] T017 [P] [US2] Create `PolicyDetailSheet.tsx` component in `components/policy-explorer/PolicyDetailSheet.tsx` - uses Shadcn Sheet, accepts `policy`, `open`, `onOpenChange` props -- [ ] T018 [P] [US2] Implement JSON detection logic in `PolicyDetailSheet` - check if `settingValue.trim().startsWith('{')` or `startsWith('[')` -- [ ] T019 [P] [US2] Implement JSON formatting utility - `JSON.stringify(JSON.parse(value), null, 2)` wrapped in try/catch, fallback to plain text on error -- [ ] T020 [P] [US2] Render JSON values in `
` tag with Tailwind prose classes for readability in `PolicyDetailSheet`
-- [ ] T021 [P] [US2] Render non-JSON values as normal text in `PolicyDetailSheet`
-- [ ] T022 [P] [US2] Add Sheet styling - proper width (e.g., `w-[600px]`), padding, close button, scroll for long content (`max-height` + `overflow-auto`)
-- [ ] T023 [US2] Integrate `PolicyDetailSheet` into `PolicySearchContainer` - add state for selected policy and sheet open/close
-- [ ] T024 [US2] Connect row click handler from `PolicyTable` to open detail sheet with clicked policy data
-- [ ] T025 [US2] Test detail sheet with various data - nested JSON objects, arrays, long strings (>10KB), plain text values, malformed JSON
+- [X] T017 [P] [US2] Create `PolicyDetailSheet.tsx` component in `components/policy-explorer/PolicyDetailSheet.tsx` - uses Shadcn Sheet, accepts `policy`, `open`, `onOpenChange` props
+- [X] T018 [P] [US2] Implement JSON detection logic in `PolicyDetailSheet` - check if `settingValue.trim().startsWith('{')` or `startsWith('[')`
+- [X] T019 [P] [US2] Implement JSON formatting utility - `JSON.stringify(JSON.parse(value), null, 2)` wrapped in try/catch, fallback to plain text on error
+- [X] T020 [P] [US2] Render JSON values in `
` tag with Tailwind prose classes for readability in `PolicyDetailSheet`
+- [X] T021 [P] [US2] Render non-JSON values as normal text in `PolicyDetailSheet`
+- [X] T022 [P] [US2] Add Sheet styling - proper width (e.g., `w-[600px]`), padding, close button, scroll for long content (`max-height` + `overflow-auto`)
+- [X] T023 [US2] Integrate `PolicyDetailSheet` into `PolicySearchContainer` - add state for selected policy and sheet open/close
+- [X] T024 [US2] Connect row click handler from `PolicyTable` to open detail sheet with clicked policy data
+- [X] T025 [US2] Test detail sheet with various data - nested JSON objects, arrays, long strings (>10KB), plain text values, malformed JSON
 
 **Checkpoint**: Clicking rows opens detail sheet, JSON is formatted, sheet closes properly
 
@@ -96,12 +96,12 @@
 
 ### Implementation for User Story 3
 
-- [ ] T026 [US3] Integrate existing `SearchInput` component into `PolicySearchContainer` in `components/policy-explorer/PolicySearchContainer.tsx`
-- [ ] T027 [US3] Add search handler in `PolicySearchContainer` - calls `searchPolicySettings()` Server Action with search term
-- [ ] T028 [US3] Implement search state management using `useTransition()` hook for pending state
-- [ ] T029 [US3] Update table to show search results when search term is entered, show initial 50 policies when search is cleared
-- [ ] T030 [US3] Verify null values are filtered from search results (handled by Server Action from T006)
-- [ ] T031 [US3] Add loading indicator while search is in progress (use `isPending` from `useTransition`)
+- [X] T026 [US3] Integrate existing `SearchInput` component into `PolicySearchContainer` in `components/policy-explorer/PolicySearchContainer.tsx`
+- [X] T027 [US3] Add search handler in `PolicySearchContainer` - calls `searchPolicySettings()` Server Action with search term
+- [X] T028 [US3] Implement search state management using `useTransition()` hook for pending state
+- [X] T029 [US3] Update table to show search results when search term is entered, show initial 50 policies when search is cleared
+- [X] T030 [US3] Verify null values are filtered from search results (handled by Server Action from T006)
+- [X] T031 [US3] Add loading indicator while search is in progress (use `isPending` from `useTransition`)
 
 **Checkpoint**: Search works, results exclude null values, loading states correct
 
@@ -115,11 +115,11 @@
 
 ### Implementation for User Story 4
 
-- [ ] T032 [P] [US4] Define policy type to badge color mapping - create utility function or constant (e.g., Compliance=blue, Configuration=gray, Security=red, etc.)
-- [ ] T033 [P] [US4] Implement badge rendering in `PolicyTable` - replace plain text `policyType` with Shadcn Badge component
-- [ ] T034 [P] [US4] Apply badge variant/color based on policy type in `PolicyTable` component
-- [ ] T035 [US4] Test badge colors with real data - verify at least 3 distinct colors are visible and accessible (contrast check)
-- [ ] T036 [US4] Verify hover effects on table rows - cursor changes to pointer, background color changes
+- [X] T032 [P] [US4] Define policy type to badge color mapping - create utility function or constant (e.g., Compliance=blue, Configuration=gray, Security=red, etc.)
+- [X] T033 [P] [US4] Implement badge rendering in `PolicyTable` - replace plain text `policyType` with Shadcn Badge component
+- [X] T034 [P] [US4] Apply badge variant/color based on policy type in `PolicyTable` component
+- [X] T035 [US4] Test badge colors with real data - verify at least 3 distinct colors are visible and accessible (contrast check)
+- [X] T036 [US4] Verify hover effects on table rows - cursor changes to pointer, background color changes
 
 **Checkpoint**: Badges show distinct colors, hover effects smooth, visual hierarchy improved
 
@@ -129,9 +129,9 @@
 
 **Purpose**: Update navigation and routing to consolidate under "Policy Explorer"
 
-- [ ] T037 Update `config/nav.ts` - replace `{ title: "Search", href: "/search" }` with `{ title: "Policy Explorer", href: "/search" }` (keep /search route for now)
-- [ ] T038 Remove "All Settings" menu item from `config/nav.ts` if it exists (from settings-overview feature)
-- [ ] T039 Update page metadata (title, description) in `app/(app)/search/page.tsx` to reflect "Policy Explorer" branding
+- [X] T037 Update `config/nav.ts` - replace `{ title: "Search", href: "/search" }` with `{ title: "Policy Explorer", href: "/search" }` (keep /search route for now)
+- [X] T038 Remove "All Settings" menu item from `config/nav.ts` if it exists (from settings-overview feature)
+- [X] T039 Update page metadata (title, description) in `app/(app)/search/page.tsx` to reflect "Policy Explorer" branding
 - [ ] T040 Add redirect from `/settings-overview` to `/search` if that route exists (optional, for backwards compatibility)
 
 **Checkpoint**: Navigation shows single "Policy Explorer" entry, old routes redirect if needed
@@ -142,7 +142,7 @@
 
 **Purpose**: Final refinements and end-to-end validation
 
-- [ ] T041 Update `EmptyState` component message in `components/search/EmptyState.tsx` - change to Policy Explorer context if still used
+- [X] T041 Update `EmptyState` component message in `components/search/EmptyState.tsx` - change to Policy Explorer context if still used
 - [ ] T042 Add loading skeleton to table (optional) - show placeholder rows while initial data loads
 - [ ] T043 Verify responsive layout - test table and detail sheet on mobile viewport (< 768px)
 - [ ] T044 Add error boundary for detail sheet - graceful error handling if sheet rendering fails