diff --git a/apps/web/src/app/(app)/orgs/[slug]/members/page.tsx b/apps/web/src/app/(app)/orgs/[slug]/members/page.tsx index 8f4a77a4..8304a5de 100644 --- a/apps/web/src/app/(app)/orgs/[slug]/members/page.tsx +++ b/apps/web/src/app/(app)/orgs/[slug]/members/page.tsx @@ -15,6 +15,7 @@ import { OrgTabs } from "@/components/OrgTabs" import { ExportButton } from "@/components/export/ExportButton" import { EmailLink } from "@/components/EmailLink" import { ClubImageEditor } from "@/components/ClubImageEditor" +import { ClubProfileEditor } from "@/components/ClubProfileEditor" import { ConfirmSubmit } from "@/components/ui/ConfirmDialog" import { TermDatesForm } from "@/components/TermDatesForm" import { RosterAddForm } from "@/components/RosterAddForm" @@ -124,6 +125,14 @@ export default async function MembersPage({ canUpload={storageConfigured()} /> )} + {canEditImage && ( + + )} diff --git a/apps/web/src/app/(app)/orgs/actions.ts b/apps/web/src/app/(app)/orgs/actions.ts index f722480e..46d962c9 100644 --- a/apps/web/src/app/(app)/orgs/actions.ts +++ b/apps/web/src/app/(app)/orgs/actions.ts @@ -9,7 +9,12 @@ import { requireCapability } from "@/lib/admin/guard" import { withTenantScope } from "@/lib/tenant-scope" import { storageConfigured, uploadDocument } from "@/lib/s3" import { ACCEPT_IMAGE, MAX_IMAGE_BYTES, inspectUpload } from "@/lib/uploads" -import { Refusal, reportable } from "@/lib/admin/action-state" +import { + Refusal, + reportable, + reportingWith, + type DialogActionState, +} from "@/lib/admin/action-state" import { recordAuditEvent } from "@/lib/audit-record" async function requireUserId() { @@ -114,6 +119,98 @@ async function auditImage(org: { id: string; institutionId: string }, actorId: s }) } +/** + * A CLUB EDITS ITS OWN DESCRIPTION. + * + * ── The gap this closes ───────────────────────────────────────────────────── + * + * Before this, the only thing a club could change about itself was its IMAGE. + * Its description — the sentence every member, every prospective member and + * every export reads first — could be changed by exactly one person: an OSE + * Director, in `/admin/clubs`. A president looking at their own club's page saw + * a paragraph about their club that they had no way to correct. + * + * Nothing was broken, which is why it lasted. `requireOrgManager` below has + * always refused with "You do not have permission to edit this club" — a + * sentence written for an edit path that did not exist. + * + * ── WHAT IS DELIBERATELY NOT HERE ────────────────────────────────────────── + * + * `category` stays with the OSE. It is the institution's TAXONOMY — the thing + * clubs are grouped and compared by across the whole directory — so a club + * re-filing itself would change a number on somebody else's report. That is a + * different decision from describing yourself, and it belongs where the other + * institution-wide decisions are. + * + * `name` and `slug` stay too. A slug is in every link anybody has ever shared, + * and renaming a recognised organisation is an institution act with a record, + * not a text box. + * + * The club owns how it DESCRIBES itself. The institution owns how it is FILED. + */ +export const updateOrgProfile = reportingWith(async (formData: FormData) => { + const userId = await requireUserId() + return withTenantScope(userId, async () => { + const organizationId = String(formData.get("organizationId") ?? "") + const org = await requireOrgManager(userId, organizationId) + + // Trimmed, and empty means "cleared" rather than "unchanged" — a club + // deleting its description on purpose must be able to. + const description = String(formData.get("description") ?? "").trim() + const shortName = String(formData.get("shortName") ?? "").trim() + + if (description.length > 2000) { + throw new Refusal("That description is too long. Keep it under 2,000 characters.") + } + if (shortName.length > 80) { + throw new Refusal("That short name is too long. Keep it under 80 characters.") + } + + /* + * THE EDIT AND ITS RECORD COMMIT TOGETHER, OR NEITHER DOES. + * + * The first version updated and then recorded. If the audit write failed, + * the description was already changed and the action reported failure — so + * the club's page showed new text, nothing in the log said who wrote it, + * and the person who did it had been told it did not work. Every later + * reading of that record is wrong, and nothing anywhere is red. + * + * `recordAuditEvent` takes its client as an argument precisely so it can be + * handed a transaction. The record says WHAT it was as well as that it + * changed: "somebody edited it" is not an answer to "who removed the + * accessibility note". + */ + await db.$transaction(async (tx) => { + await tx.organization.update({ + where: { id: org.id }, + data: { description: description || null, shortName: shortName || null }, + }) + await recordAuditEvent(tx, { + institutionId: org.institutionId, + organizationId: org.id, + actorId: userId, + action: "Club.ProfileEdited", + resourceType: "Organization", + resourceId: org.id, + outcome: "ALLOW", + metadata: { + fromDescription: org.description, + toDescription: description || null, + fromShortName: org.shortName, + toShortName: shortName || null, + }, + }) + }) + + revalidatePath(`/orgs/${org.slug}/members`) + revalidatePath("/orgs") + revalidatePath("/admin/clubs") + // `ok` rather than `{}`: useActionState's initial value is also `{}`, so + // an empty object cannot tell success from 'nothing submitted yet'. + return { ok: true } + }) +}) + /** Upload a club image to object storage; the /api/org-image proxy serves it. */ export const uploadOrgImage = reportable(async function uploadOrgImageImpl(formData: FormData) { const userId = await requireUserId() diff --git a/apps/web/src/components/ClubProfileEditor.tsx b/apps/web/src/components/ClubProfileEditor.tsx new file mode 100644 index 00000000..a4948c52 --- /dev/null +++ b/apps/web/src/components/ClubProfileEditor.tsx @@ -0,0 +1,165 @@ +"use client" + +import { useActionState, useEffect, useState } from "react" +import { AlertCircle, PenSquare } from "@/components/ui/icons" +import { Overlay } from "@/components/ui/Overlay" +import { updateOrgProfile } from "@/app/(app)/orgs/actions" +import type { DialogActionState } from "@/lib/admin/action-state" + +/** + * A CLUB EDITS THE SENTENCE ITS OWN MEMBERS READ. + * + * ── The gap ───────────────────────────────────────────────────────────────── + * + * Found while sweeping for the defect the owner reported on the budget: a value + * on screen with no way to change it from where it is shown. + * + * A club's description is the first thing anybody reads about it — on its + * members page, in the club directory, and in the workspace export. Until now + * exactly one person could change it: an OSE Director, in `/admin/clubs`. A + * president reading a stale paragraph about their own club had no control + * anywhere, and no explanation of why not. + * + * The same shape as the budget cell: nothing disabled, nothing broken, nothing + * to report — just an affordance that was never there. + * + * ── Where the line is drawn, and why it is drawn there ────────────────────── + * + * The club owns how it DESCRIBES itself. The institution owns how it is FILED. + * + * So `description` and `shortName` are here, and `category` is not: category is + * the taxonomy the whole directory is grouped and counted by, so a club + * re-filing itself would move a figure on somebody else's report. `name` and + * `slug` are not here either — a slug sits inside every link anybody has ever + * shared of this club. + * + * The refusal is not in this component. `requireOrgManager` decides, on the + * server, on every submission; this only decides whether the button is drawn. + */ +export function ClubProfileEditor({ + organizationId, + name, + shortName, + description, +}: { + organizationId: string + name: string + shortName: string | null + description: string | null +}) { + const [open, setOpen] = useState(false) + const [state, formAction, pending] = useActionState( + updateOrgProfile, + {} + ) + + // Controlled, so a refusal leaves what was typed exactly where it was — + // React empties an uncontrolled form when the action resolves, which would + // throw away a paragraph somebody had just written. + const [desc, setDesc] = useState(description ?? "") + const [short, setShort] = useState(shortName ?? "") + + useEffect(() => { + /* + * DEPENDS ON `state`, NOT ON `state.ok`. + * + * `ok` is `true` after the first successful save and STAYS true — a second + * save returns a new state object carrying the same `true`, so an effect + * keyed on the boolean never runs again and the dialog stays open on every + * save after the first. `useActionState` returns a fresh object per + * submission, which is the thing that actually changes. + */ + if (state.ok) setOpen(false) + }, [state]) + + return ( + <> + + + {open && ( + { + if (!next) setOpen(false) + }} + title={`About ${name}`} + description="This is what members and prospective members read first." + size="md" + > +
+ + +