diff --git a/components/policy-explorer/PolicyDetailSheet.tsx b/components/policy-explorer/PolicyDetailSheet.tsx index a43414a..52a1911 100644 --- a/components/policy-explorer/PolicyDetailSheet.tsx +++ b/components/policy-explorer/PolicyDetailSheet.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useState } from 'react'; import { Sheet, SheetContent, @@ -7,10 +8,15 @@ import { SheetHeader, SheetTitle, } from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import type { PolicySettingSearchResult } from '@/lib/actions/policySettings'; import { PolicyTypeBadge } from './PolicyTypeBadge'; import { formatDistanceToNow } from 'date-fns'; import { de } from 'date-fns/locale'; +import { Copy, ExternalLink, Check } from 'lucide-react'; +import { useCopyToClipboard } from '@/lib/hooks/useCopyToClipboard'; +import { getIntunePortalLink } from '@/lib/utils/policy-table-helpers'; interface PolicyDetailSheetProps { policy: PolicySettingSearchResult | null; @@ -38,6 +44,9 @@ export function PolicyDetailSheet({ open, onOpenChange, }: PolicyDetailSheetProps) { + const [activeTab, setActiveTab] = useState<'details' | 'raw'>('details'); + const { copy, isCopied } = useCopyToClipboard(); + if (!policy) return null; const isJson = isJsonString(policy.settingValue); @@ -45,22 +54,92 @@ export function PolicyDetailSheet({ ? formatJson(policy.settingValue) : policy.settingValue; + const intuneUrl = getIntunePortalLink(policy.policyType, policy.graphPolicyId); + + const handleCopyField = (value: string, fieldName: string) => { + copy(value, `${fieldName} copied to clipboard`); + }; + + const handleOpenInIntune = () => { + if (intuneUrl) { + window.open(intuneUrl, '_blank', 'noopener,noreferrer'); + } else { + // Fallback: copy policy ID + copy(policy.graphPolicyId, 'Policy ID copied to clipboard'); + } + }; + + // Generate raw JSON for the entire policy object + const rawJson = JSON.stringify(policy, null, 2); + return ( - {policy.settingName} + + {policy.settingName} + {intuneUrl && ( + + )} + Policy Setting Details -
+ {/* Tabs */} +
+ + +
+ + {/* Details Tab */} + {activeTab === 'details' && ( +
{/* Policy Name */}
-

- Policy Name -

+
+

+ Policy Name +

+ +

{policy.policyName}

@@ -74,17 +153,45 @@ export function PolicyDetailSheet({ {/* Setting Name */}
-

- Setting Name -

+
+

+ Setting Name +

+ +

{policy.settingName}

{/* Setting Value */}
-

- Setting Value -

+
+

+ Setting Value +

+ +
{isJson ? (
                 {displayValue}
@@ -96,6 +203,30 @@ export function PolicyDetailSheet({
             )}
           
+ {/* Graph Policy ID */} +
+
+

+ Graph Policy ID +

+ +
+

+ {policy.graphPolicyId} +

+
+ {/* Last Synced */}

@@ -111,7 +242,60 @@ export function PolicyDetailSheet({ {new Date(policy.lastSyncedAt).toLocaleString('de-DE')}

+ + {/* Open in Intune Button */} +
+ {intuneUrl ? ( + + ) : ( + + )} +
+ )} + + {/* Raw JSON Tab */} + {activeTab === 'raw' && ( +
+
+

Complete Policy Object

+ +
+
+              {rawJson}
+            
+
+ )} ); diff --git a/lib/hooks/useCopyToClipboard.ts b/lib/hooks/useCopyToClipboard.ts new file mode 100644 index 0000000..d301efb --- /dev/null +++ b/lib/hooks/useCopyToClipboard.ts @@ -0,0 +1,70 @@ +/** + * useCopyToClipboard Hook + * + * Wrapper for Clipboard API with success/error handling and toast notifications. + * + * Features: + * - Copy text to clipboard + * - Success/error state tracking + * - Automatic toast notifications + * - Fallback for older browsers + */ + +import { useState } from 'react'; +import { toast } from 'sonner'; + +interface CopyToClipboardResult { + copy: (text: string, successMessage?: string) => Promise; + isCopied: boolean; + error: Error | null; +} + +export function useCopyToClipboard(): CopyToClipboardResult { + const [isCopied, setIsCopied] = useState(false); + const [error, setError] = useState(null); + + const copy = async (text: string, successMessage: string = 'Copied to clipboard') => { + // Reset state + setIsCopied(false); + setError(null); + + try { + // Modern Clipboard API + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + } else { + // Fallback for older browsers or non-secure contexts + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + const successful = document.execCommand('copy'); + textArea.remove(); + + if (!successful) { + throw new Error('Copy command failed'); + } + } + + setIsCopied(true); + toast.success(successMessage); + + // Reset isCopied after 2 seconds + setTimeout(() => { + setIsCopied(false); + }, 2000); + } catch (err) { + const copyError = err instanceof Error ? err : new Error('Failed to copy'); + setError(copyError); + toast.error('Failed to copy to clipboard'); + console.error('Copy error:', copyError); + } + }; + + return { copy, isCopied, error }; +} diff --git a/lib/utils/policy-table-helpers.ts b/lib/utils/policy-table-helpers.ts new file mode 100644 index 0000000..d4ffc0b --- /dev/null +++ b/lib/utils/policy-table-helpers.ts @@ -0,0 +1,115 @@ +/** + * Policy Table Helper Functions + * + * Utilities for formatting, sorting, and generating links for policy data. + */ + +import type { PolicySettingRow } from '@/lib/types/policy-table'; + +/** + * Generate Intune Portal URL for a policy based on its type and ID + * @param policyType The type of policy (e.g., 'deviceConfiguration') + * @param graphPolicyId The Microsoft Graph policy ID + * @returns Intune Portal URL or null if URL construction fails + */ +export function getIntunePortalLink( + policyType: string, + graphPolicyId: string +): string | null { + if (!graphPolicyId) { + return null; + } + + const baseUrl = 'https://intune.microsoft.com'; + + // Map policy types to Intune portal paths + const policyTypeUrls: Record = { + // Device Configuration + deviceConfiguration: `${baseUrl}/#view/Microsoft_Intune_DeviceSettings/DeviceConfigProfilesMenu/~/properties/policyId/${graphPolicyId}`, + + // Compliance Policies + compliancePolicy: `${baseUrl}/#view/Microsoft_Intune_DeviceSettings/DeviceCompliancePoliciesMenu/~/properties/policyId/${graphPolicyId}`, + + // Device Management Scripts + deviceManagementScript: `${baseUrl}/#view/Microsoft_Intune_DeviceSettings/DeviceManagementScriptsMenu/~/properties/scriptId/${graphPolicyId}`, + + // Windows Update for Business + windowsUpdateForBusiness: `${baseUrl}/#view/Microsoft_Intune_DeviceSettings/UpdateRingsMenu/~/properties/policyId/${graphPolicyId}`, + + // iOS Update Configuration + iosUpdateConfiguration: `${baseUrl}/#view/Microsoft_Intune_DeviceSettings/iOSUpdateConfigurationsMenu/~/properties/policyId/${graphPolicyId}`, + + // Settings Catalog + settingsCatalog: `${baseUrl}/#view/Microsoft_Intune_DeviceSettings/ConfigurationPolicyMenu/~/properties/policyId/${graphPolicyId}`, + + // Endpoint Protection + endpointProtection: `${baseUrl}/#view/Microsoft_Intune_Workflows/SecurityBaselinesSummaryMenu/~/properties/templateId/${graphPolicyId}`, + + // macOS Extensions + macOSExtensionsConfiguration: `${baseUrl}/#view/Microsoft_Intune_DeviceSettings/DeviceConfigProfilesMenu/~/properties/policyId/${graphPolicyId}`, + }; + + // Check if we have a known URL pattern + if (policyType in policyTypeUrls) { + return policyTypeUrls[policyType]; + } + + // Fallback: Generic device settings URL + return `${baseUrl}/#view/Microsoft_Intune_DeviceSettings/DeviceSettingsMenu`; +} + +/** + * Format policy type for display (convert camelCase to Title Case) + * @param policyType Policy type in camelCase (e.g., 'deviceConfiguration') + * @returns Formatted title (e.g., 'Device Configuration') + */ +export function formatPolicyType(policyType: string): string { + // Insert spaces before capital letters + const withSpaces = policyType.replace(/([A-Z])/g, ' $1'); + + // Capitalize first letter and trim + return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1).trim(); +} + +/** + * Truncate long text with ellipsis + * @param text Text to truncate + * @param maxLength Maximum length before truncation + * @returns Truncated text with ellipsis if needed + */ +export function truncateText(text: string, maxLength: number = 100): string { + if (text.length <= maxLength) { + return text; + } + return text.substring(0, maxLength) + '...'; +} + +/** + * Format date for display (relative or absolute) + * @param date Date to format + * @param relative Whether to show relative time (e.g., "2 hours ago") + * @returns Formatted date string + */ +export function formatDate(date: Date | string, relative: boolean = false): string { + const dateObj = typeof date === 'string' ? new Date(date) : date; + + if (relative) { + const now = new Date(); + const diffMs = now.getTime() - dateObj.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins} min${diffMins > 1 ? 's' : ''} ago`; + + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; + + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; + + const diffWeeks = Math.floor(diffDays / 7); + return `${diffWeeks} week${diffWeeks > 1 ? 's' : ''} ago`; + } + + return dateObj.toLocaleString(); +}