diff --git a/contrib/docker-compose/docker-compose-ldap.yml b/contrib/docker-compose/docker-compose-ldap.yml deleted file mode 100644 index 069da4f2a..000000000 --- a/contrib/docker-compose/docker-compose-ldap.yml +++ /dev/null @@ -1,64 +0,0 @@ -version: '3.6' - -# Standalone demo stack for trying Navidrome with LDAP authentication. -# Start it from this directory with: -# docker compose -f docker-compose-ldap.yml up -d -# -# After creating the first local Navidrome admin at http://localhost:4533, -# open Settings -> LDAP and add a source similar to: -# { -# "sources": [{ -# "name": "Demo LDAP", -# "enabled": true, -# "url": "ldap://openldap:1389", -# "bindDN": "cn=admin,dc=example,dc=org", -# "bindPassword": "adminpassword", -# "userBaseDN": "ou=users,dc=example,dc=org", -# "userFilter": "(%s=%s)", -# "userNameAttribute": "uid", -# "displayNameAttribute": "cn", -# "emailAttribute": "mail", -# "groupBaseDN": "ou=groups,dc=example,dc=org", -# "groupFilter": "(objectClass=groupOfNames)", -# "groupNameAttribute": "cn", -# "groupMemberAttribute": "member", -# "adminGroupDNs": ["cn=admins,ou=groups,dc=example,dc=org"] -# }] -# } - -volumes: - navidrome_data: - openldap_data: - -services: - navidrome: - container_name: "navidrome-ldap-demo" - image: deluan/navidrome:latest - restart: unless-stopped - read_only: true - ports: - - "4533:4533" - volumes: - - "navidrome_data:/data" - # Bind your local music folder here when testing playback: - # - "/mnt/music:/music:ro" - depends_on: - - openldap - - openldap: - container_name: "navidrome-openldap-demo" - image: bitnami/openldap:2.6 - restart: unless-stopped - environment: - LDAP_ROOT: "dc=example,dc=org" - LDAP_ADMIN_USERNAME: "admin" - LDAP_ADMIN_PASSWORD: "adminpassword" - LDAP_USERS: "alice,bob" - LDAP_PASSWORDS: "alicepassword,bobpassword" - LDAP_GROUP: "users" - ports: - # Exposed only for local ldapsearch/debugging; Navidrome uses the service - # name openldap:1389 on the compose network. - - "1389:1389" - volumes: - - "openldap_data:/bitnami/openldap" diff --git a/ui/src/ldap/index.jsx b/ui/src/ldap/index.jsx index be7d8e236..2b1432a79 100644 --- a/ui/src/ldap/index.jsx +++ b/ui/src/ldap/index.jsx @@ -1,63 +1,447 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { Button, Card, + CardActions, CardContent, + Checkbox, + Chip, + Divider, + FormControl, + FormControlLabel, + InputLabel, + List, + ListItem, + ListItemSecondaryAction, + ListItemText, + MenuItem, + Select, + Step, + StepLabel, + Stepper, TextField, Typography, } from '@material-ui/core' -import SettingsEthernetIcon from '@material-ui/icons/SettingsEthernet' +import ArrowDownwardIcon from '@material-ui/icons/ArrowDownward' +import ArrowUpwardIcon from '@material-ui/icons/ArrowUpward' +import DeleteIcon from '@material-ui/icons/Delete' +import EditIcon from '@material-ui/icons/Edit' import { useNotify } from 'react-admin' import httpClient from '../dataProvider/httpClient' import { REST_URL } from '../consts' -const defaultConfig = { sources: [] } +const emptySource = { + name: '', + enabled: true, + url: 'ldap://ldap.example.org:389', + startTLS: false, + insecureSkipVerify: false, + bindDN: '', + bindPassword: '', + userBaseDN: '', + userFilter: '(%s=%s)', + userNameAttribute: 'uid', + displayNameAttribute: 'cn', + emailAttribute: 'mail', + groupBaseDN: '', + groupFilter: '(|(objectClass=groupOfNames)(objectClass=group))', + groupNameAttribute: 'cn', + groupMemberAttribute: 'member', + requiredGroupDNs: [], + adminGroupDNs: [], + directBindDNTemplate: '', + cache: { users: [], groups: [] }, +} + +const steps = ['Server', 'Bind & filters', 'Fetch users/groups', 'Map access'] + +const groupLabel = (group) => group.name || group.dn + +const uniqueByDN = (groups = []) => { + const seen = new Set() + return groups.filter((group) => { + if (!group.dn || seen.has(group.dn)) { + return false + } + seen.add(group.dn) + return true + }) +} + +const textField = (source, setSource, key, label, props = {}) => ( + setSource({ ...source, [key]: event.target.value })} + fullWidth + margin="normal" + variant="outlined" + {...props} + /> +) + +const groupSelect = (source, setSource, key, label, groups) => ( + + {label} + + +) + +const SourceWizard = ({ initialSource, onCancel, onSave, onTest, testing }) => { + const notify = useNotify() + const [activeStep, setActiveStep] = useState(0) + const [source, setSource] = useState({ ...emptySource, ...initialSource }) + const groups = useMemo(() => uniqueByDN(source.cache?.groups), [source.cache]) + + const validateStep = () => { + if (activeStep === 0 && (!source.name || !source.url)) { + notify('LDAP name and URL are required', 'warning') + return false + } + if (activeStep === 1 && (!source.userBaseDN || !source.userNameAttribute)) { + notify('User base DN and username attribute are required', 'warning') + return false + } + return true + } + + const next = () => { + if (validateStep()) { + setActiveStep(activeStep + 1) + } + } + + const testSource = () => { + onTest(source).then((testedSource) => { + setSource({ ...source, ...testedSource }) + setActiveStep(3) + }) + } + + return ( + + + + {source.id ? 'Edit LDAP Server' : 'Add LDAP Server'} + + + {steps.map((label) => ( + + {label} + + ))} + + + {activeStep === 0 && ( + <> + {textField(source, setSource, 'name', 'Display name')} + {textField(source, setSource, 'url', 'LDAP URL', { + helperText: + 'Example: ldap://ldap.example.org:389 or ldaps://ldap.example.org:636', + })} + + setSource({ ...source, enabled: event.target.checked }) + } + /> + } + label="Enabled" + /> + + setSource({ ...source, startTLS: event.target.checked }) + } + /> + } + label="Use StartTLS" + /> + + setSource({ + ...source, + insecureSkipVerify: event.target.checked, + }) + } + /> + } + label="Skip TLS certificate verification" + /> + + )} + + {activeStep === 1 && ( + <> + Service account bind + {textField(source, setSource, 'bindDN', 'Bind DN', { + helperText: + 'Leave blank for anonymous bind if your LDAP server allows it.', + })} + {textField(source, setSource, 'bindPassword', 'Bind password', { + type: 'password', + })} + {textField( + source, + setSource, + 'directBindDNTemplate', + 'Direct user bind DN template', + { + helperText: + 'Optional. Example: uid=%s,ou=users,dc=example,dc=org. Service-account search bind is preferred.', + }, + )} + + Users + {textField(source, setSource, 'userBaseDN', 'User base DN')} + {textField(source, setSource, 'userFilter', 'User filter', { + helperText: + 'Use %s placeholders for attribute and escaped username, e.g. (%s=%s).', + })} + {textField( + source, + setSource, + 'userNameAttribute', + 'Username attribute', + )} + {textField( + source, + setSource, + 'displayNameAttribute', + 'Display name attribute', + )} + {textField(source, setSource, 'emailAttribute', 'Email attribute')} + + Groups + {textField(source, setSource, 'groupBaseDN', 'Group base DN')} + {textField(source, setSource, 'groupFilter', 'Group filter')} + {textField( + source, + setSource, + 'groupNameAttribute', + 'Group name attribute', + )} + {textField( + source, + setSource, + 'groupMemberAttribute', + 'Group member attribute', + { + helperText: + 'Use member for OpenLDAP groupOfNames. FreeIPA memberOf is collected from user entries automatically.', + }, + )} + + )} + + {activeStep === 2 && ( + <> + + Test the LDAP connection and service-account bind, then fetch + users, groups, and memberships for interactive mapping. + + + + Cached users: {source.cache?.users?.length || 0} · Cached groups:{' '} + {source.cache?.groups?.length || 0} + + + )} + + {activeStep === 3 && ( + <> + + Select the groups that are allowed to log in. Admin groups also + grant Navidrome administrator access. + + {groups.length === 0 ? ( + + No groups have been discovered yet. Go back and fetch directory + data before mapping groups. + + ) : ( + <> + {groupSelect( + source, + setSource, + 'requiredGroupDNs', + 'Allowed login groups', + groups, + )} + {groupSelect( + source, + setSource, + 'adminGroupDNs', + 'Admin groups', + groups, + )} + + )} + + Preview: {source.cache?.users?.length || 0} users and{' '} + {source.cache?.groups?.length || 0} groups cached for this source. + + + )} + + + + {activeStep > 0 && ( + + )} + {activeStep < steps.length - 1 ? ( + + ) : ( + + )} + + + ) +} export const LdapList = () => { const notify = useNotify() - const [text, setText] = useState(JSON.stringify(defaultConfig, null, 2)) + const [sources, setSources] = useState([]) + const [editingIndex, setEditingIndex] = useState(null) const [loading, setLoading] = useState(false) + const [testing, setTesting] = useState(false) - useEffect(() => { - httpClient(`${REST_URL}/ldap`) - .then(({ json }) => - setText(JSON.stringify({ sources: json.sources || [] }, null, 2)), - ) - .catch(() => notify('Could not load LDAP configuration', 'warning')) - }, [notify]) - - const save = () => { + const load = () => { setLoading(true) - httpClient(`${REST_URL}/ldap`, { method: 'PUT', body: text }) - .then(({ json }) => { - setText(JSON.stringify({ sources: json.sources || [] }, null, 2)) - notify('LDAP configuration saved') - }) - .catch((e) => - notify(`Could not save LDAP configuration: ${e.message}`, 'warning'), - ) + httpClient(`${REST_URL}/ldap`) + .then(({ json }) => setSources(json.sources || [])) + .catch(() => notify('Could not load LDAP configuration', 'warning')) .finally(() => setLoading(false)) } - const test = () => { - const cfg = JSON.parse(text) - const source = cfg.sources?.[0] - if (!source) { - notify('Add at least one LDAP source to test', 'warning') + useEffect(load, [notify]) + + const saveSources = (nextSources) => { + setLoading(true) + return httpClient(`${REST_URL}/ldap`, { + method: 'PUT', + body: JSON.stringify({ sources: nextSources }), + }) + .then(({ json }) => { + setSources(json.sources || nextSources) + notify('LDAP configuration saved') + }) + .catch((e) => { + notify(`Could not save LDAP configuration: ${e.message}`, 'warning') + throw e + }) + .finally(() => setLoading(false)) + } + + const saveSource = (source) => { + const nextSources = [...sources] + if (editingIndex === 'new') { + nextSources.push(source) + } else { + nextSources[editingIndex] = source + } + saveSources(nextSources).then(() => setEditingIndex(null)) + } + + const moveSource = (index, direction) => { + const target = index + direction + if (target < 0 || target >= sources.length) { return } - setLoading(true) - httpClient(`${REST_URL}/ldap/test`, { + const nextSources = [...sources] + const movedSource = nextSources[index] + nextSources[index] = nextSources[target] + nextSources[target] = movedSource + saveSources(nextSources) + } + + const deleteSource = (index) => { + const nextSources = sources.filter( + (_, sourceIndex) => sourceIndex !== index, + ) + saveSources(nextSources) + } + + const testSource = (source) => { + setTesting(true) + return httpClient(`${REST_URL}/ldap/test`, { method: 'POST', body: JSON.stringify(source), }) - .then(({ json }) => + .then(({ json }) => { notify( `LDAP test found ${json.cache?.users?.length || 0} users and ${json.cache?.groups?.length || 0} groups`, - ), - ) - .catch((e) => notify(`LDAP test failed: ${e.message}`, 'warning')) - .finally(() => setLoading(false)) + ) + return json + }) + .catch((e) => { + notify(`LDAP test failed: ${e.message}`, 'warning') + throw e + }) + .finally(() => setTesting(false)) + } + + if (editingIndex !== null) { + return ( + setEditingIndex(null)} + onSave={saveSource} + onTest={testSource} + testing={testing} + /> + ) } return ( @@ -67,36 +451,71 @@ export const LdapList = () => { LDAP Authentication - Configure LDAP sources as JSON. Sources are evaluated after internal - auth for external clients; the login page shows enabled sources as - tabs. + LDAP sources are tried in the order shown below after internal auth + for external clients. Use the arrow buttons to change fallback + priority. - setText(e.target.value)} - /> + {sources.length === 0 ? ( + + No LDAP servers configured yet. + + ) : ( + + {sources.map((source, index) => ( + + + + + + + + + + ))} + + )} + + - - + ) }