mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-05 21:51:23 +02:00
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.
454 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|