From 7de4aaa498db8018daba0dec154db3f5e454628b Mon Sep 17 00:00:00 2001 From: Bianca Date: Mon, 26 Oct 2026 20:33:10 +0100 Subject: [PATCH 01/10] fix(telegram): stop reading the audit log's legacy admin_id PoliNetworkOrg/backend#97 drops tg_audit_log.admin_id, after copying it into actor_tg_id, and the anonymous path agent mode relied on. The audit actor now comes from actorTgId only, and the agent-mode docs say the backend refuses its calls. --- AGENTS.md | 6 ++++-- README.md | 2 +- src/features/dashboard/overview.functions.ts | 6 +++--- src/features/telegram/grants.functions.ts | 2 +- src/features/telegram/user-detail/profile.tsx | 1 - src/server/backend.server.ts | 2 +- 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 216f602d..d12ff51e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,8 +59,10 @@ PORT=1xxxx AGENT_MODE=true pnpm dev ``` `AGENT_MODE` (development only) signs in a fake administrator with every permission and disables the auth redirects between -`/login` and `/dashboard`, so open `/dashboard/...` or `/login` directly. Data still comes from `BACKEND_URL` (default -`http://localhost:3000`). When changing auth or those redirects, also verify the normal flow with `AGENT_MODE=false`. +`/login` and `/dashboard`, so open `/dashboard/...` or `/login` directly. It has no access token, so the backend +(`BACKEND_URL`, default `http://localhost:3000`) refuses its calls: pages that read the backend show their error state, +and People serves its sample data. To see real backend data, sign in through the IdP with `AGENT_MODE=false`. When +changing auth or those redirects, also verify the normal flow with `AGENT_MODE=false`. > [!IMPORTANT] > Never run a destructive action on more than one row unless the prompt explicitly asks for it; otherwise ask the user diff --git a/README.md b/README.md index 4996fc63..afedd772 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Set the following runtime variables (the build does not need secrets): | `SESSION_SECRET` | Independent random secret of at least 32 characters, encrypting the 10-minute state/nonce/PKCE cookie. Rotation invalidates pending login attempts. | | `AGENT_PERMISSIONS` | Development only. Comma-separated permissions for the agent-mode user (default: all). `people:self` alone previews the restricted People view. | | `AGENT_PERSON` | Development only. The agent-mode user's People record (default `P-0007`, Direttivo and President). `P-0098` is a Capo Admin and the Vice-president, `P-0034` the IT Team Lead, `P-0115` an IT member, `P-0045` has a fee receipt to review, `P-0021` a rejected one; `match:P-0115` previews the first sign-in, `none` a sign-in without a record. Sending an invite prints its link in the dev server's console. | -| `AGENT_MODE` | Fake all-permission user in development only. Backend must be a local fake or local legacy test instance. Production ignores this mode. | +| `AGENT_MODE` | Fake all-permission user in development only. It has no access token, so the backend refuses its calls; People serves sample data. Production ignores this mode. | | `PORT` | Local listening port (3001 by default); use a port ≥10000 for agent previews. | | `TEST_REDIS_URL` | Opt-in disposable Redis for integration tests. Tests create/delete only UUID session keys. | diff --git a/src/features/dashboard/overview.functions.ts b/src/features/dashboard/overview.functions.ts index bdf746a8..9973b428 100644 --- a/src/features/dashboard/overview.functions.ts +++ b/src/features/dashboard/overview.functions.ts @@ -46,7 +46,7 @@ export const getOverviewCounts = createServerFn() /** * `forbidden` without `tg:users:read`; `unavailable` while the backend predates `tg.users.getCreationHistory` or refuses - * the call as unauthenticated (agent mode's anonymous path), so the Overview shows the total without the chart instead + * the call as unauthenticated (agent mode has no token), so the Overview shows the total without the chart instead * of an error. */ export type TelegramUserHistory = @@ -98,8 +98,8 @@ export const getTelegramUserHistory = createServerFn() if (result.error !== null) throw new Error(result.error) return { status: "ok", days: result.days, today: romeToday() } } catch (error) { - // NOT_FOUND: a backend that predates the procedure. UNAUTHORIZED: the procedure is token-only, and agent mode - // (development) calls the backend anonymously. Either way the Overview shows the total without the chart. + // NOT_FOUND: a backend that predates the procedure. UNAUTHORIZED: agent mode (development) has no token. Either + // way the Overview shows the total without the chart. if ( error instanceof TRPCClientError && (error.data?.code === "NOT_FOUND" || error.data?.code === "UNAUTHORIZED") diff --git a/src/features/telegram/grants.functions.ts b/src/features/telegram/grants.functions.ts index a9461098..1ffb96a2 100644 --- a/src/features/telegram/grants.functions.ts +++ b/src/features/telegram/grants.functions.ts @@ -23,7 +23,7 @@ const grantInput = z }) .refine(({ since, until }) => until > since, { message: "The grant end must be after its start.", path: ["until"] }) -// The backend takes the author from the access token; actor fields are only read from legacy callers. +// The backend takes the author from the access token. export const createTelegramGrant = createServerFn({ method: "POST" }) .middleware([grantsWriteMiddleware]) .validator(grantInput) diff --git a/src/features/telegram/user-detail/profile.tsx b/src/features/telegram/user-detail/profile.tsx index 0c4d76bf..efe381bd 100644 --- a/src/features/telegram/user-detail/profile.tsx +++ b/src/features/telegram/user-detail/profile.tsx @@ -292,7 +292,6 @@ type AuditActor = { name: string; title?: string } function auditActor(audit: Audit): AuditActor { if (audit.admin) return { name: telegramUserName(audit.admin) } if (audit.actorTgId !== null) return { name: `User ${audit.actorTgId}` } - if (audit.adminId !== null) return { name: `User ${audit.adminId}` } if (audit.actorSub) { return { name: audit.client === "dashboard" ? "Dashboard admin" : "PoliNetwork account", diff --git a/src/server/backend.server.ts b/src/server/backend.server.ts index 528302d5..d6bacaa4 100644 --- a/src/server/backend.server.ts +++ b/src/server/backend.server.ts @@ -13,7 +13,7 @@ import { env } from "@/env" /** * A backend client for one request, carrying the signed-in user's access token (RFC v3 §11): the backend verifies it - * and enforces the user's permissions. `null` only in agent mode, which uses the backend's legacy anonymous path. + * and enforces the user's permissions. `null` only in agent mode, which has no token: the backend refuses its calls. */ export function createBackendClient(accessToken: string | null) { return createTRPCClient({ links: backendLinks(accessToken) }) From 1dacccd8d0ee4d8d22f899cfcfb887bbe19978bd Mon Sep 17 00:00:00 2001 From: Bianca Date: Mon, 26 Oct 2026 20:53:41 +0100 Subject: [PATCH 02/10] docs(people): say the backend serves the people contract --- src/features/people/people.contract.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/features/people/people.contract.ts b/src/features/people/people.contract.ts index 55d43982..a8f508e4 100644 --- a/src/features/people/people.contract.ts +++ b/src/features/people/people.contract.ts @@ -1,8 +1,8 @@ import { z } from "zod" /** - * The `people.*` backend contract (docs/people/contract.md). The backend doesn't serve it yet: agent mode answers from - * `people.fake.server.ts`, and real responses are parsed with these schemas before they reach a page. + * The `people.*` backend contract (docs/people/contract.md), served by the backend's `src/people/router.ts`. Agent mode + * answers from `people.fake.server.ts`; real responses are parsed with these schemas before they reach a page. */ export const TEAM_IDS = ["it", "hr", "design", "intl", "events"] as const @@ -387,7 +387,7 @@ export type ImportPreview = z.infer export const capoAreaSchema = z.object({ scope: z.string(), - // `admins` is null while the backend does not know a group's Telegram admins. + // `admins` is null until the bot has reported the group's Telegram admins. groups: z.array(groupSchema.extend({ admins: z.number().int().nullable() })), admins: z.array(personRowSchema), requests: z.array(z.object({ id: z.string(), person: personRefSchema, group: groupSchema, requestedOn: z.string() })), From 2ed9c905cee0a05eea1cdf0b65f2e3951e8b05ff Mon Sep 17 00:00:00 2001 From: Bianca Date: Mon, 26 Oct 2026 21:08:56 +0100 Subject: [PATCH 03/10] docs(people): one source of truth for roles, members and group admins The backend no longer copies PoliNetwork Auth roles and the Microsoft 365 Soci group into the register: it reads them, and Telegram group admins through the bot, on every operation. Being in the Soci group is being a member, and the register keeps only history and People's own data. --- AGENTS.md | 2 +- docs/people/decisions.md | 33 +++++++++++++++++++++------------ docs/people/plan.md | 3 +++ 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2e13c92f..65899d7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ What the app is and how to run it: [`README.md`](README.md). UI rules: [`docs/de `people:receipts:sign` downloads and uploads signed receipts for the officer on the caller's record. Preparing CIE requests additionally requires `esign:sign` and reuses the existing eSign UI and agent; the backend owns the durable receipt/request association and completion (the People backend contract is still pending). The - backend fills the register from PoliNetwork Auth roles bound to People and the Microsoft 365 Soci group, so a + backend reads roles from PoliNetwork Auth and members from the Microsoft 365 Soci group without copying them, so a record's course and year may be empty and its campus `null`. Handoff notes, decisions and the plan: `docs/people/`. - **Shared UI**: `src/components/shell`, `src/components/primitives`, `src/components/ui`. diff --git a/docs/people/decisions.md b/docs/people/decisions.md index 9abc68ed..a0cc02d1 100644 --- a/docs/people/decisions.md +++ b/docs/people/decisions.md @@ -69,21 +69,30 @@ Known prototype defects to not reproduce: ## Where the register's people come from -The backend keeps the register and fills it from two sources besides the dashboard and census imports (backend -README, "People register sources"): +Every fact has one source of truth, and the backend stores no copy of the others: on every People operation it reads +them first and lays them over the register (backend README, "People register sources"). + +| Fact | Source | The register keeps | +| ----------------------------- | ------------------------------------------------- | --------------------------------------------------------------- | +| Admin, team member, team Lead | **PoliNetwork Auth** roles bound to People | When each started and ended, why, Capo Admin scope, team fields | +| Member and member number | **The Microsoft 365 Soci group** and `employeeId` | Suspension or expiry, admission date, fees, receipts | +| Admins of a Telegram group | **Telegram**, through the bot | Nothing | - **PoliNetwork Auth roles.** Master Admin binds hand-made roles to People on the role's page in the IdP: `admin`, `team::member` or `team::lead` (team keys `it`, `hr`, `design`, `intl`, `events`). Their holders get a linked - record with the matching Admin and team entries. PoliNetwork Auth is the source of truth: Add to team, End - membership, End admin role, census answers and account links on a linked record send it one command for the - signed-in user, and the IdP applies all of it or nothing, refusing what that user couldn't do in its own dashboard - (no self-grants, Leads need `people:appoint`, nobody hands out permissions they don't hold). A role takes effect in - People only once the IdP confirms it: until then the record keeps its previous roles and other role edits of that - person wait. Linking a record ends the roles recorded before it until the IdP confirms them; a refusal outside a - direct dashboard action flags the record for HR. Records without an account, and roles with no bound role, stay - owned by People. -- **The Microsoft 365 Soci group** ("Members only" in Microsoft 365). Each member gets a membership numbered by the - account's `employeeId`. Leaving the group ends nothing; the register keeps the status. + record, and on a linked record an Admin or team entry is active exactly while its role is held in the IdP, starting + when the IdP gave it. Add to team, End membership, End admin role, census answers and account links on a linked + record send the IdP one command for the signed-in user, and the IdP applies all of it or nothing, refusing what that + user couldn't do in its own dashboard (no self-grants, Leads need `people:appoint`, nobody hands out permissions they + don't hold). Until the IdP confirms it the record shows the roles the IdP holds, so a requested role grants nothing, + and other role edits of that person wait; a refusal outside a direct dashboard action flags the record for HR. If the + IdP hasn't answered for an hour, People pages fail instead of showing old roles. Records without an account, and + roles with no bound role, stay owned by People. +- **The Microsoft 365 Soci group** ("Members only" in Microsoft 365). Being in the group is being a member, numbered by + the account's `employeeId`; a member who leaves the group becomes former (members admitted in the last two days are + kept while the group listing catches up). +- **Telegram group admins** come from the bot, which reads them from Telegram. While the bot doesn't answer, groups + show no admin information and People keeps what it last knew. Records created this way have only a name: course, year and campus show "—" until HR edits the profile, and My area asks the person to choose them before answering the census. diff --git a/docs/people/plan.md b/docs/people/plan.md index e48f2390..bb84a867 100644 --- a/docs/people/plan.md +++ b/docs/people/plan.md @@ -202,3 +202,6 @@ Newest last. One line per commit or session: date, what was done, what is next. - 2026-10-25: the register now fills from PoliNetwork Auth's bound roles and the Microsoft 365 Soci group (backend `feat/people-role-sync`, IdP `feat/people-role-api`). `profile.campus` is nullable and course/year may be empty; pages show "—", the profile form starts empty and My area asks for them. The Design team is Design & Marketing. +- 2026-10-26: one source of truth per fact: the backend reads PoliNetwork Auth roles, the Microsoft 365 Soci group and + Telegram group admins on every operation instead of copying them (backend `feat/people-single-source`); see + "Where the register's people come from" in `decisions.md`. From 904c4eec6cca6f85b1446330f3514aeb2bde2d16 Mon Sep 17 00:00:00 2001 From: Bianca Date: Mon, 26 Oct 2026 22:56:27 +0100 Subject: [PATCH 04/10] fix(people)!: align admin views and census with Auth roles Remove Telegram group columns, join-request actions and census group selections. Keep the Capo area scoped to assigned active admins and update contracts, fixtures, docs and regression tests. BREAKING CHANGE: Requires the matching backend People contract without Telegram group assignments and join-request procedures. --- docs/people/brief.md | 17 ++- docs/people/contract.md | 23 ++-- docs/people/decisions.md | 10 +- docs/people/plan.md | 9 +- src/features/people/admin-dialogs.tsx | 11 +- src/features/people/admins-page.tsx | 69 ++-------- src/features/people/capo-page.tsx | 121 +----------------- src/features/people/my-area-dialogs.tsx | 4 +- src/features/people/my-area-page.tsx | 32 +---- src/features/people/people-capo.functions.ts | 14 +- src/features/people/people-write.functions.ts | 2 +- src/features/people/people.api.server.ts | 4 - src/features/people/people.contract.ts | 10 +- src/features/people/people.fake.server.ts | 96 +------------- src/features/people/people.fixtures.server.ts | 96 +++----------- src/features/people/person-dialog.tsx | 4 +- src/features/people/person-page.tsx | 34 +---- tests/people-access.test.mjs | 60 ++++----- tests/server-security.test.mjs | 6 +- 19 files changed, 116 insertions(+), 506 deletions(-) diff --git a/docs/people/brief.md b/docs/people/brief.md index 7fb8df8c..11c400c5 100644 --- a/docs/people/brief.md +++ b/docs/people/brief.md @@ -90,8 +90,7 @@ Plus, outside the People service: - Profile: personal email, phone, Telegram username, course, year, campus, other associations, student representative (where). - Member: number, status, admitted on, current year fee status. Link to the Microsoft 365 account. - - Admin: active since, groups they administer (detected by the bot, with Telegram glyphs), groups they asked to join, - available as Capo Admin, Capo Admin of reference. + - Admin: role from PoliNetwork Auth, active since, available as Capo Admin, Capo Admin of reference. - Teams: one row per team with role (Member/Lead), since, and the team-specific fields. - Census history: one row per academic year with answer and source ("Imported from Forms" / "Dashboard"). - Consents: volunteer privacy policy (version, date), photo/video (granted/denied, revocable). @@ -104,9 +103,9 @@ Plus, outside the People service: member", "Change status", "Change member number" (confirm dialog explaining the Microsoft 365 account is updated too), "Export" (confirm, since it exports personal data). -4. Admins: name, course, year, campus, active since, groups administered (count + chips), groups requested, student - representative, other associations, available as Capo Admin, Capo Admin of reference, member yes/no, census status. - Quick filters: "No groups", "Available as Capo Admin", "Declared member but not registered", "No census answer". +4. Admins: name, course, year, campus, active since, student representative, other associations, available as Capo + Admin, Capo Admin of reference, member yes/no, census status. Roles come from PoliNetwork Auth. + Quick filters: "Available as Capo Admin", "Declared member but not registered", "No census answer". 5. Teams: - Index: one card per team (lead avatar, member count, missing census answers), then one table of all team members @@ -134,12 +133,12 @@ Plus, outside the People service: IdP accounts, show suggested matches with the reason ("Same verified Telegram account" / "Similar name — check before linking"), a required reason field, and confirm. -8. Capo Admin (scoped): header "Ingegneria Informatica · 3 groups". The admins of my groups with name, year, active - since, representative, other associations, phone, and Telegram with quick "Open in Telegram" / "Open in WhatsApp" - buttons. My groups, flagging groups without admins. Requests to join my groups. +8. Capo Admin (scoped): header "Ingegneria Informatica". Active admins who have the caller as Capo of reference, with + name, year, active since, representative, other associations, phone, and Telegram with quick "Open in Telegram" / + "Open in WhatsApp" buttons. 9. My area (self-service), for a linked person: my profile (with "Request a correction"), my roles (member number and - status; admin groups; teams), the open census ("Confirm for 2026/27" or "I'm leaving", with fields pre-filled from + status; admin role; teams), the open census ("Confirm for 2026/27" or "I'm leaving", with fields pre-filled from last year), my consents (with revoke), my Capo Admin and my Team Lead, and my team's roster (name, role, Telegram, PoliNetwork email only). Also show: - the first-time link flow: "Is this you?" with the matched record summary and "Yes, link my account" / "This isn't diff --git a/docs/people/contract.md b/docs/people/contract.md index f7fe6eeb..9ee32faa 100644 --- a/docs/people/contract.md +++ b/docs/people/contract.md @@ -49,8 +49,6 @@ type AdminRole = { since: string endedOn: string | null endReason: string | null - groups: { chatId: string; title: string }[] // detected by the bot - requested: { chatId: string; title: string; requestedOn: string }[] availableAsCapo: boolean capoOfReference: { id: PersonId; name: string } | null capoScope: string | null // set when this person is a Capo Admin, e.g. "Ingegneria Informatica" @@ -168,15 +166,14 @@ The UI links to full person records only with `people:read`. Write permissions d | `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, groups: { chatId, title, admins: number }[], admins: PersonRow[], requests: { id, person, group, requestedOn }[] }` | Capo Admin (scope from record) | -| `people.capo.approve` / `.decline` | mutation | `{ requestId }` | `void` | Capo Admin of that group | +| `people.capo.get` | query | — | `{ scope, admins: PersonRow[] }` | 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.confirmMatch` | mutation | — | `void` | `people:self` | | `people.me.rejectMatch` | mutation | — | `void` (creates a Needs review item) | `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` | | `people.me.askToBeAdded` | mutation | `{ roles: string[], groupsOrTeam?, telegramUsername? }` | `void` | `people:self` | -| `people.me.answerCensus` | mutation | `{ answer, course, year, campus, keepGroups: chatId[], keepTeams: TeamId[], reason?, note? }` | `void` (`CONFLICT` once answered: the answer is final) | `people:self` | +| `people.me.answerCensus` | mutation | `{ answer, course, year, campus, keepTeams: TeamId[], reason?, note? }` | `void` (`CONFLICT` once answered: the answer is final) | `people:self` | | `people.me.requestCorrection` | mutation | `{ field, value, note? }` | `void` | `people:self` | | `people.me.revokePhotoConsent` | mutation | — | `void` | `people:self` | @@ -403,9 +400,9 @@ The dashboard checks these too, but the backend owns them (from the security rev - **Unconfirmed match**: while the signed-in account is only a `match` candidate (not confirmed, or rejected), it acts as a person with no record: no team, no Capo Admin scope, no census answer or correction on the candidate's record. Only `people.me.get`, `confirmMatch` and `rejectMatch` see the candidate. -- **`people.capo.approve` / `.decline`**: only requests for groups in the caller's own Capo Admin scope; anything else - answers as already handled, so request IDs of other scopes leak nothing. Approving records the decision; the Telegram bot, not the dashboard, makes the person an admin of - the group. +- **`people.capo.get`**: requires an active Capo Admin role on the caller's confirmed record and returns only active + admins who have that person as Capo of reference. Contacts exclude personal email and member details still require + `people:members:read`. The response contains the scope and admins, with no Telegram group data. - **`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: @@ -438,10 +435,8 @@ including a former membership. It must never overwrite a member number, account `due` or `paid`; `receipt` requires an actual uploaded transfer receipt and cannot be selected at admission. `paid` issues a receipt, so it also needs `people:fees:write`; without it the dashboard admits with the fee due. -### Census group changes +### Census role changes -Team roles deselected in `people.me.answerCensus` end immediately. Telegram admin rights require HR to remove them -and the bot to confirm the result. The backend persists each deselected group's chat ID as pending HR work, adds a -`admin-removal` discrepancy to the record, and records the request in Activity. My area shows the pending state. -Reading the record or People list reconciles pending removals with the bot's current group assignments; confirmed -removals clear their discrepancy and add a completion event. A leaving answer requests removal from every group. +Team roles deselected in `people.me.answerCensus` end through PoliNetwork Auth. A leaving answer also ends the admin +role through Auth; a continuing answer keeps it. People does not track Telegram group assignments, requests to join +or pending group removals. Older records may still carry an `admin-removal` discrepancy, but no new ones are created. diff --git a/docs/people/decisions.md b/docs/people/decisions.md index a0cc02d1..a1e15002 100644 --- a/docs/people/decisions.md +++ b/docs/people/decisions.md @@ -76,7 +76,6 @@ them first and lays them over the register (backend README, "People register sou | ----------------------------- | ------------------------------------------------- | --------------------------------------------------------------- | | Admin, team member, team Lead | **PoliNetwork Auth** roles bound to People | When each started and ended, why, Capo Admin scope, team fields | | Member and member number | **The Microsoft 365 Soci group** and `employeeId` | Suspension or expiry, admission date, fees, receipts | -| Admins of a Telegram group | **Telegram**, through the bot | Nothing | - **PoliNetwork Auth roles.** Master Admin binds hand-made roles to People on the role's page in the IdP: `admin`, `team::member` or `team::lead` (team keys `it`, `hr`, `design`, `intl`, `events`). Their holders get a linked @@ -91,8 +90,8 @@ them first and lays them over the register (backend README, "People register sou - **The Microsoft 365 Soci group** ("Members only" in Microsoft 365). Being in the group is being a member, numbered by the account's `employeeId`; a member who leaves the group becomes former (members admitted in the last two days are kept while the group listing catches up). -- **Telegram group admins** come from the bot, which reads them from Telegram. While the bot doesn't answer, groups - show no admin information and People keeps what it last knew. +- **Admin means the bound role in Auth.** People does not query the bot for Telegram group assignments or track + requests to join groups. A Capo Admin sees the active admins who have them as Capo of reference. Records created this way have only a name: course, year and campus show "—" until HR edits the profile, and My area asks the person to choose them before answering the census. @@ -131,9 +130,8 @@ asks the person to choose them before answering the census. - **Linking**: segmented scope Needs review (queue, Dismiss ghost / Resolve outline) and Pending invites (table, Resend / Revoke). Link account dialog: search, suggested matches with reason badge, required reason, inline conflict error that keeps the dialog open. -- **Capo Admin**: context line names the scope ("Ingegneria Informatica · 3 groups"). Cards My groups (warning badge on - groups without admins) and Requests to join (Decline ghost / Approve outline) above the Admins of my groups table - (phone, Telegram, Open in Telegram / Open in WhatsApp; no personal email). +- **Capo Admin**: context line names the scope ("Ingegneria Informatica"). The table lists active admins who have the + caller as Capo of reference (phone, Telegram, Open in Telegram / Open in WhatsApp; no personal email). - **My area**: open census first while unanswered, pre-filled from last year (year advanced by one). My roles, People to contact (Capo Admin, Team Lead, HR), My profile with Request a correction (no direct edits: HR owns records), My consents with Revoke on photo/video. Team roster: name, role, Telegram, PoliNetwork email only. First sign-in: "Is diff --git a/docs/people/plan.md b/docs/people/plan.md index bb84a867..558a9e09 100644 --- a/docs/people/plan.md +++ b/docs/people/plan.md @@ -108,7 +108,7 @@ src/routes/dashboard/account/ me.tsx (My area), index.tsx (Sign-in & security) ### 8. Capo Admin -- [x] My groups, Requests to join (approve/decline), Admins of my groups with Telegram/WhatsApp links +- [x] Active admins assigned to the Capo Admin of reference, with Telegram/WhatsApp contact links ### 9. My area @@ -202,6 +202,7 @@ Newest last. One line per commit or session: date, what was done, what is next. - 2026-10-25: the register now fills from PoliNetwork Auth's bound roles and the Microsoft 365 Soci group (backend `feat/people-role-sync`, IdP `feat/people-role-api`). `profile.campus` is nullable and course/year may be empty; pages show "—", the profile form starts empty and My area asks for them. The Design team is Design & Marketing. -- 2026-10-26: one source of truth per fact: the backend reads PoliNetwork Auth roles, the Microsoft 365 Soci group and - Telegram group admins on every operation instead of copying them (backend `feat/people-single-source`); see - "Where the register's people come from" in `decisions.md`. +- 2026-10-26: one source of truth per fact: the backend reads PoliNetwork Auth roles and the Microsoft 365 Soci group + instead of copying them (backend `feat/people-single-source`); see "Where the register's people come from" in + `decisions.md`. Admins come from Auth. Removed Telegram group assignments, group join requests and group removals + from the contract, pages, census and agent-mode data. Capo Admin lists active admins assigned to the caller. diff --git a/src/features/people/admin-dialogs.tsx b/src/features/people/admin-dialogs.tsx index 542630f3..4291ac2e 100644 --- a/src/features/people/admin-dialogs.tsx +++ b/src/features/people/admin-dialogs.tsx @@ -12,7 +12,6 @@ import { Spinner, } from "@/components/primitives" import { Textarea } from "@/components/ui/textarea" -import { pluralize } from "@/lib/format" import { cn } from "@/lib/utils" import { setCapoScope } from "./people-appoint.functions" @@ -25,9 +24,9 @@ import { getPeople } 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; groups: number } +export type AdminTarget = { personId: string; name: string } -/** Ends an admin role with a reason; the bot removes their rights in every group they administer. */ +/** Ends the admin role in PoliNetwork Auth with a reason. */ export function EndAdminDialog({ open, onOpenChange, @@ -64,7 +63,7 @@ export function EndAdminDialog({ open={open} onOpenChange={onOpenChange} title="End admin role?" - description={`${target.name} stops being an admin of ${pluralize(target.groups, "group")}. The bot removes their admin rights there; the record keeps the history.`} + description={`${target.name} stops being an admin. The record keeps the history.`} noun="change" dirty={reason !== null || details.trim() !== ""} submitLabel="End admin role" @@ -224,7 +223,7 @@ export function CapoScopeDialog({ open={open} onOpenChange={onOpenChange} title={scope ? "Change Capo Admin scope" : "Appoint as Capo Admin"} - description={`${target.name} coordinates the admins of the course's groups and sees them in Telegram › Capo Admin.`} + description={`${target.name} coordinates the course's admins and sees them in Telegram › Capo Admin.`} noun="appointment" dirty={value !== scope} submitLabel={scope ? "Change scope" : "Appoint Capo Admin"} @@ -265,7 +264,7 @@ export function EndCapoDialog({ tone="danger" icon={Crown} title="End Capo Admin role?" - description={`${target.name} stops being Capo Admin of ${scope}. They stay an admin of their groups; the admins who answered to them have no Capo Admin until you set one.`} + description={`${target.name} stops being Capo Admin of ${scope}. They stay an admin; the admins who answered to them have no Capo Admin until you set one.`} confirmLabel="End Capo Admin role" onConfirm={async () => { await save({ data: { personId: target.personId, scope: null } }) diff --git a/src/features/people/admins-page.tsx b/src/features/people/admins-page.tsx index cc765d4f..485057eb 100644 --- a/src/features/people/admins-page.tsx +++ b/src/features/people/admins-page.tsx @@ -46,7 +46,7 @@ import { type AdminRole, CAMPUSES, type Campus, type PersonRow, YEARS } from "./ type Admin = PersonRow & { admin: AdminRole } type Scope = "active" | "former" type Year = (typeof YEARS)[number] -type Quick = "noGroups" | "availableAsCapo" | "declaredMember" | "noCensus" +type Quick = "availableAsCapo" | "declaredMember" | "noCensus" type AdminDialog = { kind: "end" | "capoRef" | "capoScope" | "endCapo"; admin: Admin } function isAdmin(person: PersonRow): person is Admin { @@ -54,7 +54,6 @@ function isAdmin(person: PersonRow): person is Admin { } const QUICK = { - noGroups: { label: "No groups", test: (person: Admin) => person.admin.groups.length === 0 }, availableAsCapo: { label: "Available as Capo Admin", test: (person: Admin) => person.admin.availableAsCapo }, declaredMember: { label: "Declared member but not registered", @@ -69,16 +68,13 @@ const QUICK = { }, } satisfies Record boolean }> -const QUICK_IDS = ["noGroups", "availableAsCapo", "declaredMember", "noCensus"] satisfies Quick[] +const QUICK_IDS = ["availableAsCapo", "declaredMember", "noCensus"] satisfies Quick[] function matchesQuery(person: Admin, query: string) { if (!query) return true - return [ - personName(person), - person.profile.course, - person.profile.telegramUsername ?? "", - ...person.admin.groups.map((group) => group.title), - ].some((value) => value.toLocaleLowerCase().includes(query)) + return [personName(person), person.profile.course, person.profile.telegramUsername ?? ""].some((value) => + value.toLocaleLowerCase().includes(query) + ) } function CensusCell({ person }: { person: Admin }) { @@ -104,14 +100,10 @@ function tableColumns(year: string): DataTableColumn[] { secondary={person.profile.course} muted={person.admin.status === "former"} /> - {/* Mobile cards: groups and census on one labelled line, then the Capo Admin scope. */} + {/* Mobile cards: status and census on one labelled line, then the Capo Admin scope. */} - {person.admin.status !== "active" - ? "Former admin" - : person.admin.groups.length - ? pluralize(person.admin.groups.length, "group") - : "No groups"} + {person.admin.status !== "active" ? "Former admin" : `Since ${formatDay(person.admin.since)}`} · Census @@ -144,42 +136,11 @@ function tableColumns(year: string): DataTableColumn[] { width: 128, priority: 5, mobile: "hidden", - cell: (person) => {formatDay(person.admin.since)}, - }, - { - id: "groups", - label: "Groups", - width: 232, - mobile: "hidden", cell: (person) => person.admin.status === "former" ? ( - Ended {formatDay(person.admin.endedOn ?? "")} - ) : person.admin.groups.length ? ( - group.title} - renderItem={(group) => } - /> - ) : ( - No groups - ), - }, - { - id: "requested", - label: "Requested", - width: 160, - priority: 6, - mobile: "hidden", - cell: (person) => - person.admin.requested.length ? ( - group.title} - renderItem={(group) => } - /> + Ended {formatDay(person.admin.endedOn ?? "")} ) : ( - + {formatDay(person.admin.since)} ), }, { @@ -256,7 +217,7 @@ function tableColumns(year: string): DataTableColumn[] { const DEFAULT_SORT: TableSort = { column: "name", direction: "asc" } -/** People › Admins: admins of the Telegram course groups, their groups, Capo Admin and census answer. */ +/** People › Admins: the admins PoliNetwork Auth knows, their Capo Admin and census answer. */ export function AdminsPage({ people }: { people: PersonRow[] }) { const canWrite = useCan("people:write") const canAppoint = useCan("people:appoint") @@ -325,20 +286,18 @@ export function AdminsPage({ people }: { people: PersonRow[] }) { setDialogOpen(true) } - const target: AdminTarget | null = dialog - ? { personId: dialog.admin.id, name: personName(dialog.admin), groups: dialog.admin.admin.groups.length } - : null + const target: AdminTarget | null = dialog ? { personId: dialog.admin.id, name: personName(dialog.admin) } : null return ( <> @@ -494,7 +453,7 @@ export function AdminsPage({ people }: { people: PersonRow[] }) { ) } diff --git a/src/features/people/capo-page.tsx b/src/features/people/capo-page.tsx index 569692bd..1b2873b9 100644 --- a/src/features/people/capo-page.tsx +++ b/src/features/people/capo-page.tsx @@ -1,50 +1,35 @@ -import { useServerFn } from "@tanstack/react-start" import { MessageCircle, Send, ShieldUser } from "lucide-react" import { useDeferredValue, useState } from "react" import { buttonMotion, - ChipOverflow, DataTable, type DataTableColumn, EmptyState, IconButton, PlatformGlyph, RowActions, - SectionCard, - SectionEmpty, SectionHeading, - StatusBadge, type TableSort, Unset, } from "@/components/primitives" -import { appToast, Count, PageBar, PageContent, Toolbar } from "@/components/shell" +import { Count, PageBar, PageContent, Toolbar } from "@/components/shell" import { Button } from "@/components/ui/button" import { pluralize } from "@/lib/format" -import { approveJoinRequest, declineJoinRequest } from "./people-capo.functions" import { formatDay, personName, telegramUrl, whatsappUrl } from "./people-format" -import { errorMessage, useRefreshAfter } from "./people-hooks" -import { PersonAvatar, PersonCell, TruncatedChip } from "./people-ui" +import { PersonCell } from "./people-ui" import type { CapoArea, PersonRow } from "./people.contract" -type CapoRequest = CapoArea["requests"][number] - const DEFAULT_SORT: TableSort = { column: "name", direction: "asc" } -/** Telegram › Capo Admin: the groups of the signed-in Capo Admin's course, their admins and requests to join. */ +/** Telegram › Capo Admin: the admins who have the signed-in Capo Admin as Capo of reference. */ export function CapoPage({ area }: { area: CapoArea }) { - const approve = useServerFn(approveJoinRequest) - const decline = useServerFn(declineJoinRequest) - const refresh = useRefreshAfter() const [query, setQuery] = useState("") const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase()) const [sort, setSort] = useState(DEFAULT_SORT) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(20) - const [pending, setPending] = useState(null) - // Telegram titles repeat across courses: match groups by chat ID. - const chatIds = new Set(area.groups.map((group) => group.chatId)) const filtered = area.admins.filter( (person) => @@ -57,20 +42,6 @@ export function CapoPage({ area }: { area: CapoArea }) { const sorted = filtered.toSorted((a, b) => personName(a).localeCompare(personName(b)) * sign) const rows = sorted.slice((page - 1) * pageSize, page * pageSize) - async function decide(request: CapoRequest, approved: boolean) { - setPending(request.id) - try { - await (approved ? approve : decline)({ data: { requestId: request.id } }) - } catch (caught) { - console.error(caught) - appToast.error(errorMessage(caught, approved ? "Couldn't approve the request." : "Couldn't decline the request.")) - setPending(null) - return - } - await refresh(approved ? `Approved ${request.person.name} for ${request.group.title}.` : "Request declined.") - setPending(null) - } - const columns: DataTableColumn[] = [ { id: "name", @@ -99,24 +70,6 @@ export function CapoPage({ area }: { area: CapoArea }) { cell: (person) => person.admin ? {formatDay(person.admin.since)} : , }, - { - id: "groups", - label: "Groups", - width: 200, - mobile: "full", - cell: (person) => { - const groups = person.admin?.groups.filter((group) => chatIds.has(group.chatId)) ?? [] - return groups.length ? ( - group.title} - renderItem={(group) => } - /> - ) : ( - - ) - }, - }, { id: "representative", label: "Representative", @@ -158,71 +111,11 @@ export function CapoPage({ area }: { area: CapoArea }) { return ( <> - +
-
- -
    - {area.groups.map((group) => ( -
  • - - - {group.title} - - {group.admins ? `${pluralize(group.admins, "admin")} · ` : ""} - {group.chatId} - - - {group.admins ? null : No admins} -
  • - ))} -
-
- - {area.requests.length === 0 ? ( - - ) : ( -
    - {area.requests.map((request) => ( -
  • - - {/* At least 160px for the name and date; the buttons wrap under them on phones. */} - - {request.person.name} - - {request.group.title} · asked {formatDay(request.requestedOn)} - - - - - - -
  • - ))} -
- )} -
-
-
- + person.id} @@ -304,7 +197,7 @@ export function CapoPage({ area }: { area: CapoArea }) { ) } diff --git a/src/features/people/my-area-dialogs.tsx b/src/features/people/my-area-dialogs.tsx index ebcefb6a..b958e146 100644 --- a/src/features/people/my-area-dialogs.tsx +++ b/src/features/people/my-area-dialogs.tsx @@ -164,7 +164,7 @@ export function CorrectionDialog({ open, onOpenChange }: { open: boolean; onOpen } const ROLE_OPTIONS = { - "Admin of Telegram groups": "admin", + "PoliNetwork admin": "admin", "Team member": "team", "Member (socio)": "member", } satisfies Record @@ -239,7 +239,7 @@ export function AskToBeAddedDialog({ open, onOpenChange }: { open: boolean; onOp label="Telegram username" htmlFor="ask-telegram" optional - hint="Helps HR find you in the groups the bot knows." + hint="Helps HR match your request to your record." error={telegramError} > candidate.year === campaign.year) : undefined - // Groups are kept by chat ID: titles aren't unique. - const adminGroups = isActiveAdmin(person) ? (person.admin?.groups ?? []) : [] - const groups = adminGroups.map((group) => group.chatId) const teams = activeTeams(person).map((membership) => membership.team) // Records created from PoliNetwork Auth or Microsoft 365 have no course or campus until someone fills them in. const [course, setCourse] = useState(person.profile.course || null) const [year, setYear] = useState(nextYear(person.profile.year)) const [campus, setCampus] = useState(person.profile.campus) const [showMissing, setShowMissing] = useState(false) - const [keepGroups, setKeepGroups] = useState(groups) const [keepTeams, setKeepTeams] = useState(teams) const [saving, setSaving] = useState(false) const [leavingOpen, setLeavingOpen] = useState(false) const leavingKey = useOpenGeneration(leavingOpen) if (!campaign) return null - const answer = course && year && campus ? { course, year, campus, keepGroups, keepTeams } : null + const answer = course && year && campus ? { course, year, campus, keepTeams } : null - // Team roles update immediately; Telegram removals wait for confirmation. if (entry?.answer) { return ( @@ -291,11 +285,6 @@ function CensusCard({ area }: { area: Linked }) { meta={`Answered ${entry.answeredOn ? formatDay(entry.answeredOn) : ""}${entry.source ? ` · ${CENSUS_SOURCE[entry.source]}` : ""}`} /> - {person.discrepancies.some((item) => item.kind === "admin-removal") && ( - - Your Telegram group changes are waiting for HR to remove the admin rights. - - )} ) } @@ -394,22 +383,6 @@ function CensusCard({ area }: { area: Linked }) { />
- {groups.length ? ( - - adminGroups.find((group) => group.chatId === chatId)?.title ?? chatId} - /> - - ) : null} {teams.length ? (
} - title={`Admin of ${pluralize(admin.groups.length, "group")}`} + title="Admin" meta={`Since ${formatDay(admin.since)}`} - detail={admin.groups.map((group) => group.title).join(", ") || undefined} /> ) : null} {admin?.capoScope ? ( diff --git a/src/features/people/people-capo.functions.ts b/src/features/people/people-capo.functions.ts index 5444d736..53ff6c67 100644 --- a/src/features/people/people-capo.functions.ts +++ b/src/features/people/people-capo.functions.ts @@ -3,23 +3,11 @@ import { createServerFn } from "@tanstack/react-start" import { peopleSelfMiddleware } from "@/server/auth.middleware" import { peopleApi } from "./people.api.server" -import { joinRequestInputSchema } from "./people.contract" /** - * Telegram › Capo Admin: the signed-in person's scope, its groups, their admins and requests to join. The backend + * Telegram › Capo Admin: the signed-in person's scope and the admins who have them as Capo of reference. The backend * scopes it to the Capo Admin role on their own record; `people:self` only lets them ask. */ export const getCapoArea = createServerFn({ method: "GET" }) .middleware([peopleSelfMiddleware]) .handler(async ({ context }) => (await peopleApi(context)).getCapoArea()) - -/** Makes the requester an admin of the group; the bot grants the rights. Only the group's Capo Admin. */ -export const approveJoinRequest = createServerFn({ method: "POST" }) - .middleware([peopleSelfMiddleware]) - .validator(joinRequestInputSchema) - .handler(async ({ data, context }) => (await peopleApi(context)).approveJoinRequest(data)) - -export const declineJoinRequest = createServerFn({ method: "POST" }) - .middleware([peopleSelfMiddleware]) - .validator(joinRequestInputSchema) - .handler(async ({ data, context }) => (await peopleApi(context)).declineJoinRequest(data)) diff --git a/src/features/people/people-write.functions.ts b/src/features/people/people-write.functions.ts index 71223478..63ab7071 100644 --- a/src/features/people/people-write.functions.ts +++ b/src/features/people/people-write.functions.ts @@ -51,7 +51,7 @@ export const unlinkPersonAccount = createServerFn({ method: "POST" }) .validator(personRefInputSchema) .handler(async ({ data, context }) => (await peopleApi(context)).unlinkAccount(data)) -/** The bot removes their admin rights in every group; the record keeps the history. */ +/** Ends the admin role through PoliNetwork Auth; the record keeps the history. */ export const endAdminRole = createServerFn({ method: "POST" }) .middleware([peopleWriteMiddleware]) .validator(endAdminInputSchema) diff --git a/src/features/people/people.api.server.ts b/src/features/people/people.api.server.ts index 85e5d059..0736a86a 100644 --- a/src/features/people/people.api.server.ts +++ b/src/features/people/people.api.server.ts @@ -120,8 +120,6 @@ export type PeopleApi = { linkAccount: (input: LinkAccountInput) => Promise unlinkAccount: (input: { personId: string }) => Promise getCapoArea: () => Promise - approveJoinRequest: (input: { requestId: string }) => Promise - declineJoinRequest: (input: { requestId: string }) => Promise getMyRoles: () => Promise getMyArea: () => Promise confirmMatch: () => Promise @@ -204,8 +202,6 @@ function backendPeopleApi(accessToken: string | null): PeopleApi { linkAccount: (input) => mutate("linking.link", input), unlinkAccount: (input) => mutate("linking.unlink", input), getCapoArea: () => query("capo.get", undefined, capoAreaSchema), - approveJoinRequest: (input) => mutate("capo.approve", input), - declineJoinRequest: (input) => mutate("capo.decline", input), getMyRoles: () => query("me.roles", undefined, myRolesSchema), getMyArea: () => query("me.get", undefined, myAreaSchema), confirmMatch: () => mutate("me.confirmMatch"), diff --git a/src/features/people/people.contract.ts b/src/features/people/people.contract.ts index 9a6553f2..3efbe4cd 100644 --- a/src/features/people/people.contract.ts +++ b/src/features/people/people.contract.ts @@ -59,7 +59,6 @@ export type PhotoConsent = (typeof PHOTO_CONSENTS)[number] export type Signer = (typeof SIGNERS)[number] const personRefSchema = z.object({ id: z.string(), name: z.string() }) -const groupSchema = z.object({ chatId: z.string(), title: z.string() }) export const profileSchema = z.object({ firstName: z.string(), @@ -93,9 +92,6 @@ export const adminRoleSchema = z.object({ since: z.string(), endedOn: z.string().nullable(), endReason: z.string().nullable(), - /** Detected by the bot. */ - groups: z.array(groupSchema), - requested: z.array(groupSchema.extend({ requestedOn: z.string() })), availableAsCapo: z.boolean(), capoOfReference: personRefSchema.nullable(), /** Set when this person is a Capo Admin, e.g. "Ingegneria Informatica". */ @@ -161,6 +157,7 @@ export const personRowSchema = z.object({ }) export const discrepancySchema = z.object({ + /** `admin-removal` is only on older records: People no longer tracks which Telegram groups an admin keeps. */ kind: z.enum(["declared-member", "telegram-mismatch", "needs-review", "admin-removal"]), message: z.string(), suggestedTelegram: z.string().nullable(), @@ -389,9 +386,7 @@ export type ImportPreview = z.infer export const capoAreaSchema = z.object({ scope: z.string(), - groups: z.array(groupSchema.extend({ admins: z.number().int() })), admins: z.array(personRowSchema), - requests: z.array(z.object({ id: z.string(), person: personRefSchema, group: groupSchema, requestedOn: z.string() })), }) export type CapoArea = z.infer @@ -664,8 +659,6 @@ export const linkAccountInputSchema = z.object({ reason: reasonSchema, }) -export const joinRequestInputSchema = z.object({ requestId: opaqueIdSchema }) - export const askToBeAddedInputSchema = z.object({ roles: z .array(z.enum(["admin", "team", "member"])) @@ -683,7 +676,6 @@ export const answerCensusInputSchema = z.object({ course: z.string().trim().min(1).max(120), year: z.enum(YEARS), campus: campusSchema, - keepGroups: z.array(z.string().max(120)).max(100), keepTeams: z.array(teamIdSchema).max(TEAM_IDS.length), reason: z.string().trim().max(200), note: z.string().trim().max(500), diff --git a/src/features/people/people.fake.server.ts b/src/features/people/people.fake.server.ts index 1fa71049..3d236c1b 100644 --- a/src/features/people/people.fake.server.ts +++ b/src/features/people/people.fake.server.ts @@ -27,14 +27,11 @@ import { addDays, buildPeopleStore, emptyFeeState, - FAMILIES, - FAMILY_IDS, FEE_AMOUNT_CENTS, FEE_BENEFICIARY, FEE_DUE_ON, FEE_IBAN, FEE_YEAR, - GROUP_IDS, m365Address, OFFICERS, receiptId, @@ -98,8 +95,6 @@ function log(person: StoredPerson, actor: string, text: string) { function discrepancies(person: StoredPerson): Discrepancy[] { const out: Discrepancy[] = [] - for (const pending of person.pendingGroupRemovals ?? []) - out.push({ kind: "admin-removal", message: pending.message, suggestedTelegram: null }) const current = person.census.find((entry) => entry.year === "2026/27") const isMember = person.member !== null && person.member.status !== "former" if (current?.declaredMember && !isMember) { @@ -156,11 +151,7 @@ function record(person: StoredPerson, viewer: PeopleViewer): PersonRecord { return { ...row(person, viewer, canRead || ownRecord), notes: canRead ? person.notes : null, - discrepancies: canRead - ? discrepancies(person) - : discrepancies(person).filter((item) => - person.pendingGroupRemovals?.some((pending) => pending.message === item.message) - ), + discrepancies: canRead ? discrepancies(person) : [], activity: [...person.activity].sort((a, b) => (a.at < b.at ? 1 : -1)), fees: ownRecord || viewer.permissions.includes("people:members:read") ? fees(person) : null, } @@ -330,21 +321,6 @@ function csvCell(value: string) { return `"${safe.replaceAll('"', '""')}"` } -/** - * A request to join one of the groups the signed-in Capo Admin coordinates. Any other request reads as already - * handled, so a caller can't tell whether a request of another course exists. - */ -function capoRequest(viewer: PeopleViewer, requestId: string) { - const scope = linkedRecord(viewer)?.admin?.capoScope - const family = scope ? familyOfScope(scope) : undefined - const titles: readonly string[] = family ? FAMILIES[family].groups : [] - const request = data().joinRequests.find( - (candidate) => candidate.id === requestId && titles.includes(candidate.group) - ) - if (!request) throw new Error("This request was already handled.") - return request -} - function leadsOf(person: StoredPerson | null): TeamId[] { if (!person) return [] return person.teams.filter((team) => team.role === "lead" && !team.endedOn).map((team) => team.team) @@ -381,10 +357,6 @@ function campaigns(): Campaign[] { ] } -function familyOfScope(scope: string) { - return FAMILY_IDS.find((family) => FAMILIES[family].scope === scope) -} - function buildPreview(fileName: string): ImportPreview { const people = data().people.filter((person) => person.census.some((entry) => entry.source === "forms")) const reimport = importedFiles.has(fileName) @@ -469,9 +441,9 @@ function buildPreview(fileName: string): ImportPreview { name: fullName(person), detail: [ "Telegram username differs from the record", - "Says they administer Statistica; the bot hasn't seen them there", + "Says they're an admin; PoliNetwork Auth doesn't list them", "Campus Cremona in the form, Leonardo on the record", - "Answered “No” but administers 2 groups", + "Answered “No” but still holds the admin role", ][index % 4], answer: answer(person), })), @@ -519,7 +491,7 @@ function buildPreview(fileName: string): ImportPreview { : { telegramId: person.profile.telegramId ?? "", name: fullName(person), - info: person.admin ? `Admin in ${Math.max(1, person.admin.groups.length)} groups` : "Member of 3 groups", + info: person.admin ? "Admin" : "Member of 3 groups", }, })), } @@ -579,8 +551,6 @@ export function fakePeopleApi(viewer: PeopleViewer): PeopleApi { since: today(), endedOn: null, endReason: null, - groups: [], - requested: [], availableAsCapo: false, capoOfReference: null, capoScope: null, @@ -692,8 +662,6 @@ export function fakePeopleApi(viewer: PeopleViewer): PeopleApi { status: "former", endedOn: today(), endReason: input.reason, - groups: [], - requested: [], capoScope: null, }) log(person, actor, `ended the admin role: ${input.reason}`) @@ -827,10 +795,6 @@ export function fakePeopleApi(viewer: PeopleViewer): PeopleApi { question: "Telegram username", answer: `@${person.censusTelegram ?? person.profile.telegramUsername ?? "—"}`, }, - { - question: "Groups you administer", - answer: person.admin?.groups.map((group) => group.title).join(", ") || "—", - }, { question: "Are you a PoliNetwork member (socio)?", answer: entry.declaredMember ? "Yes" : "No" }, { question: "Available as Capo Admin?", answer: person.admin?.availableAsCapo ? "Yes" : "No" }, { @@ -1035,58 +999,19 @@ export function fakePeopleApi(viewer: PeopleViewer): PeopleApi { getCapoArea: async () => { require(viewer, "people:self") const me = linkedRecord(viewer) - const scope = me?.admin?.capoScope - const family = scope ? familyOfScope(scope) : undefined - if (!scope || !family) throw new Error("UNAUTHORIZED") - const titles = FAMILIES[family].groups + const scope = me?.admin?.status === "active" ? me.admin.capoScope : null + if (!me || !scope) throw new Error("UNAUTHORIZED") const admins = data().people.filter( - (person) => - person.admin?.status === "active" && person.admin.groups.some((group) => titles.includes(group.title)) + (person) => person.admin?.status === "active" && person.admin.capoOfReference?.id === me.id ) return { scope, - groups: titles.map((title) => ({ - chatId: GROUP_IDS.get(title) ?? "", - title, - admins: admins.filter((person) => person.admin?.groups.some((group) => group.title === title)).length, - })), admins: admins.map((person) => ({ ...row(person, viewer, true), profile: { ...person.profile, personalEmail: null }, })), - requests: data() - .joinRequests.filter((request) => titles.includes(request.group)) - .map((request) => { - const person = find(request.personId) - return { - id: request.id, - person: { id: person.id, name: fullName(person) }, - group: { chatId: GROUP_IDS.get(request.group) ?? "", title: request.group }, - requestedOn: request.requestedOn, - } - }), } satisfies CapoArea }, - approveJoinRequest: async (input) => { - require(viewer, "people:self") - const store = data() - const request = capoRequest(viewer, input.requestId) - const person = find(request.personId) - if (person.admin && !person.admin.groups.some((group) => group.title === request.group)) { - person.admin.groups.push({ chatId: GROUP_IDS.get(request.group) ?? "", title: request.group }) - person.admin.requested = person.admin.requested.filter((group) => group.title !== request.group) - } - store.joinRequests = store.joinRequests.filter((candidate) => candidate !== request) - log(person, actor, `approved ${person.profile.firstName} as admin of ${request.group}`) - }, - declineJoinRequest: async (input) => { - require(viewer, "people:self") - const store = data() - const request = capoRequest(viewer, input.requestId) - const person = find(request.personId) - if (person.admin) person.admin.requested = person.admin.requested.filter((group) => group.title !== request.group) - store.joinRequests = store.joinRequests.filter((candidate) => candidate !== request) - }, getMyRoles: async () => { const me = linkedRecord(viewer) return { capoScope: me?.admin?.capoScope ?? null, leads: leadsOf(me) } @@ -1243,13 +1168,6 @@ export function fakePeopleApi(viewer: PeopleViewer): PeopleApi { declaredMember: me.member !== null && me.member.status !== "former", reminders: 0, }) - for (const group of me.admin?.groups ?? []) { - if (input.answer === "continues" && input.keepGroups.includes(group.chatId)) continue - const message = `Telegram admin removal pending: ${group.title} (${group.chatId}). HR must remove the admin rights; requested in census 2026/27.` - me.pendingGroupRemovals ??= [] - if (!me.pendingGroupRemovals.some((item) => item.chatId === group.chatId)) - me.pendingGroupRemovals.push({ chatId: group.chatId, message }) - } for (const membership of me.teams) if (!membership.endedOn && (input.answer === "leaving" || !input.keepTeams.includes(membership.team))) Object.assign(membership, { endedOn: today(), endReason: input.reason || "Census 2026/27" }) diff --git a/src/features/people/people.fixtures.server.ts b/src/features/people/people.fixtures.server.ts index 89376b56..7bcd6fe4 100644 --- a/src/features/people/people.fixtures.server.ts +++ b/src/features/people/people.fixtures.server.ts @@ -34,7 +34,6 @@ export type StoredPerson = Omit reviews: Review[] - joinRequests: JoinRequest[] /** Every receipt ever issued, voided ones included. */ receipts: Receipt[] } @@ -265,41 +261,16 @@ const COURSES: Course[] = [ export const COURSE_NAMES = COURSES.map((course) => course.name) export const FAMILIES = { - inf: { scope: "Ingegneria Informatica", groups: ["Fondamenti di Informatica", "Basi di Dati", "Reti Logiche"] }, - ges: { - scope: "Ingegneria Gestionale", - groups: ["Economia e Organizzazione Aziendale", "Ricerca Operativa", "Statistica", "Gestione dei Progetti"], - }, - ind: { - scope: "Ingegneria Industriale", - groups: ["Fisica Tecnica", "Meccanica Razionale", "Termodinamica Applicata", "Aerodinamica"], - }, - ele: { - scope: "Ingegneria dell'Informazione", - groups: ["Elettrotecnica", "Fondamenti di Elettronica", "Segnali e Sistemi", "Geometria e Algebra Lineare"], - }, - des: { - scope: "Design", - groups: ["Laboratorio di Comunicazione Visiva", "Storia del Design", "Disegno per il Design"], - }, - arc: { - scope: "Architettura", - groups: ["Storia dell'Architettura", "Laboratorio di Progettazione 1", "Scienza delle Costruzioni"], - }, -} satisfies Record + inf: { scope: "Ingegneria Informatica" }, + ges: { scope: "Ingegneria Gestionale" }, + ind: { scope: "Ingegneria Industriale" }, + ele: { scope: "Ingegneria dell'Informazione" }, + des: { scope: "Design" }, + arc: { scope: "Architettura" }, +} satisfies Record export const FAMILY_IDS: FamilyId[] = ["inf", "ges", "ind", "ele", "des", "arc"] -/** Stable fake Telegram chat ids per group title. */ -export const GROUP_IDS: Map = new Map( - FAMILY_IDS.flatMap((family, familyIndex) => - FAMILIES[family].groups.map((title, index): [string, string] => [ - title, - `-100${1734200000 + familyIndex * 977331 + index * 40517}`, - ]) - ) -) - const REPS = ["Consiglio di Corso di Studio", "Giunta di Scuola", "Consiglio degli Studenti", "Senato Accademico"] const ASSOCIATIONS = [ "Skyward", @@ -478,10 +449,6 @@ function campusOf(value: string) { return campus } -function group(title: string) { - return { chatId: GROUP_IDS.get(title) ?? "", title } -} - export function buildPeopleStore(): PeopleStore { const random = mulberry32(1442) const pick = (items: readonly T[]) => items[Math.floor(random() * items.length)] @@ -610,17 +577,7 @@ export function buildPeopleStore(): PeopleStore { const drafts = new Map(people.map((p) => [p.id, { teams: [] }])) const draft = (p: StoredPerson) => drafts.get(p.id) ?? { teams: [] } - const familyGroups = (family: FamilyId) => FAMILIES[family].groups.filter((title) => title !== "Reti Logiche") const makeAdmin = (p: StoredPerson, status: "active" | "former") => { - const pool = familyGroups(p.family) - const groups = random() < 0.09 ? [] : sample(pool, 1 + Math.floor(random() * Math.min(3, pool.length))) - const requested = - random() < 0.12 - ? sample( - FAMILIES[p.family].groups.filter((title) => !groups.includes(title)), - 1 - ) - : [] const rep = random() < 0.15 ? pick(REPS) : null const assoc = random() < 0.2 ? sample(ASSOCIATIONS, random() < 0.3 ? 2 : 1) : [] const availableAsCapo = random() < 0.12 @@ -632,8 +589,6 @@ export function buildPeopleStore(): PeopleStore { since, endedOn, endReason, - groups: status === "former" ? [] : groups.map(group), - requested: status === "former" ? [] : requested.map((title) => ({ ...group(title), requestedOn: "2026-10-09" })), availableAsCapo, capoOfReference: null, capoScope: null, @@ -766,32 +721,26 @@ export function buildPeopleStore(): PeopleStore { const keyAdmin = ( id: string, since: string, - groups: string[], extra: Partial = {} ) => { const p = person(id) makeAdmin(p, "active") const admin = draft(p).admin if (!admin) return - Object.assign( - admin, - { since, groups: groups.map(group), requested: [], rep: null, assoc: [], availableAsCapo: false }, - extra - ) + Object.assign(admin, { since, rep: null, assoc: [], availableAsCapo: false }, extra) } - keyAdmin("P-0142", "2024-10-03", ["Fondamenti di Informatica", "Basi di Dati"], { - requested: [{ ...group("Reti Logiche"), requestedOn: "2026-10-09" }], + keyAdmin("P-0142", "2024-10-03", { availableAsCapo: true, rep: "Consiglio di Corso di Studio", assoc: ["Skyward"], }) - keyAdmin("P-0098", "2022-09-20", ["Fondamenti di Informatica"], { assoc: ["JEMP"] }) - keyAdmin("P-0034", "2023-03-01", ["Basi di Dati"]) - keyAdmin("P-0115", "2025-09-25", ["Fondamenti di Informatica", "Basi di Dati"], { rep: "Consiglio degli Studenti" }) - keyAdmin("P-0063", "2025-02-11", ["Ricerca Operativa", "Statistica"]) - keyAdmin("P-0021", "2023-10-12", ["Segnali e Sistemi"]) - keyAdmin("P-0088", "2024-03-05", ["Fisica Tecnica", "Termodinamica Applicata"]) - keyAdmin("P-0076", "2026-03-02", ["Aerodinamica"]) + keyAdmin("P-0098", "2022-09-20", { assoc: ["JEMP"] }) + keyAdmin("P-0034", "2023-03-01") + keyAdmin("P-0115", "2025-09-25", { rep: "Consiglio degli Studenti" }) + keyAdmin("P-0063", "2025-02-11") + keyAdmin("P-0021", "2023-10-12") + keyAdmin("P-0088", "2024-03-05") + keyAdmin("P-0076", "2026-03-02") const chiara = person("P-0131") makeAdmin(chiara, "former") const chiaraAdmin = draft(chiara).admin @@ -1074,13 +1023,13 @@ export function buildPeopleStore(): PeopleStore { giulia.activity = [ { actor: "Luca Ferri", text: "sent an invite to giulia.rossi.02@gmail.com", at: "2026-10-06T18:42:00+02:00" }, - { actor: "Giulia Rossi", text: "asked to join Reti Logiche", at: "2026-10-09T21:30:00+02:00" }, + { actor: "Luca Ferri", text: "set Davide Conti as Capo Admin of reference", at: "2026-10-09T21:30:00+02:00" }, { actor: "Census import", text: "added the 2026/27 answer “Continues” from Modulo Volontari 2026/27", at: "2026-10-01T10:05:00+02:00", }, - { actor: "Telegram bot", text: "detected Giulia as admin of Basi di Dati", at: "2026-09-24T09:12:00+02:00" }, + { actor: "PoliNetwork Auth", text: "confirmed the admin role", at: "2026-09-24T09:12:00+02:00" }, { actor: "Sara Galli", text: "added Giulia to IT as Member", at: "2025-03-04T16:20:00+01:00" }, ] @@ -1186,13 +1135,6 @@ export function buildPeopleStore(): PeopleStore { } review[0].account = { status: "review", reviewId: "r1", issue: "Its Telegram account is claimed by another sign-in" } - const informatics = people.filter((p) => p.family === "inf" && p.admin?.status === "active" && !(p.id in KEYS)) - const joinRequests: JoinRequest[] = [ - { id: "j1", personId: "P-0142", group: "Reti Logiche", requestedOn: "2026-10-09" }, - { id: "j2", personId: (informatics[0] ?? giulia).id, group: "Reti Logiche", requestedOn: "2026-10-11" }, - { id: "j3", personId: (informatics[1] ?? giulia).id, group: "Basi di Dati", requestedOn: "2026-10-12" }, - ] - // Someone known only from a role in PoliNetwork Auth: the backend links the record but has no course or campus. const fromAuth = people.find( (p) => !(p.id in KEYS) && p.account.status === "linked" && p.teams.length && !p.admin && !p.member @@ -1200,7 +1142,7 @@ export function buildPeopleStore(): PeopleStore { if (fromAuth) Object.assign(fromAuth.profile, { course: "", year: "", campus: null, personalEmail: null, phone: null }) - return { people, byId, reviews, joinRequests, receipts: seedFees(people, byId) } + return { people, byId, reviews, receipts: seedFees(people, byId) } } /** The fee year People › Receipts opens on, and the one My area asks to pay. */ diff --git a/src/features/people/person-dialog.tsx b/src/features/people/person-dialog.tsx index 3d367537..ee11bf01 100644 --- a/src/features/people/person-dialog.tsx +++ b/src/features/people/person-dialog.tsx @@ -200,9 +200,9 @@ export function PersonDialog({ className={checkboxControl} /> - Admin of Telegram groups + PoliNetwork admin - The bot fills in their groups. Members are added from the member register, and teams from Teams. + The admin role is managed in PoliNetwork Auth. Add members from the member register and teams from Teams. diff --git a/src/features/people/person-page.tsx b/src/features/people/person-page.tsx index d380562b..28a9c295 100644 --- a/src/features/people/person-page.tsx +++ b/src/features/people/person-page.tsx @@ -104,7 +104,7 @@ export function PersonPage({ person }: { person: PersonRecord }) { const archived = hasNoRoles(person) const roles = roleLabels(person) const { profile, admin, member, account } = person - const adminTarget = { personId: person.id, name, groups: admin?.groups.length ?? 0 } + const adminTarget = { personId: person.id, name } const { busy: downloading, download: downloadFile } = useFileDownload() const edit = useDialogState() @@ -413,38 +413,6 @@ export function PersonPage({ person }: { person: PersonRecord }) { ]} /> - {admin.status === "active" || admin.requested.length ? ( - - {admin.status === "active" ? ( - Groups they administer · detected by the bot - ) : null} - {admin.status === "active" && admin.groups.length === 0 ? ( - } - title="No groups" - meta="The bot hasn't seen them as admin in any group." - /> - ) : null} - {admin.groups.map((group) => ( - } - title={group.title} - meta={{group.chatId}} - /> - ))} - {admin.requested.length ? Asked to join : null} - {admin.requested.map((group) => ( - } - title={group.title} - meta={`Asked ${formatDay(group.requestedOn)}`} - end={Pending} - /> - ))} - - ) : null} ) : null} diff --git a/tests/people-access.test.mjs b/tests/people-access.test.mjs index 120b6874..2dbf331a 100644 --- a/tests/people-access.test.mjs +++ b/tests/people-access.test.mjs @@ -13,7 +13,6 @@ registerHooks({ }) const { fakePeopleApi } = await import("../src/features/people/people.fake.server.ts") -const { FAMILIES } = await import("../src/features/people/people.fixtures.server.ts") const ALL = [ "people:read", @@ -34,34 +33,30 @@ const teamId = activeLead(lead).team const teammate = records.find( (person) => person.id !== lead.id && person.teams.some((team) => team.team === teamId && !team.endedOn) ) -const capoOf = (group) => - records.find((person) => { - const scope = person.admin?.status === "active" ? person.admin.capoScope : null - return scope && Object.values(FAMILIES).some((family) => family.scope === scope && family.groups.includes(group)) - }) -const otherCapo = (group) => - records.find((person) => person.admin?.status === "active" && person.admin.capoScope && person !== capoOf(group)) +const capo = records.find((person) => person.admin?.status === "active" && person.admin.capoScope) const unauthorized = { message: "UNAUTHORIZED" } -void test("join requests are approved or declined only by the Capo Admin of the request's group", async () => { - const { requests } = await fakePeopleApi({ permissions: SELF, personId: capoOf("Reti Logiche").id }).getCapoArea() - const request = requests.find((candidate) => candidate.group.title === "Reti Logiche") - assert.ok(request, "the fixtures hold a request to join Reti Logiche") - - // Anyone else's request reads as already handled, so callers can't tell it exists. - const hidden = { message: "This request was already handled." } - for (const personId of [plain.id, "none", otherCapo("Reti Logiche").id]) { - const viewer = fakePeopleApi({ permissions: SELF, personId }) - await assert.rejects(viewer.approveJoinRequest({ requestId: request.id }), hidden) - await assert.rejects(viewer.declineJoinRequest({ requestId: request.id }), hidden) +void test("a Capo Admin sees only active admins who have them as Capo of reference", async () => { + for (const personId of [plain.id, "none", null]) + await assert.rejects(fakePeopleApi({ permissions: SELF, personId }).getCapoArea(), unauthorized) + await assert.rejects(hr.getCapoArea(), unauthorized) + await assert.rejects(fakePeopleApi({ permissions: [], personId: capo.id }).getCapoArea(), unauthorized) + + const area = await fakePeopleApi({ permissions: SELF, personId: capo.id }).getCapoArea() + assert.deepEqual(Object.keys(area).sort(), ["admins", "scope"]) + assert.equal(area.scope, capo.admin.capoScope) + const expected = records.filter( + (person) => person.admin?.status === "active" && person.admin.capoOfReference?.id === capo.id + ) + assert.ok(expected.length, "the fixtures hold admins assigned to this Capo Admin") + assert.deepEqual(area.admins.map((person) => person.id).sort(), expected.map((person) => person.id).sort()) + for (const person of area.admins) { + assert.equal(person.profile.personalEmail, null) + assert.equal(person.member, null) + assert.equal("groups" in person.admin, false) + assert.equal("requested" in person.admin, false) } - // Even `people:write` gives no Capo Admin powers. - await assert.rejects(hr.approveJoinRequest({ requestId: request.id }), hidden) - - await fakePeopleApi({ permissions: SELF, personId: capoOf("Reti Logiche").id }).approveJoinRequest({ - requestId: request.id, - }) }) void test("an unconfirmed first-sign-in match gets none of the record's powers", async () => { @@ -75,7 +70,6 @@ void test("an unconfirmed first-sign-in match gets none of the record's powers", unauthorized ) - const capo = capoOf("Reti Logiche") await assert.rejects(fakePeopleApi({ permissions: SELF, personId: `match:${capo.id}` }).getCapoArea(), unauthorized) const { profile } = lead @@ -85,7 +79,6 @@ void test("an unconfirmed first-sign-in match gets none of the record's powers", course: profile.course, year: profile.year, campus: profile.campus, - keepGroups: [], keepTeams: [], reason: "", note: "", @@ -150,21 +143,22 @@ void test("member details need people:members:read, except on the viewer's own r assert.deepEqual((await self.getPerson(member.id)).member, member.member) }) -void test("a census answer that drops a group leaves an admin-removal item, not a Linking review", async () => { +void test("an admin can confirm the census without Telegram groups or removal work", async () => { const admin = records.find( (person) => person.admin?.status === "active" && - person.admin.groups.length && person.account.status === "linked" && !person.census.some((entry) => entry.year === "2026/27" && entry.answer) ) assert.ok(admin, "the fixtures hold a linked admin who hasn't answered") const self = fakePeopleApi({ permissions: SELF, personId: admin.id }) const { course, campus } = admin.profile - await self.answerCensus({ answer: "continues", course, year: "1st year MSc", campus, keepGroups: [], keepTeams: [] }) - const kinds = (await hr.getPerson(admin.id)).discrepancies.map((discrepancy) => discrepancy.kind) - assert.ok(kinds.includes("admin-removal")) - assert.ok(!kinds.includes("needs-review")) + const keepTeams = admin.teams.filter((team) => !team.endedOn).map((team) => team.team) + await self.answerCensus({ answer: "continues", course, year: "1st year MSc", campus, keepTeams }) + const person = await hr.getPerson(admin.id) + assert.deepEqual(person.admin, admin.admin) + assert.equal(person.census[0].answer, "continues") + assert.ok(!person.discrepancies.some((item) => item.kind === "admin-removal")) }) void test("a Lead sees contacts of current team members only", async () => { diff --git a/tests/server-security.test.mjs b/tests/server-security.test.mjs index 96b48be5..d7174617 100644 --- a/tests/server-security.test.mjs +++ b/tests/server-security.test.mjs @@ -282,10 +282,7 @@ test("dashboard mutations enforce their exact write scope", async () => { ["peopleReceiptsSignMiddleware", ["src/features/people/people-receipts.functions.ts"]], ["peopleAppointMiddleware", ["src/features/people/people-appoint.functions.ts"]], ["peopleTeamWriteMiddleware", ["src/features/people/people-teams.functions.ts"]], - [ - "peopleSelfMiddleware", - ["src/features/people/people-capo.functions.ts", "src/features/people/people-self.functions.ts"], - ], + ["peopleSelfMiddleware", ["src/features/people/people-self.functions.ts"]], ].map(([expectedMiddleware, files]) => ({ expectedMiddleware, files })) // "file#name" scopes an entry to one mutation, for files whose mutations need different permissions. @@ -401,7 +398,6 @@ test("event handlers integrate protected server-function redirects with the rout "src/features/people/link-account-dialog.tsx": ["linkPersonAccount"], "src/features/people/census-page.tsx": ["sendCensusReminders", "closeCampaign"], "src/features/people/linking-page.tsx": ["resolveLinkingReview", "dismissLinkingReview"], - "src/features/people/capo-page.tsx": ["approveJoinRequest", "declineJoinRequest"], "src/features/people/my-area-page.tsx": [ "acceptMyInvite", "answerMyCensus", From 8f604fabb2066846f30608d6a028b2cff7f3604e Mon Sep 17 00:00:00 2001 From: Bianca Date: Tue, 27 Oct 2026 10:56:57 +0100 Subject: [PATCH 05/10] feat(people): add people and team roles before their first Auth login Require only first and last name, with optional contacts, team memberships and admin status. Show preliminary Auth identities and Master-approved linking reviews; keep profile census fields optional and document the cross-service flow. --- docs/people/contract.md | 104 +++++++++------- docs/people/decisions.md | 4 + docs/people/pre-registration.md | 46 +++++++ src/features/people/linking-page.tsx | 20 +++ src/features/people/people.contract.ts | 29 ++++- src/features/people/people.fake.server.ts | 19 ++- src/features/people/person-dialog.tsx | 145 +++++++++++++++------- src/features/people/person-page.tsx | 10 +- tests/people-access.test.mjs | 15 +++ 9 files changed, 290 insertions(+), 102 deletions(-) create mode 100644 docs/people/pre-registration.md diff --git a/docs/people/contract.md b/docs/people/contract.md index 9ee32faa..8631e261 100644 --- a/docs/people/contract.md +++ b/docs/people/contract.md @@ -130,57 +130,57 @@ The UI links to full person records only with `people:read`. Write permissions d ## 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.members.create` | mutation | `{ personId, number, admittedOn, fee }` | `void` | `people:members:write` | -| `people.members.setStatus` | mutation | `{ personId, status, reason? }` | `void` | `people:members:write` | -| `people.members.setNumber` | mutation | `{ personId, number }` | `void` (also updates the M365 member ID) | `people:members:write` | -| `people.members.export` | mutation | — | `{ filename, csv }` | `people:members:write` | -| `people.members.list` | query | — | `MemberRegisterRow[]` | `people:members:read` | -| `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.census.campaigns` | query | — | `{ id, year, status: "open" \| "closed", openedOn, closesOn, expected, continuing, leaving, noAnswer }[]` | `people:read` | -| `people.census.campaign` | query | `{ id }` | `{ campaign, people: PersonRow[] }` | `people:read` | -| `people.census.answers` | query | `{ personId, year }` | `{ question, answer }[]` | `people:read` | -| `people.census.remind` | mutation | `{ campaignId, personId: PersonId \| null }` (`null` reminds everyone) | `{ sent, skipped }` (skips reminded < 3 days) | `people:write` | -| `people.census.close` | mutation | `{ campaignId }` | `void` | `people:write` | -| `people.census.importPreview` | mutation | `FormData` (`campaignId`, `file` .xlsx/.csv) | `ImportPreview` | `people:write` | -| `people.census.importCommit` | mutation | `{ importId, duplicates: Record, telegram: Record }` | `{ imported }` | `people:write` | -| `people.linking.reviews` | query | — | `Review[]` | `people:read` | -| `people.linking.resolve` | mutation | `{ reviewId, resolution, reason }` | `void` | `people:write` | -| `people.linking.dismiss` | mutation | `{ reviewId }` | `void` | `people:write` | -| `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[] }` | 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.confirmMatch` | mutation | — | `void` | `people:self` | -| `people.me.rejectMatch` | mutation | — | `void` (creates a Needs review item) | `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` | -| `people.me.askToBeAdded` | mutation | `{ roles: string[], groupsOrTeam?, telegramUsername? }` | `void` | `people:self` | -| `people.me.answerCensus` | mutation | `{ answer, course, year, campus, keepTeams: TeamId[], reason?, note? }` | `void` (`CONFLICT` once answered: the answer is final) | `people:self` | -| `people.me.requestCorrection` | mutation | `{ field, value, note? }` | `void` | `people:self` | -| `people.me.revokePhotoConsent` | mutation | — | `void` | `people:self` | +| Procedure | Kind | Input | Output | Needs | +| ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `people.persons.list` | query | — | `PersonRow[]` | `people:read` | +| `people.persons.get` | query | `{ id }` | `PersonRecord` | `people:read` | +| `people.persons.create` | mutation | `{ firstName, lastName, requestId?, personalEmail?, phone?, telegramUsername?, telegramId?, admin?, teams? }` | `{ 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.members.create` | mutation | `{ personId, number, admittedOn, fee }` | `void` | `people:members:write` | +| `people.members.setStatus` | mutation | `{ personId, status, reason? }` | `void` | `people:members:write` | +| `people.members.setNumber` | mutation | `{ personId, number }` | `void` (also updates the M365 member ID) | `people:members:write` | +| `people.members.export` | mutation | — | `{ filename, csv }` | `people:members:write` | +| `people.members.list` | query | — | `MemberRegisterRow[]` | `people:members:read` | +| `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.census.campaigns` | query | — | `{ id, year, status: "open" \| "closed", openedOn, closesOn, expected, continuing, leaving, noAnswer }[]` | `people:read` | +| `people.census.campaign` | query | `{ id }` | `{ campaign, people: PersonRow[] }` | `people:read` | +| `people.census.answers` | query | `{ personId, year }` | `{ question, answer }[]` | `people:read` | +| `people.census.remind` | mutation | `{ campaignId, personId: PersonId \| null }` (`null` reminds everyone) | `{ sent, skipped }` (skips reminded < 3 days) | `people:write` | +| `people.census.close` | mutation | `{ campaignId }` | `void` | `people:write` | +| `people.census.importPreview` | mutation | `FormData` (`campaignId`, `file` .xlsx/.csv) | `ImportPreview` | `people:write` | +| `people.census.importCommit` | mutation | `{ importId, duplicates: Record, telegram: Record }` | `{ imported }` | `people:write` | +| `people.linking.reviews` | query | — | `Review[]` | `people:read` | +| `people.linking.resolve` | mutation | `{ reviewId, resolution, reason }` | `void` | `people:write` | +| `people.linking.dismiss` | mutation | `{ reviewId }` | `void` | `people:write` | +| `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[] }` | 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.confirmMatch` | mutation | — | `void` | `people:self` | +| `people.me.rejectMatch` | mutation | — | `void` (creates a Needs review item) | `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` | +| `people.me.askToBeAdded` | mutation | `{ roles: string[], groupsOrTeam?, telegramUsername? }` | `void` | `people:self` | +| `people.me.answerCensus` | mutation | `{ answer, course, year, campus, keepTeams: TeamId[], reason?, note? }` | `void` (`CONFLICT` once answered: the answer is final) | `people:self` | +| `people.me.requestCorrection` | mutation | `{ field, value, note? }` | `void` | `people:self` | +| `people.me.revokePhotoConsent` | mutation | — | `void` | `people:self` | ```ts type Review = { id: string - kind: "telegram-conflict" | "ask-to-be-added" | "two-records" | "not-me" | "name-only" + kind: "telegram-conflict" | "ask-to-be-added" | "two-records" | "not-me" | "name-only" | "preliminary-link" since: string account: { name: string @@ -440,3 +440,13 @@ also needs `people:fees:write`; without it the dashboard admits with the fee due Team roles deselected in `people.me.answerCensus` end through PoliNetwork Auth. A leaving answer also ends the admin role through Auth; a continuing answer keeps it. People does not track Telegram group assignments, requests to join or pending group removals. Older records may still carry an `admin-removal` discrepancy, but no new ones are created. + +### Preliminary Auth people + +`persons.create` requires only names. Optional `teams` starts member roles; `admin` starts the bound admin role. It creates the identity and roles in Auth, not a new local production authority. `requestId` is a client-stable UUID for safe retries. Course/year/campus are not prerequisites, and remain optional on profile edits; census submission and import still validate their own required data. + +Rows may include `preliminary: true`. A preliminary person has no login account or My area access, even while their organizational roles are recorded in Auth. Auth's stable registration ID preserves the People record and its history when a Master Admin approves a link to the real login account. + +The `preliminary-link` review kind carries Auth's verified-email/Telegram proposal, a name-only hint or a conflict explanation. Resolutions are `link-anyway` (Master Admin approval checked in Auth) and `decline` (keep identities separate). Phone and mutable usernames are contact hints only. No name or unverified contact automatically merges identities or grants roles. Auth-owned identity links cannot be detached locally with `linking.unlink`. + +See `pre-registration.md` for the design, rollout and verification scope. diff --git a/docs/people/decisions.md b/docs/people/decisions.md index a1e15002..857d95fb 100644 --- a/docs/people/decisions.md +++ b/docs/people/decisions.md @@ -161,3 +161,7 @@ remain an option. Contacts (phone, personal email) need `people:read`, or Capo Admin scope / Team Lead role over that person. HR notes and full census answers need `people:read`. The backend enforces all of this again; the dashboard only hides. + +## People before the first login + +New people can be registered with only names and optional contacts, plus team/admin roles. The preliminary identity and assignments live in Auth; People keeps the register history and its own optional census fields. On a later login, verified identity matches and name-only hints become proposals for a Master Admin in Linking. No automatic merge or self-grant is allowed. See `pre-registration.md`. diff --git a/docs/people/pre-registration.md b/docs/people/pre-registration.md new file mode 100644 index 00000000..d9ae2f68 --- /dev/null +++ b/docs/people/pre-registration.md @@ -0,0 +1,46 @@ +# People before the first Auth login + +## Product contract + +A People writer can create a person with first and last name, optional email, phone, Telegram ID/username, and optional admin/team roles. Course, academic year, campus and census consent are not prerequisites. These minimal identity details and organizational roles live in Auth, including before login. People keeps its existing register/history, not an independent authoritative role assignment. + +The preliminary subject has no credentials, provider accounts or sessions. At a later real login, verified email or verified Telegram ID can produce a link proposal for a Master Admin. Names and phone numbers never authorize a link; name matches are review hints, not proof. Mutable Telegram usernames are contact/display data. Conflicting verified identities or multiple candidates require manual review. Role-bearing identity links must never be made by name, unverified email or self-grant. + +Master approval binds the preliminary person to the real account and transfers the current Auth roles atomically, preserving the register identity and history. Existing account roles are retained. Repeated creation/link commands are idempotent; conflicting replays fail. Nothing is automatically granted merely because someone signs in with a similar name. + +## Technical decisions + +Auth owns a preliminary-person record associated with a non-login subject and its normal role assignments. The preliminary-person ID remains stable after account linking. Declared contact data is separate from verified provider evidence and never enters access snapshots as verified identity. + +Extend the existing backend-only People interface with bounded, idempotent creation and Master-approved linking operations, using the existing service-client authentication, RBAC mutation lock, delegation checks and audit trail. Directory responses identify preliminary subjects and their canonical linked account, and include verified-identity link proposals. Backend reconciliation follows the stable Auth person identity instead of creating a duplicate record on first login. + +People's create input makes census fields optional and accepts initial team/admin assignments. The UI distinguishes preliminary identities from linked accounts. Existing local-only test fixtures remain development data; normal production creation fails explicitly when Auth is unavailable rather than silently creating a local authority. + +## Acceptance and verification + +1. Create with only first/last name and a team; no census fields required. +2. Create an admin before login; role is stored in Auth and no token/session is issued. +3. Retry creation with the same command: one person; changed payload with reused ID: conflict. +4. Verified email/Telegram ID produce a proposal. Same name alone, unverified email, username or phone do not automatically link. +5. Conflicting or multiply-matched identities stay unresolved. +6. Only Master Admin approves the identity link; roles transfer atomically and retain existing account assignments; invalid delegation applies nothing. +7. First login and approval keep the People record/history and do not create a second person or lose roles. +8. Admin/team changes after approval follow Auth as before. +9. Both API boundaries validate inputs and permissions, and no declared Telegram contact is treated as verified by the bot/access snapshot. + +## Roadmap + +1. Auth: additive schema/migration, creation/link service, derived matching proposals, integration tests. +2. Backend: Auth client contract, stable reconciliation, creation and review integration, regression tests. +3. Dashboard: minimal creation, optional contact/census details, initial admin/team choice, accurate preliminary/link-review states; agent-mode parity. +4. Verify migrations on disposable PostgreSQL, focused identity/security tests, full checks/builds, review all task changes, and update existing Auth/backend/admin PRs without merging or deploying. + +## Implemented flow + +The new `people_registration` table in Auth (migration `0024`) refers to an Auth subject without provider accounts or sessions. Bound roles use Auth's existing `user_role` table. The same backend-only endpoint accepts `create-person`, `update-person` and `link-person` commands, protected by `idp:people:roles` and acting-user checks. Ordinary role commands remain compatible. + +A new `registrationId` identifies the person before and after linking. `provisional` is explicit; declared contacts are distinct from verified evidence. Auth derives review proposals using its existing verified app-email policy and live Telegram identity evidence; homonyms and conflicting identifiers are labeled for review, never linked automatically. Master approval transfers bound assignments atomically to an existing login account, preserves its existing roles and provider accounts, and clears assignments on the preliminary subject. + +People creates and updates registered identities through Auth and reconciles by `registrationId`. It does not grant a preliminary identity My area access or silently create a local production identity if Auth is unavailable. Existing historical/local-only records and imports retain their prior handling; this change does not migrate them automatically. Census fields remain People-specific and are optional when editing a profile; census submission/import validation stays strict. + +Verification includes matching unit tests, Auth PostgreSQL creation/link/idempotency tests, backend first-login and restart recovery tests, agent-mode minimal creation tests, and browser creation with only names plus IT at desktop/mobile widths. Production configuration and a real external-provider login are not part of these local checks. diff --git a/src/features/people/linking-page.tsx b/src/features/people/linking-page.tsx index 5e680daa..90ac0431 100644 --- a/src/features/people/linking-page.tsx +++ b/src/features/people/linking-page.tsx @@ -43,6 +43,7 @@ const ISSUE = { "two-records": "Two records match this sign-in", "not-me": "Said “This isn't me” at first sign-in", "name-only": "Matched by name only; email and Telegram differ", + "preliminary-link": "Auth person awaiting Master Admin approval of their login account", } satisfies Record type Resolution = { value: string; label: string; description: string } @@ -53,6 +54,25 @@ function resolutionsFor(review: Review): ResolutionChoices { const record = review.record ? `${review.record.name} (${review.record.id})` : "the record" const other = review.other ? `${review.other.name} (${review.other.id})` : "the other record" switch (review.kind) { + case "preliminary-link": + return { + title: "Link the preliminary Auth person?", + description: + review.message ?? + `Check that this login belongs to ${record}. A Master Admin must approve the identity link in Auth.`, + options: [ + { + value: "link-anyway", + label: "Approve identity link", + description: "Auth transfers their current roles to the verified login account. Master Admin only.", + }, + { + value: "decline", + label: "Different person", + description: "Keep the preliminary person and this login separate.", + }, + ], + } case "telegram-conflict": return { title: "Resolve Telegram conflict", diff --git a/src/features/people/people.contract.ts b/src/features/people/people.contract.ts index cc7ee214..4201a129 100644 --- a/src/features/people/people.contract.ts +++ b/src/features/people/people.contract.ts @@ -32,7 +32,14 @@ export const MEMBER_STATUSES = ["active", "suspended", "expired", "former"] as c export const FEE_STATUSES = ["due", "receipt", "paid", "unpaid"] as const export const ACCOUNT_STATUSES = ["none", "invite", "linked", "review"] as const export const CENSUS_ANSWERS = ["continues", "leaving"] as const -export const REVIEW_KINDS = ["telegram-conflict", "ask-to-be-added", "two-records", "not-me", "name-only"] as const +export const REVIEW_KINDS = [ + "telegram-conflict", + "ask-to-be-added", + "two-records", + "not-me", + "name-only", + "preliminary-link", +] as const /** A receipt's pipeline: issued unsigned, signed by an officer, emailed to the member; or the email failed, or voided. */ export const RECEIPT_STATUSES = ["toSign", "signed", "sent", "failed", "voided"] as const export const PAYMENT_METHODS = ["transfer", "cash"] as const @@ -143,6 +150,7 @@ export const consentsSchema = z.object({ /** One row of the People lists: everything but notes, activity and discrepancies. */ export const personRowSchema = z.object({ + preliminary: z.boolean().optional(), id: z.string(), profile: profileSchema, /** `null` without `people:members:read`, except on the viewer's own record: `isMember` still says whether they are one. */ @@ -583,8 +591,21 @@ export const personInputSchema = z.object({ year: z.enum(YEARS), campus: campusSchema, }) -export const createPersonInputSchema = personInputSchema.extend({ admin: z.boolean() }) -export const updatePersonInputSchema = personInputSchema.extend({ id: personIdSchema }) +export const createPersonInputSchema = personInputSchema.extend({ + requestId: z.uuid().optional(), + personalEmail: z.union([z.literal(""), z.email().max(254)]).default(""), + phone: z.string().trim().max(40).default(""), + telegramUsername: telegramUsernameSchema.default(""), + telegramId: z.union([z.literal(""), z.string().regex(/^[1-9]\d{0,19}$/)]).default(""), + course: z.string().trim().max(120).default(""), + year: z.union([z.literal(""), z.enum(YEARS)]).default(""), + campus: campusSchema.nullable().default(null), + admin: z.boolean().default(false), + teams: z.array(teamIdSchema).max(TEAM_IDS.length).default([]), +}) +export const updatePersonInputSchema = createPersonInputSchema + .omit({ admin: true, teams: true, telegramId: true, requestId: true }) + .extend({ id: personIdSchema }) export const setNoteInputSchema = z.object({ id: personIdSchema, note: z.string().trim().max(2000) }) export const createMemberInputSchema = z.object({ @@ -766,7 +787,7 @@ export const sendSignedInputSchema = z.object({ }) export const transferInputSchema = z.object({ transferDate: dateSchema, amountCents: amountCentsSchema }) -export type CreatePersonInput = z.infer +export type CreatePersonInput = z.input export type UpdatePersonInput = z.infer export type SetNoteInput = z.infer export type CreateMemberInput = z.infer diff --git a/src/features/people/people.fake.server.ts b/src/features/people/people.fake.server.ts index 3d236c1b..23a656b2 100644 --- a/src/features/people/people.fake.server.ts +++ b/src/features/people/people.fake.server.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto" import { nextReceiptNumber, receiptFileName, receiptNumberFromFileName } from "./people-fees" import type { PeopleApi, PeopleViewer } from "./people.api.server" +import { createPersonInputSchema } from "./people.contract" import { type AccountMatch, type Campaign, @@ -130,6 +131,7 @@ function row(person: StoredPerson, viewer: PeopleViewer, withContacts: boolean): const withMember = ownRecord || viewer.permissions.includes("people:members:read") return { id: person.id, + preliminary: person.preliminary, profile: { ...person.profile, personalEmail: withContacts ? person.profile.personalEmail : null, @@ -521,15 +523,17 @@ export function fakePeopleApi(viewer: PeopleViewer): PeopleApi { require(viewer, "people:read") return record(find(id), viewer) }, - createPerson: async (input) => { + createPerson: async (raw) => { + const input = createPersonInputSchema.parse(raw) require(viewer, "people:write") const { people, byId } = data() - if (people.some((person) => person.profile.personalEmail === input.personalEmail)) { + if (input.personalEmail && people.some((person) => person.profile.personalEmail === input.personalEmail)) { throw new Error(`${input.personalEmail} already belongs to another record.`) } const id = `P-${String(people.length + 1).padStart(4, "0")}` const person: StoredPerson = { id, + preliminary: true, family: "inf", profile: { firstName: input.firstName, @@ -537,7 +541,7 @@ export function fakePeopleApi(viewer: PeopleViewer): PeopleApi { personalEmail: input.personalEmail || null, phone: input.phone || null, telegramUsername: input.telegramUsername || null, - telegramId: null, + telegramId: input.telegramId || null, course: input.course, year: input.year, campus: input.campus, @@ -556,7 +560,14 @@ export function fakePeopleApi(viewer: PeopleViewer): PeopleApi { capoScope: null, } : null, - teams: [], + teams: input.teams.map((team) => ({ + team, + role: "member", + since: today(), + endedOn: null, + endReason: null, + fields: {}, + })), census: [], consents: { privacy: null, photo: null }, account: { status: "none" }, diff --git a/src/features/people/person-dialog.tsx b/src/features/people/person-dialog.tsx index ee11bf01..c5acd74d 100644 --- a/src/features/people/person-dialog.tsx +++ b/src/features/people/person-dialog.tsx @@ -9,8 +9,19 @@ import { cn } from "@/lib/utils" import { yearOf } from "./people-format" import { errorMessage, useRefreshAfter } from "./people-hooks" +import { ChoiceChips } from "./people-ui" import { createPerson, updatePerson } from "./people-write.functions" -import { CAMPUSES, type Campus, COURSES, type PersonRow, TELEGRAM_USERNAME_PATTERN, YEARS } from "./people.contract" +import { + CAMPUSES, + type Campus, + COURSES, + type PersonRow, + type TeamId, + TEAM_IDS, + TEAMS, + TELEGRAM_USERNAME_PATTERN, + YEARS, +} from "./people.contract" type Year = (typeof YEARS)[number] @@ -47,22 +58,25 @@ export function PersonDialog({ } const [values, setValues] = useState(initial) const [admin, setAdmin] = useState(false) + const [teams, setTeams] = useState([]) + const [telegramId, setTelegramId] = useState("") + const [requestId] = useState(() => crypto.randomUUID()) const [submitted, setSubmitted] = useState(false) const errors: Partial> = submitted ? { firstName: values.firstName.trim() ? undefined : "Enter a first name.", lastName: values.lastName.trim() ? undefined : "Enter a last name.", - personalEmail: EMAIL_PATTERN.test(values.personalEmail.trim()) ? undefined : "Enter a valid email address.", + personalEmail: + !values.personalEmail.trim() || EMAIL_PATTERN.test(values.personalEmail.trim()) + ? undefined + : "Enter a valid email address.", telegramUsername: validTelegram(values.telegramUsername) ? undefined : "Enter 5–32 letters, digits or underscores.", - course: values.course.trim() ? undefined : "Choose a course.", - year: values.year ? undefined : "Choose a year.", - campus: values.campus ? undefined : "Choose a campus.", } : {} - const dirty = JSON.stringify(values) !== JSON.stringify(initial) || admin + const dirty = JSON.stringify(values) !== JSON.stringify(initial) || admin || teams.length > 0 || telegramId !== "" function set(key: K, value: (typeof values)[K]) { setValues((current) => ({ ...current, [key]: value })) @@ -74,11 +88,9 @@ export function PersonDialog({ if ( !values.firstName.trim() || !values.lastName.trim() || - !EMAIL_PATTERN.test(values.personalEmail.trim()) || + (values.personalEmail.trim() !== "" && !EMAIL_PATTERN.test(values.personalEmail.trim())) || !validTelegram(values.telegramUsername) || - !values.course.trim() || - !year || - !campus + (telegramId !== "" && !/^[1-9]\d{0,19}$/.test(telegramId)) ) { return } @@ -89,14 +101,14 @@ export function PersonDialog({ phone: values.phone.trim(), telegramUsername: values.telegramUsername.trim(), course: values.course.trim(), - year, + year: year ?? ("" as const), campus, } try { if (person) { await update({ data: { ...fields, id: person.id } }) } else { - const created = await create({ data: { ...fields, admin } }) + const created = await create({ data: { ...fields, year: year ?? "", requestId, telegramId, admin, teams } }) await refresh("Person added.") onOpenChange(false) await navigate({ to: "/dashboard/people/$personId", params: { personId: created.id } }) @@ -127,7 +139,7 @@ export function PersonDialog({ description={ person ? "Changes are saved to the record and shown in its activity." - : "Creates a record. The person doesn't need an account; you can invite them later." + : "Creates a person in Auth before their first login. A Master Admin can link their verified account later." } noun="person" dirty={dirty} @@ -141,7 +153,7 @@ export function PersonDialog({ - + @@ -151,7 +163,7 @@ export function PersonDialog({ label="Telegram username" htmlFor="person-telegram" optional - hint="Used to match the Telegram user the bot already knows." + hint="Optional contact hint. Linking uses a verified Telegram ID or email." error={errors.telegramUsername} > - - ({ value: course, label: course }))} - onChange={(course) => set("course", course)} - placeholder="Choose a course" - invalid={!!errors.course} - /> - - - - id="person-year" - value={values.year} - options={YEARS.map((year) => ({ value: year, label: year }))} - onChange={(year) => set("year", year)} - placeholder="Choose a year" - invalid={!!errors.year} - /> - - - - id="person-campus" - value={values.campus} - options={CAMPUSES.map((campus) => ({ value: campus, label: campus }))} - onChange={(campus) => set("campus", campus)} - placeholder="Choose a campus" - invalid={!!errors.campus} + {person ? ( + <> + + ({ value: course, label: course }))} + onChange={(course) => set("course", course)} + placeholder="Choose a course" + invalid={!!errors.course} + /> + + + + id="person-year" + value={values.year} + options={YEARS.map((year) => ({ value: year, label: year }))} + onChange={(year) => set("year", year)} + placeholder="Choose a year" + invalid={!!errors.year} + /> + + + + id="person-campus" + value={values.campus} + options={CAMPUSES.map((campus) => ({ value: campus, label: campus }))} + onChange={(campus) => set("campus", campus)} + placeholder="Choose a campus" + invalid={!!errors.campus} + /> + + + ) : ( + + setTelegramId(event.target.value.trim())} + inputMode="numeric" + aria-invalid={submitted && telegramId !== "" && !/^[1-9]\d{0,19}$/.test(telegramId) ? true : undefined} + className={fieldControl} + aria-describedby={fieldHintId("person-telegram-id")} + /> + + )} + + {!person ? ( + + TEAMS[team].name} /> - + ) : null} {person ? null : ( diff --git a/src/features/people/person-page.tsx b/src/features/people/person-page.tsx index 28a9c295..3b4f2a25 100644 --- a/src/features/people/person-page.tsx +++ b/src/features/people/person-page.tsx @@ -197,6 +197,12 @@ export function PersonPage({ person }: { person: PersonRecord }) {
+ {person.preliminary ? ( + + This person is registered in Auth before their first login. Roles are already recorded there; a Master + Admin must approve the link to their login account. + + ) : null} } @@ -543,7 +549,7 @@ export function PersonPage({ person }: { person: PersonRecord }) { footer={ canWrite && account.status !== "review" ? (
- {account.status === "none" && profile.personalEmail ? ( + {account.status === "none" && !person.preliminary && profile.personalEmail ? ( + } + /> + ) : ( + + ) + } + /> ) : ( Promise dismissReview: (input: { reviewId: string }) => Promise listInvites: () => Promise + listRecentLinks: () => Promise sendInvite: (input: { personId: string }) => Promise revokeInvite: (input: { personId: string }) => Promise searchAccounts: (input: SearchAccountsInput) => Promise @@ -196,6 +199,7 @@ function backendPeopleApi(accessToken: string | null): PeopleApi { resolveReview: (input) => mutate("linking.resolve", input), dismissReview: (input) => mutate("linking.dismiss", input), listInvites: () => query("linking.invites", undefined, inviteSchema.array()), + listRecentLinks: () => query("linking.recent", undefined, recentLinkSchema.array()), sendInvite: (input) => mutate("linking.sendInvite", input), revokeInvite: (input) => mutate("linking.revokeInvite", input), searchAccounts: (input) => query("linking.searchAccounts", input, accountMatchSchema.array()), diff --git a/src/features/people/people.contract.ts b/src/features/people/people.contract.ts index 9fc4d1d4..f6a7612b 100644 --- a/src/features/people/people.contract.ts +++ b/src/features/people/people.contract.ts @@ -140,7 +140,15 @@ export const accountSchema = z.discriminatedUnion("status", [ expiresOn: z.string(), sentBy: z.string(), }), - z.object({ status: z.literal("linked"), email: z.string(), via: z.string(), linkedOn: z.string(), how: z.string() }), + z.object({ + status: z.literal("linked"), + email: z.string(), + via: z.string(), + linkedOn: z.string(), + how: z.string(), + /** Who made the link: the account holder, an admin's sub, or `system`. Absent on older links. */ + by: z.string().optional(), + }), z.object({ status: z.literal("review"), reviewId: z.string(), issue: z.string() }), ]) @@ -326,6 +334,22 @@ export const inviteSchema = z.object({ }) export type Invite = z.infer +/** A link made recently, so HR can spot and undo a mistaken one. */ +export const recentLinkSchema = z.object({ + personId: z.string(), + name: z.string(), + email: z.string(), + via: z.string(), + how: z.string(), + linkedOn: z.string(), + linkedBy: z.enum(["account-holder", "admin", "system", "unknown"]), + /** Created in Auth before its first login: only a Master Admin can undo the link. */ + preliminary: z.boolean(), +}) +export type RecentLink = z.infer +/** How far back Linking lists recent links. */ +export const RECENT_LINK_DAYS = 30 + export const accountMatchSchema = z.object({ id: z.string(), name: z.string(), diff --git a/src/features/people/people.fake.server.ts b/src/features/people/people.fake.server.ts index 65b6b1cb..cda390d3 100644 --- a/src/features/people/people.fake.server.ts +++ b/src/features/people/people.fake.server.ts @@ -20,6 +20,7 @@ import { type Signer, type SignedUpload, TEAM_IDS, + RECENT_LINK_DAYS, TEAMS, type TeamId, type TeamSummary, @@ -884,6 +885,7 @@ export function fakePeopleApi(viewer: PeopleViewer): PeopleApi { via: review.account.via, linkedOn: today(), how: `Resolved by ${actor}`, + by: "admin", } log(target, actor, `linked ${review.account.email}: ${input.reason}`) } else if (target && target.account.status === "review") { @@ -898,6 +900,33 @@ export function fakePeopleApi(viewer: PeopleViewer): PeopleApi { const target = review?.record ? store.byId.get(review.record.id) : undefined if (target?.account.status === "review") target.account = { status: "none" } }, + listRecentLinks: async () => { + require(viewer, "people:read") + const since = new Date(Date.now() - RECENT_LINK_DAYS * 86400000).toISOString().slice(0, 10) + return data() + .people.flatMap((person) => + person.account.status === "linked" && person.account.linkedOn >= since + ? [ + { + personId: person.id, + name: fullName(person), + email: person.account.email, + via: person.account.via, + how: person.account.how, + linkedOn: person.account.linkedOn, + linkedBy: + person.account.by === "admin" + ? ("admin" as const) + : person.account.by === "account-holder" + ? ("account-holder" as const) + : ("unknown" as const), + preliminary: person.preliminary === true, + }, + ] + : [] + ) + .toSorted((a, b) => b.linkedOn.localeCompare(a.linkedOn)) + }, listInvites: async () => { require(viewer, "people:read") return data().people.flatMap((person) => @@ -998,6 +1027,7 @@ export function fakePeopleApi(viewer: PeopleViewer): PeopleApi { via: account.via.split(" on ")[0].replace("Signed in with ", ""), linkedOn: today(), how: `Linked by ${actor}`, + by: "admin", } log(person, actor, `linked ${account.email}: ${input.reason}`) }, @@ -1148,6 +1178,7 @@ export function fakePeopleApi(viewer: PeopleViewer): PeopleApi { via: "Telegram", linkedOn: today(), how: `Confirmed by ${fullName(me)} at first sign-in`, + by: "account-holder", } }, rejectMatch: async () => { @@ -1173,6 +1204,7 @@ export function fakePeopleApi(viewer: PeopleViewer): PeopleApi { via: "Email", linkedOn: today(), how: "Accepted the email invitation", + by: "account-holder", } }, askToBeAdded: async () => { diff --git a/src/features/people/people.functions.ts b/src/features/people/people.functions.ts index e4de0598..32f44af4 100644 --- a/src/features/people/people.functions.ts +++ b/src/features/people/people.functions.ts @@ -29,6 +29,11 @@ export const getPendingInvites = createServerFn({ method: "GET" }) .middleware([peopleReadMiddleware]) .handler(async ({ context }) => (await peopleApi(context)).listInvites()) +/** Links of the last 30 days, so HR can spot and undo a mistaken one. */ +export const getRecentLinks = createServerFn({ method: "GET" }) + .middleware([peopleReadMiddleware]) + .handler(async ({ context }) => (await peopleApi(context)).listRecentLinks()) + /** A census year's full answers: HR and the Direttivo only (`people:read`). */ export const getCensusAnswers = createServerFn({ method: "GET" }) .middleware([peopleReadMiddleware]) diff --git a/src/routes/dashboard/people/linking.tsx b/src/routes/dashboard/people/linking.tsx index 3979af3c..20b93645 100644 --- a/src/routes/dashboard/people/linking.tsx +++ b/src/routes/dashboard/people/linking.tsx @@ -3,12 +3,12 @@ import { createFileRoute } from "@tanstack/react-router" import { TableSkeleton } from "@/components/primitives" import { PageBar, PageContent } from "@/components/shell" import { LinkingPage } from "@/features/people/linking-page" -import { getLinkingReviews, getPendingInvites } from "@/features/people/people.functions" +import { getLinkingReviews, getPendingInvites, getRecentLinks } from "@/features/people/people.functions" export const Route = createFileRoute("/dashboard/people/linking")({ loader: async () => { - const [reviews, invites] = await Promise.all([getLinkingReviews(), getPendingInvites()]) - return { reviews, invites } + const [reviews, invites, recent] = await Promise.all([getLinkingReviews(), getPendingInvites(), getRecentLinks()]) + return { reviews, invites, recent } }, pendingComponent: () => ( <> @@ -22,6 +22,6 @@ export const Route = createFileRoute("/dashboard/people/linking")({ }) function LinkingRoute() { - const { reviews, invites } = Route.useLoaderData() - return + const { reviews, invites, recent } = Route.useLoaderData() + return } diff --git a/tests/people-access.test.mjs b/tests/people-access.test.mjs index b4adce30..cf8d361e 100644 --- a/tests/people-access.test.mjs +++ b/tests/people-access.test.mjs @@ -233,3 +233,16 @@ void test("a person can be added before login with just names and team/admin rol ["it"] ) }) + +void test("recent links need read access and disappear after an authorized unlink", async () => { + await assert.rejects(fakePeopleApi({ permissions: SELF, personId: plain.id }).listRecentLinks(), unauthorized) + const recent = await hr.listRecentLinks() + assert.ok(recent.length, "confirmed links are visible to HR") + assert.ok(recent.some((link) => link.linkedBy === "account-holder")) + const linked = recent[0] + const reader = fakePeopleApi({ permissions: ["people:read"], personId: null }) + await assert.rejects(reader.unlinkAccount({ personId: linked.personId }), unauthorized) + assert.ok((await hr.listRecentLinks()).some((link) => link.personId === linked.personId)) + await hr.unlinkAccount({ personId: linked.personId }) + assert.ok(!(await hr.listRecentLinks()).some((link) => link.personId === linked.personId)) +})