Initial commit

This commit is contained in:
Jarek Krochmalski
2025-12-28 21:16:03 +01:00
commit 62e3c6439e
552 changed files with 104858 additions and 0 deletions

View File

@@ -0,0 +1,508 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as Tabs from '$lib/components/ui/tabs';
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Select from '$lib/components/ui/select';
import { Network, Pencil, Plus, Check, RefreshCw, TriangleAlert, Trash2, Star } from 'lucide-svelte';
import * as Alert from '$lib/components/ui/alert';
import { focusFirstInput } from '$lib/utils';
export interface LdapRoleMapping {
groupDn: string;
roleId: number;
}
export interface LdapConfig {
id: number;
name: string;
enabled: boolean;
serverUrl: string;
bindDn?: string;
bindPassword?: string;
baseDn: string;
userFilter: string;
usernameAttribute: string;
emailAttribute: string;
displayNameAttribute: string;
groupBaseDn?: string;
groupFilter?: string;
adminGroup?: string;
roleMappings?: LdapRoleMapping[];
tlsEnabled: boolean;
tlsCa?: string;
}
interface Role {
id: number;
name: string;
isSystem: boolean;
}
interface Props {
open: boolean;
ldap?: LdapConfig | null;
roles?: Role[];
isEnterprise?: boolean;
onClose: () => void;
onSaved: () => void;
}
let { open = $bindable(), ldap = null, roles = [], isEnterprise = false, onClose, onSaved }: Props = $props();
const isEditing = $derived(ldap !== null);
// Form state
let formName = $state('');
let formEnabled = $state(false);
let formServerUrl = $state('');
let formBindDn = $state('');
let formBindPassword = $state('');
let formBaseDn = $state('');
let formUserFilter = $state('(uid={{username}})');
let formUsernameAttr = $state('uid');
let formEmailAttr = $state('mail');
let formDisplayNameAttr = $state('cn');
let formGroupBaseDn = $state('');
let formGroupFilter = $state('');
let formAdminGroup = $state('');
let formRoleMappings = $state<LdapRoleMapping[]>([]);
let formTlsEnabled = $state(false);
let formTlsCa = $state('');
let formError = $state('');
let formErrors = $state<{ name?: string; serverUrl?: string; baseDn?: string }>({});
let formSaving = $state(false);
let formModalTab = $state<'connection' | 'groups'>('connection');
// Role mapping helpers
function addRoleMapping() {
formRoleMappings = [...formRoleMappings, { groupDn: '', roleId: 0 }];
}
function removeRoleMapping(index: number) {
formRoleMappings = formRoleMappings.filter((_, i) => i !== index);
}
function updateRoleMappingRole(index: number, roleId: number) {
formRoleMappings = formRoleMappings.map((mapping, i) =>
i === index ? { ...mapping, roleId } : mapping
);
}
function updateRoleMappingGroupDn(index: number, groupDn: string) {
formRoleMappings = formRoleMappings.map((mapping, i) =>
i === index ? { ...mapping, groupDn } : mapping
);
}
function resetForm() {
formName = '';
formEnabled = false;
formServerUrl = '';
formBindDn = '';
formBindPassword = '';
formBaseDn = '';
formUserFilter = '(uid={{username}})';
formUsernameAttr = 'uid';
formEmailAttr = 'mail';
formDisplayNameAttr = 'cn';
formGroupBaseDn = '';
formGroupFilter = '';
formAdminGroup = '';
formRoleMappings = [];
formTlsEnabled = false;
formTlsCa = '';
formError = '';
formErrors = {};
formSaving = false;
formModalTab = 'connection';
}
// Initialize form when ldap changes or modal opens
$effect(() => {
if (open) {
if (ldap) {
formName = ldap.name;
formEnabled = ldap.enabled;
formServerUrl = ldap.serverUrl;
formBindDn = ldap.bindDn || '';
formBindPassword = ldap.bindPassword || '';
formBaseDn = ldap.baseDn;
formUserFilter = ldap.userFilter || '(uid={{username}})';
formUsernameAttr = ldap.usernameAttribute || 'uid';
formEmailAttr = ldap.emailAttribute || 'mail';
formDisplayNameAttr = ldap.displayNameAttribute || 'cn';
formGroupBaseDn = ldap.groupBaseDn || '';
formGroupFilter = ldap.groupFilter || '';
formAdminGroup = ldap.adminGroup || '';
formRoleMappings = ldap.roleMappings || [];
formTlsEnabled = ldap.tlsEnabled || false;
formTlsCa = ldap.tlsCa || '';
formError = '';
formErrors = {};
formSaving = false;
formModalTab = 'connection';
} else {
resetForm();
}
}
});
async function save() {
formErrors = {};
let hasErrors = false;
if (!formName.trim()) {
formErrors.name = 'Name is required';
hasErrors = true;
}
if (!formServerUrl.trim()) {
formErrors.serverUrl = 'Server URL is required';
hasErrors = true;
}
if (!formBaseDn.trim()) {
formErrors.baseDn = 'Base DN is required';
hasErrors = true;
}
if (hasErrors) return;
formSaving = true;
formError = '';
try {
const url = isEditing ? `/api/auth/ldap/${ldap!.id}` : '/api/auth/ldap';
const method = isEditing ? 'PUT' : 'POST';
// Filter valid role mappings (both groupDn and roleId must be set)
const validRoleMappings = formRoleMappings.filter(m => m.groupDn.trim() && m.roleId);
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formName.trim(),
enabled: formEnabled,
serverUrl: formServerUrl.trim(),
bindDn: formBindDn.trim() || undefined,
bindPassword: formBindPassword || undefined,
baseDn: formBaseDn.trim(),
userFilter: formUserFilter.trim() || '(uid={{username}})',
usernameAttribute: formUsernameAttr.trim() || 'uid',
emailAttribute: formEmailAttr.trim() || 'mail',
displayNameAttribute: formDisplayNameAttr.trim() || 'cn',
groupBaseDn: formGroupBaseDn.trim() || undefined,
groupFilter: formGroupFilter.trim() || undefined,
adminGroup: formAdminGroup.trim() || undefined,
roleMappings: validRoleMappings.length > 0 ? validRoleMappings : undefined,
tlsEnabled: formTlsEnabled,
tlsCa: formTlsCa.trim() || undefined
})
});
if (response.ok) {
open = false;
onSaved();
} else {
const data = await response.json();
formError = data.error || `Failed to ${isEditing ? 'update' : 'create'} LDAP configuration`;
}
} catch {
formError = `Failed to ${isEditing ? 'update' : 'create'} LDAP configuration`;
} finally {
formSaving = false;
}
}
function handleClose() {
open = false;
onClose();
}
</script>
<Dialog.Root bind:open onOpenChange={(o) => { if (o) { formError = ''; formErrors = {}; formModalTab = 'connection'; focusFirstInput(); } }}>
<Dialog.Content class="max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
<Dialog.Header class="flex-shrink-0">
<Dialog.Title class="flex items-center gap-2">
{#if isEditing}
<Pencil class="w-5 h-5" />
Edit LDAP configuration
{:else}
<Network class="w-5 h-5" />
Add LDAP configuration
{/if}
</Dialog.Title>
</Dialog.Header>
<div class="flex-1 overflow-y-auto py-2">
{#if formError}
<Alert.Root variant="destructive" class="mb-4">
<TriangleAlert class="h-4 w-4" />
<Alert.Description>{formError}</Alert.Description>
</Alert.Root>
{/if}
<Tabs.Root bind:value={formModalTab}>
<Tabs.List class="grid w-full grid-cols-2 mb-4">
<Tabs.Trigger value="connection">Connection</Tabs.Trigger>
<Tabs.Trigger value="groups">Group settings</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="connection" class="space-y-4">
<!-- Basic Settings -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-muted-foreground">Basic settings</h4>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label>Name <span class="text-destructive">*</span></Label>
<Input
bind:value={formName}
placeholder="Corporate LDAP"
class={formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''}
oninput={() => formErrors.name = undefined}
/>
{#if formErrors.name}
<p class="text-xs text-destructive">{formErrors.name}</p>
{/if}
</div>
<div class="space-y-2">
<Label>Server URL <span class="text-destructive">*</span></Label>
<Input
bind:value={formServerUrl}
placeholder="ldap://ldap.example.com:389"
class={formErrors.serverUrl ? 'border-destructive focus-visible:ring-destructive' : ''}
oninput={() => formErrors.serverUrl = undefined}
/>
{#if formErrors.serverUrl}
<p class="text-xs text-destructive">{formErrors.serverUrl}</p>
{/if}
</div>
</div>
<div class="flex items-center gap-2">
<Checkbox
checked={formEnabled}
onCheckedChange={(checked) => formEnabled = checked === true}
/>
<Label class="text-sm font-normal cursor-pointer" onclick={() => formEnabled = !formEnabled}>
Enable this LDAP configuration
</Label>
</div>
</div>
<!-- Bind Credentials -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-muted-foreground">Bind credentials (optional)</h4>
<p class="text-xs text-muted-foreground">Service account used to search for users. Leave empty for anonymous bind.</p>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label>Bind DN</Label>
<Input
bind:value={formBindDn}
placeholder="cn=admin,dc=example,dc=com"
/>
</div>
<div class="space-y-2">
<Label>Bind password</Label>
<Input
type="password"
bind:value={formBindPassword}
placeholder={isEditing ? 'Leave blank to keep existing' : 'Enter password'}
/>
</div>
</div>
</div>
<!-- Search Settings -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-muted-foreground">User search settings</h4>
<div class="space-y-2">
<Label>Base DN <span class="text-destructive">*</span></Label>
<Input
bind:value={formBaseDn}
placeholder="dc=example,dc=com"
class={formErrors.baseDn ? 'border-destructive focus-visible:ring-destructive' : ''}
oninput={() => formErrors.baseDn = undefined}
/>
{#if formErrors.baseDn}
<p class="text-xs text-destructive">{formErrors.baseDn}</p>
{:else}
<p class="text-xs text-muted-foreground">The base DN to search for users.</p>
{/if}
</div>
<div class="space-y-2">
<Label>User filter</Label>
<Input
bind:value={formUserFilter}
placeholder={`(uid={{username}})`}
/>
<p class="text-xs text-muted-foreground">
LDAP filter to find users. Use <code class="text-xs bg-muted px-1 rounded">{`{{username}}`}</code> as placeholder.<br />
<span class="text-muted-foreground/70">OpenLDAP: <code class="text-xs bg-muted px-1 rounded">(uid={`{{username}}`})</code> &bull; AD: <code class="text-xs bg-muted px-1 rounded">(sAMAccountName={`{{username}}`})</code></span>
</p>
</div>
</div>
<!-- Attribute Mapping -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-muted-foreground">Attribute mapping</h4>
<div class="grid grid-cols-3 gap-4">
<div class="space-y-2">
<Label>Username attribute</Label>
<Input
bind:value={formUsernameAttr}
placeholder="uid"
/>
</div>
<div class="space-y-2">
<Label>Email attribute</Label>
<Input
bind:value={formEmailAttr}
placeholder="mail"
/>
</div>
<div class="space-y-2">
<Label>Display name attribute</Label>
<Input
bind:value={formDisplayNameAttr}
placeholder="cn"
/>
</div>
</div>
</div>
<!-- TLS Settings -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-muted-foreground">TLS settings</h4>
<div class="flex items-center gap-2">
<Checkbox
checked={formTlsEnabled}
onCheckedChange={(checked) => formTlsEnabled = checked === true}
/>
<Label class="text-sm font-normal cursor-pointer" onclick={() => formTlsEnabled = !formTlsEnabled}>
Enable TLS (LDAPS or StartTLS)
</Label>
</div>
{#if formTlsEnabled}
<div class="space-y-2">
<Label>CA certificate (optional)</Label>
<textarea
bind:value={formTlsCa}
class="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
></textarea>
</div>
{/if}
</div>
</Tabs.Content>
<Tabs.Content value="groups" class="space-y-4">
<!-- Group Settings -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-muted-foreground">Group settings</h4>
<p class="text-xs text-muted-foreground">Configure group-based access control. These settings are optional.</p>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label>Group base DN</Label>
<Input
bind:value={formGroupBaseDn}
placeholder="ou=groups,dc=example,dc=com"
/>
<p class="text-xs text-muted-foreground">The base DN to search for groups.</p>
</div>
<div class="space-y-2">
<Label>Admin group</Label>
<Input
bind:value={formAdminGroup}
placeholder="cn=admins,ou=groups,dc=example,dc=com"
/>
<p class="text-xs text-muted-foreground">Members of this group will be admins.</p>
</div>
</div>
<div class="space-y-2">
<Label>Member filter</Label>
<Input
bind:value={formGroupFilter}
placeholder={'(&(objectClass=groupOfNames)(member={{user_dn}}))'}
/>
<p class="text-xs text-muted-foreground">
Filter to find groups the user belongs to. Use <code class="text-xs bg-muted px-1 rounded">{'{{user_dn}}'}</code> as placeholder.
</p>
</div>
</div>
<!-- Role Mappings (Enterprise) -->
{#if isEnterprise}
<div class="space-y-4">
<div class="flex items-center gap-2">
<h4 class="text-sm font-medium text-muted-foreground">Group to role mappings</h4>
<Star class="w-3.5 h-3.5 text-amber-500" />
</div>
<p class="text-xs text-muted-foreground">Map LDAP groups to Dockhand roles. Users in these groups will be assigned the corresponding role.</p>
{#if formRoleMappings.length > 0}
<div class="space-y-2">
{#each formRoleMappings as mapping, index}
<div class="flex items-center gap-2">
<Input
value={mapping.groupDn}
placeholder="cn=developers,ou=groups,dc=example,dc=com"
class="flex-1"
oninput={(e) => updateRoleMappingGroupDn(index, e.currentTarget.value)}
/>
<Select.Root
type="single"
value={mapping.roleId ? String(mapping.roleId) : undefined}
onValueChange={(value) => updateRoleMappingRole(index, parseInt(value))}
>
<Select.Trigger class="w-40">
{#if mapping.roleId}
{@const role = roles.find(r => r.id === mapping.roleId)}
{role?.name || 'Select role'}
{:else}
Select role
{/if}
</Select.Trigger>
<Select.Content>
{#each roles.filter(r => !r.isSystem || r.name !== 'Admin') as role}
<Select.Item value={String(role.id)}>{role.name}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Button
variant="ghost"
size="icon"
class="h-9 w-9 text-muted-foreground hover:text-destructive"
onclick={() => removeRoleMapping(index)}
>
<Trash2 class="w-4 h-4" />
</Button>
</div>
{/each}
</div>
{/if}
<Button variant="outline" size="sm" onclick={addRoleMapping}>
<Plus class="w-4 h-4 mr-1" />
Add mapping
</Button>
</div>
{/if}
</Tabs.Content>
</Tabs.Root>
</div>
<Dialog.Footer class="flex-shrink-0 border-t pt-4">
<Button variant="outline" onclick={handleClose}>Cancel</Button>
<Button onclick={save} disabled={formSaving}>
{#if formSaving}
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
{:else if isEditing}
<Check class="w-4 h-4 mr-1" />
{:else}
<Plus class="w-4 h-4 mr-1" />
{/if}
{isEditing ? 'Save' : 'Add configuration'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,320 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import {
Network,
Plus,
Pencil,
Trash2,
RefreshCw,
Crown,
Key
} from 'lucide-svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { canAccess } from '$lib/stores/auth';
import { licenseStore } from '$lib/stores/license';
import LdapModal from './LdapModal.svelte';
import { EmptyState } from '$lib/components/ui/empty-state';
interface LdapRoleMapping {
groupDn: string;
roleId: number;
}
interface LdapConfig {
id: number;
name: string;
enabled: boolean;
serverUrl: string;
bindDn?: string;
bindPassword?: string;
baseDn: string;
userFilter: string;
usernameAttribute: string;
emailAttribute: string;
displayNameAttribute: string;
groupBaseDn?: string;
groupFilter?: string;
adminGroup?: string;
roleMappings?: LdapRoleMapping[];
tlsEnabled: boolean;
tlsCa?: string;
}
interface Role {
id: number;
name: string;
isSystem: boolean;
}
interface Props {
onTabChange?: (tab: string) => void;
}
let { onTabChange = () => {} }: Props = $props();
// LDAP state
let ldapConfigs = $state<LdapConfig[]>([]);
let ldapLoading = $state(true);
let showLdapModal = $state(false);
let editingLdap = $state<LdapConfig | null>(null);
let confirmDeleteLdapId = $state<number | null>(null);
let ldapTesting = $state<number | null>(null);
let ldapTestResult = $state<{ success: boolean; error?: string; userCount?: number } | null>(null);
let roles = $state<Role[]>([]);
async function fetchLdapConfigs() {
ldapLoading = true;
try {
const response = await fetch('/api/auth/ldap');
if (response.ok) {
ldapConfigs = await response.json();
}
} catch (error) {
console.error('Failed to fetch LDAP configs:', error);
toast.error('Failed to fetch LDAP configurations');
} finally {
ldapLoading = false;
}
}
async function fetchRoles() {
try {
const response = await fetch('/api/roles');
if (response.ok) {
roles = await response.json();
}
} catch (error) {
console.error('Failed to fetch roles:', error);
}
}
function openLdapModal(config: LdapConfig | null) {
editingLdap = config;
showLdapModal = true;
}
function handleLdapModalClose() {
showLdapModal = false;
editingLdap = null;
}
async function handleLdapModalSaved() {
showLdapModal = false;
editingLdap = null;
await fetchLdapConfigs();
}
async function deleteLdapConfig(configId: number) {
try {
const response = await fetch(`/api/auth/ldap/${configId}`, { method: 'DELETE' });
if (response.ok) {
await fetchLdapConfigs();
toast.success('LDAP configuration deleted');
} else {
toast.error('Failed to delete LDAP configuration');
}
} catch (error) {
console.error('Failed to delete LDAP config:', error);
toast.error('Failed to delete LDAP configuration');
} finally {
confirmDeleteLdapId = null;
}
}
async function testLdapConnection(configId: number) {
ldapTesting = configId;
ldapTestResult = null;
try {
const response = await fetch(`/api/auth/ldap/${configId}/test`, { method: 'POST' });
const data = await response.json();
ldapTestResult = data;
if (data.success) {
toast.success(`LDAP connection successful - found ${data.userCount} users`);
} else {
toast.error(`LDAP connection failed: ${data.error}`);
}
} catch (error) {
ldapTestResult = { success: false, error: 'Failed to test connection' };
toast.error('Failed to test LDAP connection');
} finally {
ldapTesting = null;
}
}
async function toggleLdapEnabled(config: LdapConfig) {
try {
const response = await fetch(`/api/auth/ldap/${config.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...config, enabled: !config.enabled })
});
if (response.ok) {
await fetchLdapConfigs();
toast.success(`LDAP ${config.enabled ? 'disabled' : 'enabled'}`);
} else {
toast.error('Failed to toggle LDAP configuration');
}
} catch (error) {
console.error('Failed to toggle LDAP config:', error);
toast.error('Failed to toggle LDAP configuration');
}
}
// Fetch data when license is confirmed as enterprise
let hasFetched = $state(false);
$effect(() => {
if ($licenseStore.isEnterprise && !$licenseStore.loading && !hasFetched) {
hasFetched = true;
fetchLdapConfigs();
fetchRoles();
}
});
</script>
{#if $licenseStore.loading}
<Card.Root>
<Card.Content class="py-12">
<div class="flex justify-center">
<RefreshCw class="w-6 h-6 animate-spin text-muted-foreground" />
</div>
</Card.Content>
</Card.Root>
{:else if !$licenseStore.isEnterprise}
<Card.Root>
<Card.Content class="py-12">
<div class="text-center">
<h3 class="text-lg font-medium mb-2 flex items-center justify-center gap-2">
<Crown class="w-5 h-5 text-amber-500" />
Enterprise feature
</h3>
<p class="text-sm text-muted-foreground mb-4 max-w-md mx-auto">
LDAP / Active Directory integration is available with an enterprise license. Connect to your organization's directory services for centralized authentication.
</p>
<Button onclick={() => onTabChange('license')}>
<Key class="w-4 h-4 mr-2" />
Activate license
</Button>
</div>
</Card.Content>
</Card.Root>
{:else}
<div class="space-y-4">
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title class="text-sm font-medium flex items-center gap-2">
<Network class="w-4 h-4" />
LDAP configurations
</Card.Title>
<p class="text-xs text-muted-foreground mt-1">Connect to LDAP or Active Directory servers for centralized user authentication.</p>
</div>
{#if $canAccess('settings', 'edit')}
<Button size="sm" onclick={() => openLdapModal(null)}>
<Plus class="w-4 h-4 mr-1" />
Add LDAP
</Button>
{/if}
</div>
</Card.Header>
<Card.Content>
{#if ldapLoading}
<div class="flex justify-center py-8">
<RefreshCw class="w-6 h-6 animate-spin text-muted-foreground" />
</div>
{:else if ldapConfigs.length === 0}
<EmptyState
icon={Network}
title="No LDAP providers configured"
description="Click 'Add LDAP' to configure a new LDAP server"
class="py-8"
/>
{:else}
<div class="space-y-3">
{#each ldapConfigs as config}
<div class="flex items-center justify-between p-3 border rounded-lg">
<div class="flex items-center gap-3">
<Network class="w-5 h-5 text-muted-foreground" />
<div>
<div class="flex items-center gap-2">
<span class="font-medium">{config.name}</span>
{#if config.enabled}
<Badge variant="default" class="text-xs">Enabled</Badge>
{:else}
<Badge variant="secondary" class="text-xs">Disabled</Badge>
{/if}
</div>
<p class="text-xs text-muted-foreground">{config.serverUrl}</p>
</div>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onclick={() => testLdapConnection(config.id)}
disabled={ldapTesting === config.id}
>
{#if ldapTesting === config.id}
<RefreshCw class="w-4 h-4 animate-spin" />
{:else}
Test
{/if}
</Button>
{#if $canAccess('settings', 'edit')}
<Button
variant="outline"
size="sm"
onclick={() => toggleLdapEnabled(config)}
>
{config.enabled ? 'Disable' : 'Enable'}
</Button>
<Button
variant="outline"
size="sm"
onclick={() => openLdapModal(config)}
>
<Pencil class="w-4 h-4" />
</Button>
<ConfirmPopover
open={confirmDeleteLdapId === config.id}
action="Delete"
itemType="LDAP config"
itemName={config.name}
onConfirm={() => deleteLdapConfig(config.id)}
onOpenChange={(open) => confirmDeleteLdapId = open ? config.id : null}
>
{#snippet children({ open })}
<Trash2 class="w-4 h-4 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
{/if}
</div>
</div>
{/each}
</div>
{#if ldapTestResult}
<div class="mt-4 p-3 rounded-lg {ldapTestResult.success ? 'bg-green-500/10 text-green-600' : 'bg-destructive/10 text-destructive'}">
{#if ldapTestResult.success}
<p class="text-sm">Connection successful! Found {ldapTestResult.userCount} users.</p>
{:else}
<p class="text-sm">Connection failed: {ldapTestResult.error}</p>
{/if}
</div>
{/if}
{/if}
</Card.Content>
</Card.Root>
</div>
{/if}
<LdapModal
bind:open={showLdapModal}
ldap={editingLdap}
{roles}
isEnterprise={$licenseStore.isEnterprise}
onClose={handleLdapModalClose}
onSaved={handleLdapModalSaved}
/>