feat(16-04): rebuild GroupFormModal to three states without AD binding
- Remove AD radio-selection block, discovery effect/state, and the
two-step create-then-PATCH-bind flow from GroupFormModal.tsx (D-07):
a local group can no longer be bound to an AD group from this dialog.
- Add locked name field with provenance hint + AD-DN read-only line and
an editable internalName field for imported groups (D-03/D-04); create
state gets a hint linking to the LDAP import area.
- Visible save-error line (never a silent catch{}) that distinguishes a
409 name collision from a generic failure; dialog stays open, inputs
are preserved.
- Add Group.internalName to the page.tsx interface now (Rule 3 — the
modal cannot typecheck without it; Task 2 adds the display-cell usage).
- Add seven new admin.groups i18n keys to de.json/en.json (orphaned
ldapBind.* keys removed in Task 3).
This commit is contained in:
@@ -1,17 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import type { Group } from '../page';
|
import type { Group } from '../page';
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
interface LdapDirectoryEntry {
|
|
||||||
dn: string;
|
|
||||||
name: string;
|
|
||||||
type: 'group' | 'ou';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupFormModalProps {
|
interface GroupFormModalProps {
|
||||||
group: Group | null;
|
group: Group | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -19,103 +14,57 @@ interface GroupFormModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create/rename dialog for a Tessera group, including the optional AD
|
* Create/rename dialog for a Tessera group (D-03, D-04, D-07). A local
|
||||||
* binding (D-05, D-18): the AD group is picked from the existing LDAP
|
* group can no longer be bound to an AD group from here — AD groups are
|
||||||
* group/OU discovery (GET /ldap/groups), never typed in by hand, and the
|
* imported via the LDAP area instead, never through this form.
|
||||||
* selection is a radio list — exactly one AD group per Tessera group.
|
*
|
||||||
|
* Three states, driven entirely by the `group` prop and its `ldapDn`:
|
||||||
|
* - create (`group === null`): a single required name field, plus a hint
|
||||||
|
* pointing to the LDAP import flow.
|
||||||
|
* - edit, local group (`group.ldapDn === null`): the name field stays
|
||||||
|
* freely editable, exactly as before.
|
||||||
|
* - edit, imported group (`group.ldapDn !== null`): the name field is
|
||||||
|
* locked to the AD-provided value (server-enforced, not just UI
|
||||||
|
* convention — see GroupsService.update()), the AD DN is shown read-only
|
||||||
|
* for traceability, and an independent, optional "internal name" field
|
||||||
|
* is editable instead. The sync never touches internalName.
|
||||||
*/
|
*/
|
||||||
export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps) {
|
export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps) {
|
||||||
const t = useTranslations('admin.groups');
|
const t = useTranslations('admin.groups');
|
||||||
const tLdap = useTranslations('admin.ldap');
|
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
|
|
||||||
|
const isImported = group?.ldapDn != null;
|
||||||
|
|
||||||
const [name, setName] = useState(group?.name ?? '');
|
const [name, setName] = useState(group?.name ?? '');
|
||||||
const [ldapDn, setLdapDn] = useState<string | null>(group?.ldapDn ?? null);
|
const [internalName, setInternalName] = useState(group?.internalName ?? '');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [discovered, setDiscovered] = useState<LdapDirectoryEntry[] | null>(null);
|
|
||||||
const [discovering, setDiscovering] = useState(false);
|
|
||||||
const [discoverError, setDiscoverError] = useState<string | null>(null);
|
|
||||||
const [discoverSearch, setDiscoverSearch] = useState('');
|
|
||||||
|
|
||||||
const fetchLdapGroups = useCallback(async () => {
|
|
||||||
setDiscovering(true);
|
|
||||||
setDiscoverError(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/ldap/groups`, {
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setDiscovered(await res.json());
|
|
||||||
} else {
|
|
||||||
setDiscoverError(t('ldapBind.discoverError'));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setDiscoverError(t('ldapBind.discoverError'));
|
|
||||||
} finally {
|
|
||||||
setDiscovering(false);
|
|
||||||
}
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
// Only discover while unbound: a bound group shows the DN chip instead
|
|
||||||
// of the search UI (see render below), so there is nothing to fetch for.
|
|
||||||
useEffect(() => {
|
|
||||||
if (ldapDn === null && discovered === null && !discovering && !discoverError) {
|
|
||||||
fetchLdapGroups();
|
|
||||||
}
|
|
||||||
}, [ldapDn, discovered, discovering, discoverError, fetchLdapGroups]);
|
|
||||||
|
|
||||||
const filteredDiscovered = useMemo(() => {
|
|
||||||
const q = discoverSearch.trim().toLowerCase();
|
|
||||||
if (!q) return discovered;
|
|
||||||
return discovered?.filter(
|
|
||||||
(entry) => entry.name.toLowerCase().includes(q) || entry.dn.toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
}, [discovered, discoverSearch]);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
try {
|
try {
|
||||||
if (group) {
|
const res = group
|
||||||
const res = await fetch(`${API_URL}/groups/${group.id}`, {
|
? await fetch(`${API_URL}/groups/${group.id}`, {
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ name, ldapDn }),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
setSaveError(t('saveError'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// CreateGroupDto only accepts `name` — a binding chosen while
|
|
||||||
// creating is applied with a second PATCH once the group exists.
|
|
||||||
const createRes = await fetch(`${API_URL}/groups`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ name }),
|
|
||||||
});
|
|
||||||
if (!createRes.ok) {
|
|
||||||
setSaveError(t('saveError'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const created = await createRes.json();
|
|
||||||
if (ldapDn) {
|
|
||||||
const bindRes = await fetch(`${API_URL}/groups/${created.id}`, {
|
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ ldapDn }),
|
body: JSON.stringify(
|
||||||
|
isImported
|
||||||
|
? { internalName: internalName.trim() === '' ? null : internalName.trim() }
|
||||||
|
: { name },
|
||||||
|
),
|
||||||
|
})
|
||||||
|
: await fetch(`${API_URL}/groups`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
});
|
});
|
||||||
if (!bindRes.ok) {
|
if (!res.ok) {
|
||||||
setSaveError(t('saveError'));
|
setSaveError(res.status === 409 ? t('saveErrorNameTaken') : t('saveError'));
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
onSaved();
|
onSaved();
|
||||||
onClose();
|
onClose();
|
||||||
@@ -138,94 +87,46 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={name}
|
disabled={isImported}
|
||||||
|
value={isImported ? (group?.name ?? '') : name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
</div>
|
{isImported && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('nameLockedHint')}</p>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-foreground">{t('ldapBinding')}</label>
|
|
||||||
|
|
||||||
{ldapDn !== null ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm text-foreground">
|
|
||||||
{t('ldapBind.bound', { ldapDn })}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setLdapDn(null);
|
|
||||||
setDiscoverError(null);
|
|
||||||
}}
|
|
||||||
className="text-sm font-medium text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{t('ldapBind.unbind')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm text-muted-foreground">{t('ldapBind.hint')}</p>
|
|
||||||
|
|
||||||
{discovering && (
|
|
||||||
<p className="text-sm text-muted-foreground">{tCommon('loading')}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{discoverError && (
|
|
||||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
|
||||||
{discoverError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!discovering && !discoverError && discovered && discovered.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={discoverSearch}
|
|
||||||
onChange={(e) => setDiscoverSearch(e.target.value)}
|
|
||||||
placeholder={t('ldapBind.searchPlaceholder')}
|
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
<div className="max-h-64 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
|
||||||
{filteredDiscovered && filteredDiscovered.length > 0 ? (
|
|
||||||
filteredDiscovered.map((entry) => (
|
|
||||||
<label
|
|
||||||
key={entry.dn}
|
|
||||||
className="flex items-center gap-3 px-4 py-2 text-sm hover:bg-muted/30 cursor-pointer"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="ldapGroupBinding"
|
|
||||||
checked={ldapDn === entry.dn}
|
|
||||||
onChange={() => setLdapDn(entry.dn)}
|
|
||||||
/>
|
|
||||||
<span className="rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
|
||||||
{entry.type === 'ou'
|
|
||||||
? tLdap('groupFilter.typeOu')
|
|
||||||
: tLdap('groupFilter.typeGroup')}
|
|
||||||
</span>
|
|
||||||
<span className="font-medium text-foreground">{entry.name}</span>
|
|
||||||
<span className="font-mono text-xs text-muted-foreground truncate">
|
|
||||||
{entry.dn}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="px-4 py-3 text-sm text-muted-foreground">
|
|
||||||
{t('ldapBind.noResults')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!discovering && !discoverError && discovered && discovered.length === 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground">{t('ldapBind.noResults')}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!group && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('createLdapHint')}{' '}
|
||||||
|
<Link href="/admin/ldap" className="text-primary hover:underline">
|
||||||
|
{t('goToLdap')}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isImported && group && (
|
||||||
|
<>
|
||||||
|
<p className="font-mono text-xs text-muted-foreground">
|
||||||
|
{t('adDnLabel', { ldapDn: group.ldapDn ?? '' })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
{t('internalName')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={internalName}
|
||||||
|
onChange={(e) => setInternalName(e.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('internalNameHint')}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{saveError && (
|
{saveError && (
|
||||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
{saveError}
|
{saveError}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface Group {
|
|||||||
tenantId: string;
|
tenantId: string;
|
||||||
name: string;
|
name: string;
|
||||||
ldapDn: string | null;
|
ldapDn: string | null;
|
||||||
|
internalName: string | null;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
@@ -402,7 +402,14 @@
|
|||||||
"actions": "Aktionen",
|
"actions": "Aktionen",
|
||||||
"membersButton": "Mitglieder",
|
"membersButton": "Mitglieder",
|
||||||
"saveError": "Gruppe konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
"saveError": "Gruppe konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||||
|
"saveErrorNameTaken": "Der Name ist bereits vergeben.",
|
||||||
"deleteError": "Gruppe konnte nicht gelöscht werden. Bitte erneut versuchen.",
|
"deleteError": "Gruppe konnte nicht gelöscht werden. Bitte erneut versuchen.",
|
||||||
|
"nameLockedHint": "Von der AD-Gruppe übernommen. Wird beim nächsten Sync automatisch aktualisiert.",
|
||||||
|
"adDnLabel": "AD-DN: {ldapDn}",
|
||||||
|
"internalName": "Interner Name",
|
||||||
|
"internalNameHint": "Wird von der Synchronisation nie verändert. Bleibt das Feld leer, zeigt die Oberfläche stattdessen den AD-Namen.",
|
||||||
|
"createLdapHint": "AD-Gruppen werden im LDAP-Bereich importiert, nicht hier angelegt.",
|
||||||
|
"goToLdap": "Zum LDAP-Bereich",
|
||||||
"ldapBind": {
|
"ldapBind": {
|
||||||
"hint": "Wähle eine AD-Gruppe aus der Liste, um die Mitgliedschaft automatisch zu synchronisieren.",
|
"hint": "Wähle eine AD-Gruppe aus der Liste, um die Mitgliedschaft automatisch zu synchronisieren.",
|
||||||
"bound": "Gebunden an: {ldapDn}",
|
"bound": "Gebunden an: {ldapDn}",
|
||||||
|
|||||||
@@ -402,7 +402,14 @@
|
|||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"membersButton": "Members",
|
"membersButton": "Members",
|
||||||
"saveError": "Could not save group. Please try again.",
|
"saveError": "Could not save group. Please try again.",
|
||||||
|
"saveErrorNameTaken": "This name is already taken.",
|
||||||
"deleteError": "Could not delete group. Please try again.",
|
"deleteError": "Could not delete group. Please try again.",
|
||||||
|
"nameLockedHint": "Taken from the AD group. Updated automatically on the next sync.",
|
||||||
|
"adDnLabel": "AD DN: {ldapDn}",
|
||||||
|
"internalName": "Internal Name",
|
||||||
|
"internalNameHint": "Never changed by the sync. If left empty, the interface shows the AD name instead.",
|
||||||
|
"createLdapHint": "AD groups are imported in the LDAP area, not created here.",
|
||||||
|
"goToLdap": "Go to LDAP area",
|
||||||
"ldapBind": {
|
"ldapBind": {
|
||||||
"hint": "Choose an AD group from the list to sync membership automatically.",
|
"hint": "Choose an AD group from the list to sync membership automatically.",
|
||||||
"bound": "Bound to: {ldapDn}",
|
"bound": "Bound to: {ldapDn}",
|
||||||
|
|||||||
Reference in New Issue
Block a user