Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/web/src/app/(app)/orgs/[slug]/members/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -124,6 +125,14 @@ export default async function MembersPage({
canUpload={storageConfigured()}
/>
)}
{canEditImage && (
<ClubProfileEditor
organizationId={org.id}
name={org.name}
shortName={org.shortName}
description={org.description}
/>
)}
<ExportButton href={`/api/export/orgs/${slug}/roster`} label="the roster" />
</div>
<OrgTabs slug={slug} />
Expand Down
99 changes: 98 additions & 1 deletion apps/web/src/app/(app)/orgs/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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<DialogActionState>(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()
Expand Down
165 changes: 165 additions & 0 deletions apps/web/src/components/ClubProfileEditor.tsx
Original file line number Diff line number Diff line change
@@ -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<DialogActionState, FormData>(
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 (
<>
<button
type="button"
onClick={() => setOpen(true)}
aria-label={`Edit ${name}'s description`}
className="inline-flex shrink-0 items-center gap-1.5 rounded border border-border px-3 py-1.5 text-xs font-medium text-text-2 hover:bg-base hover:text-text-1 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[--primary]"
>
<PenSquare size={13} /> Edit description
</button>

{open && (
<Overlay
isOpen
onOpenChange={(next) => {
if (!next) setOpen(false)
}}
title={`About ${name}`}
description="This is what members and prospective members read first."
size="md"
>
<form action={formAction} className="space-y-3">
<input type="hidden" name="organizationId" value={organizationId} />

<label className="flex flex-col gap-1 text-xs text-text-2">
Description
<textarea
name="description"
rows={6}
maxLength={2000}
value={desc}
onChange={(e) => setDesc(e.target.value)}
placeholder="What this club does, who it is for, and how to get involved."
className="w-full rounded border border-border bg-surface px-3 py-2 text-sm text-text-1 placeholder:text-text-3"
/>
<span className="text-[11px] text-text-3">
{desc.length}/2000. Leave it empty to remove the description entirely.
</span>
</label>

<label className="flex flex-col gap-1 text-xs text-text-2">
Short name (optional)
<input
name="shortName"
maxLength={80}
value={short}
onChange={(e) => setShort(e.target.value)}
placeholder="The name without the acronym, for tight spaces"
className="h-9 w-full rounded border border-border bg-surface px-3 text-sm text-text-1 placeholder:text-text-3"
/>
</label>

{/*
Said, rather than left to be discovered. A president who comes
here to fix the category and finds no field should learn why, not
conclude the page is broken.
*/}
<p className="rounded border border-border bg-base p-2.5 text-[11px] text-text-3">
A club&apos;s category, name and web address are set by the Office of Student
Engagement — they are how every club is filed and linked to across Tenure. Ask the
OSE to change one.
</p>

{state.error && (
<p role="alert" className="flex items-start gap-1.5 text-xs text-[--error]">
<AlertCircle size={13} className="mt-0.5 shrink-0" />
{state.error}
</p>
)}

<div className="flex gap-2">
<button
type="button"
onClick={() => setOpen(false)}
className="h-9 flex-1 rounded border border-border text-sm text-text-1 hover:bg-base"
>
Cancel
</button>
<button
disabled={pending}
className="h-9 flex-1 rounded bg-[--primary] text-sm font-medium text-[--primary-text] hover:opacity-90 disabled:opacity-50"
>
{pending ? "Saving…" : "Save"}
</button>
</div>
</form>
</Overlay>
)}
</>
)
}
Loading
Loading