Files
bambuddy/frontend/src/components/LDAPSettings.tsx
T
maziggy 180db8e8ad Expand Settings search with module-level registry
Search field at the top of Settings now finds Sidebar Links,
  Spoolman, Spool/Color Catalog, all four Failure Detection
  sections, Email auth (Advanced + SMTP test), 2FA (TOTP, Email
  OTP, Linked Accounts), SSO/OIDC, LDAP Server Config, and the
  four Backup sub-cards (GitHub, History, Local, Scheduled).

  Replaces the hardcoded searchIndex array in SettingsPage.tsx
  with a module-level registry (frontend/src/lib/settingsSearch.ts).
  Each settings card calls registerSettingsSearch(...) at module
  scope, so adding a new card means adding one colocated line
  instead of editing a distant central array. Anchor ids were
  added to the corresponding Card elements in the affected
  components so scrollIntoView lands on the right section.
2026-04-13 16:13:04 +02:00

454 lines
18 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Shield, Lock, Unlock, AlertTriangle, CheckCircle, Loader2, Send } from 'lucide-react';
import { api } from '../api/client';
import type { AppSettings } from '../api/client';
import { Card, CardContent, CardHeader } from './Card';
import { Button } from './Button';
import { Collapsible } from './Collapsible';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
const SECURITY_PORT_MAP: Record<string, string> = {
starttls: '389',
ldaps: '636',
};
interface LDAPFormState {
ldap_server_url: string;
ldap_bind_dn: string;
ldap_bind_password: string;
ldap_search_base: string;
ldap_user_filter: string;
ldap_security: string;
ldap_group_mapping: string;
ldap_auto_provision: boolean;
ldap_default_group: string;
}
export function LDAPSettings() {
const { t } = useTranslation();
const { showToast } = useToast();
const queryClient = useQueryClient();
const { authEnabled } = useAuth();
const [form, setForm] = useState<LDAPFormState>({
ldap_server_url: '',
ldap_bind_dn: '',
ldap_bind_password: '',
ldap_search_base: '',
ldap_user_filter: '(sAMAccountName={username})',
ldap_security: 'starttls',
ldap_group_mapping: '',
ldap_auto_provision: false,
ldap_default_group: '',
});
// Fetch settings
const { data: settings, isLoading } = useQuery({
queryKey: ['settings'],
queryFn: () => api.getSettings(),
});
// Fetch LDAP status
const { data: ldapStatus } = useQuery({
queryKey: ['ldapStatus'],
queryFn: () => api.getLDAPStatus(),
});
// Fetch groups for mapping display
const { data: groups = [] } = useQuery({
queryKey: ['groups'],
queryFn: () => api.getGroups(),
});
// Load settings into form
useEffect(() => {
if (settings) {
setForm({
ldap_server_url: settings.ldap_server_url || '',
ldap_bind_dn: settings.ldap_bind_dn || '',
ldap_bind_password: '', // Never show password
ldap_search_base: settings.ldap_search_base || '',
ldap_user_filter: settings.ldap_user_filter || '(sAMAccountName={username})',
ldap_security: settings.ldap_security || 'starttls',
ldap_group_mapping: settings.ldap_group_mapping || '',
ldap_auto_provision: settings.ldap_auto_provision ?? false,
ldap_default_group: settings.ldap_default_group || '',
});
}
}, [settings]);
// Save settings
const saveMutation = useMutation({
mutationFn: (data: Partial<AppSettings>) => api.updateSettings(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] });
queryClient.invalidateQueries({ queryKey: ['ldapStatus'] });
showToast(t('settings.ldap.settingsSaved') || 'LDAP settings saved', 'success');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
// Toggle LDAP
const toggleMutation = useMutation({
mutationFn: (enabled: boolean) => api.updateSettings({ ldap_enabled: enabled }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] });
queryClient.invalidateQueries({ queryKey: ['ldapStatus'] });
showToast(
ldapStatus?.ldap_enabled
? (t('settings.ldap.disabled') || 'LDAP authentication disabled')
: (t('settings.ldap.enabled') || 'LDAP authentication enabled'),
'success'
);
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
// Test connection
const testMutation = useMutation({
mutationFn: () => api.testLDAP(),
onSuccess: (data: { success: boolean; message: string }) => {
showToast(data.message, data.success ? 'success' : 'error');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const handleSave = () => {
if (!form.ldap_server_url) {
showToast(t('settings.ldap.errors.serverRequired') || 'LDAP server URL is required', 'error');
return;
}
if (!form.ldap_search_base) {
showToast(t('settings.ldap.errors.searchBaseRequired') || 'Search base DN is required', 'error');
return;
}
// Build the update payload — only include password if user entered one
const update: Record<string, unknown> = {
ldap_server_url: form.ldap_server_url,
ldap_bind_dn: form.ldap_bind_dn,
ldap_search_base: form.ldap_search_base,
ldap_user_filter: form.ldap_user_filter,
ldap_security: form.ldap_security,
ldap_group_mapping: form.ldap_group_mapping,
ldap_auto_provision: form.ldap_auto_provision,
ldap_default_group: form.ldap_default_group,
};
if (form.ldap_bind_password) {
update.ldap_bind_password = form.ldap_bind_password;
}
saveMutation.mutate(update as Partial<AppSettings>);
};
const handleToggle = () => {
if (!authEnabled) {
showToast(t('settings.ldap.errors.enableAuthFirst') || 'Enable authentication first', 'error');
return;
}
if (!ldapStatus?.ldap_enabled && !ldapStatus?.ldap_configured) {
showToast(t('settings.ldap.errors.configureLdapFirst') || 'Save LDAP settings first', 'error');
return;
}
toggleMutation.mutate(!ldapStatus?.ldap_enabled);
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-12">
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
</div>
);
}
const ldapEnabled = ldapStatus?.ldap_enabled ?? false;
const inputClasses = "w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors";
return (
<div className="space-y-3">
{/* LDAP Toggle */}
<Card id="card-ldap-toggle">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">
{t('settings.ldap.title') || 'LDAP Authentication'}
</h2>
</div>
<Button
onClick={handleToggle}
disabled={toggleMutation.isPending}
variant={ldapEnabled ? 'danger' : 'primary'}
>
{ldapEnabled ? (
<>
<Unlock className="w-4 h-4" />
{t('common.disable') || 'Disable'}
</>
) : (
<>
<Lock className="w-4 h-4" />
{t('common.enable') || 'Enable'}
</>
)}
</Button>
</div>
</CardHeader>
<CardContent>
{ldapEnabled ? (
<div className="bg-green-500/10 border border-green-500/30 rounded-lg p-4">
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-400 mt-0.5 flex-shrink-0" />
<div className="space-y-2">
<p className="text-white font-medium">
{t('settings.ldap.enabledDesc') || 'LDAP authentication is enabled'}
</p>
<ul className="text-sm text-green-300 space-y-1 list-disc list-inside">
<li>{t('settings.ldap.feature1') || 'Users can login with LDAP credentials'}</li>
<li>{t('settings.ldap.feature2') || 'Local admin account remains as fallback'}</li>
<li>{t('settings.ldap.feature3') || 'LDAP groups are mapped to BamBuddy groups on login'}</li>
</ul>
</div>
</div>
</div>
) : (
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-yellow-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-white font-medium">
{t('settings.ldap.disabledDesc') || 'LDAP authentication is disabled'}
</p>
<p className="text-sm text-yellow-300 mt-1">
{t('settings.ldap.disabledHint') || 'Configure and save LDAP settings below, then enable.'}
</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* LDAP Server Configuration */}
<Card id="card-ldap-server">
<CardHeader>
<h2 className="text-lg font-semibold text-white">
{t('settings.ldap.serverConfig') || 'LDAP Server Configuration'}
</h2>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Server URL + Security (side by side) */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-bambu-gray mb-1">
{t('settings.ldap.serverUrl') || 'Server URL'}
</label>
<input
type="text"
className={inputClasses}
placeholder="ldaps://ldap.example.com:636"
value={form.ldap_server_url}
onChange={e => setForm({ ...form, ldap_server_url: e.target.value })}
/>
<p className="text-xs text-bambu-gray mt-1">
{t('settings.ldap.serverUrlHint') || 'Use ldaps:// for SSL or ldap:// with StartTLS'}
</p>
</div>
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">
{t('settings.ldap.security') || 'Security'}
</label>
<div className="flex gap-2">
{(['starttls', 'ldaps'] as const).map(sec => (
<button
key={sec}
onClick={() => setForm({ ...form, ldap_security: sec })}
className={`flex-1 px-2 py-2 rounded-lg text-sm font-medium transition-colors ${
form.ldap_security === sec
? 'bg-bambu-green text-black'
: 'bg-bambu-dark-secondary text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
}`}
>
{sec === 'starttls' ? 'StartTLS' : 'LDAPS'}
</button>
))}
</div>
<p className="text-xs text-bambu-gray mt-1">
{t('settings.ldap.securityHint') || `Default port: ${SECURITY_PORT_MAP[form.ldap_security]}`}
</p>
</div>
</div>
{/* Bind DN + Password (side by side) */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">
{t('settings.ldap.bindDn') || 'Bind DN (Service Account)'}
</label>
<input
type="text"
className={inputClasses}
placeholder="cn=service-account,ou=service,dc=example,dc=com"
value={form.ldap_bind_dn}
onChange={e => setForm({ ...form, ldap_bind_dn: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">
{t('settings.ldap.bindPassword') || 'Bind Password'}
</label>
<input
type="password"
className={inputClasses}
placeholder={settings?.ldap_bind_dn ? '••••••••' : ''}
value={form.ldap_bind_password}
onChange={e => setForm({ ...form, ldap_bind_password: e.target.value })}
/>
</div>
</div>
{/* Search Base + User Filter (side by side) */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">
{t('settings.ldap.searchBase') || 'Search Base DN'}
</label>
<input
type="text"
className={inputClasses}
placeholder="ou=users,dc=example,dc=com"
value={form.ldap_search_base}
onChange={e => setForm({ ...form, ldap_search_base: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">
{t('settings.ldap.userFilter') || 'User Search Filter'}
</label>
<input
type="text"
className={inputClasses}
placeholder="(sAMAccountName={username})"
value={form.ldap_user_filter}
onChange={e => setForm({ ...form, ldap_user_filter: e.target.value })}
/>
</div>
</div>
{/* Advanced (collapsed by default) */}
<Collapsible
summary={
<span className="text-sm font-medium text-bambu-gray">
{t('settings.ldap.advanced') || 'Advanced'}
</span>
}
className="border-t border-bambu-dark-tertiary pt-3"
summaryClassName="py-1"
>
<div className="space-y-3">
{/* Auto Provision */}
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-white">
{t('settings.ldap.autoProvision') || 'Auto-provision users'}
</label>
<p className="text-xs text-bambu-gray mt-0.5">
{t('settings.ldap.autoProvisionHint') || 'Automatically create a BamBuddy account on first LDAP login'}
</p>
</div>
<button
onClick={() => setForm({ ...form, ldap_auto_provision: !form.ldap_auto_provision })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${
form.ldap_auto_provision ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
form.ldap_auto_provision ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Default Group (fallback for users with no mapped groups) */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">
{t('settings.ldap.defaultGroup') || 'Default group'}
</label>
<select
className={inputClasses}
value={form.ldap_default_group}
onChange={e => setForm({ ...form, ldap_default_group: e.target.value })}
>
<option value="">{t('settings.ldap.defaultGroupNone') || '— None (reject login) —'}</option>
{groups.map(g => (
<option key={g.id} value={g.name}>{g.name}</option>
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
{t('settings.ldap.defaultGroupHint') || 'Fallback group assigned when an LDAP user authenticates but is not listed in any mapped group. Leave empty to leave unmapped users without permissions.'}
</p>
</div>
{/* Group Mapping */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">
{t('settings.ldap.groupMapping') || 'Group Mapping (JSON)'}
</label>
<textarea
className={`${inputClasses} font-mono text-sm`}
rows={4}
placeholder={'{\n "CN=PrintFarm_Admins,OU=Groups,DC=example,DC=com": "Administrators",\n "CN=PrintFarm_Users,OU=Groups,DC=example,DC=com": "Operators"\n}'}
value={form.ldap_group_mapping}
onChange={e => setForm({ ...form, ldap_group_mapping: e.target.value })}
/>
<p className="text-xs text-bambu-gray mt-1">
{t('settings.ldap.groupMappingHint') || 'Map LDAP group DNs to BamBuddy groups. Available groups: '}{groups.map(g => g.name).join(', ')}
</p>
</div>
</div>
</Collapsible>
{/* Action Buttons */}
<div className="flex gap-3 pt-2">
<Button
onClick={handleSave}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CheckCircle className="w-4 h-4" />
)}
{t('common.save') || 'Save'}
</Button>
<Button
variant="secondary"
onClick={() => testMutation.mutate()}
disabled={testMutation.isPending}
>
{testMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
{t('settings.ldap.testConnection') || 'Test Connection'}
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
}