Files
dockhand/routes/settings/auth/ldap/LdapModal.svelte
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

509 lines
17 KiB
Svelte

<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>