diff --git a/.m/config.yaml b/.m/config.yaml index b5ea9ae..67b5311 100644 --- a/.m/config.yaml +++ b/.m/config.yaml @@ -1,6 +1,3 @@ -# Project-specific mai configuration -# Auto-generated by 'mai init' — run 'mai setup' to customize - provider: claude providers: claude: @@ -47,21 +44,13 @@ worker: name_scheme: role default_level: standard auto_discard: false - max_workers: 5 + max_workers: 7 persistent: true head: - name: "paliadin" - max_loops: 50 - infinity_mode: false - max_idle_duration: 2h0m0s - backoff_intervals: - - 5 - - 10 - - 15 - - 30 + name: paliadin capacity: global: - max_workers: 5 + max_workers: 7 max_heads: 3 per_worker: max_tasks_lifetime: 0 diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 6d41911..f968a0d 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -1415,10 +1415,15 @@ const translations: Record> = { "projects.team.role.local_counsel": "Local Counsel", "projects.team.role.expert": "Experte", "projects.team.role.observer": "Beobachter", + "projects.team.responsibility.admin": "Admin", + "projects.team.responsibility.admin.hint": "Kann Team und Rollen auf diesem Projekt und Unterprojekten verwalten", "projects.team.responsibility.lead": "Leitung", "projects.team.responsibility.member": "Mitglied", "projects.team.responsibility.observer": "Beobachter", "projects.team.responsibility.external": "Extern", + "projects.team.error.last_admin": "Mindestens ein Admin muss auf diesem Projekt oder einem übergeordneten verbleiben.", + "projects.team.error.forbidden": "Diese Aktion ist nicht erlaubt.", + "projects.team.error.generic": "Aktion fehlgeschlagen.", "projects.team.profession.partner": "Partner", "projects.team.profession.of_counsel": "Of Counsel", "projects.team.profession.associate": "Associate", @@ -4094,10 +4099,15 @@ const translations: Record> = { "projects.team.role.local_counsel": "Local Counsel", "projects.team.role.expert": "Expert", "projects.team.role.observer": "Observer", + "projects.team.responsibility.admin": "Admin", + "projects.team.responsibility.admin.hint": "Can manage team and roles on this project and its sub-projects", "projects.team.responsibility.lead": "Lead", "projects.team.responsibility.member": "Member", "projects.team.responsibility.observer": "Observer", "projects.team.responsibility.external": "External", + "projects.team.error.last_admin": "At least one admin must remain on this project or an ancestor.", + "projects.team.error.forbidden": "This action is not permitted.", + "projects.team.error.generic": "Action failed.", "projects.team.profession.partner": "Partner", "projects.team.profession.of_counsel": "Of Counsel", "projects.team.profession.associate": "Associate", diff --git a/frontend/src/client/projects-detail.ts b/frontend/src/client/projects-detail.ts index 611a6f5..8f63df3 100644 --- a/frontend/src/client/projects-detail.ts +++ b/frontend/src/client/projects-detail.ts @@ -34,6 +34,12 @@ interface Project { grant_date?: string | null; court?: string | null; case_number?: string | null; + // t-paliad-223: piggybacked onto the GET /api/projects/{id} payload so + // the team panel can render an inline on the Rolle cell. Everyone else sees the read-only + // . The bool comes from the GET /api/projects/{id} payload. + const canEditResponsibility = !!project?.effective_admin; + body.innerHTML = teamMembers .map((m) => { // t-paliad-148: profession is firm-wide (read-only badge) and @@ -2519,11 +2530,20 @@ function renderTeam() { : ""; const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : ""; const profCls = m.user_profession ? "projekt-team-profession" : "projekt-team-profession projekt-team-profession--none"; + + // Inline-select only on direct rows where the caller can edit. + // Inherited rows stay read-only — the edit must happen at the + // ancestor where the row is direct. + const responsibilityCell = + canEditResponsibility && !m.inherited + ? renderResponsibilitySelect(m.user_id, responsibility) + : `${esc(responsibilityLabel)}`; + return ` ${esc(m.user_display_name || m.user_email)} · ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""} ${esc(professionLabel)} - ${esc(responsibilityLabel)} + ${responsibilityCell} ${source} ${removeBtn} `; @@ -2542,6 +2562,47 @@ function renderTeam() { if (resp.ok) { await loadTeam(project.id); renderTeam(); + } else { + await showTeamErrorToast(resp); + } + }); + }); + + body.querySelectorAll(".team-responsibility-select").forEach((sel) => { + // Capture the pre-change value on focus so we can roll back the + // for the responsibility cell. +// Options mirror the IsValidResponsibility set in approval_levels.go. +function renderResponsibilitySelect(userID: string, current: string): string { + const options = ["admin", "lead", "member", "observer", "external"] + .map((v) => { + const label = tDyn(`projects.team.responsibility.${v}`) || v; + const sel = v === current ? " selected" : ""; + return ``; + }) + .join(""); + return ``; +} + +// 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,