diff --git a/AGENTS.md b/AGENTS.md index 8cd2500..7fc0b11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,8 +10,8 @@ What the app is and how to run it: [`README.md`](README.md). UI rules: [`docs/de `openid-client`. ID tokens are signature-checked. Tokens remain in the dedicated Redis server session store. `src/start.ts` validates the configured Origin on all state-changing requests. There is no Better Auth proxy. - **Server functions** attach `adminMiddleware` for reads, and their exact permission middleware for mutations: - grants, Telegram groups, WhatsApp groups, labels, website content, reports, and Microsoft 365 member creation and - member-number changes. + grants, Telegram groups, WhatsApp groups, labels, website content, reports, People (write, appoint, link, team), and + Microsoft 365 member creation and member-number changes. The backend independently checks every procedure. `context.backend` forwards a bearer token, never cookies. - **Features** (`src/features//`): pages, dialogs, validation and `*.functions.ts` server functions. - **Microsoft 365**: read the member directory with `azure:members:read` and create with `azure:members:create` through @@ -23,12 +23,16 @@ What the app is and how to run it: [`README.md`](README.md). UI rules: [`docs/de the Socio role, so adding or removing anyone in a group would grant or revoke IdP roles. Nothing else about users or groups can be edited from the dashboard. - **People** (`src/features/people/`): every page reads and writes through `peopleApi(context)`, which parses the - backend's `people.*` responses with `people.contract.ts` and, in agent mode, uses the in-memory fake - (`AGENT_PERMISSIONS`, `AGENT_PERSON` pick the viewer). `/dashboard` admits `people:self` without `admin:access`; - Capo Admin and Team Lead come from the person's own record (`peopleRoles` in the route context) and only open their - sections. Member register, Receipts, Census and My area's fee card are unconnected mockups (#138): static sample data - from `mockup-data.ts`, no server functions, disabled actions and a `MockupBanner`, gated by `people:read`, to be - rebuilt in Phase 2 (#144) and Phase 3 (#145). Handoff notes, decisions and the plan: `docs/people/`. + backend's `people.*` responses with `people.contract.ts` (a copy of the backend's) and, in agent mode, uses the + in-memory fake (`AGENT_PERMISSIONS`, `AGENT_PERSON` pick the viewer). A person's roles are assignments (Admin, Capo + Admin, team member or Lead, nothing else): `pending` until the record is linked to an account, then held by + PoliNetwork Auth, which may refuse a change; pages show each assignment's status and Auth's refusal word for word. + `people:write` edits records, admins and invites, `people:appoint` Capo Admins and Leads, `people:link` links and + unlinks by hand. `/dashboard` admits `people:self` without `admin:access`; Capo Admin and Team Lead come from the + person's own record (`peopleRoles` in the route context) and only open their sections. Member register, Receipts, + Census and My area's fee card are unconnected mockups (#138): static sample data from `mockup-data.ts`, no server + functions, disabled actions and a `MockupBanner`, gated by `people:read`, to be rebuilt in Phase 2 (#144) and + Phase 3 (#145). Handoff notes, decisions and the plan: `docs/people/`. - **Shared UI**: `src/components/shell`, `src/components/primitives`, `src/components/ui`. ## Conventions diff --git a/docs/people/contract.md b/docs/people/contract.md index 2310a1a..52d88d6 100644 --- a/docs/people/contract.md +++ b/docs/people/contract.md @@ -1,196 +1,81 @@ # People area: backend contract -The dashboard calls these procedures under `people.*` on the tRPC backend (`@polinetwork/backend`). They do not exist -yet: this file is the spec for the backend project. **`src/features/people/people.contract.ts` is the source of -truth** for every shape and input (zod schemas); where this file differs, the code wins. In agent mode the dashboard -serves the same shapes from `src/features/people/people.fixtures.server.ts`. - -Every procedure checks permissions server-side; the "Needs" column is the minimum. Dates are ISO `YYYY-MM-DD` strings -(`Europe/Rome`); timestamps ISO 8601. - -> [!IMPORTANT] -> **Removed from the contract (#138).** Soci (the member register), fees and receipts, the census, record matching -> and Telegram group tracking are no longer part of People. The dashboard no longer calls `people.members.*`, -> `people.fees.*`, `people.receipts.*`, `people.census.*`, `people.linking.reviews` / `resolve` / `dismiss`, -> `people.capo.approve` / `decline`, or `people.me.confirmMatch` / `rejectMatch` / `answerCensus` / -> `requestCorrection` / `revokePhotoConsent` / `uploadTransferReceipt` / `receipt` / `askToBeAdded`, and the backend -> drops them in PoliNetworkOrg/backend#104 (issue PoliNetworkOrg/backend#100). The Member register, Receipts and Census pages stay as unconnected UI mockups with their -> own sample data (`src/features/people/mockup-data.ts`), to be rebuilt in Phase 2 (#144, Soci and fees on Microsoft -> 365 members) and Phase 3 (#145, the census). -> -> Until the backend cleanup deploys, the backend still sends `member`, `isMember`, `census`, `fees`, `admin.groups`, -> `admin.requested`, `capo.get`'s `groups` and `requests`, and in My area `feeInstructions`, `campaign`, the `none` -> state's `request` and the contacts' and roster's `email`. The dashboard's schemas strip unknown keys, so these are -> ignored, and nothing requires them. An account in `review` reads as `none`, and a My area `match` as `none`. - -## Shared types - -```ts -type PersonId = string // "P-0142" -type AccountStatus = "none" | "invite" | "linked" -type TeamId = "it" | "hr" | "design" | "intl" | "events" -type TeamRole = "member" | "lead" -type Campus = "Leonardo" | "Bovisa" | "Cremona" | "Piacenza" | "Mantova" | "Lecco" | "External" - -type Profile = { - firstName: string - lastName: string - personalEmail: string | null // omitted (null) without contact rights - phone: string | null // omitted (null) without contact rights - telegramUsername: string | null - telegramId: string | null - course: string - year: string // "2nd year", "1st year MSc", "Graduated" - campus: Campus - otherAssociations: string[] - studentRepresentative: string | null -} - -type AdminRole = { - status: "active" | "former" - since: string - endedOn: string | null - endReason: string | null - availableAsCapo: boolean - capoOfReference: { id: PersonId; name: string } | null - capoScope: string | null // set when this person is a Capo Admin, e.g. "Ingegneria Informatica" -} - -type TeamMembership = { - team: TeamId - role: TeamRole - since: string - endedOn: string | null - endReason: string | null - fields: Record // per team, see TEAM_FIELDS -} - -type Account = - | { status: "none" } - | { - status: "invite" - email: string - sentOn: string - expiresOn: string - sentBy: string - } - | { - status: "linked" - email: string - via: string - linkedOn: string - how: string - } - -// Every list row: the record without notes, discrepancies and activity. -type PersonRow = Omit - -type PersonRecord = { - id: PersonId - profile: Profile - admin: AdminRole | null - teams: TeamMembership[] - consents: { - privacy: { version: string; acceptedOn: string } | null - photo: { status: "granted" | "denied" | "revoked"; on: string } | null - } - account: Account - notes: string | null // null without people:read - discrepancies: { - kind: string // the dashboard acts on "telegram-mismatch" only - message: string - suggestedTelegram?: string - }[] - activity: { actor: string; text: string; at: string }[] -} -``` - -Team fields (`TEAM_FIELDS`): `it` → `github` (text), `areas` (Frontend, Backend, Bot, Infrastructure); `design` → -`tools` (Figma, Illustrator, Photoshop, After Effects, InDesign, Premiere), `portfolio` (URL); `hr` and `events` → -`focus` (multi); `intl` → `languages` (multi). +The dashboard calls these procedures under `people.*` on the tRPC backend (`@polinetwork/backend`), served by the +backend's `src/people/router.ts` (Phase 1: PoliNetworkOrg/backend#105 and #106). **`src/features/people/people.contract.ts` +is the source of truth** for every shape and input; it is a copy of the backend's `src/people/contract.ts`, so change +both together. In agent mode the dashboard serves the same shapes from `people.fake.server.ts` over +`people.fixtures.server.ts`. + +Every procedure checks permissions server-side, and every role change is checked again by PoliNetwork Auth. Dates are +ISO `YYYY-MM-DD` strings; timestamps ISO 8601. Actors (`assignedBy`, `sentBy`, `linkedBy`, activity `actor`) are +account IDs, or `auth` / `system`; the dashboard shows the name of the record linked to the account when it has it. + +Soci, fees and receipts, the census, record matching and Telegram group tracking are not part of People (#138). Their +pages are unconnected mockups with their own sample data (`mockup-data.ts`). + +## Model + +- **Person:** a record with a name, an optional contact email (only for invites, never to match accounts), phone and + Telegram username (informational only), HR notes and history. +- **Account:** `none`, `invite` (an open invite: address, sent on, expiry, sender) or `linked` (account ID, `method` + `invite` / `manual` / `auth`, when and by whom). `auth` is a record People created for someone given a bound role by + hand in PoliNetwork Auth. +- **Assignment:** `admin`, `capo` (with its `capoScope`) or `team` (with `team`, `teamRole` `member` / `lead` and the + team's fields). An admin assignment has a `capoOfReference`. Each has `since`, `endedOn` / `endReason` once ended, + `assignedBy`, and where it stands with Auth: + + | `status` | Meaning | Dashboard label | + | --------- | ------------------------------------------------------------------------- | ------------------------- | + | `pending` | The record isn't linked, so Auth knows nothing of it | Pending until linked | + | `sent` | Sent to Auth, not confirmed yet (`change` says whether it starts or ends) | Waiting for Auth / Ending | + | `applied` | Auth holds it (or, for an end, no longer does) | Applied | + | `refused` | Auth refused; `refusal` is its reason, shown word for word | Refused by Auth | + + A refused start ends the assignment (it never took effect); a refused end leaves it current. While any change of a + linked record is `sent`, the backend refuses another role change for it. + +- **Teams:** `it`, `hr`, `design` (Design & Marketing), `intl`, `events`. `teams.list` reports per team whether Auth + binds a member and a Lead role (`assignable`); a role that isn't bound can't be assigned, and the dashboard doesn't + offer it. Only these kinds exist: Admin, Capo Admin, team member, team Lead. ## Procedures -| Procedure | Kind | Input | Output | Needs | -| ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -| `people.persons.list` | query | — | `PersonRow[]` | `people:read` | -| `people.persons.get` | query | `{ id }` | `PersonRecord` | `people:read` | -| `people.persons.create` | mutation | `Profile` fields + `{ admin: boolean }` | `{ id }` | `people:write` | -| `people.persons.update` | mutation | `{ id } & Partial` | `void` | `people:write` | -| `people.persons.setNote` | mutation | `{ id, note: string }` (empty string clears the note) | `void` | `people:write` | -| `people.admins.end` | mutation | `{ personId, reason }` | `void` | `people:write` | -| `people.admins.setCapoScope` | mutation | `{ personId, scope: string \| null }` | `void` | `people:appoint` | -| `people.admins.setCapoOfReference` | mutation | `{ personId, capoId: PersonId \| null }` | `void` | `people:write` | -| `people.teams.list` | query | — | `{ id, name, lead, members: PersonRow[] }[]` (members include ended memberships) | `people:read`, or Lead of that team (only their team) | -| `people.teams.candidates` | query | `{ teamId }` | `{ id, name, course }[]`: people not active in the team, for Add to team | `people:read`, or Lead of that team | -| `people.teams.addMember` | mutation | `{ teamId, personId, role, since, fields }` | `void` | `people:write` or Lead (role `lead` needs `people:appoint`) | -| `people.teams.updateFields` | mutation | `{ teamId, personId, fields }` | `void` | `people:write` or Lead | -| `people.teams.endMembership` | mutation | `{ teamId, personId, lastDay, reason, note? }` | `void` | `people:write` or Lead | -| `people.linking.invites` | query | — | `{ personId, name, email, sentOn, expiresOn, sentBy }[]` | `people:read` | -| `people.linking.sendInvite` | mutation | `{ personId }` (sends or resends; the old link stops working) | `void` | `people:write` | -| `people.linking.revokeInvite` | mutation | `{ personId }` | `void` | `people:write` | -| `people.linking.searchAccounts` | query | `{ personId, query }` | `{ id, name, email, telegram, via, linkedTo: { id, name } \| null, reason: "same-telegram" \| "same-email" \| "similar-name" }[]` | `people:write` | -| `people.linking.link` | mutation | `{ personId, accountId, reason }` (fails `CONFLICT` when the account is linked elsewhere) | `void` | `people:write` | -| `people.linking.unlink` | mutation | `{ personId }` | `void` | `people:write` | -| `people.capo.get` | query | — | `{ scope, admins: PersonRow[] }` (active admins whose Capo Admin of reference is the caller) | Capo Admin (scope from record) | -| `people.me.get` | query | — | `MyArea` (below) | `people:self` | -| `people.me.roles` | query | — | `{ capoScope: string \| null, leads: TeamId[] }` (caller's Capo Admin scope and led teams for navigation) | `people:self` | -| `people.me.acceptInvite` | mutation | `{ token }` (43-character base64url, from the invite link `/dashboard/account/me?peopleInvite=`) | `void` (links the caller's account to the invited record) | `people:self` | - -```ts -type MyArea = - | { - state: "linked" - person: PersonRecord - contacts: { - role: "capo" | "lead" | "hr" - name: string - telegram: string | null - team?: TeamId - }[] - roster: { - team: TeamId - members: { - name: string - role: TeamRole - telegram: string | null - }[] - } | null - } - | { state: "none" } -``` - -## Permissions - -IdP permissions (in `src/server/permissions.ts`): `people:self`, `people:read`, `people:write` and `people:appoint`. -`people:members:read`, `people:members:write`, `people:fees:write` and `people:receipts:sign` are gone with Soci, fees -and receipts (#138, deleted from the IdP in PoliNetworkOrg/auth#77). Capo Admin and Team Lead are not permissions: -they come from the signed-in person's record (`people.me.get`), and the backend scopes `people.capo.get` and team -writes to them. - -## Backend rules - -The dashboard checks these too, but the backend owns them (from the security review of PR 97): - -- **`admin:access`**: every `people.*` procedure except `people.me.*`, `people.capo.*` and the team procedures needs it on - top of the permission in the table. `people:self` alone reaches only My area, Capo Admin and the caller's own team. For team procedures, the general - `people:read`/`people:write` branch requires `admin:access`; otherwise require `people:self` and an active, confirmed - Lead role over the requested team. A stray general permission without `admin:access` never widens team access. -- **`people.teams.list`**: a Lead without `people:read` gets contacts (phone, personal email) only for the team's - current members; former members come back without them. -- **`people.teams.endMembership`**: ending a lead's membership needs `people:appoint`, like appointing one. A team has at most one Lead: - `people.teams.addMember` with `role: "lead"` fails `CONFLICT` while another Lead's appointment is open. -- **`people.admins.end`**: ending a Capo Admin's admin role also ends the scope, so it needs `people:appoint` too. -- **`people.me.acceptInvite`**: the token alone identifies the invited record (store it hashed); it fails while the - invite is revoked, expired or already used, or when the caller already has a linked record. -- **Input limits**: the input schemas in `people.contract.ts` bound every string, array and record (team fields: at most - 10 keys of 40 characters, values of 200, lists of 20). Telegram usernames are empty or 5–32 letters, digits and - underscores, with an optional leading `@`. - -Security review verification (PR #97): scope checks, the Capo Admin area and ending Lead roles are exercised in -`tests/people-access.test.mjs`; bounded input and parsing of the current backend's legacy fields in -`tests/people-input.test.mjs`. General People middleware starts from `adminMiddleware`; team exceptions follow the -rule above. - -Input limits also cover opaque IDs (128 chars), emails (254) and real ISO calendar dates -(invalid days are rejected). Reject oversize requests before parsing. +| Procedure | Kind | Input | Needs | +| ------------------------------------- | ---------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------- | +| `persons.list` / `persons.get` | query | — / `{ id }` | `admin:access` + `people:read` | +| `persons.create` | mutation | name, `contactEmail`, `phone`, `telegramUsername` (empty when unknown) | `people:write` | +| `persons.update` / `setNote` | mutation | `{ id, … }` / `{ id, note }` | `people:write` | +| `admins.list` | query | — | `people:read` | +| `admins.assign` | mutation | `{ personId, since, capoId }` | `people:write` | +| `admins.end` | mutation | `{ personId, reason }` (Capo Admin role ended first) | `people:write` | +| `admins.setCapoOfReference` | mutation | `{ personId, capoId }` | `people:write` | +| `capos.list` | query | — | `people:read` | +| `capos.appoint` | mutation | `{ personId, since, scope }` (a current admin) | `people:appoint` | +| `capos.setScope` / `capos.end` | mutation | `{ personId, scope }` / `{ personId, reason }` | `people:appoint` | +| `teams.list` / `teams.candidates` | query | — / `{ teamId }` | `people:read`, or a Lead for their own team | +| `teams.addMember` | mutation | `{ teamId, personId, role, since, fields }` | `people:write` or the team's Lead; `people:appoint` for a Lead | +| `teams.updateFields` | mutation | `{ teamId, personId, fields }` | `people:write` or the team's Lead | +| `teams.endMembership` | mutation | `{ teamId, personId, reason }` | as `addMember`, by the membership's role | +| `linking.invites` / `recent` | query | — | `people:read` | +| `linking.sendInvite` / `revokeInvite` | mutation | `{ personId }` | `people:write`, or Lead of one of the person's teams | +| `linking.searchAccounts` | query | `{ query }` | `people:link` | +| `linking.link` | mutation | `{ personId, accountId, reason }` | `people:link` | +| `linking.unlink` | mutation | `{ personId, reason }` | `people:link` | +| `capo.get` | query | — | `people:self`, as a current Capo Admin | +| `me.roles` / `me.get` | query | — | `people:self` | +| `me.invite` / `me.acceptInvite` | query / mutation | `{ token }` | any signed-in account | + +Nobody gives themselves a role (giving one up is always allowed), and nobody hands out a role granting permissions they +don't hold; the backend applies the same rules as Auth (`src/people/rules.ts`) to pending assignments, and Auth checks +linked ones again. A Capo Admin of reference must be in effect: not an appointment still waiting for Auth. Start dates +can't be later than today in Europe/Rome. + +**Linking** sends every pending assignment to Auth: by hand, acting as whoever links; by invite, acting as each +assignment's `assignedBy`. **Unlinking** asks Auth to take the record's roles back first; once it confirms, the account +is detached and the assignments wait as `pending` again. + +## Dashboard composition + +Add person creates the record, then calls `admins.assign`, `capos.appoint` and `teams.addMember` for the roles it starts +with (`createPerson` in `people-write.functions.ts`). A refused role doesn't undo the record: the page reports which +roles weren't given, in the backend's words. diff --git a/docs/people/permissions.md b/docs/people/permissions.md index 111b00e..ce50d0a 100644 --- a/docs/people/permissions.md +++ b/docs/people/permissions.md @@ -2,21 +2,24 @@ Permissions are additive and never imply one another. Administrative pages/actions below also need `admin:access`. Only My area, an active Capo Admin's scope and a Team Lead's own teams work with `people:self` alone. Record roles -count only after account linking is confirmed. “Team member” means a volunteer in a team. `people:members:read`, +count only once PoliNetwork Auth holds them (the record is linked and the role applied). “Team member” means a volunteer in a team. `people:members:read`, `people:members:write`, `people:fees:write` and `people:receipts:sign` were removed in #138. | Viewer / extra permissions | Pages | Actions | | ---------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| Volunteer: `people:self`, P-0115 | My area, Sign-in & security | Read own record and roles | -| Team Lead: `people:self`, P-0034 | Above + own team(s) under People | Add team members, edit team fields, end ordinary team memberships; never appoint or end a Lead without `people:appoint` + admin access | -| Capo Admin: `people:self`, P-0098 | My area + Telegram › Capo Admin | Contact the admins who have them as Capo Admin of reference | +| Volunteer: `people:self`, P-0021 | My area, Sign-in & security | Read own record and roles | +| Team Lead: `people:self`, P-0030 | Above + own team(s) under People | Add team members, edit team fields, end ordinary team memberships; never appoint or end a Lead without `people:appoint` + admin access | +| Capo Admin: `people:self`, P-0003 | My area + Telegram › Capo Admin | Read-only list of the admins who have them as Capo Admin of reference | | `people:read` | People overview, person records, Admins, Teams, Linking | Read contacts and notes; no editing, linking or appointments | +| `people:link` | Link account and Unlink on records and Overview | Search the IdP's accounts, link one by hand, unlink (Auth revokes the roles first) | | `people:read` (mockups) | Member register, Receipts, Census; the fee card in My area | None: unconnected mockups with sample data (#138), to be rebuilt in Phase 2 (#144) and Phase 3 (#145) | | Master Admin: all explicit permissions, P-0007 | All permitted sections | All People mutations | -`people:write` enables profile, admin-role, team and linking mutations on readable pages; -`people:appoint` enables Capo/Lead appointments and ending those roles. Without their read prerequisites, write -permissions alone do not open a page. Buttons and row links follow these same rules; one available action is direct. +`people:write` enables record, admin-role, team and invite mutations on readable pages; +`people:appoint` enables Capo/Lead appointments and ending those roles; `people:link` enables linking by hand and +unlinking. Nobody gives themselves a role, though anyone may give one up: P-0007 (agent mode's default `AGENT_PERSON`) +can't add roles to their own record. Without their read prerequisites, write permissions alone do not open a page. +Buttons and row links follow these same rules; one available action is direct. ## Verification diff --git a/docs/people/plan.md b/docs/people/plan.md index 55da677..3771b90 100644 --- a/docs/people/plan.md +++ b/docs/people/plan.md @@ -208,3 +208,15 @@ Newest last. One line per commit or session: date, what was done, what is next. `people:members:read`, `people:members:write`, `people:fees:write` and `people:receipts:sign`. The schemas strip the fields the current backend still sends until its own cleanup deploys. The backend counterpart is PoliNetworkOrg/backend#104, which deploys after this. + +- 2026-10-31: Phase 1 pages (#139), on the API from PoliNetworkOrg/backend#105 and #106. The contract is a copy of + the backend's: records with a name and contacts, and assignments (Admin, Capo Admin, team member or Lead) whose + status says where each stands with PoliNetwork Auth (pending until linked, waiting for Auth, applied, refused with + Auth's reason). Overview filters by role, team, account and status; the person page lists each role with its status, + the past roles and the history, with actors named from linked records. Add person needs only a name, and can start + with roles. Admins shows admins with their Capo Admin of reference and the Capo Admins with their scope; the Capo + Admin area is read-only. Teams honour which roles Auth binds. New permission `people:link` for linking by hand and + unlinking (with a reason; Auth revokes the roles first). Course, year and campus are gone from records, so #132's + "—" handling has nothing left to apply to; the Design team is Design & Marketing. Agent-mode fixtures cover linked, + invited and unlinked records and every assignment status; fake Auth confirms changes after a few seconds and refuses + every change for P-0012. diff --git a/src/features/people/account-action-dialog.tsx b/src/features/people/account-action-dialog.tsx index 6684181..b51908e 100644 --- a/src/features/people/account-action-dialog.tsx +++ b/src/features/people/account-action-dialog.tsx @@ -1,20 +1,20 @@ import { useServerFn } from "@tanstack/react-start" -import { Mail, RefreshCw, Unlink, X } from "lucide-react" +import { Mail, RefreshCw, X } from "lucide-react" import { ConfirmDialog } from "@/components/primitives" import { useRefreshAfter } from "./people-hooks" -import { revokePersonInvite, sendPersonInvite, unlinkPersonAccount } from "./people-write.functions" +import { revokePersonInvite, sendPersonInvite } from "./people-invites.functions" export type AccountAction = { - kind: "invite" | "resend" | "revoke" | "unlink" + kind: "invite" | "resend" | "revoke" personId: string name: string - /** The invite address, or the linked sign-in for `unlink`. */ + /** The invite address: the record's contact email. */ email: string } -/** Send, resend or revoke an invite, or unlink an account. Keep `action` after closing so the text survives the exit. */ +/** Send, resend or revoke an invite. Keep `action` after closing so the text survives the exit. */ export function AccountActionDialog({ action, open, @@ -26,7 +26,6 @@ export function AccountActionDialog({ }) { const send = useServerFn(sendPersonInvite) const revoke = useServerFn(revokePersonInvite) - const unlink = useServerFn(unlinkPersonAccount) const refresh = useRefreshAfter() if (!action) return null const data = { personId: action.personId } @@ -40,7 +39,7 @@ export function AccountActionDialog({ tone="primary" icon={resend ? RefreshCw : Mail} title={resend ? "Resend invite?" : "Send invite?"} - description={`${action.name} gets an email at ${action.email} with a link to sign in and link their account. The link expires in 14 days${resend ? " and the previous one stops working." : "."}`} + description={`${action.name} gets an email at ${action.email} with a link that works once. They can sign in with any account, not only this address. It expires in 14 days${resend ? ", and the previous link stops working." : "."}`} confirmLabel={resend ? "Resend invite" : "Send invite"} onConfirm={async () => { await send({ data }) @@ -49,33 +48,17 @@ export function AccountActionDialog({ /> ) } - if (action.kind === "revoke") { - return ( - { - await revoke({ data }) - await refresh("Invite revoked.") - }} - /> - ) - } return ( { - await unlink({ data }) - await refresh("Account unlinked.") + await revoke({ data }) + await refresh("Invite revoked.") }} /> ) diff --git a/src/features/people/admin-dialogs.tsx b/src/features/people/admin-dialogs.tsx index 0942703..3d1390d 100644 --- a/src/features/people/admin-dialogs.tsx +++ b/src/features/people/admin-dialogs.tsx @@ -1,9 +1,8 @@ import { useServerFn } from "@tanstack/react-start" -import { Crown } from "lucide-react" import { useMemo, useState } from "react" import { - ConfirmDialog, + DateField, fieldControl, fieldHintId, FormDialog, @@ -11,50 +10,93 @@ import { SelectField, Spinner, } from "@/components/primitives" +import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { cn } from "@/lib/utils" -import { setCapoScope } from "./people-appoint.functions" -import { personName } from "./people-format" -import { errorMessage, useLoaded, useRefreshAfter } from "./people-hooks" -import { endAdminRole, setCapoOfReference } from "./people-write.functions" -import { COURSES, type PersonRow } from "./people.contract" -import { getPeople } from "./people.functions" +import { appointCapo, endCapo, setCapoScope } from "./people-appoint.functions" +import { personName, todayIso } from "./people-format" +import { errorMessage, useLoaded, useRefreshAfter, useReloadAfterFailure } from "./people-hooks" +import { type PersonOption, PersonPicker } from "./people-ui" +import { assignAdmin, endAdminRole, setCapoOfReference } from "./people-write.functions" +import type { CapoSummary } from "./people.contract" +import { getAdminsAndCapos } from "./people.functions" const END_REASONS = ["Graduated", "No longer has time", "Left the association", "Removed by HR", "Other"] as const type EndReason = (typeof END_REASONS)[number] export type AdminTarget = { personId: string; name: string } -/** Ends an admin role with a reason; the bot removes their admin rights. */ -export function EndAdminDialog({ +const NONE = "none" + +/** + * "Giulia Rossi · Ingegneria Informatica", for choosing a Capo Admin. Not one whose appointment Auth hasn't confirmed + * yet: the backend only accepts a Capo Admin in effect. + */ +function capoOptions(capos: readonly CapoSummary[], exclude?: string) { + return capos + .filter((capo) => capo.person.id !== exclude && !(capo.capo.change === "start" && capo.capo.status === "sent")) + .map((capo) => ({ + value: capo.person.id, + label: `${personName(capo.person)}${capo.capo.capoScope ? ` · ${capo.capo.capoScope}` : ""}`, + })) + .toSorted((a, b) => a.label.localeCompare(b.label)) +} + +/** The Capo Admins, from the page when it has them, otherwise loaded while the dialog is open. */ +function useCapos(open: boolean, capos: readonly CapoSummary[] | undefined) { + const load = useServerFn(getAdminsAndCapos) + const { data, failed } = useLoaded( + useMemo(() => (open && !capos ? async () => (await load()).capos : null), [open, capos, load]) + ) + return { capos: capos ?? data, failed } +} + +/** A role change's outcome: pending until the record is linked, otherwise sent to PoliNetwork Auth. */ +export function changeOutcome(linked: boolean, done: string) { + return linked ? `${done} PoliNetwork Auth applies it shortly.` : `${done} It waits until the record is linked.` +} + +/** + * Makes a person an admin. `person` fixes who (their record); otherwise the dialog offers `candidates`. For a linked + * record Auth applies it, and may refuse; for an unlinked one it waits as pending. + */ +export function AssignAdminDialog({ open, onOpenChange, - target, + person: fixedPerson, + candidates = [], + capos: givenCapos, }: { open: boolean onOpenChange: (open: boolean) => void - target: AdminTarget + person?: PersonOption & { linked: boolean } + candidates?: readonly (PersonOption & { linked: boolean })[] + capos?: readonly CapoSummary[] }) { - const end = useServerFn(endAdminRole) + const assign = useServerFn(assignAdmin) const refresh = useRefreshAfter() - const [reason, setReason] = useState(null) - const [details, setDetails] = useState("") + const reload = useReloadAfterFailure() + const [person, setPerson] = useState(fixedPerson ?? null) + const [since, setSince] = useState(todayIso()) + const [capo, setCapo] = useState(NONE) const [submitted, setSubmitted] = useState(false) - const reasonError = submitted && !reason ? "Choose a reason." : undefined - const detailsError = submitted && reason === "Other" && !details.trim() ? "Describe the reason." : undefined + const { capos, failed } = useCapos(open, givenCapos) + const personError = submitted && !person ? "Choose a person." : undefined + const sinceError = submitted && !since ? "Choose the start date." : undefined async function submit() { setSubmitted(true) - if (!reason || (reason === "Other" && !details.trim())) return - const text = reason === "Other" ? details.trim() : details.trim() ? `${reason}: ${details.trim()}` : reason + if (!person || !since) return try { - await end({ data: { personId: target.personId, reason: text } }) + await assign({ data: { personId: person.id, since, capoId: capo === NONE ? null : capo } }) } catch (caught) { console.error(caught) - throw new Error(errorMessage(caught, "Couldn't end the admin role."), { cause: caught }) + reload() + throw new Error(errorMessage(caught, "Couldn't make them an admin."), { cause: caught }) } - await refresh("Admin role ended.") + const linked = fixedPerson?.linked ?? candidates.find((candidate) => candidate.id === person.id)?.linked ?? false + await refresh(changeOutcome(linked, `${person.name} is an admin.`)) onOpenChange(false) } @@ -62,79 +104,237 @@ export function EndAdminDialog({
- - - id="end-admin-reason" - value={reason} - options={END_REASONS.map((value) => ({ value, label: value }))} - onChange={setReason} - placeholder="Choose a reason" - invalid={!!reasonError} + {fixedPerson ? null : ( + + + + )} + + -