feat: Policy Explorer UX Upgrade (003)

Implemented MVP with all core features:
- Browse 50 newest policies on load with null filtering
- Click row to view details in slide-over sheet
- JSON detection and pretty formatting
- Search with real-time filtering
- Badge colors for policy types (Security=red, Compliance=blue, Config=gray, App=outline)
- Navigation consolidated to 'Policy Explorer'

New components:
- PolicyTable.tsx - table with badges and hover effects
- PolicySearchContainer.tsx - search state management
- PolicyDetailSheet.tsx - JSON detail view with formatting
- PolicyExplorerClient.tsx - client wrapper
- lib/utils/policyBadges.ts - badge color mapping

Updated:
- lib/actions/policySettings.ts - added getRecentPolicySettings() with null filtering
- app/(app)/search/page.tsx - converted to Server Component
- config/nav.ts - renamed Search to Policy Explorer, removed All Settings
- components/search/EmptyState.tsx - updated messaging

Tasks complete: 36/47 (MVP ready)
- Phase 1-7: All critical features implemented
- Phase 8: Core polish complete (T041), optional tasks remain

TypeScript:  No errors
Status: Production-ready MVP
This commit is contained in:
Ahmed Darrazi 2025-12-07 02:28:15 +01:00
parent ae999e925d
commit f592e5f55b
14 changed files with 712 additions and 178 deletions

View File

@ -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<PolicySettingSearchResult | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const handlePolicyClick = (policy: PolicySettingSearchResult) => {
setSelectedPolicy(policy);
setSheetOpen(true);
};
return (
<>
<PolicySearchContainer
initialPolicies={initialPolicies}
onPolicyClick={handlePolicyClick}
/>
<PolicyDetailSheet
policy={selectedPolicy}
open={sheetOpen}
onOpenChange={setSheetOpen}
/>
</>
);
}

View File

@ -1,79 +1,18 @@
'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 { SyncButton } from '@/components/search/SyncButton';
import { import { PolicyExplorerClient } from './PolicyExplorerClient';
searchPolicySettings, import { getRecentPolicySettings } from '@/lib/actions/policySettings';
seedMyTenantData,
type PolicySettingSearchResult,
} from '@/lib/actions/policySettings';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Metadata } from 'next';
import { toast } from 'sonner';
import { Database } from 'lucide-react';
import { useRouter } from 'next/navigation';
export default function SearchPage() { export const metadata: Metadata = {
const router = useRouter(); title: 'Policy Explorer | TenantPilot',
const [results, setResults] = useState<PolicySettingSearchResult[]>([]); description: 'Browse and search Microsoft Intune policy settings with detailed views and filtering',
const [searchTerm, setSearchTerm] = useState('');
const [hasSearched, setHasSearched] = useState(false);
const [isPending, startTransition] = useTransition();
const [isSeeding, startSeedTransition] = useTransition();
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 ( return (
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8"> <main className="flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8">
<div className="mx-auto w-full max-w-6xl"> <div className="mx-auto w-full max-w-6xl">
@ -81,73 +20,18 @@ export default function SearchPage() {
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<CardTitle>Global Policy Search</CardTitle> <CardTitle>Policy Explorer</CardTitle>
<CardDescription> <CardDescription>
Search across all your Intune policy settings by keyword Browse and search Intune policy settings
</CardDescription> </CardDescription>
</div> </div>
<SyncButton /> <SyncButton />
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col gap-6"> <PolicyExplorerClient initialPolicies={initialData.data ?? []} />
<SearchInput onSearch={handleSearch} isSearching={isPending} />
{isPending && (
<div className="flex items-center justify-center py-8">
<div className="flex items-center gap-2">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="text-sm text-muted-foreground">
Searching...
</span>
</div>
</div>
)}
{!isPending && hasSearched && (
<>
{results.length > 0 ? (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Found {results.length} result{results.length !== 1 ? 's' : ''}
</p>
<ResultsTable results={results} />
</div>
) : (
<EmptyState searchTerm={searchTerm} />
)}
</>
)}
{!hasSearched && !isPending && (
<EmptyState />
)}
</div>
</CardContent> </CardContent>
</Card> </Card>
{/* Seed Data Button - Development Helper */}
<div className="mt-4 flex justify-end">
<Button
onClick={handleSeedData}
disabled={isSeeding}
variant="outline"
size="sm"
className="gap-2"
>
{isSeeding ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
Seeding...
</>
) : (
<>
<Database className="h-4 w-4" />
Seed My Data
</>
)}
</Button>
</div>
</div> </div>
</main> </main>
); );

View File

@ -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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-[600px] sm:max-w-[600px] overflow-y-auto">
<SheetHeader>
<SheetTitle>{policy.settingName}</SheetTitle>
<SheetDescription>
Policy Setting Details
</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-6">
{/* Policy Name */}
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-1">
Policy Name
</h3>
<p className="text-sm">{policy.policyName}</p>
</div>
{/* Policy Type */}
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-1">
Policy Type
</h3>
<p className="text-sm capitalize">
{policy.policyType.replace(/([A-Z])/g, ' $1').trim()}
</p>
</div>
{/* Setting Name */}
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-1">
Setting Name
</h3>
<p className="text-sm font-mono">{policy.settingName}</p>
</div>
{/* Setting Value */}
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
Setting Value
</h3>
{isJson ? (
<pre className="text-xs bg-muted p-4 rounded-md overflow-x-auto max-h-96 overflow-y-auto">
<code>{displayValue}</code>
</pre>
) : (
<p className="text-sm whitespace-pre-wrap break-words">
{displayValue}
</p>
)}
</div>
{/* Last Synced */}
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-1">
Last Synced
</h3>
<p className="text-sm">
{formatDistanceToNow(new Date(policy.lastSyncedAt), {
addSuffix: true,
locale: de,
})}
</p>
<p className="text-xs text-muted-foreground mt-1">
{new Date(policy.lastSyncedAt).toLocaleString('de-DE')}
</p>
</div>
</div>
</SheetContent>
</Sheet>
);
}

View File

@ -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<PolicySettingSearchResult[]>(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 (
<div className="space-y-4">
<SearchInput
onSearch={handleSearch}
isSearching={isPending}
/>
{policies.length === 0 && hasSearched && (
<EmptyState />
)}
{policies.length === 0 && !hasSearched && initialPolicies.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<p>Keine Policies gefunden - Starten Sie einen Sync</p>
</div>
)}
{policies.length > 0 && (
<PolicyTable policies={policies} onRowClick={onPolicyClick} />
)}
</div>
);
}

View File

@ -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 (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Setting Name</TableHead>
<TableHead>Setting Value</TableHead>
<TableHead>Policy Name</TableHead>
<TableHead>Policy Type</TableHead>
<TableHead>Last Synced</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{policies.map((policy) => (
<TableRow
key={policy.id}
onClick={() => onRowClick(policy)}
className="cursor-pointer hover:bg-accent"
>
<TableCell className="font-medium">
{policy.settingName}
</TableCell>
<TableCell className="max-w-xs truncate">
{policy.settingValue}
</TableCell>
<TableCell>{policy.policyName}</TableCell>
<TableCell>
{(() => {
const badgeConfig = getPolicyBadgeConfig(policy.policyType);
return (
<Badge variant={badgeConfig.variant}>
{badgeConfig.label}
</Badge>
);
})()}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{formatDistanceToNow(new Date(policy.lastSyncedAt), {
addSuffix: true,
locale: de,
})}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}

View File

@ -17,7 +17,7 @@ export function EmptyState({ searchTerm }: EmptyStateProps) {
</p> </p>
) : ( ) : (
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Enter a search term to find policy settings No policy settings available. Trigger a sync to import policies from Intune.
</p> </p>
)} )}
</div> </div>

36
components/ui/badge.tsx Normal file
View File

@ -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<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

140
components/ui/sheet.tsx Normal file
View File

@ -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<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={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<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -8,8 +8,7 @@ type AdditionalLinks = {
export const defaultLinks: SidebarLink[] = [ export const defaultLinks: SidebarLink[] = [
{ href: "/dashboard", title: "Home", icon: HomeIcon }, { href: "/dashboard", title: "Home", icon: HomeIcon },
{ href: "/search", title: "Search", icon: Search }, { href: "/search", title: "Policy Explorer", icon: Search },
{ href: "/settings-overview", title: "All Settings", icon: Database },
{ href: "/account", title: "Account", icon: User }, { href: "/account", title: "Account", icon: User },
{ href: "/settings", title: "Settings", icon: Cog }, { href: "/settings", title: "Settings", icon: Cog },
]; ];

View File

@ -2,7 +2,7 @@
import { db, policySettings, type PolicySetting } from '@/lib/db'; import { db, policySettings, type PolicySetting } from '@/lib/db';
import { getUserAuth } from '@/lib/auth/utils'; 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'; import { env } from '@/lib/env.mjs';
export interface PolicySettingSearchResult { export interface PolicySettingSearchResult {
@ -49,10 +49,12 @@ export interface AllSettingsResult {
* 3. Including explicit WHERE tenantId = ? in ALL queries * 3. Including explicit WHERE tenantId = ? in ALL queries
* *
* @param searchTerm - Search query (min 2 characters) * @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 * @returns Search results filtered by user's tenant
*/ */
export async function searchPolicySettings( export async function searchPolicySettings(
searchTerm: string searchTerm: string,
limit: number = 100
): Promise<SearchResult> { ): Promise<SearchResult> {
try { try {
const { session } = await getUserAuth(); const { session } = await getUserAuth();
@ -76,7 +78,10 @@ export async function searchPolicySettings(
const sanitizedSearchTerm = searchTerm.slice(0, 200); const sanitizedSearchTerm = searchTerm.slice(0, 200);
const searchPattern = `%${sanitizedSearchTerm}%`; 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 const results = await db
.select({ .select({
id: policySettings.id, id: policySettings.id,
@ -90,6 +95,9 @@ export async function searchPolicySettings(
.where( .where(
and( and(
eq(policySettings.tenantId, tenantId), // CRITICAL: Tenant isolation 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( or(
ilike(policySettings.settingName, searchPattern), ilike(policySettings.settingName, searchPattern),
ilike(policySettings.settingValue, searchPattern) ilike(policySettings.settingValue, searchPattern)
@ -97,7 +105,7 @@ export async function searchPolicySettings(
) )
) )
.orderBy(policySettings.settingName) .orderBy(policySettings.settingName)
.limit(100); .limit(safeLimit);
return { return {
success: true, success: true,
@ -166,11 +174,11 @@ export async function getPolicySettingById(
* *
* **Security**: Enforces tenant isolation with explicit WHERE tenantId filter * **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 * @returns Recent policy settings for user's tenant
*/ */
export async function getRecentPolicySettings( export async function getRecentPolicySettings(
limit: number = 20 limit: number = 50
): Promise<RecentSettingsResult> { ): Promise<RecentSettingsResult> {
try { try {
const { session } = await getUserAuth(); const { session } = await getUserAuth();

56
lib/utils/policyBadges.ts Normal file
View File

@ -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(' ');
}

93
package-lock.json generated
View File

@ -12,6 +12,7 @@
"@auth/drizzle-adapter": "^1.11.1", "@auth/drizzle-adapter": "^1.11.1",
"@paralleldrive/cuid2": "^3.0.4", "@paralleldrive/cuid2": "^3.0.4",
"@radix-ui/react-avatar": "^1.1.11", "@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-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slot": "^1.2.4", "@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": { "node_modules/@radix-ui/react-direction": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",

View File

@ -22,6 +22,7 @@
"@auth/drizzle-adapter": "^1.11.1", "@auth/drizzle-adapter": "^1.11.1",
"@paralleldrive/cuid2": "^3.0.4", "@paralleldrive/cuid2": "^3.0.4",
"@radix-ui/react-avatar": "^1.1.11", "@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-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",

View File

@ -21,9 +21,9 @@
**Purpose**: Verify prerequisites and install missing Shadcn UI components **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` - [X] 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` - [X] 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] T003 Create new directory `components/policy-explorer/` for new feature components
--- ---
@ -35,11 +35,11 @@
### Backend Server Actions ### 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 - [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
- [ ] T005 Extend `searchPolicySettings()` Server Action in `lib/actions/policySettings.ts` - add optional `limit` parameter (default 100, max 200) - [X] 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) - [X] 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` - [X] 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] T008 Test Server Actions manually - verify tenant isolation, null filtering, and limit parameters work correctly
**Checkpoint**: Backend ready - all Server Actions functional and tested **Checkpoint**: Backend ready - all Server Actions functional and tested
@ -53,14 +53,14 @@
### Implementation for User Story 1 ### 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 - [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
- [ ] T010 [P] [US1] Add row click handler prop to `PolicyTable` component - accepts `onRowClick: (policy: PolicySettingSearchResult) => void` - [X] 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 - [X] 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 - [X] 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` - [X] 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` - [X] 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" - [X] 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] 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 **Checkpoint**: Page loads with 50 newest policies, no null values, empty state works
@ -74,15 +74,15 @@
### Implementation for User Story 2 ### 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 - [X] 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('[')` - [X] 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 - [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
- [ ] T020 [P] [US2] Render JSON values in `<pre>` tag with Tailwind prose classes for readability in `PolicyDetailSheet` - [X] T020 [P] [US2] Render JSON values in `<pre>` tag with Tailwind prose classes for readability in `PolicyDetailSheet`
- [ ] T021 [P] [US2] Render non-JSON values as normal text in `PolicyDetailSheet` - [X] 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`) - [X] 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 - [X] 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 - [X] 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] 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 **Checkpoint**: Clicking rows opens detail sheet, JSON is formatted, sheet closes properly
@ -96,12 +96,12 @@
### Implementation for User Story 3 ### Implementation for User Story 3
- [ ] T026 [US3] Integrate existing `SearchInput` component into `PolicySearchContainer` in `components/policy-explorer/PolicySearchContainer.tsx` - [X] 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 - [X] 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 - [X] 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 - [X] 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) - [X] 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] T031 [US3] Add loading indicator while search is in progress (use `isPending` from `useTransition`)
**Checkpoint**: Search works, results exclude null values, loading states correct **Checkpoint**: Search works, results exclude null values, loading states correct
@ -115,11 +115,11 @@
### Implementation for User Story 4 ### 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.) - [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.)
- [ ] T033 [P] [US4] Implement badge rendering in `PolicyTable` - replace plain text `policyType` with Shadcn Badge component - [X] 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 - [X] 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) - [X] 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] 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 **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" **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) - [X] 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) - [X] 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] 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) - [ ] 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 **Checkpoint**: Navigation shows single "Policy Explorer" entry, old routes redirect if needed
@ -142,7 +142,7 @@
**Purpose**: Final refinements and end-to-end validation **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 - [ ] 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) - [ ] 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 - [ ] T044 Add error boundary for detail sheet - graceful error handling if sheet rendering fails