From 30affbbc7efefd95519bea47255bdf953d52f157 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 6 Aug 2026 16:28:07 +0200 Subject: [PATCH] feat(16-04): rebuild GroupFormModal to three states without AD binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- .../groups/components/GroupFormModal.tsx | 239 +++++------------- .../src/app/(portal)/admin/groups/page.tsx | 1 + apps/web/src/messages/de.json | 7 + apps/web/src/messages/en.json | 7 + 4 files changed, 85 insertions(+), 169 deletions(-) diff --git a/apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx b/apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx index db54f64..2e5d3ae 100644 --- a/apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx +++ b/apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx @@ -1,17 +1,12 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useState } from 'react'; +import Link from 'next/link'; import { useTranslations } from 'next-intl'; import type { Group } from '../page'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; -interface LdapDirectoryEntry { - dn: string; - name: string; - type: 'group' | 'ou'; -} - interface GroupFormModalProps { group: Group | null; onClose: () => void; @@ -19,103 +14,57 @@ interface GroupFormModalProps { } /** - * Create/rename dialog for a Tessera group, including the optional AD - * binding (D-05, D-18): the AD group is picked from the existing LDAP - * group/OU discovery (GET /ldap/groups), never typed in by hand, and the - * selection is a radio list — exactly one AD group per Tessera group. + * Create/rename dialog for a Tessera group (D-03, D-04, D-07). A local + * group can no longer be bound to an AD group from here — AD groups are + * imported via the LDAP area instead, never through this form. + * + * 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) { const t = useTranslations('admin.groups'); - const tLdap = useTranslations('admin.ldap'); const tCommon = useTranslations('common'); + const isImported = group?.ldapDn != null; + const [name, setName] = useState(group?.name ?? ''); - const [ldapDn, setLdapDn] = useState(group?.ldapDn ?? null); + const [internalName, setInternalName] = useState(group?.internalName ?? ''); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); - const [discovered, setDiscovered] = useState(null); - const [discovering, setDiscovering] = useState(false); - const [discoverError, setDiscoverError] = useState(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) => { e.preventDefault(); setSaving(true); setSaveError(null); try { - if (group) { - const res = 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}`, { + const res = group + ? await fetch(`${API_URL}/groups/${group.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, 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) { - setSaveError(t('saveError')); - return; - } - } + if (!res.ok) { + setSaveError(res.status === 409 ? t('saveErrorNameTaken') : t('saveError')); + return; } onSaved(); onClose(); @@ -138,94 +87,46 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps) 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" /> - - -
- - - {ldapDn !== null ? ( -
-

- {t('ldapBind.bound', { ldapDn })} -

- -
- ) : ( -
-

{t('ldapBind.hint')}

- - {discovering && ( -

{tCommon('loading')}

- )} - - {discoverError && ( -
- {discoverError} -
- )} - - {!discovering && !discoverError && discovered && discovered.length > 0 && ( -
- 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" - /> -
- {filteredDiscovered && filteredDiscovered.length > 0 ? ( - filteredDiscovered.map((entry) => ( - - )) - ) : ( -

- {t('ldapBind.noResults')} -

- )} -
-
- )} - - {!discovering && !discoverError && discovered && discovered.length === 0 && ( -

{t('ldapBind.noResults')}

- )} -
+ {isImported && ( +

{t('nameLockedHint')}

)}
+ {!group && ( +

+ {t('createLdapHint')}{' '} + + {t('goToLdap')} + +

+ )} + + {isImported && group && ( + <> +

+ {t('adDnLabel', { ldapDn: group.ldapDn ?? '' })} +

+ +
+ + setInternalName(e.target.value)} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + /> +

{t('internalNameHint')}

+
+ + )} + {saveError && (
{saveError} diff --git a/apps/web/src/app/(portal)/admin/groups/page.tsx b/apps/web/src/app/(portal)/admin/groups/page.tsx index 2bfab0d..572e579 100644 --- a/apps/web/src/app/(portal)/admin/groups/page.tsx +++ b/apps/web/src/app/(portal)/admin/groups/page.tsx @@ -14,6 +14,7 @@ export interface Group { tenantId: string; name: string; ldapDn: string | null; + internalName: string | null; isDefault: boolean; createdAt: string; updatedAt: string; diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index ffa292b..dee74a1 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -402,7 +402,14 @@ "actions": "Aktionen", "membersButton": "Mitglieder", "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.", + "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": { "hint": "Wähle eine AD-Gruppe aus der Liste, um die Mitgliedschaft automatisch zu synchronisieren.", "bound": "Gebunden an: {ldapDn}", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 792a52b..aa1f358 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -402,7 +402,14 @@ "actions": "Actions", "membersButton": "Members", "saveError": "Could not save group. Please try again.", + "saveErrorNameTaken": "This name is already taken.", "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": { "hint": "Choose an AD group from the list to sync membership automatically.", "bound": "Bound to: {ldapDn}",