From 2ed0ef3177266a139dc5f8ee79977555bcb789ad Mon Sep 17 00:00:00 2001 From: mAi Date: Wed, 20 May 2026 14:40:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(team-admin):=20t-paliad-223=20Slice=20A=20?= =?UTF-8?q?=E2=80=94=20Project=20Admin=20role=20+=20inheritable=20role-edi?= =?UTF-8?q?t=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #48 — adds 'admin' as fifth project_teams.responsibility value, plumbs an inheritable role-edit gate via the materialised ltree path. - migration 110: ALTER responsibility CHECK, CREATE paliad.effective_project_admin(uuid,uuid) STABLE SECURITY DEFINER (mirrors can_see_project shape), REPLACE project_teams_update / _insert / _delete RLS policies. Idempotent + down-mig provided. Dry-run BEGIN..ROLLBACK clean on live supabase. - services/approval_levels.go: ResponsibilityAdmin const + IsValidResponsibility extension. responsibilityOpensGate UNCHANGED — admin is orthogonal to the 4-Augen approval gate. - services/team_service.go: ChangeResponsibility() with last-admin guard inside tx (counts admins on project + ancestor chain, excludes the row being changed). RemoveMember() also runs the guard when removing an admin row. New IsEffectiveProjectAdmin() driving the frontend affordance. legacyRoleFromResponsibility: admin → 'lead' (deprecated shadow column). - services/project_service.go: ErrLastProjectAdmin sentinel mapped to 409 in writeServiceError. - handlers/teams.go: new PATCH /api/projects/{id}/team/{user_id}. RLS-enforced; non-admins get 404 to avoid existence leakage. - handlers/projects.go: GET /api/projects/{id} now wraps the payload with effective_admin bool so the frontend drives the inline-select affordance without a second round-trip. - frontend/src/projects-detail.tsx + client/projects-detail.ts: admin appears as 5th option in 'Mitglied hinzufügen' dropdown. Team-list Rolle cell switches to an inline for callers who can + // change responsibilities (global_admin or effective_project_admin on + // this project / ancestor). Optional for back-compat with cached + // payloads. + effective_admin?: boolean; updated_at: string; created_at: string; } @@ -2494,6 +2500,11 @@ function renderTeam() { } empty.style.display = "none"; + // t-paliad-223: callers with effective_project_admin authority see an + // inline if the PATCH fails (e.g. last-admin guard). + sel.dataset.previous = sel.value; + sel.addEventListener("focus", () => { + sel.dataset.previous = sel.value; + }); + sel.addEventListener("change", async () => { + if (!project) return; + const userID = sel.dataset.userId!; + const previous = sel.dataset.previous || "member"; + const next = sel.value; + if (next === previous) return; + sel.disabled = true; + try { + const resp = await fetch( + `/api/projects/${project.id}/team/${encodeURIComponent(userID)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ responsibility: next }), + }, + ); + if (!resp.ok) { + sel.value = previous; + await showTeamErrorToast(resp); + return; + } + sel.dataset.previous = next; + // Refresh the team list so derived/descendant sections re-render + // with the new authority shape. + await loadTeam(project.id); + renderTeam(); + } finally { + sel.disabled = false; } }); }); @@ -2725,7 +2786,54 @@ function wireExportButton(projectID: string): void { function canRemoveTeamMember(m: ProjectTeamMember): boolean { if (!me) return false; if (m.user_id === me.id) return true; - return me.global_role === "global_admin"; + if (me.global_role === "global_admin") return true; + // t-paliad-223: effective_project_admin (from the project payload) + // also covers remove. RLS makes the request fail anyway if the bit is + // stale; this just hides the affordance. + return !!project?.effective_admin; +} + +// t-paliad-223: build the inline ${options}`; +} + +// t-paliad-223: surface backend error responses (last-admin guard / 403 +// from RLS / etc.) as a transient toast. We have no global toast service +// yet on this page, so write into #team-msg. +async function showTeamErrorToast(resp: Response): Promise { + const msg = document.getElementById("team-msg") as HTMLParagraphElement | null; + if (!msg) return; + let text = ""; + try { + const data = (await resp.json()) as { error?: string }; + text = data?.error || ""; + } catch { + text = ""; + } + if (!text) { + if (resp.status === 409) text = t("projects.team.error.last_admin") || "Mindestens ein Admin muss auf diesem Projekt oder einem übergeordneten verbleiben."; + else if (resp.status === 403 || resp.status === 404) text = t("projects.team.error.forbidden") || "Diese Aktion ist nicht erlaubt."; + else text = t("projects.team.error.generic") || "Aktion fehlgeschlagen."; + } + msg.textContent = text; + msg.classList.add("form-msg--error"); + // Auto-clear after 5s so a stale error doesn't linger past the next + // successful action. + window.setTimeout(() => { + if (msg.textContent === text) { + msg.textContent = ""; + msg.classList.remove("form-msg--error"); + } + }, 5000); } function initTeamForm(id: string) { diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index db38878..e3fbbc1 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -2233,6 +2233,9 @@ export type I18nKey = | "projects.team.derived.from" | "projects.team.derived.visibility" | "projects.team.direct" + | "projects.team.error.forbidden" + | "projects.team.error.generic" + | "projects.team.error.last_admin" | "projects.team.inherited.hint" | "projects.team.profession.associate" | "projects.team.profession.hint" @@ -2243,6 +2246,8 @@ export type I18nKey = | "projects.team.profession.paralegal" | "projects.team.profession.partner" | "projects.team.profession.senior_pa" + | "projects.team.responsibility.admin" + | "projects.team.responsibility.admin.hint" | "projects.team.responsibility.external" | "projects.team.responsibility.lead" | "projects.team.responsibility.member" diff --git a/frontend/src/projects-detail.tsx b/frontend/src/projects-detail.tsx index 25ac68b..2282da6 100644 --- a/frontend/src/projects-detail.tsx +++ b/frontend/src/projects-detail.tsx @@ -262,6 +262,7 @@ export function renderProjectsDetail(): string {
; everyone else sees a read-only . +func (s *TeamService) IsEffectiveProjectAdmin(ctx context.Context, userID, projectID uuid.UUID) (bool, error) { + var b bool + if err := s.db.GetContext(ctx, &b, + `SELECT paliad.effective_project_admin($1, $2)`, + userID, projectID); err != nil { + return false, fmt.Errorf("effective_project_admin: %w", err) + } + return b, nil +} + // --------------------------------------------------------------------------- // pathToIDStrings splits a materialised path into its UUID labels as strings,