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({
- No accounts match. Check the spelling, or send an invite.
+ No accounts match. Check the spelling, or send an invite: they can accept it with any account.
) : null}
{accounts?.length ? (
-
+
{accounts.map((account) => (
- {account.email}
-
- {account.telegram ? (
- <>
-
- {account.telegram}
- ·
- >
- ) : null}
- {account.linkedTo ? `Linked to ${account.linkedTo.name} (${account.linkedTo.id})` : account.via}
-
+ {account.email || account.id}
+ {account.linkedTo ? (
+
+ Linked to {account.linkedTo.name} ({account.linkedTo.id})
+
+ ) : null}
}
- end={{REASONS[account.reason].label}}
+ end={account.linkedTo ? Linked : undefined}
/>
))}
@@ -149,3 +135,78 @@ export function LinkAccountDialog({
)
}
+
+/**
+ * Unlinks a record's account. Auth takes the record's roles back first; once it confirms, the account is detached and
+ * the roles wait as pending for the next link. If Auth refuses, the account stays linked with its roles.
+ */
+export function UnlinkAccountDialog({
+ open,
+ onOpenChange,
+ person,
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ person: { id: string; name: string; roles: number }
+}) {
+ const unlink = useServerFn(unlinkPersonAccount)
+ const refresh = useRefreshAfter()
+ const [reason, setReason] = useState("")
+ const [submitted, setSubmitted] = useState(false)
+ const max = 500 - UNLINK_REASON.length
+ const reasonError = submitted && !reason.trim() ? "Enter a reason." : undefined
+
+ async function submit() {
+ setSubmitted(true)
+ if (!reason.trim()) return
+ try {
+ await unlink({ data: { personId: person.id, reason: reason.trim() } })
+ } catch (caught) {
+ console.error(caught)
+ throw new Error(errorMessage(caught, "Couldn't unlink the account."), { cause: caught })
+ }
+ await refresh(
+ person.roles
+ ? "Unlinking: PoliNetwork Auth takes the roles back, then the account is detached."
+ : "Account unlinked."
+ )
+ onOpenChange(false)
+ }
+
+ return (
+
+
+
+
+ )
+}
diff --git a/src/features/people/my-area-page.tsx b/src/features/people/my-area-page.tsx
index 09533ba..bf448f6 100644
--- a/src/features/people/my-area-page.tsx
+++ b/src/features/people/my-area-page.tsx
@@ -1,6 +1,6 @@
import { Link, useNavigate } from "@tanstack/react-router"
import { useServerFn } from "@tanstack/react-start"
-import { Camera, ChevronRight, Crown, FileText, Send, UserRound, Users, UsersRound } from "lucide-react"
+import { ChevronRight, Crown, Send, ShieldUser, UserRound, Users, UsersRound } from "lucide-react"
import { useState } from "react"
import {
@@ -15,7 +15,6 @@ import {
PlatformGlyph,
SectionCard,
SectionHeading,
- StatusBadge,
Unset,
} from "@/components/primitives"
import { appToast, PageBar, PageContent, useCan } from "@/components/shell"
@@ -23,17 +22,18 @@ import { Button } from "@/components/ui/button"
import { MyFeeCard } from "./my-fee-card"
import {
- activeTeams,
+ assignmentLabel,
formatDay,
- isActiveAdmin,
+ isTeamAssignment,
+ LINK_METHOD,
+ openAssignments,
personName,
- PHOTO_CONSENT,
teamName,
telegramUrl,
} from "./people-format"
import { errorMessage, useRefreshAfter } from "./people-hooks"
import { acceptMyInvite } from "./people-self.functions"
-import { PersonAvatar, PersonCell, Row, RowIcon, Rows, StateBadge } from "./people-ui"
+import { AssignmentBadge, PersonAvatar, PersonCell, RefusalText, Row, RowIcon, Rows } from "./people-ui"
import type { MyArea } from "./people.contract"
type Linked = Extract
@@ -85,8 +85,8 @@ function InviteView({ token }: { token: string }) {
}
>
- HR invited you to link this sign-in to your PoliNetwork record, so you can see your roles. Use the account
- with the email address the invite was sent to.
+ HR invited you to link this sign-in to your PoliNetwork record, so you get your roles. You can use any
+ account, not only the one at the address the invite was sent to. The link works once.
- {archived ? (
-
- No active roles left. The record stays for history; it no longer appears in team or admin lists.
+ {refusals.map((assignment) => (
+
+ PoliNetwork Auth refused to {assignment.change === "start" ? "give" : "take back"} the{" "}
+ {assignmentLabel(assignment)} role: {assignment.refusal ?? "no reason given"}
+ {/[.!?]$/.test(assignment.refusal ?? "") ? "" : "."}
+ {assignment.change === "start" ? " Someone with clearance can assign it again." : ""}
+
+ ))}
+ {waiting ? (
+
+ A role change is waiting for PoliNetwork Auth. Other role changes for {person.firstName} wait until it
+ answers.
) : null}
- {person.discrepancies.map((discrepancy, index) => (
- void applyCensusTelegram(discrepancy.suggestedTelegram ?? "")}
- >
- Use @{discrepancy.suggestedTelegram}
-
- ) : undefined
- }
- >
- {discrepancy.message}
+ {pending && !linked ? (
+
+ {person.firstName}'s roles wait until the record is linked to an account: send an invite, or link one
+ by hand.
- ))}
+ ) : null}
+ {archived && !refusals.length ? (
+ No current roles. The record stays for history.
+ ) : null}