diff --git a/AGENTS.md b/AGENTS.md index 561a02c8..7e0abadb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,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`. @@ -60,8 +60,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..bd5e2105 100644 --- a/README.md +++ b/README.md @@ -33,23 +33,23 @@ Signing in as a real user needs a development client registered in the IdP. Set the following runtime variables (the build does not need secrets): -| Variable | Meaning | -| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `APP_URL` | Dashboard's public origin. Required outside development; HTTPS in production. Development defaults to `http://localhost:$PORT` (3001). Used for exact Origin CSRF validation and callback/logout URLs. | -| `BACKEND_URL` | tRPC backend origin; use the in-cluster service address in production. Defaults to `http://localhost:3000` in development. | -| `IDP_URL` | Public IdP origin; defaults to `https://auth.polinetwork.org`. Issuer is `$IDP_URL/api/auth`. Browser redirects retain this public origin. | -| `IDP_INTERNAL_URL` | Optional internal IdP origin, e.g. `http://auth-service.auth.svc.cluster.local:3000`. Rewrites discovery, token, JWKS and revocation transport only; issuer, JWT assertion audiences (public token/revoke endpoints) and browser URLs remain public. | -| `OIDC_CLIENT_ID` | Registered confidential dashboard client, with authorization code and refresh grants, scopes `openid profile email offline_access backend:admin`, and backend resource link. | -| `OIDC_CLIENT_PRIVATE_JWK` | Private signing JWK JSON with `kid` and `alg` (`EdDSA`/Ed25519 or `ES256`/P-256); inject from this service's Key Vault. Register only its public JWKS in the IdP. | -| `OIDC_CLIENT_SECRET` | Alternative `client_secret_basic` credential; set exactly one client credential mode. | -| `BACKEND_RESOURCE` | Backend OAuth resource/audience; defaults to `https://backend.internal.polinetwork.org`. Sent on every authorize, code exchange and refresh request. | -| `REDIS_URL` | Dedicated dashboard Redis connection. Required outside development; development defaults to `redis://localhost:6379`. Never point to shared backend Redis. | -| `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. | -| `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. | +| Variable | Meaning | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `APP_URL` | Dashboard's public origin. Required outside development; HTTPS in production. Development defaults to `http://localhost:$PORT` (3001). Used for exact Origin CSRF validation and callback/logout URLs. | +| `BACKEND_URL` | tRPC backend origin; use the in-cluster service address in production. Defaults to `http://localhost:3000` in development. | +| `IDP_URL` | Public IdP origin; defaults to `https://auth.polinetwork.org`. Issuer is `$IDP_URL/api/auth`. Browser redirects retain this public origin. | +| `IDP_INTERNAL_URL` | Optional internal IdP origin, e.g. `http://auth-service.auth.svc.cluster.local:3000`. Rewrites discovery, token, JWKS and revocation transport only; issuer, JWT assertion audiences (public token/revoke endpoints) and browser URLs remain public. | +| `OIDC_CLIENT_ID` | Registered confidential dashboard client, with authorization code and refresh grants, scopes `openid profile email offline_access backend:admin`, and backend resource link. | +| `OIDC_CLIENT_PRIVATE_JWK` | Private signing JWK JSON with `kid` and `alg` (`EdDSA`/Ed25519 or `ES256`/P-256); inject from this service's Key Vault. Register only its public JWKS in the IdP. | +| `OIDC_CLIENT_SECRET` | Alternative `client_secret_basic` credential; set exactly one client credential mode. | +| `BACKEND_RESOURCE` | Backend OAuth resource/audience; defaults to `https://backend.internal.polinetwork.org`. Sent on every authorize, code exchange and refresh request. | +| `REDIS_URL` | Dedicated dashboard Redis connection. Required outside development; development defaults to `redis://localhost:6379`. Never point to shared backend Redis. | +| `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, `awaiting:P-0115` a first sign-in waiting for a Master Admin to approve the link, `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. 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. | Before review run `pnpm check`, `pnpm typecheck`, `pnpm test` and `pnpm build`. Run real Redis tests with `TEST_REDIS_URL=redis://127.0.0.1: pnpm test`. The OIDC integration test uses a local fake provider with 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..ce2e6539 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" @@ -132,58 +130,65 @@ 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, 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.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.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.recent` | query | — | `RecentLink[]`: linked records from the last 30 days, with method, date and actor category | `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" + | "identity-match" since: string account: { name: string @@ -261,6 +266,13 @@ type MyArea = } reason: string } + | { + // A record made in People before this account's first sign-in; a Master Admin decides the link in Auth. + state: "awaiting-approval" + // Only for a verified email or Telegram match: weaker matches may be someone else's record. + candidate: { id: PersonId; name: string; roles: string[] } | null + reason: string + } | { state: "none"; request: { sentOn: string } | null } ``` @@ -403,9 +415,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 +450,18 @@ 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 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 backend matches sign-ins to records with one rule and one outcome policy ([linking.md](linking.md)). The `preliminary-link` review kind is a verified or Telegram-username match on a preliminary Auth person; resolutions are `link-anyway` (Master Admin approval checked in Auth) and `decline` (keep identities separate). The `identity-match` kind is a match HR confirms on any other record: a verified match on a record that carries roles, a role holder matched by Telegram ID or a non-PoliNetwork address, or a Telegram username; resolutions are `link-anyway` and `decline`. Phone numbers are contact hints only. No name or unverified contact automatically merges identities or grants roles. `linking.unlink` delegates Auth-owned identity links to Auth: only a Master Admin can undo them, returning directly assigned People-bound roles to the preliminary person. Other records require `people:write` and revoke the linked account's bound roles through Auth before detaching. Unlinking keeps the record and its history, clears copied identity IDs and excludes this account from future proposals for that record. -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. +See `pre-registration.md` for the design, rollout and verification scope. diff --git a/docs/people/decisions.md b/docs/people/decisions.md index 9abc68ed..857d95fb 100644 --- a/docs/people/decisions.md +++ b/docs/people/decisions.md @@ -69,21 +69,29 @@ 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 | - **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). +- **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. @@ -122,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 @@ -154,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/linking.md b/docs/people/linking.md new file mode 100644 index 00000000..a2ef46af --- /dev/null +++ b/docs/people/linking.md @@ -0,0 +1,193 @@ +# People account linking: simplification plan + +Status: implemented (milestones 1–5). Scope: auth#73, backend#99 and this PR (admin#136). Splitting the rest of the backend's +`src/people/service.ts` is a separate, later proposal (milestone 6). + +## 1. Problem + +A People record (backend register) points to a PoliNetwork Auth account through `accountSub`. Six paths create that +link, and the rule "is this account the same person as this record?" is written three times with different inputs: + +| Where | Used for | Compares | +| --------------------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------- | +| Auth `people-registration-matching.ts` | preliminary persons | declared email/Telegram ID/username vs verified app email, Telegram ID and username | +| backend `candidate()` in `service.ts` | "Is this you?" at first sign-in | record personal email and Telegram ID vs account email (`emailVerified`) and Telegram ID | +| backend `syncDirectory()` in `roles.ts` | automatic link of role holders | Microsoft 365 object ID, Telegram ID, `@polinetwork.org` address | + +Consequences found while verifying the current PRs: + +- **Email matching never works in production.** The backend reads accounts from `/api/internal/esign-users`, which + sends no `emailVerified`; `people/index.ts` turns the missing field into `false`. "Is this you?" by email and the + "two records" review are dead code against real Auth. +- The three rules drifted: the duplicate name-only review and the username gap came from this. +- The backend reads accounts twice: `esign-users` plus the access snapshot for Telegram IDs (`options.directory`), and + `people-roles` for roles and preliminary persons (`peopleRoles.directory`). +- Policy is inconsistent. A declared Telegram ID on a preliminary person only produces a proposal for a Master Admin; + the same declared ID on an older record (typed by HR, or picked by username during a census import) links a role + holder automatically, or lets the person self-confirm a record that carries roles (Auth then refuses the self-grant + and the roles land in review). +- Linking code is spread over `service.ts` (lines ~480–640, ~1260–1500, ~1500–1640) and `roles.ts`. + +## 2. Goals and non-goals + +Goals: + +1. One identity-matching rule, in one pure module, with one table of evidence and outcomes. +2. One account feed from Auth carrying verified identity per account. +3. All linking paths in one backend module; `service.ts` only authorizes and delegates. +4. Every path keeps its current user-visible flow unless listed in §5 "Behaviour changes". +5. A written model (this file) that matches the code. + +Non-goals (this round): + +- Existing review kinds and resolutions stay as they are. One kind is added, `identity-match` (resolutions + `link-anyway`, `decline`), for matches HR approves on records that are not preliminary: D1, D2 and username + matches. Merging all kinds into one is possible later. +- No change to how Auth stores preliminary persons, approves links or transfers roles. +- No split of fees, receipts, census, teams or members out of `service.ts` (milestone 6, separate PR). + +## 3. Ownership + +- **Auth** owns accounts, verified evidence, roles, preliminary persons and the approval of a preliminary link + (Master Admin, enforced by Auth). +- **The backend register** owns People records and the `accountSub` link of records it created. +- **Matching** lives in the backend, next to the records it compares. Auth cannot match older records without the + backend sending them their contacts, which would copy register data into Auth. Rejected for that reason. + +## 4. Design + +### 4.1 One account feed + +`GET /api/internal/people-roles` returns every login account (preliminary subjects are reported as today), each with: + +| Field | Meaning | +| -------------------------------- | ----------------------------------------------------- | +| `sub`, `name` | Auth subject and display name | +| `email` | contact address for display (as today) | +| `verifiedEmail` | the app email when verified (`appEmail`), else `null` | +| `azureId` | PoliNetwork Microsoft 365 object ID | +| `telegramId`, `telegramUsername` | from Telegram sign-in evidence | +| `roles`, `assigned`, `since` | bound roles (as today) | + +`proposals` and `people-registration-matching.ts` are removed from Auth. The backend stops calling `esign-users` for +People (e-signing keeps using it) and reads only this feed, through the existing cached `readDirectory()`. + +### 4.2 One matching rule — `src/people/identity.ts` (pure, no I/O) + +Evidence on the account side is verified by Auth. Evidence on the record side is declared (HR, census, preliminary +person) except `azureId` and `m365Email`, which come from Microsoft 365. + +```ts +type Strength = "verified" | "username" | "name" +matchAccount(records: Person[], account: Account): Match +// Match = { kind: "none" } | { kind: "one", person, strength } | { kind: "conflict", persons } +``` + +Rules, in order; the first tier with a hit decides: + +1. **verified**: `azureId` equal; or `verifiedEmail` equal to the record's `personalEmail` or `m365Email`; or account + `telegramId` equal to the record's `telegramId`. +2. **username**: Telegram username equal (case-insensitive, without `@`). +3. **name**: full name equal. + +Conflict: more than one record in the deciding tier; or the single record carries a different `telegramId` or +`azureId` than the account (a different Microsoft 365 account goes to HR as `identity-match`). Records are looked up +through an index of these keys, so a pass over every Auth account costs lookups, not a scan of the register. Records already linked to another account, and records the account holder rejected ("This isn't me", or a review +declined or dismissed by HR), are excluded, so a declined match does not open again. + +### 4.3 One outcome policy + +| Match | Record | Outcome | +| ------------------------------------------------------------------------------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------- | +| verified, one | preliminary person | `preliminary-link` review; the person sees "awaiting approval"; Master Admin approves in Auth | +| verified, one | no account, grants no roles | "Is this you?": the person confirms | +| verified, one | no account, carries roles | `identity-match` review: HR links and the roles are granted in one step (D1) | +| verified (Microsoft 365 ID, or the `@polinetwork.org` address Microsoft 365 gave the record), one | account holds a bound role | automatic link (as today) | +| verified (Telegram ID only), one | account holds a bound role | `identity-match` review, no automatic link (D2) | +| username, one | any | `identity-match` (`preliminary-link` for a preliminary person); the person sees "being checked" | +| name, one | any | `name-only` review; nothing shown to the person | +| conflict | any | `two-records` / `telegram-conflict` review | + +### 4.4 One linking module — `src/people/linking.ts` + +Functions over `PeopleState` with explicit inputs, shared by every path: + +- `accountsOf(directory)`: Auth's login accounts, the only account source (`esign-users` is for e-signing only). +- `decide(match, account)` / `decideFor(state, account)`: the policy table above. +- `openReview` / `reviewFor`: open a review or reuse the open one for the same account, kind and record, recording + what the account holder may see (`shown`: record, notice or hidden). +- `attachAccount`: the only place a record gets an account in People; refuses an account or record already taken. +- `signInState(state, account)`: `match`, `awaiting` or `none` for My area. +- `reconcilePreliminary(state, accounts)`: matches accounts against preliminary persons whenever Auth's directory + changes, since their holder may never open My area, and closes reviews that no longer apply. + +In `service.ts`, `linkRecord` is the one entry point the procedures call: it asks Auth to link a preliminary person +(`peopleRoles.link`), otherwise uses `attachAccount` and sends the record's roles to Auth. Review resolution, invites, +search and unlinking stay procedures in `service.ts` and go through it. `syncDirectory` (`roles.ts`) refreshes +linked records and, for role holders without one, follows `decideFor`. + +## 5. Behaviour changes + +1. Email "Is this you?" and the `two-records` review start working against real Auth. +2. Preliminary matching moves from Auth to the backend: same outcomes, one rule. +3. A conflict or a username match tells the account holder they are being checked instead of "not on the lists". +4. A role holder whose only match is a username, a name or a preliminary person waits for the review instead of + getting a new empty record next to it. Ambiguous username or name matches (several records) match nobody. +5. A record that carries roles is no longer self-confirmed: "Is this you?" is offered only for records that grant + nothing; otherwise HR approves (D1). +6. A linked record whose account's verified identity (now including the verified email) also appears on another + record is flagged for review, as Telegram and Microsoft 365 identities already were. +7. A role holder is linked automatically only by a Microsoft 365 identity; a Telegram ID match becomes an HR review + (D2). + +## 6. Verification + +- `identity.test.ts`: one case per row of §4.2 and per conflict, including preliminary persons, rejected and already + linked records, `@`/case in usernames. +- Linking service tests: one per path (automatic, "Is this you?" yes/no, invite, HR link and unlink, each review + resolution, preliminary approval and refusal, ask to be added while awaiting), and the D1/D2 outcomes. +- Regression: an Auth feed with a verified email produces "Is this you?" (fails today). +- Auth: `people-roles` integration test asserts `verifiedEmail`, `telegramUsername` and that every login account is + listed; preliminary subjects keep their fields. +- All existing suites green: Auth (`vp check`, `vp test` with PostgreSQL), backend (`bun run test` with PostgreSQL, + typecheck, biome), dashboard (`vp check`, typecheck, tests, build). + +## 7. Roadmap + +1. **Auth feed (auth#73).** Add `verifiedEmail`, list every login account, drop `proposals` and the matching module; + update its tests and README. Done when the integration test passes and no backend contract field is removed + without milestone 3. +2. **Matching module (backend#99).** `identity.ts` with the table-driven tests; no caller yet. +3. **Linking module (backend#99).** Move the paths into `linking.ts`; switch People to the single feed; delete + `candidate()`, the proposal loop in `applySources` and the matching filter in `syncDirectory`. Done when every + existing People test passes unchanged and `options.directory` is no longer used by People. +4. **Policy (backend#99).** Apply D1 and D2 with their tests. +5. **Dashboard and docs (admin#136).** Add the `identity-match` kind to the contract, the Linking page and agent mode; update `pre-registration.md` and + this file to "implemented". +6. **Later, separate PR (with the People maintainers).** Split `service.ts` by domain: census and import, fees, + receipts and e-signing, teams, members. + +## 8. Decisions + +- **D1 (decided 2026-10-28).** A record that carries roles, with a verified match and no account, goes to an HR review + that links and grants in one step. Self-confirmation is only for records that grant nothing. +- **D2 (decided 2026-10-28).** Automatic linking of a role holder uses only Microsoft 365 identities (object ID or + `@polinetwork.org` address). A Telegram ID match becomes an HR review. + +No open decisions remain. + +## 7. Checking and undoing links + +Linking has a **Recently linked** view of the last 30 days. It lists the record, login address, linking method, day +and who linked it (account holder, admin or system). Old links without actor metadata show an unknown actor. This +is a check of current links; the record's activity retains earlier links and unlinks. Reading requires `people:read`; +unlinking additionally requires `people:write` and an explicit confirmation. + +For a preliminary person, People asks Auth to undo the link. Auth requires a Master Admin, atomically returns the +account's directly assigned People-bound roles to the preliminary subject and clears the link, with an audit event. +Inherited roles, other role assignments and provider accounts stay with the login account. For an ordinary record, +Auth must confirm revocation of its bound roles before People removes the account link. Auth can refuse removal of +an inherited role: change its granting role in Auth first. + +The record and its history remain. Identity IDs copied from the detached account are cleared, and a resolved +rejection excludes that account from future proposals for this record. Explicit admin linking remains possible. diff --git a/docs/people/plan.md b/docs/people/plan.md index e48f2390..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,3 +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 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/docs/people/pre-registration.md b/docs/people/pre-registration.md new file mode 100644 index 00000000..7c38b336 --- /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. A Telegram username matching the signed-in account produces a weaker proposal, since usernames can change hands; it never links on its own. 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; a Telegram username a weaker one. Same name alone, unverified email, username or phone do not automatically link. Matching follows [linking.md](linking.md). +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, identity evidence for every account, 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 reports verified identity evidence for every account, and the backend derives review proposals with the single matching rule in [linking.md](linking.md); 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/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/people/account-action-dialog.tsx b/src/features/people/account-action-dialog.tsx index 66841810..640c9096 100644 --- a/src/features/people/account-action-dialog.tsx +++ b/src/features/people/account-action-dialog.tsx @@ -12,6 +12,8 @@ export type AccountAction = { name: string /** The invite address, or the linked sign-in for `unlink`. */ email: string + /** For `unlink`: the person was created in Auth before their first login, so only a Master Admin can undo the link. */ + preliminary?: boolean } /** Send, resend or revoke an invite, or unlink an account. Keep `action` after closing so the text survives the exit. */ @@ -71,7 +73,7 @@ export function AccountActionDialog({ onOpenChange={onOpenChange} icon={Unlink} title="Unlink account?" - description={`${action.email} can no longer see ${action.name}'s record. The record and its history stay.`} + description={`${action.email} can no longer see ${action.name}'s record, loses the roles it gives in PoliNetwork Auth and won't be offered it again. The record and its history stay.${action.preliminary ? " Only a Master Admin can undo this link: the roles go back to the person created in Auth." : ""}`} confirmLabel="Unlink account" onConfirm={async () => { await unlink({ data }) 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 83271567..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 === 0 ? No admins : null} -
  • - ))} -
-
- - {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/linking-page.tsx b/src/features/people/linking-page.tsx index 5e680daa..52f3202f 100644 --- a/src/features/people/linking-page.tsx +++ b/src/features/people/linking-page.tsx @@ -1,6 +1,6 @@ import { Link } from "@tanstack/react-router" import { useServerFn } from "@tanstack/react-start" -import { CircleCheck, Mail, RefreshCw, X } from "lucide-react" +import { CircleCheck, History, Mail, RefreshCw, Unlink, X } from "lucide-react" import { type ReactNode, useDeferredValue, useState } from "react" import { @@ -32,9 +32,16 @@ import { formatDay, isExpired } from "./people-format" import { errorMessage, useRefreshAfter } from "./people-hooks" import { PersonCell } from "./people-ui" import { dismissLinkingReview, resolveLinkingReview } from "./people-write.functions" -import type { Invite, Review } from "./people.contract" +import { type Invite, RECENT_LINK_DAYS, type RecentLink, type Review } from "./people.contract" -type Scope = "review" | "invites" +type Scope = "review" | "invites" | "recent" + +const LINKED_BY = { + "account-holder": "Themselves", + admin: "An admin", + system: "Automatically", + unknown: "—", +} satisfies Record type ReviewKind = Review["kind"] const ISSUE = { @@ -43,6 +50,8 @@ 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", + "identity-match": "Sign-in matches a record HR must confirm", } satisfies Record type Resolution = { value: string; label: string; description: string } @@ -53,6 +62,42 @@ 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 "identity-match": + return { + title: "Link this sign-in to the record?", + description: review.message ?? `The sign-in matches ${record}. Check with them before linking.`, + options: [ + { + value: "link-anyway", + label: `Link to ${review.record?.id ?? "the record"}`, + description: "Links the account; Auth grants the record's roles if you may give them.", + }, + { + value: "decline", + label: "Different person", + description: "Keep the record and this sign-in separate.", + }, + ], + } case "telegram-conflict": return { title: "Resolve Telegram conflict", @@ -295,8 +340,16 @@ function ResolveDialog({ ) } -/** People › Linking: sign-ins the backend couldn't link on its own, and invites still waiting. */ -export function LinkingPage({ reviews, invites }: { reviews: Review[]; invites: Invite[] }) { +/** People › Linking: sign-ins the backend couldn't link on its own, invites still waiting, and recent links to check. */ +export function LinkingPage({ + reviews, + invites, + recent, +}: { + reviews: Review[] + invites: Invite[] + recent: RecentLink[] +}) { const canWrite = useCan("people:write") const dismiss = useServerFn(dismissLinkingReview) const refresh = useRefreshAfter() @@ -315,6 +368,7 @@ export function LinkingPage({ reviews, invites }: { reviews: Review[]; invites: const inviteRows = invites .filter((invite) => matches([invite.name, invite.email])) .toSorted((a, b) => a.expiresOn.localeCompare(b.expiresOn)) + const recentRows = recent.filter((link) => matches([link.name, link.email])) async function dismissReview(review: Review) { try { @@ -332,6 +386,17 @@ export function LinkingPage({ reviews, invites }: { reviews: Review[]; invites: setAccountOpen(true) } + function openUnlink(link: RecentLink) { + setAccountAction({ + kind: "unlink", + personId: link.personId, + name: link.name, + email: link.email, + preliminary: link.preliminary, + }) + setAccountOpen(true) + } + const reviewColumns: DataTableColumn[] = [ { id: "account", @@ -419,6 +484,47 @@ export function LinkingPage({ reviews, invites }: { reviews: Review[]; invites: }, ] + const recentColumns: DataTableColumn[] = [ + { + id: "person", + label: "Record", + fill: true, + minWidth: 200, + mobile: "main", + cell: (link) => , + }, + { + id: "email", + label: "Signed in as", + width: 248, + mobile: "full", + cell: (link) => {link.email || link.via}, + }, + { + id: "how", + label: "How", + width: 264, + priority: 3, + mobile: "text", + cell: (link) => {link.how}, + }, + { + id: "by", + label: "Linked by", + width: 136, + priority: 4, + mobile: "meta", + cell: (link) => {LINKED_BY[link.linkedBy]}, + }, + { + id: "on", + label: "Linked on", + width: 120, + mobile: "meta", + cell: (link) => {formatDay(link.linkedOn)}, + }, + ] + return ( <> @@ -436,6 +542,7 @@ export function LinkingPage({ reviews, invites }: { reviews: Review[]; invites: items={[ { value: "review", label: "Needs review", count: reviews.length }, { value: "invites", label: "Pending invites", count: invites.length }, + { value: "recent", label: "Recently linked", count: recent.length }, ]} value={scope} onValueChange={setScope} @@ -445,8 +552,10 @@ export function LinkingPage({ reviews, invites }: { reviews: Review[]; invites: deferredQuery ? ( scope === "review" ? ( - ) : ( + ) : scope === "invites" ? ( + ) : ( + ) ) : undefined } @@ -505,6 +614,51 @@ export function LinkingPage({ reviews, invites }: { reviews: Review[]; invites: ) } /> + ) : scope === "recent" ? ( + link.personId} + rowLabel={(link) => link.name} + rowHref={(link) => `/dashboard/people/${link.personId}`} + actions={ + canWrite + ? (link) => ( + + openUnlink(link)} + /> + + ) + : undefined + } + actionsWidth={56} + empty={ + deferredQuery ? ( + setQuery("")}> + Clear search + + } + /> + ) : ( + + ) + } + /> ) : ( @@ -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} > type Match = Extract +type Awaiting = Extract type Year = (typeof YEARS)[number] /** Account › My area: what the signed-in person's record says, and the yearly census. */ export function MyAreaPage({ area, invite }: { area: MyArea; invite: string | null }) { if (invite && area.state !== "linked") return if (area.state === "match") return + if (area.state === "awaiting-approval") return if (area.state === "none") return return } @@ -162,6 +163,39 @@ function MatchView({ area }: { area: Match }) { ) } +/** A record made before the first sign-in: nothing to do until a Master Admin approves the link. */ +function AwaitingApprovalView({ area }: { area: Awaiting }) { + const { candidate } = area + return ( + <> + + + +
+ {candidate ? ( +
+ +
+ {candidate.name} + {candidate.id} + {candidate.roles.length ? ( + + {candidate.roles.map((role) => ( + {role} + ))} + + ) : null} +
+
+ ) : null} + {area.reason} +
+
+
+ + ) +} + /** Opened from the invite link HR emails. */ function InviteView({ token }: { token: string }) { const accept = useServerFn(acceptMyInvite) @@ -255,25 +289,20 @@ function CensusCard({ area }: { area: Linked }) { const save = useServerFn(answerMyCensus) const refresh = useRefreshAfter() const entry = campaign ? person.census.find((candidate) => 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 +320,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 +418,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..3c388608 100644 --- a/src/features/people/people.api.server.ts +++ b/src/features/people/people.api.server.ts @@ -50,7 +50,9 @@ import { type ImportPreview, importPreviewSchema, type Invite, + type RecentLink, inviteSchema, + recentLinkSchema, type LinkAccountInput, memberRegisterRowSchema, type MemberRegisterRow, @@ -114,14 +116,13 @@ export type PeopleApi = { resolveReview: (input: ResolveReviewInput) => Promise dismissReview: (input: { reviewId: string }) => Promise listInvites: () => Promise + listRecentLinks: () => Promise sendInvite: (input: { personId: string }) => Promise revokeInvite: (input: { personId: string }) => Promise searchAccounts: (input: SearchAccountsInput) => Promise 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 @@ -198,14 +199,13 @@ 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()), 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 46f2178b..f6a7612b 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 @@ -32,7 +32,15 @@ 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", + "identity-match", +] 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 @@ -59,7 +67,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 +100,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". */ @@ -136,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() }), ]) @@ -147,6 +159,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. */ @@ -161,6 +174,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(), @@ -320,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(), @@ -389,10 +419,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. - 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() })), }) export type CapoArea = z.infer @@ -455,6 +482,13 @@ export const myAreaSchema = z.discriminatedUnion("state", [ }), reason: z.string(), }), + z.object({ + /** People already holds a record made before this account's first sign-in; a Master Admin decides the link. */ + state: z.literal("awaiting-approval"), + /** Only when a verified email or Telegram account matched: weaker matches may be someone else's record. */ + candidate: z.object({ id: z.string(), name: z.string(), roles: z.array(z.string()) }).nullable(), + reason: z.string(), + }), z.object({ state: z.literal("none"), request: z.object({ sentOn: z.string() }).nullable() }), ]) export type MyArea = z.infer @@ -589,8 +623,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({ @@ -665,8 +712,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"])) @@ -684,7 +729,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), @@ -775,7 +819,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 1fa71049..cda390d3 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, @@ -19,6 +20,7 @@ import { type Signer, type SignedUpload, TEAM_IDS, + RECENT_LINK_DAYS, TEAMS, type TeamId, type TeamSummary, @@ -27,14 +29,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, @@ -50,7 +49,7 @@ import { downloadName, looksSigned, receiptPdf, sampleTransferPdf, zipFiles } fr */ let store: PeopleStore | null = null -/** The agent user's own state when `AGENT_PERSON` is `none` or `match:P-…`. */ +/** The agent user's own state when `AGENT_PERSON` is `none`, `match:P-…` or `awaiting:P-…`. */ type SelfState = { matchConfirmed: boolean matchRejected: boolean @@ -98,8 +97,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) { @@ -135,6 +132,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, @@ -156,11 +154,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, } @@ -299,7 +293,7 @@ function viewerName(viewer: PeopleViewer) { function viewerRecord(viewer: PeopleViewer) { if (self.invitedId) return data().byId.get(self.invitedId) ?? null if (!viewer.personId || viewer.personId === "none") return null - const id = viewer.personId.replace(/^match:/, "") + const id = viewer.personId.replace(/^(match|awaiting):/, "") return data().byId.get(id) ?? null } @@ -310,6 +304,7 @@ function viewerRecord(viewer: PeopleViewer) { function linkedRecord(viewer: PeopleViewer) { if (self.matchRejected) return null if (viewer.personId?.startsWith("match:") && !self.matchConfirmed) return null + if (viewer.personId?.startsWith("awaiting:")) return null return viewerRecord(viewer) } @@ -330,21 +325,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 +361,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 +445,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 +495,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", }, })), } @@ -549,15 +525,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, @@ -565,7 +543,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, @@ -579,14 +557,19 @@ export function fakePeopleApi(viewer: PeopleViewer): PeopleApi { since: today(), endedOn: null, endReason: null, - groups: [], - requested: [], availableAsCapo: false, capoOfReference: null, 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" }, @@ -692,8 +675,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 +808,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" }, { @@ -908,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") { @@ -922,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) => @@ -1022,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}`) }, @@ -1035,58 +1041,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) } @@ -1096,6 +1063,20 @@ export function fakePeopleApi(viewer: PeopleViewer): PeopleApi { const me = viewerRecord(viewer) if (!me || self.matchRejected) return { state: "none", request: self.askedOn ? { sentOn: self.askedOn } : null } satisfies MyArea + if (viewer.personId?.startsWith("awaiting:")) + return { + state: "awaiting-approval", + candidate: { + id: me.id, + name: fullName(me), + roles: [ + ...(me.admin?.status === "active" ? ["Admin"] : []), + ...me.teams.filter((team) => !team.endedOn).map((team) => TEAMS[team.team].name), + ], + }, + reason: + "Same verified email address. A Master Admin will confirm the link: you don't need to ask to be added.", + } satisfies MyArea if (viewer.personId?.startsWith("match:") && !self.matchConfirmed) { const roles = [ ...(me.member && me.member.status !== "former" ? ["Member"] : []), @@ -1197,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 () => { @@ -1222,6 +1204,7 @@ export function fakePeopleApi(viewer: PeopleViewer): PeopleApi { via: "Email", linkedOn: today(), how: "Accepted the email invitation", + by: "account-holder", } }, askToBeAdded: async () => { @@ -1243,13 +1226,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/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/features/people/person-dialog.tsx b/src/features/people/person-dialog.tsx index 3d367537..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 d380562b..3b4f2a25 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() @@ -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} } @@ -413,38 +419,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} @@ -575,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 ? (