From c99182e760337e9baf412f0ea6cb71898ba2cf56 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Mon, 9 Nov 2026 21:57:22 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat(people)!:=20add=20Soci=20=E2=80=BA=20R?= =?UTF-8?q?egister,=20the=20socio=20page=20and=20Admit=20socio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit People Phase 2's register in the dashboard (#155), on the backend's soci.members.* procedures (PoliNetworkOrg/backend#122). - Group People's sections: Volunteers (Overview, Admins, Teams, Census, Linking) and Soci (Register, Receipts). Sections take an optional group; the panel draws its title, the mobile pills keep the order. - Soci › Register (soci:read): Active / Ended / All, the number in DM Mono, alias as the name's second line, alerts for Soci accounts without a record (with Register) and for records that disagree with Soci membership. - Socio page: bookkeeping data, admission, the Microsoft 365 account and the activity; Edit and Change number with soci:write. - Admit socio (lg dialog): today's Microsoft 365 create form, the bookkeeping data, the admission date and the first-fee note, warning when the codice fiscale belongs to a former socio. - Register existing socio: an account already in Soci, its number fixed (or chosen when it has none), the admission date suggested from the account's creation, personal fields optional. - soci.api.server.ts with a copy of the backend contract and an agent-mode fake covering active, ended, unregistered and mismatched soci; sociReadMiddleware and sociWriteMiddleware. - Microsoft 365 › Members is read-only and points to People › Soci. - Remove the Member register mockup and its sample data. BREAKING CHANGE: Microsoft 365 › Members no longer creates members or changes member IDs, and the dashboard no longer reads the azure:members:create and azure:members:write permissions. Admitting and renumbering need soci:write, and the register soci:read. Refs #155 --- AGENTS.md | 23 +- README.md | 21 +- docs/design.md | 5 +- src/components/shell/nav.ts | 65 +- src/components/shell/panel.tsx | 16 +- src/features/azure/azure.functions.ts | 50 +- src/features/azure/email.ts | 4 - src/features/azure/member-dialog.tsx | 251 ------- src/features/azure/member-number-dialog.tsx | 183 ----- src/features/azure/member-number.ts | 15 - src/features/azure/members-page.tsx | 189 ++--- src/features/people/members-page.tsx | 311 -------- src/features/people/mockup-data.ts | 45 +- src/features/soci/codice-fiscale.ts | 79 +++ src/features/{azure => soci}/mailbox.ts | 2 +- src/features/soci/member-number.ts | 4 + src/features/soci/register-page.tsx | 280 ++++++++ src/features/soci/soci-format.ts | 69 ++ src/features/soci/soci-write.functions.ts | 32 + src/features/soci/soci.api.server.ts | 68 ++ src/features/soci/soci.contract.ts | 277 ++++++++ src/features/soci/soci.fake.server.ts | 326 +++++++++ src/features/soci/soci.functions.ts | 32 + src/features/soci/socio-dialogs.tsx | 706 +++++++++++++++++++ src/features/soci/socio-form.tsx | 282 ++++++++ src/features/soci/socio-page.tsx | 205 ++++++ src/lib/format.ts | 2 + src/routeTree.gen.ts | 65 +- src/routes/dashboard/azure/members.tsx | 8 +- src/routes/dashboard/people/members.tsx | 8 - src/routes/dashboard/people/soci/$number.tsx | 44 ++ src/routes/dashboard/people/soci/index.tsx | 24 + src/server/auth.middleware.ts | 35 +- src/server/permissions.ts | 4 +- tests/azure-member-number.test.mjs | 44 -- tests/people-navigation.test.mjs | 37 +- tests/server-security.test.mjs | 22 +- tests/soci.test.mjs | 84 +++ 38 files changed, 2761 insertions(+), 1156 deletions(-) delete mode 100644 src/features/azure/email.ts delete mode 100644 src/features/azure/member-dialog.tsx delete mode 100644 src/features/azure/member-number-dialog.tsx delete mode 100644 src/features/azure/member-number.ts delete mode 100644 src/features/people/members-page.tsx create mode 100644 src/features/soci/codice-fiscale.ts rename src/features/{azure => soci}/mailbox.ts (95%) create mode 100644 src/features/soci/member-number.ts create mode 100644 src/features/soci/register-page.tsx create mode 100644 src/features/soci/soci-format.ts create mode 100644 src/features/soci/soci-write.functions.ts create mode 100644 src/features/soci/soci.api.server.ts create mode 100644 src/features/soci/soci.contract.ts create mode 100644 src/features/soci/soci.fake.server.ts create mode 100644 src/features/soci/soci.functions.ts create mode 100644 src/features/soci/socio-dialogs.tsx create mode 100644 src/features/soci/socio-form.tsx create mode 100644 src/features/soci/socio-page.tsx delete mode 100644 src/routes/dashboard/people/members.tsx create mode 100644 src/routes/dashboard/people/soci/$number.tsx create mode 100644 src/routes/dashboard/people/soci/index.tsx delete mode 100644 tests/azure-member-number.test.mjs create mode 100644 tests/soci.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 58f329bc..c5e721a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,14 +14,10 @@ What the app is and how to run it: [`README.md`](README.md). UI rules: [`docs/de Microsoft 365 member creation and member-number changes. The backend independently checks every procedure. `context.backend` forwards a bearer token, never cookies. - **Features** (`src/features//`): pages, dialogs, validation and `*.functions.ts` server functions. -- **Microsoft 365**: read the member directory with `azure:members:read` and create with `azure:members:create` through - the backend's fixed new-member workflow, choosing a standard or custom address; a welcome email to a PoliNetwork - address asks for confirmation. Change a member's ID with `azure:members:write`, after a confirmation, optionally - emailing them; the backend refuses IDs already assigned and accepts this call only with a user token, so it fails in - `AGENT_MODE`. List groups and their members, read-only, with `azure:groups:read`. Group membership is never - edited from the dashboard: the backend's create workflow adds only the new user to Soci, from which the IdP derives - the Socio role, so adding or removing anyone in a group would grant or revoke IdP roles. Nothing else about users or - groups can be edited from the dashboard. +- **Microsoft 365**: read the member directory with `azure:members:read`, and list groups and their members with + `azure:groups:read`, both read-only. Group membership is never edited from the dashboard: admitting a socio (People › + Soci) adds only the new user to Soci, from which the IdP derives the Socio role, so adding or removing anyone in a group + would grant or revoke IdP roles. Nothing else about users or groups can be edited from Microsoft 365's pages. - **People** (`src/features/people/`): every page reads and writes through `peopleApi(context)`, which parses the backend's `people.*` responses with `people.contract.ts` (a copy of the backend's) and, in agent mode, uses the in-memory fake (`AGENT_PERMISSIONS`, `AGENT_PERSON` pick the viewer). A person's roles are assignments (Admin, Capo @@ -32,9 +28,14 @@ What the app is and how to run it: [`README.md`](README.md). UI rules: [`docs/de permissions yet can accept one (`peopleInviteMiddleware`: signed in, the token is the proof); the old `/dashboard/account/me?peopleInvite=` links redirect there. `/dashboard` admits `people:self` without `admin:access`; Capo Admin and Team Lead come from the person's own record (`peopleRoles` in the route context) and - only open their sections. Member register, Receipts, Census and My area's fee card are unconnected mockups (#138): - static sample data from `mockup-data.ts`, no server functions, disabled actions and a `MockupBanner`, gated by - `people:read`, to be rebuilt in Phase 2 (#144) and Phase 3 (#145). Handoff notes, decisions and the plan: `docs/people/`. + only open their sections. Receipts, Census and My area's fee card are unconnected mockups (#138): static sample data + from `mockup-data.ts`, no server functions, disabled actions and a `MockupBanner`, gated by `people:read`, to be rebuilt + in Phase 2 (#144) and Phase 3 (#145). Handoff notes, decisions and the plan: `docs/people/`. +- **Soci** (`src/features/soci/`, People's Soci group): the Libro Soci, through `sociApi(context)`, which parses the + backend's `soci.*` responses with `soci.contract.ts` (a copy of the backend's) and serves `soci.fake.server.ts` in agent + mode. `soci:read` opens Register and the socio pages, `soci:write` admits (a new Microsoft 365 account in Soci, then the + record), registers an account already in Soci, edits and changes member numbers, which moved here from Microsoft 365 › + Members. Design: `docs/people/soci.md`. - **Shared UI**: `src/components/shell`, `src/components/primitives`, `src/components/ui`. ## Conventions diff --git a/README.md b/README.md index b22dc492..8f731634 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,18 @@ # PoliNetwork Admin The dashboard manages Telegram and WhatsApp groups, labels, Telegram users and grants, website content and reports. -Microsoft 365 supports a member directory, creating a new association member through the backend's fixed workflow -(with a standard `firstname.lastname` or custom address), changing a member's ID with an optional email to them, and a -read-only list of groups. Directory access requires `azure:members:read`; creation independently requires -`azure:members:create`, and changing member IDs `azure:members:write`. Groups require `azure:groups:read`. Group -membership is never edited: creating a member adds only that new user to Soci, which gives them the Socio role. +Microsoft 365 has a read-only member directory (`azure:members:read`) and a read-only list of groups +(`azure:groups:read`). Group membership is never edited from Microsoft 365's pages: admitting a socio in People › Soci +adds only that new user to Soci, which gives them the Socio role. **People** keeps one record per volunteer (Overview, Admins, Teams, Linking), plus Telegram › Capo Admin and Account › -My area for volunteers themselves. The dashboard talks to a typed `people.*` contract -(`src/features/people/people.contract.ts`, documented in `docs/people/contract.md`); agent mode serves sample data from -`people.fake.server.ts`. Member register, Receipts and Census are unconnected UI mockups with static sample data, to be -rebuilt in Phase 2 (#144) and Phase 3 (#145). Permissions: `people:read` (which also opens the mockups), -`people:write`, `people:appoint`, and `people:self`, which alone admits non-admins to My area and, by the role on their -record, Capo Admin or their team. See `docs/people/README.md`. +My area for volunteers themselves, and the Libro Soci (Soci › Register). The dashboard talks to typed `people.*` and +`soci.*` contracts (`src/features/people/people.contract.ts`, documented in `docs/people/contract.md`, and +`src/features/soci/soci.contract.ts`); agent mode serves sample data from `people.fake.server.ts` and +`soci.fake.server.ts`. Receipts and Census are unconnected UI mockups with static sample data, to be rebuilt in Phase 2 +(#144) and Phase 3 (#145). Permissions: `people:read` (which also opens the mockups), `people:write`, `people:appoint`, +`people:self`, which alone admits non-admins to My area and, by the role on their record, Capo Admin or their team, and +`soci:read` and `soci:write` for the register. See `docs/people/README.md` and `docs/people/soci.md`. Built with TanStack Start (React 19), Vite+, Nitro, Tailwind CSS v4 and shadcn/Base UI. The server is an OIDC BFF: only it holds the IdP access/refresh/ID tokens. Browsers hold an opaque, HttpOnly session cookie; sessions live in a diff --git a/docs/design.md b/docs/design.md index a2b8c503..076a2a40 100644 --- a/docs/design.md +++ b/docs/design.md @@ -91,7 +91,10 @@ else its first. Theme toggle and the account avatar (the same active fill and in the Account page. **Panel** (240px, 200px below 1100px). Rendered only for services with two or more sections (Telegram, Microsoft 365, -Web, Reports); WhatsApp, Overview and Account have none. A 64px header with the service icon and title, then 38px items: +Web, Reports); WhatsApp, Overview and Account have none. A service may group its sections (`group` on `Section`): the +panel draws each group's title, 11px uppercase and subtle, above its first visible section, and the mobile pills only +keep the order. People groups Volunteers (Overview, Admins, Teams, Census, Linking) and Soci (Register, then Fees and +Deletion pending). A 64px header with the service icon and title, then 38px items: the current one has a muted fill, an accent icon and a 3px accent bar; on deep pages the parent section keeps the fill and accent icon without the bar (`data-ancestor`). The only counts are the open reports next to Reports › Open and the documents to sign next to Signatures › To sign, each as a small accent pill. diff --git a/src/components/shell/nav.ts b/src/components/shell/nav.ts index c4a6066d..ff271972 100644 --- a/src/components/shell/nav.ts +++ b/src/components/shell/nav.ts @@ -71,6 +71,11 @@ export type Section = { role?: PeopleRole /** Open with `people:self` alone (Account). Every other section also needs `admin:access`. */ selfService?: boolean + /** + * The panel heading this section sits under (People: Volunteers, Soci). The panel draws it above the group's first + * visible section; the mobile pills only keep the order. Sections of a group are listed together. + */ + group?: string } /** Roles that come from the person's record (`people.me.roles`), not from IdP permissions. */ @@ -102,7 +107,8 @@ export type PageMatch = | { kind: "account-me" } | { kind: "people-overview" } | { kind: "people-person"; personId: string } - | { kind: "people-members" } + | { kind: "people-soci" } + | { kind: "people-socio"; number: number } | { kind: "people-receipts" } | { kind: "people-admins" } | { kind: "people-teams" } @@ -179,31 +185,16 @@ const people: Service = { icon: LayoutList, path: "/dashboard/people", permission: "people:read", + group: "Volunteers", searchPlaceholder: "Search by name, email or Telegram…", }, - // Member register, Receipts and Census are unconnected mockups (Phase 2, #144; Phase 3, #145). - { - id: "members", - title: "Member register", - icon: IdCard, - path: "/dashboard/people/members", - permission: "people:read", - searchPlaceholder: "Search by name, number or email…", - }, - { - id: "receipts", - title: "Receipts", - icon: Receipt, - path: "/dashboard/people/receipts", - permission: "people:read", - searchPlaceholder: "Search by name or receipt number…", - }, { id: "admins", title: "Admins", icon: UserCog, path: "/dashboard/people/admins", permission: "people:read", + group: "Volunteers", searchPlaceholder: "Search by name, course or username…", }, { @@ -213,14 +204,17 @@ const people: Service = { path: "/dashboard/people/teams", permission: "people:read", role: "lead", + group: "Volunteers", searchPlaceholder: "Search team members…", }, + // Census is an unconnected mockup (Phase 3, #145). { id: "census", title: "Census", icon: ClipboardList, path: "/dashboard/people/census", permission: "people:read", + group: "Volunteers", }, { id: "linking", @@ -228,8 +222,28 @@ const people: Service = { icon: Link2, path: "/dashboard/people/linking", permission: "people:read", + group: "Volunteers", searchPlaceholder: "Search by name or email…", }, + { + id: "soci", + title: "Register", + icon: IdCard, + path: "/dashboard/people/soci", + permission: "soci:read", + group: "Soci", + searchPlaceholder: "Search by name, number or mailbox…", + }, + // Receipts is an unconnected mockup, to be replaced by Fees (Phase 2, #144). + { + id: "receipts", + title: "Receipts", + icon: Receipt, + path: "/dashboard/people/receipts", + permission: "people:read", + group: "Soci", + searchPlaceholder: "Search by name or receipt number…", + }, ], } @@ -310,7 +324,7 @@ const m365: Service = { title: "Members", icon: UserRound, path: "/dashboard/azure/members", - anyPermission: ["azure:members:read", "azure:members:create"], + permission: "azure:members:read", searchPlaceholder: "Search name, email or ID…", }, ], @@ -487,7 +501,7 @@ export function matchPath(pathname: string): PageMatch { if (head === "people") { if (second === undefined) return { kind: "people-overview" } if (third === undefined) { - if (second === "members") return { kind: "people-members" } + if (second === "soci") return { kind: "people-soci" } if (second === "receipts") return { kind: "people-receipts" } if (second === "admins") return { kind: "people-admins" } if (second === "teams") return { kind: "people-teams" } @@ -496,6 +510,8 @@ export function matchPath(pathname: string): PageMatch { if (/^P-\d{4,}$/.test(second)) return { kind: "people-person", personId: second } } if (second === "teams" && third !== undefined) return { kind: "people-team", teamId: third } + if (second === "soci" && third !== undefined && /^[1-9]\d{0,5}$/.test(third)) + return { kind: "people-socio", number: Number(third) } if (second === "census" && third !== undefined) return { kind: "people-campaign", campaignId: third } } @@ -561,7 +577,8 @@ export function serviceFor(match: PageMatch): Service { return account case "people-overview": case "people-person": - case "people-members": + case "people-soci": + case "people-socio": case "people-receipts": case "people-admins": case "people-teams": @@ -617,8 +634,9 @@ export function sectionFor(match: PageMatch): Section | null { case "people-overview": case "people-person": return section(people, "overview") - case "people-members": - return section(people, "members") + case "people-soci": + case "people-socio": + return section(people, "soci") case "people-receipts": return section(people, "receipts") case "people-admins": @@ -686,6 +704,7 @@ export function isDeepPage(match: PageMatch): boolean { match.kind === "web-tag" || match.kind === "people-person" || match.kind === "people-team" || + match.kind === "people-socio" || match.kind === "people-campaign" || match.kind === "esign-request" || match.kind === "esign-new" || diff --git a/src/components/shell/panel.tsx b/src/components/shell/panel.tsx index ae755184..0b43dcb4 100644 --- a/src/components/shell/panel.tsx +++ b/src/components/shell/panel.tsx @@ -71,9 +71,20 @@ export function SectionItems({ service, match, pending, variant = "panel" }: Sec const current = sectionFor(match) const deep = isDeepPage(match) const pills = variant === "pills" - return service.sections.map((section: Section) => { + return service.sections.flatMap((section: Section, index) => { const active = current?.path === section.path - return ( + // The panel names each group above its first section; the pills keep only the order. + const heading = + !pills && section.group && section.group !== service.sections[index - 1]?.group ? ( + + {section.group} + + ) : null + const link = ( ) + return heading ? [heading, link] : [link] }) } diff --git a/src/features/azure/azure.functions.ts b/src/features/azure/azure.functions.ts index 7e7f2288..fb64b3c3 100644 --- a/src/features/azure/azure.functions.ts +++ b/src/features/azure/azure.functions.ts @@ -1,54 +1,8 @@ import { createServerFn } from "@tanstack/react-start" -import { z } from "zod" -import { azureEmailSchema } from "@/features/azure/email" -import { - azureMembersCreateMiddleware, - azureMembersReadMiddleware, - azureMembersWriteMiddleware, -} from "@/server/auth.middleware" +import { azureMembersReadMiddleware } from "@/server/auth.middleware" +/** The directory, read-only: soci are admitted and renumbered from People › Soci. */ export const getAzureMembers = createServerFn({ method: "GET" }) .middleware([azureMembersReadMiddleware]) .handler(({ context }) => context.backend.azure.members.getAll.query()) - -const setMemberNumberResultSchema = z.object({ - error: z.string().nullable(), - notificationSent: z.boolean().nullable().optional(), -}) - -export const createAzureMember = createServerFn({ method: "POST" }) - .middleware([azureMembersCreateMiddleware]) - .validator( - z.object({ - firstName: z.string().trim().min(1), - lastName: z.string().trim().min(1), - assocNumber: z.number().int().positive(), - sendEmailTo: azureEmailSchema, - mailbox: z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("standard") }), - z.object({ kind: z.literal("custom"), localPart: z.string().trim().min(1) }), - ]), - }) - ) - .handler(async ({ data, context }) => { - const result = await context.backend.azure.members.create.mutate(data) - if (result.error !== null) throw new Error(result.error) - return result - }) - -/** Throws when the number is not saved; `notificationSent` is false when only the email failed. */ -export const setAzureMemberNumber = createServerFn({ method: "POST" }) - .middleware([azureMembersWriteMiddleware]) - .validator( - z.object({ - userId: z.string().min(1), - assocNumber: z.number().int().positive(), - notifyEmail: azureEmailSchema.optional(), - }) - ) - .handler(async ({ data, context }) => { - const result = setMemberNumberResultSchema.parse(await context.backend.azure.members.setAssocNumber.mutate(data)) - if (result.error !== null) throw new Error(result.error) - return result - }) diff --git a/src/features/azure/email.ts b/src/features/azure/email.ts deleted file mode 100644 index 8aa6d995..00000000 --- a/src/features/azure/email.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { z } from "zod" - -/** Shared email validation for Azure member notifications. */ -export const azureEmailSchema = z.email() diff --git a/src/features/azure/member-dialog.tsx b/src/features/azure/member-dialog.tsx deleted file mode 100644 index 8c07e194..00000000 --- a/src/features/azure/member-dialog.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import { useServerFn } from "@tanstack/react-start" -import { CircleAlert, TriangleAlert } from "lucide-react" -import { useState } from "react" - -import { - ConfirmDialog, - fieldControl, - fieldHintId, - FormDialog, - FormField, - SegmentedControl, -} from "@/components/primitives" -import { Input } from "@/components/ui/input" -import { createAzureMember } from "@/features/azure/azure.functions" -import { cn } from "@/lib/utils" - -import { isLocalPart, isPoliNetworkAddress, MAIL_DOMAIN, type MailboxKind, standardLocalPart } from "./mailbox" -import { isMemberNumber } from "./member-number" - -type MemberDialogProps = { - open: boolean - onOpenChange: (open: boolean) => void - onSaved: (welcomeMailSent: boolean) => Promise -} - -const MEMBER_ID_ERROR = "Enter a valid positive member ID." -const EMAIL_ERROR = "Enter a valid email address." -const SAME_ADDRESS_ERROR = "This is the address being created and has no mailbox yet. Use the member's personal email." -const LOCAL_PART_ERROR = "Use lowercase letters and digits, with single dots or hyphens between them." -const STANDARD_ERROR = "This name can't form a standard address. Choose Custom." -const SAVE_ERROR = "The member could not be saved. Check the values and your permissions." - -const MAILBOX_KINDS = [ - { value: "standard", label: "Standard" }, - { value: "custom", label: "Custom" }, -] as const - -function isEmail(value: string) { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) -} - -/** The backend's own messages ("The address … is already in use.") are kept; anything else is generic. */ -function saveError(caught: Error) { - return caught.message === "" || caught.message === "UNAUTHORIZED" ? SAVE_ERROR : caught.message -} - -/** Create a new member through the fixed backend workflow, choosing their Microsoft 365 address. */ -export function MemberDialog({ open, onOpenChange, onSaved }: MemberDialogProps) { - const createMember = useServerFn(createAzureMember) - const [firstName, setFirstName] = useState("") - const [lastName, setLastName] = useState("") - const [mailbox, setMailbox] = useState("standard") - const [customLocalPart, setCustomLocalPart] = useState("") - const [email, setEmail] = useState("") - const [memberId, setMemberId] = useState("") - const [submitted, setSubmitted] = useState(false) - const [confirmingRecipient, setConfirmingRecipient] = useState(false) - - const namesGiven = firstName.trim() !== "" && lastName.trim() !== "" - const standard = namesGiven ? standardLocalPart(firstName, lastName) : null - const localPart = mailbox === "standard" ? standard : isLocalPart(customLocalPart) ? customLocalPart : null - const address = localPart === null ? null : `${localPart}@${MAIL_DOMAIN}` - const recipient = email.trim() - const sameAddress = address !== null && recipient.toLowerCase() === address - - const addressError = - mailbox === "standard" - ? namesGiven && standard === null - ? STANDARD_ERROR - : undefined - : submitted && localPart === null - ? LOCAL_PART_ERROR - : undefined - const memberIdError = submitted && !isMemberNumber(memberId) ? MEMBER_ID_ERROR : undefined - const emailError = submitted - ? !isEmail(recipient) - ? EMAIL_ERROR - : sameAddress - ? SAME_ADDRESS_ERROR - : undefined - : undefined - const dirty = [firstName, lastName, customLocalPart, email, memberId].some((value) => value.trim() !== "") - const canSubmit = - namesGiven && - recipient !== "" && - memberId !== "" && - (mailbox === "standard" ? standard !== null : customLocalPart !== "") - - async function create() { - if (localPart === null) return - let welcomeMailSent: boolean - try { - const result = await createMember({ - data: { - firstName: firstName.trim(), - lastName: lastName.trim(), - assocNumber: Number.parseInt(memberId, 10), - sendEmailTo: recipient, - mailbox: mailbox === "custom" ? { kind: "custom", localPart } : { kind: "standard" }, - }, - }) - welcomeMailSent = result.welcomeMailSent - } catch (caught) { - console.error(caught) - throw new Error(caught instanceof Error ? saveError(caught) : SAVE_ERROR, { cause: caught }) - } - await onSaved(welcomeMailSent) - onOpenChange(false) - } - - async function submit() { - setSubmitted(true) - if (localPart === null || !isMemberNumber(memberId) || !isEmail(recipient) || sameAddress) return - // The welcome email carries the new account's credentials, so it normally goes to a personal address. - if (isPoliNetworkAddress(recipient)) { - setConfirmingRecipient(true) - return - } - await create() - } - - return ( - -
- - setFirstName(event.target.value)} - className={cn("h-9", fieldControl)} - /> - - - setLastName(event.target.value)} - className={cn("h-9", fieldControl)} - /> - -
-
- - New Microsoft 365 address - - - {mailbox === "standard" ? ( - - {address ?? `firstname.lastname@${MAIL_DOMAIN}`} - - ) : ( -
- setCustomLocalPart(event.target.value.replace(/\s/g, "").toLowerCase())} - className={cn("h-9 min-w-0 flex-1 font-mono", fieldControl)} - /> - @{MAIL_DOMAIN} -
- )} - {addressError ? ( -

- - {addressError} -

- ) : ( -

- {mailbox === "standard" - ? "Built from the first and last name, without accents, apostrophes or spaces." - : "Lowercase letters, digits, dots and hyphens."} -

- )} -
- - setEmail(event.target.value)} - className={cn("h-9", fieldControl)} - /> - - - setMemberId(event.target.value)} - className={cn("h-9 tabular-nums", fieldControl)} - /> - - - {recipient} will receive the sign-in details for{" "} - {address}. - - } - confirmLabel="Send to this address" - cancelLabel="Change recipient" - tone="primary" - icon={TriangleAlert} - onConfirm={create} - /> -
- ) -} diff --git a/src/features/azure/member-number-dialog.tsx b/src/features/azure/member-number-dialog.tsx deleted file mode 100644 index 33c3dbb3..00000000 --- a/src/features/azure/member-number-dialog.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { useServerFn } from "@tanstack/react-start" -import { Hash } from "lucide-react" -import { useState } from "react" - -import { - checkboxControl, - ConfirmDialog, - fieldControl, - fieldHintId, - FormDialog, - FormField, -} from "@/components/primitives" -import { Checkbox } from "@/components/ui/checkbox" -import { Input } from "@/components/ui/input" -import { setAzureMemberNumber } from "@/features/azure/azure.functions" -import type { AzureMember } from "@/lib/api/types" -import { cn } from "@/lib/utils" - -import { azureEmailSchema } from "./email" -import { isMemberNumber } from "./member-number" - -type MemberNumberDialogProps = { - member: AzureMember - open: boolean - onOpenChange: (open: boolean) => void - /** Shows the new member ID in the table right away; returns the function that reverts it. */ - onOptimisticUpdate: (memberId: string, employeeId: string) => () => void - /** Runs after the number is saved, before the dialog closes; `notified` is null when no email was asked for. */ - onSaved: (notified: boolean | null) => Promise -} - -const MEMBER_ID_ERROR = "Enter a valid positive member ID." -const EMAIL_ERROR = "Enter a valid email address." -const SAVE_ERROR = "The member ID could not be changed. Check the value and your permissions." - -function isEmail(value: string) { - return azureEmailSchema.safeParse(value).success -} - -/** The backend's own messages ("Member number 12 is already assigned to …") are kept; anything else is generic. */ -function saveError(caught: Error) { - return caught.message === "" || caught.message === "UNAUTHORIZED" ? SAVE_ERROR : caught.message -} - -/** Change the member ID of an existing member, after a confirmation, optionally emailing them the new one. */ -export function MemberNumberDialog({ - member, - open, - onOpenChange, - onOptimisticUpdate, - onSaved, -}: MemberNumberDialogProps) { - const setMemberNumber = useServerFn(setAzureMemberNumber) - const initialMemberId = member.employeeId ?? "" - const [memberId, setMemberId] = useState(initialMemberId) - const [notify, setNotify] = useState(true) - const [email, setEmail] = useState(member.mail ?? "") - const [submitted, setSubmitted] = useState(false) - const [confirming, setConfirming] = useState(false) - - const recipient = email.trim() - const memberIdError = submitted && !isMemberNumber(memberId) ? MEMBER_ID_ERROR : undefined - const emailError = submitted && notify && !isEmail(recipient) ? EMAIL_ERROR : undefined - const changed = memberId !== initialMemberId - const dirty = changed || !notify || email !== (member.mail ?? "") - const canSubmit = changed && memberId !== "" && (!notify || recipient !== "") - const name = member.displayName ?? member.mail ?? "this member" - - function submit() { - setSubmitted(true) - if (!isMemberNumber(memberId) || (notify && !isEmail(recipient))) return - setConfirming(true) - } - - async function save() { - const revert = onOptimisticUpdate(member.id, memberId) - let notified: boolean | null - try { - const result = await setMemberNumber({ - data: { - userId: member.id, - assocNumber: Number.parseInt(memberId, 10), - notifyEmail: notify ? recipient : undefined, - }, - }) - notified = result.notificationSent ?? null - } catch (caught) { - console.error(caught) - revert() - throw new Error(caught instanceof Error ? saveError(caught) : SAVE_ERROR, { cause: caught }) - } - await onSaved(notified) - onOpenChange(false) - } - - return ( - - {name} - {member.mail && member.displayName ? ( - <> - {" · "} - {member.mail} - - ) : null} - - } - noun="member" - dirty={dirty} - canSubmit={canSubmit} - submitLabel="Change member ID" - onSubmit={submit} - > - - setMemberId(event.target.value)} - className={cn("h-9 tabular-nums", fieldControl)} - /> - - - {notify && ( - - setEmail(event.target.value)} - className={cn("h-9", fieldControl)} - /> - - )} - - {name} goes from {member.employeeId ? `#${member.employeeId}` : "no member ID"} to{" "} - #{memberId}.{" "} - {notify ? ( - <> - An email with the new ID goes to {recipient}. - - ) : ( - "No email is sent." - )} - - } - confirmLabel="Change member ID" - tone="primary" - icon={Hash} - onConfirm={save} - /> - - ) -} diff --git a/src/features/azure/member-number.ts b/src/features/azure/member-number.ts deleted file mode 100644 index db5b93cf..00000000 --- a/src/features/azure/member-number.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function isMemberNumber(value: string) { - return /^[1-9]\d*$/.test(value) && Number.isSafeInteger(Number(value)) -} - -/** Retain saved values while the directory is stale; release them when it confirms the write. */ -export function reconcileMemberNumbers( - saved: ReadonlyMap, - members: readonly { id: string; employeeId: string | null }[] | null -) { - const confirmed = members?.filter((member) => saved.get(member.id) === member.employeeId) ?? [] - if (confirmed.length === 0) return saved - const pending = new Map(saved) - for (const member of confirmed) pending.delete(member.id) - return pending -} diff --git a/src/features/azure/members-page.tsx b/src/features/azure/members-page.tsx index dd232af4..2ecc22b8 100644 --- a/src/features/azure/members-page.tsx +++ b/src/features/azure/members-page.tsx @@ -1,29 +1,25 @@ -import { useRouter } from "@tanstack/react-router" -import { Check, Pencil, Plus, UsersRound } from "lucide-react" -import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react" +import { Link } from "@tanstack/react-router" +import { Check, UsersRound } from "lucide-react" +import { useDeferredValue, useState } from "react" import { buttonMotion, DataTable, type DataTableColumn, EmptyState, - IconButton, + InlineAlert, + inlineLink, initialsOf, - RowActions, type TableSort, ToggleFilter, Unset, } from "@/components/primitives" import { Hint } from "@/components/primitives/hint" -import { appToast, Count, PageBar, PageContent, Toolbar, useCan } from "@/components/shell" +import { Count, PageBar, PageContent, Toolbar, useCan } from "@/components/shell" import { Button } from "@/components/ui/button" import type { AzureMember } from "@/lib/api/types" import { formatLicense, pluralize } from "@/lib/format" -import { MemberDialog } from "./member-dialog" -import { reconcileMemberNumbers } from "./member-number" -import { MemberNumberDialog } from "./member-number-dialog" - const DEFAULT_SORT: TableSort = { column: "employeeId", direction: "asc" } function memberName(member: AzureMember) { @@ -151,89 +147,23 @@ const columns: DataTableColumn[] = [ }, ] -/** Microsoft 365 members: the directory with member IDs and who is still a member. */ -export function AzureMembersPage({ members }: { members: AzureMember[] | null }) { - const router = useRouter() - const canCreate = useCan("azure:members:create") - const canWrite = useCan("azure:members:write") +/** + * Microsoft 365 members: the directory with member IDs and who is in Soci, read-only. Admitting a socio and changing a + * member number happen in People › Soci, which keeps the register and Microsoft 365 together. + */ +export function AzureMembersPage({ members }: { members: AzureMember[] }) { + const canSoci = useCan("soci:read") const [query, setQuery] = useState("") const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase()) const [membersOnly, setMembersOnly] = useState(false) const [sort, setSort] = useState(DEFAULT_SORT) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(20) - const [dialogOpen, setDialogOpen] = useState(false) - const [dialogKey, setDialogKey] = useState(0) - /** Kept after closing so the dialog's content survives the exit animation. */ - const [numberTarget, setNumberTarget] = useState(null) - const [numberOpen, setNumberOpen] = useState(false) - /** Member IDs saved in this session, shown until the reloaded directory has them. */ - const [optimisticIds, setOptimisticIds] = useState>(new Map()) - useEffect(() => { - setOptimisticIds((current) => reconcileMemberNumbers(current, members)) - }, [members]) - const shown = useMemo( - () => - (members ?? []).map((member) => { - const employeeId = optimisticIds.get(member.id) - return employeeId === undefined ? member : { ...member, employeeId } - }), - [members, optimisticIds] - ) - const filtered = shown.filter((member) => (!membersOnly || member.isMember) && matches(member, deferredQuery)) + const filtered = members.filter((member) => (!membersOnly || member.isMember) && matches(member, deferredQuery)) const sorted = filtered.toSorted((a, b) => compareMembers(a, b, sort)) const rows = sorted.slice((page - 1) * pageSize, page * pageSize) const filteredView = deferredQuery !== "" || membersOnly - async function onSaved(welcomeMailSent: boolean) { - try { - await router.invalidate({ sync: true }) - } catch (caught) { - console.error(caught) - appToast.warning("The member was created, but the latest directory data could not be refreshed.") - } - if (welcomeMailSent) appToast.success("Member created and welcome email sent.") - else appToast.warning("Member created, but the welcome email couldn't be sent.") - } - - const applyOptimistic = useCallback( - (memberId: string, employeeId: string) => { - const previous = optimisticIds.get(memberId) - setOptimisticIds((current) => new Map(current).set(memberId, employeeId)) - return () => - setOptimisticIds((current) => { - const next = new Map(current) - if (previous === undefined) next.delete(memberId) - else next.set(memberId, previous) - return next - }) - }, - [optimisticIds] - ) - - async function onNumberSaved(notified: boolean | null) { - try { - await router.invalidate({ sync: true }) - } catch (caught) { - // The saved member ID stays on screen; only the rest of the directory is stale. - console.error(caught) - appToast.warning("The member ID was changed, but the latest directory data could not be refreshed.") - } - if (notified === false) appToast.warning("Member ID changed, but the email to the member couldn't be sent.") - else appToast.success(notified ? "Member ID changed and email sent." : "Member ID changed.") - } - - function openNumberDialog(member: AzureMember) { - setNumberTarget(member) - setDialogKey((key) => key + 1) - setNumberOpen(true) - } - - function openDialog() { - setDialogKey((key) => key + 1) - setDialogOpen(true) - } - function changeQuery(value: string) { setQuery(value) setPage(1) @@ -244,68 +174,45 @@ export function AzureMembersPage({ members }: { members: AzureMember[] | null }) setMembersOnly(false) } - // The empty state repeats the header primary as outline. - const addMember = (variant: "default" | "outline") => ( - - ) - return ( <> { - setMembersOnly(pressed) - setPage(1) - }} - /> - } - count={filteredView ? : undefined} - /> - ) : undefined + { + setMembersOnly(pressed) + setPage(1) + }} + /> + } + count={filteredView ? : undefined} + /> } - right={canCreate ? addMember("default") : undefined} /> - {members === null ? ( - - ) : ( +
+ + Soci are admitted, and their member numbers changed, in{" "} + {canSoci ? ( + + People › Soci + + ) : ( + "People › Soci" + )} + , which keeps the register and Microsoft 365 together. + member.id} - actions={ - canWrite - ? (member) => ( - - openNumberDialog(member)} - /> - - ) - : undefined - } - actionsWidth={60} sort={sort} onSort={(next) => { setSort(next) @@ -338,26 +245,12 @@ export function AzureMembersPage({ members }: { members: AzureMember[] | null }) icon={UsersRound} title="No members yet" text="No members were returned from Microsoft Entra." - action={canCreate ? addMember("outline") : undefined} /> ) } /> - )} +
- {canCreate ? ( - - ) : null} - {canWrite && numberTarget ? ( - - ) : null} ) } diff --git a/src/features/people/members-page.tsx b/src/features/people/members-page.tsx deleted file mode 100644 index e55b6cc4..00000000 --- a/src/features/people/members-page.tsx +++ /dev/null @@ -1,311 +0,0 @@ -import { Bell, Download, IdCard, Pencil, Plus } from "lucide-react" -import { useDeferredValue, useState } from "react" - -import { - buttonMotion, - DataTable, - type DataTableColumn, - EmptyState, - FilterMenu, - IconButton, - RowActions, - SegmentedControl, - type TableSort, - Unset, -} from "@/components/primitives" -import { Count, PageBar, PageContent, Toolbar } from "@/components/shell" -import { Button } from "@/components/ui/button" -import { pluralize } from "@/lib/format" - -import { MockupBanner } from "./mockup-banner" -import { - MOCK_FEE_STATUS, - MOCK_FEE_STATUSES, - MOCK_FEE_YEAR, - MOCK_MEMBER_STATUS, - MOCK_MEMBER_STATUSES, - MOCK_MEMBERS, - type MockFeeStatus, - type MockMember, - type MockMemberStatus, -} from "./mockup-data" -import { formatDay } from "./people-format" -import { MonoChip, PersonCell, StateBadge } from "./people-ui" - -type Scope = "all" | MockMemberStatus - -function matchesQuery(member: MockMember, query: string) { - if (!query) return true - return [member.name, member.number, member.m365Email].some((value) => value.toLocaleLowerCase().includes(query)) -} - -const SORTERS = { - number: (member: MockMember) => member.number, - name: (member: MockMember) => member.name, - admitted: (member: MockMember) => member.admittedOn, -} satisfies Record string> - -function sortKey(column: string) { - if (column === "name") return SORTERS.name - if (column === "admitted") return SORTERS.admitted - return SORTERS.number -} - -const columns: DataTableColumn[] = [ - { - id: "number", - label: "Number", - sortable: true, - width: 96, - mobile: "hidden", - cell: (member) => {member.number}, - }, - { - id: "name", - label: "Name", - sortable: true, - fill: true, - minWidth: 200, - mobile: "main", - cell: (member) => ( - <> - - {/* Mobile cards: the number, status and fee on one labelled line. */} - - {member.number} - - {member.fee ? ( - - Fee {MOCK_FEE_STATUS[member.fee].label.toLocaleLowerCase()} - - ) : null} - - - ), - }, - { - id: "status", - label: "Status", - width: 120, - mobile: "hidden", - cell: (member) => , - }, - { - id: "admitted", - label: "Admitted on", - sortable: true, - width: 128, - priority: 3, - mobile: "hidden", - cell: (member) => {formatDay(member.admittedOn)}, - }, - { - id: "fee", - label: `Fee ${MOCK_FEE_YEAR}`, - width: 168, - mobile: "hidden", - cell: (member) => (member.fee ? : ), - }, - { - id: "m365", - label: "Microsoft 365", - width: 264, - priority: 4, - mobile: "hidden", - cell: (member) => {member.m365Email}, - }, -] - -const DEFAULT_SORT: TableSort = { column: "number", direction: "asc" } - -/** - * People › Member register: an unconnected mockup with sample members, kept to be reworked in Phase 2 (#144). It reads - * nothing from the backend; search, filters and sorting work on the sample rows, and every action is disabled. - */ -export function MembersPage() { - const members = MOCK_MEMBERS - const [scope, setScope] = useState("all") - const [fee, setFee] = useState(null) - 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 inScope = scope === "all" ? members : members.filter((member) => member.status === scope) - const filtered = inScope.filter( - (member) => (fee === null || member.fee === fee) && matchesQuery(member, deferredQuery) - ) - const key = sortKey(sort.column) - const sign = sort.direction === "asc" ? 1 : -1 - const sorted = filtered.toSorted((a, b) => key(a).localeCompare(key(b)) * sign) - const rows = sorted.slice((page - 1) * pageSize, page * pageSize) - const narrowed = deferredQuery !== "" || fee !== null - const current = members.filter((member) => member.status !== "former").length - const byStatus = (status: MockMemberStatus) => members.filter((member) => member.status === status).length - - function clearFilters() { - setQuery("") - setFee(null) - setPage(1) - } - - return ( - <> - - - - - - } - left={ - { - setQuery(value) - setPage(1) - }, - placeholder: "Search by name, number or email…", - }} - filters={ - <> - - className="max-shell:hidden" - label="Member status" - items={[ - { value: "all", label: "All", count: members.length }, - ...MOCK_MEMBER_STATUSES.map((status) => ({ - value: status, - label: MOCK_MEMBER_STATUS[status].label, - count: byStatus(status), - })), - ]} - value={scope} - onValueChange={(next) => { - setScope(next) - setPage(1) - }} - /> - {/* Five statuses don't fit beside the fee filter on phones: the same choice as a menu. */} - - - label="Status" - allLabel={`All · ${members.length}`} - value={scope === "all" ? null : scope} - onChange={(next) => { - setScope(next ?? "all") - setPage(1) - }} - options={MOCK_MEMBER_STATUSES.map((status) => ({ - value: status, - label: MOCK_MEMBER_STATUS[status].label, - count: byStatus(status), - }))} - /> - - - label={`Fee ${MOCK_FEE_YEAR}`} - allLabel="Any fee status" - value={fee} - onChange={(value) => { - setFee(value) - setPage(1) - }} - options={MOCK_FEE_STATUSES.map((status) => ({ - value: status, - label: MOCK_FEE_STATUS[status].label, - count: inScope.filter((member) => member.fee === status).length, - }))} - /> - - } - count={ - narrowed ? ( - - ) : undefined - } - /> - } - /> - -
- - member.id} - rowLabel={(member) => member.name} - rowProps={(member) => ({ - className: member.status === "former" ? "text-(--pn-fg-muted)" : undefined, - })} - sort={sort} - onSort={(next) => { - setSort(next) - setPage(1) - }} - actions={(member) => ( - - - - )} - actionsWidth={60} - pagination={{ - page, - pageSize, - total: sorted.length, - onPage: setPage, - onPageSize: (size) => { - setPageSize(size) - setPage(1) - }, - }} - empty={ - narrowed ? ( - - Clear filters - - } - /> - ) : scope === "all" ? ( - - ) : ( - - ) - } - /> -
-
- - ) -} diff --git a/src/features/people/mockup-data.ts b/src/features/people/mockup-data.ts index b4005f7f..f54fc12e 100644 --- a/src/features/people/mockup-data.ts +++ b/src/features/people/mockup-data.ts @@ -1,31 +1,21 @@ import type { StatusTone } from "@/components/primitives" /** - * Static sample data for the People pages kept as unconnected UI mockups: the member register (Soci), fees, receipts - * and the census. Nothing here comes from or goes to the backend, and none of it is part of the People contract - * (`people.contract.ts`). Phase 2 (#144) rebuilds Soci, fees and receipts on Microsoft 365 members; Phase 3 (#145) - * rebuilds the census. + * Static sample data for the People pages kept as unconnected UI mockups: fees, receipts and the census. Nothing here + * comes from or goes to the backend, and none of it is part of the People contract (`people.contract.ts`). Phase 2 + * (#144) rebuilds fees on the Soci register; Phase 3 (#145) rebuilds the census. */ type Tone = { label: string; tone: StatusTone } -export const MOCK_MEMBER_STATUSES = ["active", "suspended", "expired", "former"] as const export const MOCK_FEE_STATUSES = ["due", "receipt", "paid", "unpaid"] as const export const MOCK_RECEIPT_STATUSES = ["toSign", "signed", "sent", "failed", "voided"] as const -export type MockMemberStatus = (typeof MOCK_MEMBER_STATUSES)[number] export type MockFeeStatus = (typeof MOCK_FEE_STATUSES)[number] export type MockReceiptStatus = (typeof MOCK_RECEIPT_STATUSES)[number] export type MockSigner = "president" | "vicePresident" export type MockCensusAnswer = "continues" | "leaving" -export const MOCK_MEMBER_STATUS = { - active: { label: "Active", tone: "success", description: "Full member for the current year." }, - suspended: { label: "Suspended", tone: "danger", description: "Rights paused by the Direttivo; keeps the number." }, - expired: { label: "Expired", tone: "warning", description: "Fee not renewed; becomes Former after a year." }, - former: { label: "Former", tone: "neutral", description: "No longer a member. The number is never reused." }, -} satisfies Record - export const MOCK_FEE_STATUS = { paid: { label: "Paid", tone: "success" }, receipt: { label: "Receipt uploaded", tone: "brand" }, @@ -63,35 +53,6 @@ export function formatCents(cents: number) { /** The fee year every mockup shows. */ export const MOCK_FEE_YEAR = "2026/27" -export type MockMember = { - id: string - name: string - number: string - status: MockMemberStatus - admittedOn: string - /** The current fee year's fee; `null` for former members. */ - fee: MockFeeStatus | null - m365Email: string -} - -export const MOCK_MEMBERS: readonly MockMember[] = [ - member("0001", "Giulia Rossi", "active", "2019-10-14", "paid"), - member("0004", "Marco Bianchi", "active", "2019-11-02", "receipt"), - member("0007", "Sara Colombo", "active", "2020-10-05", "due"), - member("0012", "Luca Ferrari", "suspended", "2021-03-18", "unpaid"), - member("0015", "Chiara Esposito", "expired", "2021-10-11", "unpaid"), - member("0021", "Davide Romano", "active", "2022-10-03", "paid"), - member("0023", "Elena Ricci", "active", "2023-09-28", "due"), - member("0030", "Paolo Greco", "former", "2020-01-20", null), - member("0034", "Anna Bruno", "active", "2024-10-07", "paid"), - member("0041", "Matteo Gallo", "active", "2025-10-13", "receipt"), -] - -function member(number: string, name: string, status: MockMemberStatus, admittedOn: string, fee: MockFeeStatus | null) { - const [first = "", last = ""] = name.toLocaleLowerCase().split(" ") - return { id: `mock-${number}`, name, number, status, admittedOn, fee, m365Email: `${first}.${last}@polinetwork.org` } -} - export type MockReceipt = { id: string /** The fee year's first calendar year and a sequence never reused. */ diff --git a/src/features/soci/codice-fiscale.ts b/src/features/soci/codice-fiscale.ts new file mode 100644 index 00000000..90ac6270 --- /dev/null +++ b/src/features/soci/codice-fiscale.ts @@ -0,0 +1,79 @@ +// A copy of the backend's `src/soci/codice-fiscale.ts` (PoliNetworkOrg/backend), which validates again. +/** + * The Italian codice fiscale of a natural person: format, omocodia and check character. + * + * Sixteen characters: three letters each from the surname and the name, the birth year, a month letter, the birth + * day (plus 40 for women), the Belfiore code of the birthplace, and the check character. When two people would get + * the same code (omocodia), the Agenzia delle Entrate replaces digits from the right with letters (`LMNPQRSTUV` for + * 0–9), so those seven positions may hold either. + */ + +const OMOCODIA = "LMNPQRSTUV" +const MONTHS = "ABCDEHLMPRST" +/** Positions that hold a digit, or its omocodia letter. */ +const NUMERIC = [6, 7, 9, 10, 12, 13, 14] +const FORMAT = /^[A-Z]{6}[0-9LMNPQRSTUV]{2}[ABCDEHLMPRST][0-9LMNPQRSTUV]{2}[A-Z][0-9LMNPQRSTUV]{3}[A-Z]$/ + +/** What a character in an odd position (1st, 3rd, …) adds to the check sum. */ +const ODD: Record = Object.fromEntries( + [1, 0, 5, 7, 9, 13, 15, 17, 19, 21, 2, 4, 18, 20, 11, 3, 6, 8, 12, 14, 16, 10, 22, 25, 24, 23].flatMap((value, i) => [ + [String.fromCharCode(65 + i), value], + ...(i < 10 ? [[String(i), value]] : []), + ]) +) +const even = (char: string) => (char >= "0" && char <= "9" ? Number(char) : char.charCodeAt(0) - 65) + +/** Upper case, without spaces: how the code is compared and stored. */ +export function normalizeCodiceFiscale(value: string) { + return value.replace(/\s+/g, "").toUpperCase() +} + +/** The check character the first fifteen characters call for. */ +export function checkCharacter(first15: string) { + let sum = 0 + for (let i = 0; i < 15; i++) sum += i % 2 === 0 ? (ODD[first15[i]] ?? 0) : even(first15[i]) + return String.fromCharCode(65 + (sum % 26)) +} + +/** The code with every omocodia letter turned back into its digit. */ +function withDigits(code: string) { + const chars = code.split("") + for (const i of NUMERIC) { + const digit = OMOCODIA.indexOf(chars[i]) + if (digit >= 0) chars[i] = String(digit) + } + return chars.join("") +} + +export type CodiceFiscaleProblem = "format" | "check" | "date" + +/** Why the code is not valid, or null. Expects a normalized code. */ +export function codiceFiscaleProblem(code: string): CodiceFiscaleProblem | null { + if (!FORMAT.test(code)) return "format" + if (checkCharacter(code.slice(0, 15)) !== code[15]) return "check" + const birth = encodedBirth(code) + if (!birth) return "date" + return null +} + +/** The birth data a valid code encodes: the year only to two digits. Null when the day can't exist. */ +export function encodedBirth(code: string): { yy: number; month: number; day: number; female: boolean } | null { + const plain = withDigits(code) + const yy = Number(plain.slice(6, 8)) + const month = MONTHS.indexOf(plain[8]) + 1 + const rawDay = Number(plain.slice(9, 11)) + const female = rawDay > 40 + const day = female ? rawDay - 40 : rawDay + // 29 February is allowed here: the year isn't known to the century. + const days = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] + if (day < 1 || day > days) return null + return { yy, month, day, female } +} + +/** Whether a valid code agrees with a birth date (`YYYY-MM-DD`). */ +export function matchesBirthDate(code: string, birthDate: string) { + const birth = encodedBirth(code) + if (!birth) return false + const [year, month, day] = birthDate.split("-").map(Number) + return birth.yy === year % 100 && birth.month === month && birth.day === day +} diff --git a/src/features/azure/mailbox.ts b/src/features/soci/mailbox.ts similarity index 95% rename from src/features/azure/mailbox.ts rename to src/features/soci/mailbox.ts index c2096979..e0916c68 100644 --- a/src/features/azure/mailbox.ts +++ b/src/features/soci/mailbox.ts @@ -1,4 +1,4 @@ -/** Mirrors the backend's `src/azure/mailbox.ts`, which builds and checks the address again. */ +/** Mirrors the backend's `src/azure/mailbox.ts`, which builds and checks a new socio's address again. */ export const MAIL_DOMAIN = "polinetwork.org" const LOCAL_PART = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/ diff --git a/src/features/soci/member-number.ts b/src/features/soci/member-number.ts new file mode 100644 index 00000000..fa59a391 --- /dev/null +++ b/src/features/soci/member-number.ts @@ -0,0 +1,4 @@ +/** A member number as typed: a positive integer without leading zeros, up to the backend's 999999. */ +export function isMemberNumber(value: string) { + return /^[1-9]\d{0,5}$/.test(value) +} diff --git a/src/features/soci/register-page.tsx b/src/features/soci/register-page.tsx new file mode 100644 index 00000000..ce7e538a --- /dev/null +++ b/src/features/soci/register-page.tsx @@ -0,0 +1,280 @@ +import { IdCard, Plus } from "lucide-react" +import { useDeferredValue, useState } from "react" + +import { + buttonMotion, + DataTable, + type DataTableColumn, + EmptyState, + InlineAlert, + SegmentedControl, + type TableSort, +} from "@/components/primitives" +import { Count, PageBar, PageContent, Toolbar, useCan } from "@/components/shell" +import { Button } from "@/components/ui/button" +import { formatDay } from "@/features/people/people-format" +import { useDialogState } from "@/features/people/people-hooks" +import { MonoChip, PersonCell, StateBadge } from "@/features/people/people-ui" +import { pluralize } from "@/lib/format" + +import { END_REASON, formatMemberNumber, MISMATCH, socioName, socioStatus } from "./soci-format" +import type { SociList, SocioRow, Unregistered } from "./soci.contract" +import { AdmitSocioDialog, RegisterSocioDialog } from "./socio-dialogs" + +type Scope = "active" | "ended" | "all" + +const SCOPES = [ + { value: "active", label: "Active" }, + { value: "ended", label: "Ended" }, + { value: "all", label: "All" }, +] as const + +function matchesQuery(socio: SocioRow, query: string) { + if (!query) return true + return [ + socioName(socio), + socio.alias ?? "", + String(socio.number), + formatMemberNumber(socio.number), + socio.mailbox, + ].some((value) => value.toLocaleLowerCase().includes(query)) +} + +function compare(a: SocioRow, b: SocioRow, sort: TableSort) { + const sign = sort.direction === "asc" ? 1 : -1 + if (sort.column === "admitted") return a.admittedOn.localeCompare(b.admittedOn) * sign || a.number - b.number + if (sort.column === "name") return socioName(a).localeCompare(socioName(b)) * sign + return (a.number - b.number) * sign +} + +const columns: DataTableColumn[] = [ + { + id: "number", + label: "Number", + sortable: true, + width: 96, + mobile: "hidden", + cell: (socio) => {formatMemberNumber(socio.number)}, + }, + { + id: "name", + label: "Name", + sortable: true, + fill: true, + minWidth: 200, + mobile: "main", + cell: (socio) => ( + <> + + {/* Mobile cards: the number and status on one line. */} + + {formatMemberNumber(socio.number)} + + {socio.mismatch ? : null} + + + ), + }, + { + id: "mailbox", + label: "Mailbox", + width: 264, + priority: 3, + mobile: "hidden", + cell: (socio) => {socio.mailbox}, + }, + { + id: "admitted", + label: "Admitted on", + sortable: true, + width: 128, + priority: 2, + mobile: "hidden", + cell: (socio) => {formatDay(socio.admittedOn)}, + }, + { + id: "status", + label: "Status", + width: 200, + mobile: "hidden", + cell: (socio) => ( + + + + {socio.mismatch ? : null} + + {socio.endedOn ? ( + + {socio.endReason ? END_REASON[socio.endReason] : "Ended"} · {formatDay(socio.endedOn)} + + ) : null} + + ), + }, +] + +const DEFAULT_SORT: TableSort = { column: "number", direction: "asc" } + +/** + * Soci › Register: the Libro Soci, one record per admission. Alerts above the table list the Soci group's accounts + * without a record (with Register) and the records that disagree with Soci membership. + */ +export function RegisterPage({ register, unregistered }: { register: SociList; unregistered: Unregistered[] | null }) { + const canWrite = useCan("soci:write") + const admit = useDialogState() + const registerDialog = useDialogState() + const [scope, setScope] = useState("active") + 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 members = register.members + const active = members.filter((socio) => !socio.endedOn) + const ended = members.filter((socio) => socio.endedOn) + const inScope = scope === "all" ? members : scope === "active" ? active : ended + const filtered = inScope.filter((socio) => matchesQuery(socio, deferredQuery)) + const sorted = filtered.toSorted((a, b) => compare(a, b, sort)) + const rows = sorted.slice((page - 1) * pageSize, page * pageSize) + const notInSoci = members.filter((socio) => socio.mismatch === "not_in_soci") + const stillInSoci = members.filter((socio) => socio.mismatch === "still_in_soci") + const list = (soci: SocioRow[]) => + soci.map((socio) => `${formatMemberNumber(socio.number)} ${socioName(socio)}`).join(", ") + + const admitButton = (variant: "default" | "outline") => ( + + ) + + return ( + <> + + label="Status" + items={SCOPES.map((item) => ({ + ...item, + count: item.value === "all" ? members.length : item.value === "active" ? active.length : ended.length, + }))} + value={scope} + onValueChange={(next) => { + setScope(next) + setPage(1) + }} + /> + } + search={{ + value: query, + onChange: (value) => { + setQuery(value) + setPage(1) + }, + }} + count={ + deferredQuery ? ( + + ) : undefined + } + /> + } + /> + +
+ {unregistered === null || !register.directoryAvailable ? ( + + Microsoft 365 couldn't be read: the Soci group's accounts without a record, and mismatches, aren't shown. + + ) : null} + {unregistered?.length ? ( + + Register + + ) : undefined + } + > + {pluralize(unregistered.length, "socio")} in Microsoft 365 {unregistered.length === 1 ? "has" : "have"} no + record: {unregistered.map((account) => account.displayName ?? account.mail).join(", ")}. + + ) : null} + {notInSoci.length ? ( + + {MISMATCH.not_in_soci}: {list(notInSoci)}. Check the record, or add the account back to Soci. + + ) : null} + {stillInSoci.length ? ( + + {MISMATCH.still_in_soci}: {list(stillInSoci)}. Their account still has the Socio role. + + ) : null} + socio.number} + rowHref={(socio) => `/dashboard/people/soci/${socio.number}`} + rowLabel={(socio) => `${formatMemberNumber(socio.number)} ${socioName(socio)}`} + sort={sort} + onSort={(next) => { + setSort(next) + setPage(1) + }} + pagination={{ + page, + pageSize, + total: sorted.length, + onPage: setPage, + onPageSize: (size) => { + setPageSize(size) + setPage(1) + }, + }} + empty={ + deferredQuery ? ( + + ) : ( + + ) + } + /> +
+
+ {canWrite ? ( + + ) : null} + {canWrite && unregistered?.length ? ( + + ) : null} + + ) +} diff --git a/src/features/soci/soci-format.ts b/src/features/soci/soci-format.ts new file mode 100644 index 00000000..16b6a3cc --- /dev/null +++ b/src/features/soci/soci-format.ts @@ -0,0 +1,69 @@ +import type { StatusTone } from "@/components/primitives" + +import type { END_REASONS, Residence, SocioRow } from "./soci.contract" + +type Tone = { label: string; tone: StatusTone } + +/** "0344": member numbers as the payment reference writes them. */ +export function formatMemberNumber(number: number) { + return String(number).padStart(4, "0") +} + +/** The legal name. The alias, when there is one, is its secondary line. */ +export function socioName(socio: Pick) { + return `${socio.firstName} ${socio.lastName}` +} + +export const SOCIO_STATUS = { + active: { label: "Active", tone: "success" }, + ended: { label: "Ended", tone: "neutral" }, +} satisfies Record + +export function socioStatus(socio: Pick) { + return socio.endedOn ? SOCIO_STATUS.ended : SOCIO_STATUS.active +} + +export const END_REASON = { + lapsed: "Lapsed", + resigned: "Resigned", + expelled: "Expelled", + deceased: "Deceased", + other: "Other", +} satisfies Record<(typeof END_REASONS)[number], string> + +export const ACCOUNT_STATE = { + active: { label: "Active", tone: "success" }, + pending_deletion: { label: "Pending deletion", tone: "warning" }, + deleted: { label: "Deleted", tone: "neutral" }, +} satisfies Record + +export const MISMATCH = { + not_in_soci: "Active, but not in the Soci group", + still_in_soci: "Ended, but still in the Soci group", +} satisfies Record, string> + +/** "Via Bonardi 9, 20133 Milano (MI), IT", or null when nothing is recorded. */ +export function formatResidence(residence: Residence) { + const place = [residence.postalCode, residence.city].filter(Boolean).join(" ") + const parts = [ + residence.street, + place ? `${place}${residence.province ? ` (${residence.province})` : ""}` : null, + residence.country, + ] + const text = parts.filter(Boolean).join(", ") + return text === "" ? null : text +} + +/** The bookkeeping fields still empty on a record registered from Microsoft 365. */ +export function missingDetails(socio: { + codiceFiscale: string | null + birthDate: string | null + birthPlace: string | null + residence: Residence +}) { + const missing: string[] = [] + if (!socio.codiceFiscale) missing.push("codice fiscale") + if (!socio.birthDate || !socio.birthPlace) missing.push("birth data") + if (!socio.residence.street || !socio.residence.city || !socio.residence.country) missing.push("residence") + return missing +} diff --git a/src/features/soci/soci-write.functions.ts b/src/features/soci/soci-write.functions.ts new file mode 100644 index 00000000..5101207a --- /dev/null +++ b/src/features/soci/soci-write.functions.ts @@ -0,0 +1,32 @@ +import { createServerFn } from "@tanstack/react-start" + +import { sociWriteMiddleware } from "@/server/auth.middleware" + +import { sociApi } from "./soci.api.server" +import { admitInputSchema, registerInputSchema, setNumberInputSchema, updateInputSchema } from "./soci.contract" + +/** + * A new Microsoft 365 account in Soci (licences, `employeeId`, welcome email), then its record. The backend deletes the + * account if the record can't be written. + */ +export const admitSocio = createServerFn({ method: "POST" }) + .middleware([sociWriteMiddleware]) + .validator(admitInputSchema) + .handler(async ({ data, context }) => (await sociApi(context)).admit(data)) + +/** A record for an account already in Soci, with its current member number. */ +export const registerSocio = createServerFn({ method: "POST" }) + .middleware([sociWriteMiddleware]) + .validator(registerInputSchema) + .handler(async ({ data, context }) => (await sociApi(context)).register(data)) + +export const updateSocio = createServerFn({ method: "POST" }) + .middleware([sociWriteMiddleware]) + .validator(updateInputSchema) + .handler(async ({ data, context }) => (await sociApi(context)).update(data)) + +/** The number on the account (`employeeId`) and on the record; the old one is never given to anyone else. */ +export const setSocioNumber = createServerFn({ method: "POST" }) + .middleware([sociWriteMiddleware]) + .validator(setNumberInputSchema) + .handler(async ({ data, context }) => (await sociApi(context)).setNumber(data)) diff --git a/src/features/soci/soci.api.server.ts b/src/features/soci/soci.api.server.ts new file mode 100644 index 00000000..64f5fa00 --- /dev/null +++ b/src/features/soci/soci.api.server.ts @@ -0,0 +1,68 @@ +import type { z } from "zod" + +import { createUntypedBackendClient } from "@/server/backend.server" + +import { + type AdmitInput, + type AdmitResult, + admitResultSchema, + type NumberInput, + numberInputSchema, + type RegisterInput, + type SetNumberInput, + type SetNumberResult, + setNumberResultSchema, + type SociList, + sociListSchema, + type SocioRecord, + socioRecordSchema, + type Unregistered, + unregisteredSchema, + type UpdateInput, +} from "./soci.contract" + +/** The backend's `soci.members.*` procedures, one method each. */ +export type SociApi = { + list: () => Promise + get: (input: NumberInput) => Promise + unregistered: () => Promise + admit: (input: AdmitInput) => Promise + register: (input: RegisterInput) => Promise + update: (input: UpdateInput) => Promise + setNumber: (input: SetNumberInput) => Promise +} + +/** + * The backend's `soci.*` procedures. Every response is parsed with the contract's schema: a backend that drifts from + * the contract fails loudly here, not in a page. + */ +function backendSociApi(accessToken: string | null): SociApi { + const client = createUntypedBackendClient(accessToken) + const query = async (path: string, input: I, schema: z.ZodType) => + schema.parse(await client.query(`soci.members.${path}`, input)) + const mutate = async (path: string, input: I, schema: z.ZodType) => + schema.parse(await client.mutation(`soci.members.${path}`, input)) + + return { + list: () => query("list", undefined, sociListSchema), + get: (input) => query("get", input, socioRecordSchema), + unregistered: () => query("unregistered", undefined, unregisteredSchema.array()), + admit: (input) => mutate("admit", input, admitResultSchema), + register: (input) => mutate("register", input, numberInputSchema), + update: async (input) => { + await client.mutation("soci.members.update", input) + }, + setNumber: (input) => mutate("setNumber", input, setNumberResultSchema), + } +} + +type SociContext = { agentMode: boolean; session: { accessToken: string | null } } + +/** The Soci API for this request: the sample register in agent mode, the backend otherwise. */ +export async function sociApi(context: SociContext): Promise { + if (context.agentMode) { + const { fakeSociApi } = await import("./soci.fake.server") + return fakeSociApi() + } + return backendSociApi(context.session.accessToken) +} diff --git a/src/features/soci/soci.contract.ts b/src/features/soci/soci.contract.ts new file mode 100644 index 00000000..16ed0948 --- /dev/null +++ b/src/features/soci/soci.contract.ts @@ -0,0 +1,277 @@ +import { z } from "zod" + +import { codiceFiscaleProblem, matchesBirthDate, normalizeCodiceFiscale } from "./codice-fiscale.ts" + +/** + * A copy of the backend's `src/soci/contract.ts` (PoliNetworkOrg/backend): the `soci.*` procedures. `soci.api.server.ts` + * parses every response with these shapes, and the server functions validate inputs with them before the backend + * validates them again. Keep the two copies in step. + */ + +/** The new account's address: `firstname.lastname`, or a custom part before `@polinetwork.org`. */ +const mailboxSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("standard") }), + z.object({ + kind: z.literal("custom"), + localPart: z + .string() + .trim() + .toLowerCase() + .max(64) + .regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/, "Only lowercase letters, digits, dots and hyphens"), + }), +]) + +/** How a socio's status ended. `lapsed` is set only by the automatic lapse. */ +export const END_REASONS = ["lapsed", "resigned", "expelled", "deceased", "other"] as const +/** The Microsoft 365 account behind the record. */ +export const ACCOUNT_STATES = ["active", "pending_deletion", "deleted"] as const + +export const memberNumberSchema = z.number().int().positive().max(999_999) + +const CODICE_FISCALE_MESSAGES = { + format: "Not a codice fiscale: 16 letters and digits in the official pattern.", + check: "The codice fiscale's last character doesn't match: check it for typos.", + date: "The codice fiscale encodes a birth date that doesn't exist.", +} as const + +/** Upper-cased and without spaces; format, omocodia and check character validated. */ +export const codiceFiscaleSchema = z + .string() + .transform(normalizeCodiceFiscale) + .superRefine((code, ctx) => { + const problem = codiceFiscaleProblem(code) + if (problem) ctx.addIssue({ code: "custom", message: CODICE_FISCALE_MESSAGES[problem] }) + }) + +const dateSchema = z.iso.date() +const text = (max: number) => z.string().trim().min(1).max(max) +/** A name as written on an identity document. */ +const legalName = text(80) +/** ISO 3166-1 alpha-2, e.g. `IT`. */ +const countrySchema = z + .string() + .trim() + .toUpperCase() + .regex(/^[A-Z]{2}$/, "A two-letter country code, e.g. IT") +/** The two-letter sigla of an Italian province, e.g. `MI`. */ +const provinceSchema = z + .string() + .trim() + .toUpperCase() + .regex(/^[A-Z]{2}$/, "The province's two-letter code, e.g. MI") + +export const residenceSchema = z.object({ + /** Street and number. */ + street: z.string().nullable(), + postalCode: z.string().nullable(), + city: z.string().nullable(), + /** Only in Italy. */ + province: z.string().nullable(), + country: z.string().nullable(), +}) +export type Residence = z.infer + +/** The bookkeeping data the association keeps. Nullable fields may be missing for a socio registered afterwards. */ +const bookkeepingFields = { + firstName: z.string(), + lastName: z.string(), + /** The name the person goes by, shown with the legal name. */ + alias: z.string().nullable(), + codiceFiscale: z.string().nullable(), + birthDate: z.string().nullable(), + /** The comune, or the city and country. */ + birthPlace: z.string().nullable(), + residence: residenceSchema, +} + +/** One row of the register. */ +export const socioRowSchema = z.object({ + number: z.number(), + entraObjectId: z.string(), + /** Snapshots taken at admission or registration, kept after the account is deleted. */ + mailbox: z.string(), + displayName: z.string(), + firstName: z.string(), + lastName: z.string(), + alias: z.string().nullable(), + /** To warn when a new admission belongs to a former socio. */ + codiceFiscale: z.string().nullable(), + admittedOn: z.string(), + /** `null` while the socio is active. */ + endedOn: z.string().nullable(), + endReason: z.enum(END_REASONS).nullable(), + accountState: z.enum(ACCOUNT_STATES), + /** Whether the account is in the Soci group now; `null` when Microsoft 365 couldn't be read. */ + inSoci: z.boolean().nullable(), + /** + * Where the record and Soci membership disagree: an active record whose account isn't in Soci, or an ended one + * whose account still is. `null` when they agree, or when Microsoft 365 couldn't be read. + */ + mismatch: z.enum(["not_in_soci", "still_in_soci"]).nullable(), +}) +export type SocioRow = z.infer + +export const sociListSchema = z.object({ + members: z.array(socioRowSchema), + /** False when Microsoft 365 couldn't be read: `inSoci` and `mismatch` are then unknown. */ + directoryAvailable: z.boolean(), +}) +export type SociList = z.infer + +export const activitySchema = z.object({ actor: z.string(), text: z.string(), at: z.string() }) +export type SocioActivity = z.infer + +export const socioRecordSchema = socioRowSchema.extend({ + ...bookkeepingFields, + endNote: z.string().nullable(), + accountDeletedOn: z.string().nullable(), + /** The account's current member number in Microsoft 365; `null` when it has none, or couldn't be read. */ + accountNumber: z.string().nullable(), + /** Newest first. */ + activity: z.array(activitySchema), + createdBy: z.string(), + createdAt: z.string(), + updatedBy: z.string(), + updatedAt: z.string(), +}) +export type SocioRecord = z.infer + +/** A member of the Soci group in Microsoft 365 without a record. */ +export const unregisteredSchema = z.object({ + entraObjectId: z.string(), + displayName: z.string().nullable(), + givenName: z.string().nullable(), + surname: z.string().nullable(), + mail: z.string().nullable(), + /** The member number Microsoft 365 holds, if any. */ + employeeId: z.string().nullable(), + /** When the account was created: the suggested admission date. */ + createdDateTime: z.string().nullable(), +}) +export type Unregistered = z.infer + +// Inputs. + +const blankToNull = (value: string | null | undefined) => (value === undefined || value === "" ? null : value) +const optional = >(schema: T) => + z + .union([z.literal(""), schema]) + .nullish() + .transform(blankToNull) + +const residenceInput = (required: R) => { + const field = (schema: z.ZodType) => (required ? schema : optional(schema)) + return z + .object({ + street: field(text(200)), + postalCode: field(text(16)), + city: field(text(120)), + province: optional(provinceSchema), + country: field(countrySchema), + }) + .superRefine((r, ctx) => { + const italy = r.country === "IT" + if (italy && r.postalCode && !/^\d{5}$/.test(r.postalCode)) + ctx.addIssue({ code: "custom", path: ["postalCode"], message: "An Italian postal code has five digits." }) + if (!italy && r.province) + ctx.addIssue({ code: "custom", path: ["province"], message: "The province is only for Italy." }) + if (required && italy && !r.province) + ctx.addIssue({ code: "custom", path: ["province"], message: "Choose the province." }) + }) +} + +function detailsInput(required: R) { + const field = (schema: z.ZodType) => (required ? schema : optional(schema)) + return z + .object({ + firstName: legalName, + lastName: legalName, + alias: optional(text(80)), + codiceFiscale: required ? codiceFiscaleSchema : optional(codiceFiscaleSchema), + birthDate: field(dateSchema), + birthPlace: field(text(120)), + residence: required + ? residenceInput(true) + : residenceInput(false).prefault({ street: null, postalCode: null, city: null, country: null }), + }) + .superRefine((d, ctx) => { + if (d.codiceFiscale && d.birthDate && !matchesBirthDate(d.codiceFiscale, d.birthDate)) + ctx.addIssue({ + code: "custom", + path: ["birthDate"], + message: "The birth date doesn't match the codice fiscale.", + }) + }) +} + +/** Everything required but the alias: for admitting a new socio. */ +export const requiredDetailsSchema = detailsInput(true) +/** Only the legal name required: for a socio registered afterwards, and for edits. Empty strings mean unknown. */ +export const optionalDetailsSchema = detailsInput(false) +export type SocioDetails = z.output + +// Becomes the Entra display name and mail nickname. +const accountName = z + .string() + .trim() + .min(1) + .max(64) + .regex(/^[\p{L}\p{M}' -]+$/u, "Only letters, spaces, apostrophes and hyphens") + +export const admitInputSchema = z.object({ + /** The Microsoft 365 account to create, as in today's form. */ + account: z.object({ + firstName: accountName, + lastName: accountName, + mailbox: mailboxSchema.default({ kind: "standard" }), + /** Where the credentials go. Not stored. */ + sendEmailTo: z.email(), + }), + number: memberNumberSchema, + details: requiredDetailsSchema, + admittedOn: dateSchema, +}) +export type AdmitInput = z.output + +export const registerInputSchema = z.object({ + /** An account already in the Soci group, without a record. */ + entraObjectId: z.string().min(1).max(128), + /** + * Required only when the account has no member number in Microsoft 365, which then gets this one. Otherwise it + * may be left out, and must match the account's. + */ + number: memberNumberSchema.optional(), + details: optionalDetailsSchema, + admittedOn: dateSchema, +}) +export type RegisterInput = z.output + +export const numberInputSchema = z.object({ number: memberNumberSchema }) +export type NumberInput = z.output + +export const updateInputSchema = z.object({ + number: memberNumberSchema, + details: optionalDetailsSchema, + admittedOn: dateSchema, +}) +export type UpdateInput = z.output + +export const setNumberInputSchema = z.object({ + number: memberNumberSchema, + newNumber: memberNumberSchema, + /** Emails the new number there, as Microsoft 365 › Members did. */ + notifyEmail: z.email().optional(), +}) +export type SetNumberInput = z.output + +export const admitResultSchema = z.object({ + number: z.number(), + entraObjectId: z.string(), + mailbox: z.string(), + welcomeMailSent: z.boolean(), +}) +export type AdmitResult = z.infer + +export const setNumberResultSchema = z.object({ number: z.number(), notificationSent: z.boolean().nullable() }) +export type SetNumberResult = z.infer diff --git a/src/features/soci/soci.fake.server.ts b/src/features/soci/soci.fake.server.ts new file mode 100644 index 00000000..c33965ec --- /dev/null +++ b/src/features/soci/soci.fake.server.ts @@ -0,0 +1,326 @@ +import type { SociApi } from "./soci.api.server" +import type { SocioActivity, SocioDetails, SocioRecord, SocioRow } from "./soci.contract" + +/** + * Agent mode's stand-in for the backend's `soci.members.*` procedures. State lives for the dev server's lifetime, so + * changes show up on reload. It follows the backend's rules where a page depends on them: numbers are never reused + * (by a record, or held by another account), one active record per codice fiscale, and only accounts in the Soci + * group without a record can be registered. The sample register covers active, ended, unregistered and mismatched + * soci. + */ + +type Account = { + id: string + displayName: string + givenName: string + surname: string + mail: string + employeeId: string | null + createdDateTime: string +} + +type Stored = Omit & { activity: SocioActivity[] } + +/** `held`: every number a record has ever held, and the account of that record. */ +type State = { records: Stored[]; accounts: Account[]; soci: Set; held: Map } + +const AGENT = "sub-agent" + +function account(id: string, name: string, employeeId: string | null, created: string): Account { + const [givenName = "", surname = ""] = name.split(" ") + return { + id, + displayName: name, + givenName, + surname, + mail: `${givenName}.${surname}`.toLowerCase().replace(/[^a-z.]/g, "") + "@polinetwork.org", + employeeId, + createdDateTime: `${created}T09:00:00Z`, + } +} + +const NO_RESIDENCE = { street: null, postalCode: null, city: null, province: null, country: null } + +function record( + number: number, + acc: Account, + admittedOn: string, + details: Partial, + end: Pick | null = null +): Stored { + const at = `${admittedOn}T10:00:00Z` + return { + number, + entraObjectId: acc.id, + mailbox: acc.mail, + displayName: acc.displayName, + firstName: details.firstName ?? acc.givenName, + lastName: details.lastName ?? acc.surname, + alias: details.alias ?? null, + codiceFiscale: details.codiceFiscale ?? null, + birthDate: details.birthDate ?? null, + birthPlace: details.birthPlace ?? null, + residence: details.residence ?? NO_RESIDENCE, + admittedOn, + endedOn: end?.endedOn ?? null, + endReason: end?.endReason ?? null, + endNote: end?.endNote ?? null, + accountState: end?.accountState ?? "active", + accountDeletedOn: end?.accountDeletedOn ?? null, + createdBy: AGENT, + createdAt: at, + updatedBy: AGENT, + updatedAt: at, + activity: [{ actor: AGENT, text: `Admitted with number ${number}; account ${acc.mail} created`, at }], + } +} + +const milano = (street: string) => ({ street, postalCode: "20133", city: "Milano", province: "MI", country: "IT" }) + +/** Deterministic, so the same register comes back after a dev server restart. */ +function build(): State { + const giulia = account("acc-giulia", "Giulia Rossi", "1", "2019-10-14") + const marco = account("acc-marco", "Marco Bianchi", "4", "2019-11-02") + const sara = account("acc-sara", "Sara Colombo", "7", "2020-10-05") + const luca = account("acc-luca", "Luca Ferrari", "12", "2021-03-18") + const elena = account("acc-elena", "Elena Ricci", "23", "2023-09-28") + const paolo = account("acc-paolo", "Paolo Greco", "30", "2020-01-20") + const anna = account("acc-anna", "Anna Bruno", "34", "2024-10-07") + const matteo = account("acc-matteo", "Matteo Gallo", "41", "2025-10-13") + const chiara = account("acc-chiara", "Chiara Esposito", "15", "2021-10-11") + const federico = account("acc-federico", "Federico Conti", null, "2026-10-20") + const records = [ + record(1, giulia, "2019-10-14", { + codiceFiscale: "RSSGLI95P41F205I", + birthDate: "1995-09-01", + birthPlace: "Milano", + residence: milano("Via Bonardi 9"), + }), + record(4, marco, "2019-11-02", { + firstName: "Marco Antonio", + alias: "Marco", + codiceFiscale: "BNCMCN96A15H501G", + birthDate: "1996-01-15", + birthPlace: "Roma", + residence: milano("Piazza Leonardo da Vinci 32"), + }), + record(7, sara, "2020-10-05", { + codiceFiscale: "CLMSRA99D50L219L", + birthDate: "1999-04-10", + birthPlace: "Torino", + residence: { street: "Rue de Rivoli 12", postalCode: "75001", city: "Paris", province: null, country: "FR" }, + }), + // Ended, but still in Soci: a mismatch. + record( + 12, + luca, + "2021-03-18", + { codiceFiscale: "FRRLCU98C18F205V", birthDate: "1998-03-18", birthPlace: "Milano" }, + { + endedOn: "2025-12-31", + endReason: "resigned", + endNote: "Resigned by email", + accountState: "pending_deletion", + accountDeletedOn: null, + } + ), + // Active, but no longer in Soci: a mismatch. + record(23, elena, "2023-09-28", { birthPlace: "Bergamo" }), + record( + 30, + paolo, + "2020-01-20", + { codiceFiscale: "GRCPLA97B10F205F", birthDate: "1997-02-10", birthPlace: "Milano" }, + { + endedOn: "2024-12-31", + endReason: "lapsed", + endNote: null, + accountState: "deleted", + accountDeletedOn: "2025-02-03", + } + ), + record(34, anna, "2024-10-07", {}), + // Registered from Microsoft 365, with personal data still to complete. + record(41, matteo, "2025-10-13", {}), + ] + const accounts = [giulia, marco, sara, luca, elena, anna, matteo, chiara, federico] + // Elena is missing (active record, not in Soci); Chiara and Federico have no record yet. + const soci = new Set( + ["giulia", "marco", "sara", "luca", "anna", "matteo", "chiara", "federico"].map((n) => `acc-${n}`) + ) + return { records, accounts, soci, held: new Map(records.map((r) => [r.number, r.entraObjectId])) } +} + +let state: State | null = null +const data = () => { + state ??= build() + return state +} + +const now = () => new Date().toISOString() + +function row(s: State, r: Stored): SocioRow { + const inSoci = r.accountState === "deleted" ? false : s.soci.has(r.entraObjectId) + return { + number: r.number, + entraObjectId: r.entraObjectId, + mailbox: r.mailbox, + displayName: r.displayName, + firstName: r.firstName, + lastName: r.lastName, + alias: r.alias, + codiceFiscale: r.codiceFiscale, + admittedOn: r.admittedOn, + endedOn: r.endedOn, + endReason: r.endReason, + accountState: r.accountState, + inSoci, + mismatch: !r.endedOn && !inSoci ? "not_in_soci" : r.endedOn && inSoci ? "still_in_soci" : null, + } +} + +function find(s: State, number: number) { + const found = s.records.find((r) => r.number === number) + if (!found) throw new Error(`There is no socio with number ${number}.`) + return found +} + +/** Free only if no record has ever held it (but `accountId`'s own), and no other account has it. */ +function requireFreeNumber(s: State, number: number, accountId?: string) { + const owner = s.held.get(number) + if (owner !== undefined && owner !== accountId) + throw new Error(`Member number ${number} was already given to a socio: numbers are never reused.`) + const holder = s.accounts.find((a) => a.employeeId === String(number) && a.id !== accountId) + if (holder) throw new Error(`Member number ${number} is already assigned in Microsoft 365 to ${holder.displayName}.`) +} + +function requireUniqueCode(s: State, code: string | null, self?: Stored) { + if (!code) return + const active = s.records.find((r) => r.codiceFiscale === code && !r.endedOn && r !== self) + if (active) throw new Error(`This codice fiscale belongs to socio ${active.number}, who is still active.`) +} + +const log = (r: Stored, text: string) => r.activity.unshift({ actor: AGENT, text, at: now() }) + +export function fakeSociApi(): SociApi { + return { + async list() { + const s = data() + return { + members: s.records.toSorted((a, b) => a.number - b.number).map((r) => row(s, r)), + directoryAvailable: true, + } + }, + + async get({ number }) { + const s = data() + const r = find(s, number) + const acc = s.accounts.find((a) => a.id === r.entraObjectId) + return { ...r, ...row(s, r), accountNumber: acc?.employeeId ?? null, activity: [...r.activity] } + }, + + async unregistered() { + const s = data() + return s.accounts + .filter((a) => s.soci.has(a.id) && !s.records.some((r) => r.entraObjectId === a.id)) + .map((a) => ({ + entraObjectId: a.id, + displayName: a.displayName, + givenName: a.givenName, + surname: a.surname, + mail: a.mail, + employeeId: a.employeeId, + createdDateTime: a.createdDateTime, + })) + }, + + async admit(input) { + const s = data() + const localPart = + input.account.mailbox.kind === "custom" + ? input.account.mailbox.localPart + : `${input.account.firstName}.${input.account.lastName}` + .normalize("NFD") + .replace(/\p{M}/gu, "") + .replace(/['\s]/g, "") + .toLowerCase() + const mail = `${localPart}@polinetwork.org` + if (s.accounts.some((a) => a.mail === mail)) throw new Error(`The address ${mail} is already in use.`) + requireFreeNumber(s, input.number) + requireUniqueCode(s, input.details.codiceFiscale) + const acc: Account = { + id: `acc-${crypto.randomUUID()}`, + displayName: `${input.account.firstName} ${input.account.lastName}`, + givenName: input.account.firstName, + surname: input.account.lastName, + mail, + employeeId: String(input.number), + createdDateTime: now(), + } + s.accounts.push(acc) + s.soci.add(acc.id) + const r = record(input.number, acc, input.admittedOn, input.details) + r.createdAt = r.updatedAt = r.activity[0].at = now() + s.records.push(r) + s.held.set(input.number, acc.id) + return { number: input.number, entraObjectId: acc.id, mailbox: mail, welcomeMailSent: true } + }, + + async register(input) { + const s = data() + const existing = s.records.find((r) => r.entraObjectId === input.entraObjectId) + if (existing) throw new Error(`This account already has a record: socio ${existing.number}.`) + const acc = s.accounts.find((a) => a.id === input.entraObjectId) + if (!acc || !s.soci.has(acc.id)) throw new Error("This account isn't in the Soci group in Microsoft 365.") + const accountNumber = acc.employeeId ? Number(acc.employeeId) : null + if (accountNumber !== null && input.number !== undefined && input.number !== accountNumber) + throw new Error(`The account has member number ${accountNumber}: register it with that number, then change it.`) + const number = accountNumber ?? input.number + if (number === undefined) throw new Error("The account has no member number in Microsoft 365: choose one.") + requireFreeNumber(s, number, acc.id) + requireUniqueCode(s, input.details.codiceFiscale) + acc.employeeId = String(number) + const r = record(number, acc, input.admittedOn, input.details) + r.createdAt = r.updatedAt = now() + r.activity = [{ actor: AGENT, text: `Registered from Microsoft 365 with number ${number}`, at: now() }] + s.records.push(r) + s.held.set(number, acc.id) + return { number } + }, + + async update(input) { + const s = data() + const r = find(s, input.number) + if (!r.endedOn) requireUniqueCode(s, input.details.codiceFiscale, r) + const next = { ...input.details, admittedOn: input.admittedOn } + const fields = [ + "firstName", + "lastName", + "alias", + "codiceFiscale", + "birthDate", + "birthPlace", + "residence", + "admittedOn", + ] as const + const changed = fields.filter((key) => JSON.stringify(next[key]) !== JSON.stringify(r[key])) + if (!changed.length) return + Object.assign(r, next, { updatedBy: AGENT, updatedAt: now() }) + log(r, `Edited ${changed.join(", ")}`) + }, + + async setNumber(input) { + const s = data() + const r = find(s, input.number) + if (input.newNumber === r.number) throw new Error(`The socio already has number ${r.number}.`) + const acc = s.accounts.find((a) => a.id === r.entraObjectId) + if (!acc || r.accountState === "deleted") throw new Error("The socio's account has been deleted.") + requireFreeNumber(s, input.newNumber, acc.id) + acc.employeeId = String(input.newNumber) + log(r, `Member number changed from ${r.number} to ${input.newNumber}`) + r.number = input.newNumber + s.held.set(input.newNumber, acc.id) + return { number: input.newNumber, notificationSent: input.notifyEmail ? true : null } + }, + } +} diff --git a/src/features/soci/soci.functions.ts b/src/features/soci/soci.functions.ts new file mode 100644 index 00000000..552ae3f0 --- /dev/null +++ b/src/features/soci/soci.functions.ts @@ -0,0 +1,32 @@ +import { createServerFn } from "@tanstack/react-start" + +import { sociReadMiddleware } from "@/server/auth.middleware" + +import { sociApi } from "./soci.api.server" +import { numberInputSchema } from "./soci.contract" + +/** Soci › Register: every record, with mismatches against the Soci group, and the group's accounts without one. */ +export const getSociRegister = createServerFn({ method: "GET" }) + .middleware([sociReadMiddleware]) + .handler(async ({ context }) => { + const api = await sociApi(context) + const [register, unregistered] = await Promise.all([ + api.list(), + // The register still opens when Microsoft 365 can't be read; the alert then says so. + api.unregistered().catch((error) => { + console.error(error) + return null + }), + ]) + return { register, unregistered } + }) + +export const getSocio = createServerFn({ method: "GET" }) + .middleware([sociReadMiddleware]) + .validator(numberInputSchema) + .handler(async ({ data, context }) => (await sociApi(context)).get(data)) + +/** The register alone, without reading the Soci group: the socio page checks codici fiscali against it. */ +export const getSociList = createServerFn({ method: "GET" }) + .middleware([sociReadMiddleware]) + .handler(async ({ context }) => (await sociApi(context)).list()) diff --git a/src/features/soci/socio-dialogs.tsx b/src/features/soci/socio-dialogs.tsx new file mode 100644 index 00000000..87079865 --- /dev/null +++ b/src/features/soci/socio-dialogs.tsx @@ -0,0 +1,706 @@ +import { useNavigate } from "@tanstack/react-router" +import { useServerFn } from "@tanstack/react-start" +import { Hash, TriangleAlert } from "lucide-react" +import { useState } from "react" + +import { + checkboxControl, + ConfirmDialog, + DateField, + fieldControl, + fieldHintId, + FormDialog, + FormField, + InlineAlert, + SegmentedControl, + SelectField, +} from "@/components/primitives" +import { Checkbox } from "@/components/ui/checkbox" +import { Input } from "@/components/ui/input" +import { formatDay, todayIso } from "@/features/people/people-format" +import { errorMessage, useRefreshAfter } from "@/features/people/people-hooks" +import { cn } from "@/lib/utils" + +import { isLocalPart, isPoliNetworkAddress, MAIL_DOMAIN, type MailboxKind, standardLocalPart } from "./mailbox" +import { isMemberNumber } from "./member-number" +import { formatMemberNumber, socioName } from "./soci-format" +import { admitSocio, registerSocio, setSocioNumber, updateSocio } from "./soci-write.functions" +import { + admitInputSchema, + registerInputSchema, + type SocioRecord, + type SocioRow, + type Unregistered, + updateInputSchema, +} from "./soci.contract" +import { + type DetailsDraft, + detailsBlocked, + DetailsFields, + detailsInput, + draftOf, + EMPTY_DETAILS, + type FieldErrors, + fieldErrors, + FormSection, +} from "./socio-form" + +type DialogProps = { open: boolean; onOpenChange: (open: boolean) => void } + +const MAILBOX_KINDS = [ + { value: "standard", label: "Standard" }, + { value: "custom", label: "Custom" }, +] as const + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ +const NUMBER_ERROR = "Enter a positive member number." + +/** Today's fee year, written `2026/27`: it starts on 1 September. */ +function feeYear(date: string) { + const [year = 0, month = 0] = date.split("-").map(Number) + const start = month >= 9 ? year : year - 1 + return `${start}/${String((start + 1) % 100).padStart(2, "0")}` +} + +function AdmissionDate({ + id, + value, + onChange, + error, +}: { + id: string + value: string + onChange: (value: string) => void + error?: string +}) { + return ( + + + + ) +} + +/** + * Admit socio: today's Microsoft 365 account (name, address, member number, welcome email), the bookkeeping data and + * the admission date. The backend creates the account in Soci, then writes the record, and deletes the account if the + * record can't be written. + */ +export function AdmitSocioDialog({ open, onOpenChange, register }: DialogProps & { register: readonly SocioRow[] }) { + const admit = useServerFn(admitSocio) + const refresh = useRefreshAfter() + const navigate = useNavigate() + const [firstName, setFirstName] = useState("") + const [lastName, setLastName] = useState("") + const [mailbox, setMailbox] = useState("standard") + const [customLocalPart, setCustomLocalPart] = useState("") + const [email, setEmail] = useState("") + const [number, setNumber] = useState("") + const [details, setDetails] = useState(EMPTY_DETAILS) + /** The legal name follows the account's until it is edited. */ + const [legalEdited, setLegalEdited] = useState(false) + const [admittedOn, setAdmittedOn] = useState(todayIso()) + const [errors, setErrors] = useState({}) + const [submitted, setSubmitted] = useState(false) + const [confirmingRecipient, setConfirmingRecipient] = useState(false) + + const namesGiven = firstName.trim() !== "" && lastName.trim() !== "" + const standard = namesGiven ? standardLocalPart(firstName, lastName) : null + const localPart = mailbox === "standard" ? standard : isLocalPart(customLocalPart) ? customLocalPart : null + const address = localPart === null ? null : `${localPart}@${MAIL_DOMAIN}` + const recipient = email.trim() + const sameAddress = address !== null && recipient.toLowerCase() === address + const addressError = + mailbox === "standard" + ? namesGiven && standard === null + ? "This name can't form a standard address. Choose Custom." + : undefined + : submitted && localPart === null + ? "Use lowercase letters and digits, with single dots or hyphens between them." + : undefined + const emailError = submitted + ? !EMAIL_PATTERN.test(recipient) + ? "Enter a valid email address." + : sameAddress + ? "This is the address being created and has no mailbox yet. Use the person's personal email." + : undefined + : undefined + const numberError = submitted && !isMemberNumber(number) ? NUMBER_ERROR : errors.number + const takenNumber = register.find((socio) => String(socio.number) === number) + + function setAccountName(next: { firstName?: string; lastName?: string }) { + const first = next.firstName ?? firstName + const last = next.lastName ?? lastName + setFirstName(first) + setLastName(last) + if (!legalEdited) setDetails((current) => ({ ...current, firstName: first, lastName: last })) + } + + const dirty = + [firstName, lastName, customLocalPart, email, number].some((value) => value.trim() !== "") || + JSON.stringify(details) !== JSON.stringify({ ...EMPTY_DETAILS, firstName, lastName }) + + async function create() { + const parsed = admitInputSchema.parse(input()) + try { + const result = await admit({ data: parsed }) + onOpenChange(false) + await refresh( + result.welcomeMailSent + ? `${formatMemberNumber(result.number)} admitted: ${result.mailbox} created and the welcome email sent.` + : `${formatMemberNumber(result.number)} admitted and ${result.mailbox} created, but the welcome email couldn't be sent.`, + result.welcomeMailSent ? "success" : "warning" + ) + await navigate({ to: "/dashboard/people/soci/$number", params: { number: String(result.number) } }) + } catch (caught) { + console.error(caught) + throw new Error(errorMessage(caught, "Couldn't admit the socio. Check the values and try again."), { + cause: caught, + }) + } + } + + function input() { + return { + account: { + firstName: firstName.trim(), + lastName: lastName.trim(), + mailbox: + mailbox === "custom" + ? { kind: "custom" as const, localPart: localPart ?? "" } + : { kind: "standard" as const }, + sendEmailTo: recipient, + }, + number: Number.parseInt(number, 10), + details: detailsInput(details), + admittedOn, + } + } + + async function submit() { + setSubmitted(true) + const parsed = admitInputSchema.safeParse(input()) + setErrors(fieldErrors(parsed.error)) + if (!parsed.success || localPart === null || sameAddress || detailsBlocked(details, register)) return + // The welcome email carries the new account's credentials, so it normally goes to a personal address. + if (isPoliNetworkAddress(recipient)) { + setConfirmingRecipient(true) + return + } + await create() + } + + return ( + + +
+ + setAccountName({ firstName: event.target.value })} + className={cn("h-9", fieldControl)} + /> + + + setAccountName({ lastName: event.target.value })} + className={cn("h-9", fieldControl)} + /> + +
+
+ + New Microsoft 365 address + + + {mailbox === "standard" ? ( + + {address ?? `firstname.lastname@${MAIL_DOMAIN}`} + + ) : ( +
+ setCustomLocalPart(event.target.value.replace(/\s/g, "").toLowerCase())} + className={cn("h-9 min-w-0 flex-1 font-mono", fieldControl)} + /> + @{MAIL_DOMAIN} +
+ )} +

+ {addressError ?? + (mailbox === "standard" + ? "Built from the first and last name, without accents, apostrophes or spaces." + : "Lowercase letters, digits, dots and hyphens.")} +

+
+
+ + setEmail(event.target.value)} + className={cn("h-9", fieldControl)} + /> + + + setNumber(event.target.value.trim())} + className={cn("h-9 font-mono tabular-nums", fieldControl)} + /> + +
+
+ + { + if (next.firstName !== details.firstName || next.lastName !== details.lastName) setLegalEdited(true) + setDetails(next) + }} + errors={errors} + required + register={register} + /> + + + + + The Direttivo admits someone once they've paid: the {feeYear(admittedOn)} fee counts as paid on the admission + date. + + + + {recipient} will receive the sign-in details for{" "} + {address}. + + } + confirmLabel="Send to this address" + cancelLabel="Change recipient" + tone="primary" + icon={TriangleAlert} + onConfirm={create} + /> +
+ ) +} + +/** + * Register an account already in Soci: its number is the account's (or one chosen, when it has none), the admission + * date is suggested from the account's creation, and only the legal name is required. The socio can complete the rest. + */ +export function RegisterSocioDialog({ + open, + onOpenChange, + accounts, + register, +}: DialogProps & { accounts: readonly Unregistered[]; register: readonly SocioRow[] }) { + const registerSocioFn = useServerFn(registerSocio) + const refresh = useRefreshAfter() + const [accountId, setAccountId] = useState( + accounts.length === 1 ? (accounts[0]?.entraObjectId ?? null) : null + ) + const account = accounts.find((candidate) => candidate.entraObjectId === accountId) ?? null + const [details, setDetails] = useState(() => prefill(account)) + const [admittedOn, setAdmittedOn] = useState(() => suggestedDate(account)) + const [number, setNumber] = useState("") + const [errors, setErrors] = useState({}) + const fixedNumber = account?.employeeId && isMemberNumber(account.employeeId) ? account.employeeId : null + + function prefill(next: Unregistered | null): DetailsDraft { + return { ...EMPTY_DETAILS, firstName: next?.givenName ?? "", lastName: next?.surname ?? "" } + } + function suggestedDate(next: Unregistered | null) { + return next?.createdDateTime ? next.createdDateTime.slice(0, 10) : todayIso() + } + function choose(id: string) { + const next = accounts.find((candidate) => candidate.entraObjectId === id) ?? null + setAccountId(id) + setDetails(prefill(next)) + setAdmittedOn(suggestedDate(next)) + setNumber("") + setErrors({}) + } + + function input() { + return { + entraObjectId: accountId ?? "", + number: fixedNumber ? undefined : number === "" ? undefined : Number.parseInt(number, 10), + details: detailsInput(details), + admittedOn, + } + } + + async function submit() { + if (!account) return setErrors({ account: "Choose the account." }) + if (!fixedNumber && !isMemberNumber(number)) return setErrors({ number: NUMBER_ERROR }) + const parsed = registerInputSchema.safeParse(input()) + setErrors(fieldErrors(parsed.error)) + if (!parsed.success || detailsBlocked(details, register)) return + try { + const result = await registerSocioFn({ data: parsed.data }) + onOpenChange(false) + await refresh(`${account.displayName ?? "Socio"} registered as ${formatMemberNumber(result.number)}.`) + } catch (caught) { + console.error(caught) + throw new Error(errorMessage(caught, "Couldn't register the socio. Check the values and try again."), { + cause: caught, + }) + } + } + + return ( + 1} + canSubmit={account !== null} + submitLabel="Register socio" + onSubmit={submit} + > + + + ({ + value: candidate.entraObjectId, + label: `${candidate.displayName ?? "Unnamed"} · ${candidate.mail ?? candidate.entraObjectId}`, + }))} + onChange={choose} + invalid={!!errors.account} + /> + + {account ? ( + fixedNumber ? ( +

+ Member number {formatMemberNumber(Number(fixedNumber))}, + from Microsoft 365. Change it after registering, if needed. +

+ ) : ( + + setNumber(event.target.value.trim())} + className={cn("h-9 font-mono tabular-nums", fieldControl)} + /> + + ) + ) : null} +
+ {account ? ( + <> + + + + + + + + + + ) : null} +
+ ) +} + +/** Edit the bookkeeping data and the admission date. Only the legal name is required. */ +export function EditSocioDialog({ + open, + onOpenChange, + socio, + register, +}: DialogProps & { socio: SocioRecord; register: readonly SocioRow[] }) { + const update = useServerFn(updateSocio) + const refresh = useRefreshAfter() + const initial = draftOf(socio) + const [details, setDetails] = useState(initial) + const [admittedOn, setAdmittedOn] = useState(socio.admittedOn) + const [errors, setErrors] = useState({}) + // An ended record may share its code with the person's current one. + const others = socio.endedOn ? [] : register + + async function submit() { + const parsed = updateInputSchema.safeParse({ number: socio.number, details: detailsInput(details), admittedOn }) + setErrors(fieldErrors(parsed.error)) + if (!parsed.success || detailsBlocked(details, others, socio.number)) return + try { + await update({ data: parsed.data }) + onOpenChange(false) + await refresh("Socio updated.") + } catch (caught) { + console.error(caught) + throw new Error(errorMessage(caught, "Couldn't save the changes. Check the values and try again."), { + cause: caught, + }) + } + } + + return ( + + + + + + + + + ) +} + +/** + * Change number: on the Microsoft 365 account (`employeeId`) and on the record, after a confirmation, optionally + * emailing it. The old number is never given to anyone else. + */ +export function ChangeNumberDialog({ + open, + onOpenChange, + socio, +}: DialogProps & { socio: Pick }) { + const setNumberFn = useServerFn(setSocioNumber) + const refresh = useRefreshAfter() + const navigate = useNavigate() + const [number, setNumber] = useState("") + const [notify, setNotify] = useState(true) + const [email, setEmail] = useState(socio.mailbox) + const [submitted, setSubmitted] = useState(false) + const [confirming, setConfirming] = useState(false) + const recipient = email.trim() + const numberError = submitted + ? !isMemberNumber(number) + ? NUMBER_ERROR + : Number(number) === socio.number + ? "That's the current number." + : undefined + : undefined + const emailError = submitted && notify && !EMAIL_PATTERN.test(recipient) ? "Enter a valid email address." : undefined + + function submit() { + setSubmitted(true) + if (!isMemberNumber(number) || Number(number) === socio.number || (notify && !EMAIL_PATTERN.test(recipient))) return + setConfirming(true) + } + + async function save() { + try { + const result = await setNumberFn({ + data: { number: socio.number, newNumber: Number(number), notifyEmail: notify ? recipient : undefined }, + }) + onOpenChange(false) + await navigate({ to: "/dashboard/people/soci/$number", params: { number: String(result.number) }, replace: true }) + await refresh( + result.notificationSent === false + ? "Number changed, but the email couldn't be sent." + : result.notificationSent + ? "Number changed and email sent." + : "Number changed.", + result.notificationSent === false ? "warning" : "success" + ) + } catch (caught) { + console.error(caught) + throw new Error(errorMessage(caught, "Couldn't change the number. Check the value and try again."), { + cause: caught, + }) + } + } + + return ( + + + setNumber(event.target.value.trim())} + className={cn("h-9 font-mono tabular-nums", fieldControl)} + /> + + + {notify ? ( + + setEmail(event.target.value)} + className={cn("h-9", fieldControl)} + /> + + ) : null} + + {socioName(socio)} goes from {formatMemberNumber(socio.number)} to{" "} + + {isMemberNumber(number) ? formatMemberNumber(Number(number)) : number} + + , in Microsoft 365 too. {formatMemberNumber(socio.number)} won't be given to anyone else.{" "} + {notify ? ( + <> + An email goes to {recipient}. + + ) : ( + "No email is sent." + )} + + } + confirmLabel="Change number" + tone="primary" + icon={Hash} + onConfirm={save} + /> + + ) +} diff --git a/src/features/soci/socio-form.tsx b/src/features/soci/socio-form.tsx new file mode 100644 index 00000000..2d29c8b5 --- /dev/null +++ b/src/features/soci/socio-form.tsx @@ -0,0 +1,282 @@ +import type { ReactNode } from "react" +import type { z } from "zod" + +import { DateField, fieldControl, fieldHintId, FormField, InlineAlert } from "@/components/primitives" +import { Input } from "@/components/ui/input" +import { formatDay } from "@/features/people/people-format" +import { cn } from "@/lib/utils" + +import { codiceFiscaleProblem, matchesBirthDate, normalizeCodiceFiscale } from "./codice-fiscale" +import { formatMemberNumber, socioName } from "./soci-format" +import type { SocioDetails, SocioRow } from "./soci.contract" + +/** What the bookkeeping fields hold while editing: plain strings, empty when unknown. */ +export type DetailsDraft = { + firstName: string + lastName: string + alias: string + codiceFiscale: string + birthDate: string + birthPlace: string + street: string + postalCode: string + city: string + province: string + country: string +} + +export const EMPTY_DETAILS: DetailsDraft = { + firstName: "", + lastName: "", + alias: "", + codiceFiscale: "", + birthDate: "", + birthPlace: "", + street: "", + postalCode: "", + city: "", + province: "", + country: "IT", +} + +export function draftOf(details: SocioDetails): DetailsDraft { + return { + firstName: details.firstName, + lastName: details.lastName, + alias: details.alias ?? "", + codiceFiscale: details.codiceFiscale ?? "", + birthDate: details.birthDate ?? "", + birthPlace: details.birthPlace ?? "", + street: details.residence.street ?? "", + postalCode: details.residence.postalCode ?? "", + city: details.residence.city ?? "", + province: details.residence.province ?? "", + country: details.residence.country ?? "", + } +} + +/** The contract's input shape; the schemas trim, upper-case and turn empty strings into `null`. */ +export function detailsInput(draft: DetailsDraft) { + return { + firstName: draft.firstName, + lastName: draft.lastName, + alias: draft.alias, + codiceFiscale: draft.codiceFiscale, + birthDate: draft.birthDate, + birthPlace: draft.birthPlace, + residence: { + street: draft.street, + postalCode: draft.postalCode, + city: draft.city, + province: draft.province, + country: draft.country, + }, + } +} + +const FIELD_KEYS = [ + "firstName", + "lastName", + "alias", + "codiceFiscale", + "birthDate", + "birthPlace", + "street", + "postalCode", + "city", + "province", + "country", + "account", + "number", + "admittedOn", +] as const +type FieldKey = (typeof FIELD_KEYS)[number] +export type FieldErrors = { [Key in FieldKey]?: string } + +/** The first message per field from a failed parse of a contract input; `details.residence.city` becomes `city`. */ +export function fieldErrors(error: z.ZodError | undefined) { + const errors: FieldErrors = {} + for (const issue of error?.issues ?? []) { + const name = issue.path.find((part) => part !== "details" && part !== "residence") + const key = FIELD_KEYS.find((candidate) => candidate === name) + if (key && !errors[key]) errors[key] = issue.message.startsWith("Invalid input") ? "Fill this in." : issue.message + } + return errors +} + +/** The records that already have this codice fiscale, other than `self`. */ +export function holdersOf(code: string, register: readonly SocioRow[], self?: number) { + const normalized = normalizeCodiceFiscale(code) + if (codiceFiscaleProblem(normalized)) return { active: null, former: [] } + const same = register.filter((socio) => socio.codiceFiscale === normalized && socio.number !== self) + return { active: same.find((socio) => !socio.endedOn) ?? null, former: same.filter((socio) => socio.endedOn) } +} + +function Heading({ children }: { children: ReactNode }) { + return

{children}

+} + +export function FormSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+ {title} + {children} +
+ ) +} + +type DetailsFieldsProps = { + id: string + draft: DetailsDraft + onChange: (draft: DetailsDraft) => void + errors: FieldErrors + /** Admission needs everything but the alias; a socio registered afterwards may complete them later. */ + required: boolean + /** The register, to warn when the codice fiscale belongs to a former or active socio. */ + register: readonly SocioRow[] + /** The record being edited, which doesn't count as another holder of its own code. */ + self?: number +} + +/** The bookkeeping data the association keeps: legal name, alias, codice fiscale, birth and residence. */ +export function DetailsFields({ id, draft, onChange, errors, required, register, self }: DetailsFieldsProps) { + const set = (key: keyof DetailsDraft) => (value: string) => onChange({ ...draft, [key]: value }) + const optional = !required + const italy = draft.country.trim().toUpperCase() === "IT" + const code = normalizeCodiceFiscale(draft.codiceFiscale) + const holders = holdersOf(code, register, self) + const dateMismatch = + !errors.birthDate && + draft.birthDate !== "" && + !codiceFiscaleProblem(code) && + !matchesBirthDate(code, draft.birthDate) + + const text = (key: keyof DetailsDraft, label: string, props: { mono?: boolean; optional?: boolean } = {}) => ( + + set(key)(event.target.value)} + className={cn("h-9", props.mono && "font-mono uppercase", fieldControl)} + /> + + ) + + return ( + <> +
+ {text("firstName", "Legal first name")} + {text("lastName", "Legal last name")} +
+ + set("alias")(event.target.value)} + className={cn("h-9", fieldControl)} + /> + + {text("codiceFiscale", "Codice fiscale", { mono: true, optional })} + {holders.active ? ( + + This codice fiscale belongs to socio {formatMemberNumber(holders.active.number)}, {socioName(holders.active)}, + who is still active. + + ) : holders.former.length ? ( + + This codice fiscale belongs to a former socio:{" "} + {holders.former + .map( + (socio) => + `${formatMemberNumber(socio.number)} ${socioName(socio)}, ended ${formatDay(socio.endedOn ?? "")}` + ) + .join("; ")} + . A new admission gets a new number. + + ) : null} +
+ + + + {text("birthPlace", "Birth place", { optional })} +
+ {text("street", "Street and number", { optional })} +
+ {text("postalCode", "Postal code", { optional })} + {text("city", "City", { optional })} +
+
+ + { + const country = event.target.value.toUpperCase() + onChange({ ...draft, country, province: country === "IT" ? draft.province : "" }) + }} + className={cn("h-9 font-mono uppercase", fieldControl)} + /> + + {italy ? ( + + set("province")(event.target.value.toUpperCase())} + className={cn("h-9 font-mono uppercase", fieldControl)} + /> + + ) : null} +
+ + ) +} + +/** Whether the parsed details may be sent: the codice fiscale isn't an active socio's and agrees with the birth date. */ +export function detailsBlocked(draft: DetailsDraft, register: readonly SocioRow[], self?: number) { + const code = normalizeCodiceFiscale(draft.codiceFiscale) + if (holdersOf(code, register, self).active) return true + return draft.birthDate !== "" && !codiceFiscaleProblem(code) && !matchesBirthDate(code, draft.birthDate) +} diff --git a/src/features/soci/socio-page.tsx b/src/features/soci/socio-page.tsx new file mode 100644 index 00000000..4b3af0ec --- /dev/null +++ b/src/features/soci/socio-page.tsx @@ -0,0 +1,205 @@ +import { Hash, Pencil } from "lucide-react" + +import { + buttonMotion, + InlineAlert, + KeyValueList, + type KeyValueItem, + RecordHeader, + SectionCard, + SectionEmpty, + Unset, +} from "@/components/primitives" +import { PageBar, PageContent, useCan } from "@/components/shell" +import { Button } from "@/components/ui/button" +import { actorName, formatDay } from "@/features/people/people-format" +import { useDialogState } from "@/features/people/people-hooks" +import { PersonAvatar, Row, Rows, StateBadge } from "@/features/people/people-ui" +import type { PersonRow } from "@/features/people/people.contract" +import { formatDateTime } from "@/lib/format" + +import { + ACCOUNT_STATE, + END_REASON, + formatMemberNumber, + formatResidence, + MISMATCH, + missingDetails, + socioName, + socioStatus, +} from "./soci-format" +import type { SocioRecord, SocioRow } from "./soci.contract" +import { ChangeNumberDialog, EditSocioDialog } from "./socio-dialogs" + +const BACK = { label: "the register", link: { to: "/dashboard/people/soci" } } as const + +const value = (text: string | null, mono = false) => + text ? {text} : + +/** One socio: the bookkeeping data, the Microsoft 365 account and the record's history. */ +export function SocioPage({ + socio, + register, + people, +}: { + socio: SocioRecord + register: SocioRow[] + /** People records, to name who did what; empty without `people:read`. */ + people: PersonRow[] +}) { + const canWrite = useCan("soci:write") + const edit = useDialogState() + const changeNumber = useDialogState() + const name = socioName(socio) + const missing = missingDetails(socio) + const deleted = socio.accountState === "deleted" + const numberDiffers = socio.accountNumber !== null && socio.accountNumber !== String(socio.number) + + const details: KeyValueItem[] = [ + { key: "Legal name", value: name }, + { key: "Alias", value: value(socio.alias) }, + { key: "Codice fiscale", value: value(socio.codiceFiscale, true) }, + { key: "Birth date", value: socio.birthDate ? formatDay(socio.birthDate) : }, + { key: "Birth place", value: value(socio.birthPlace) }, + { key: "Residence", value: value(formatResidence(socio.residence)) }, + ] + const admission: KeyValueItem[] = [ + { key: "Member number", value: {formatMemberNumber(socio.number)} }, + { key: "Admitted on", value: formatDay(socio.admittedOn) }, + ...(socio.endedOn + ? [ + { key: "Ended on", value: formatDay(socio.endedOn) }, + { key: "Reason", value: socio.endReason ? END_REASON[socio.endReason] : }, + { key: "Note", value: value(socio.endNote) }, + ] + : []), + ] + const account: KeyValueItem[] = [ + { key: "Mailbox", value: {socio.mailbox} }, + { key: "Display name", value: socio.displayName }, + { key: "Account", value: }, + ...(deleted + ? [{ key: "Deleted on", value: socio.accountDeletedOn ? formatDay(socio.accountDeletedOn) : }] + : [ + { + key: "Soci group", + value: socio.inSoci === null ? : socio.inSoci ? "Member" : "Not a member", + }, + { key: "Number in Microsoft 365", value: value(socio.accountNumber, true) }, + ]), + { key: "Object ID", value: {socio.entraObjectId} }, + ] + + return ( + <> + + +
+ } + badge={} + meta={ + <> + {formatMemberNumber(socio.number)} + {socio.alias ? <> · goes by {socio.alias} : null} + + } + actions={ + canWrite ? ( + <> + + {deleted ? null : ( + + )} + + ) : undefined + } + /> + + {socio.mismatch || numberDiffers || (missing.length && !socio.endedOn) ? ( +
+ {socio.mismatch ? ( + + {MISMATCH[socio.mismatch]}.{" "} + {socio.mismatch === "still_in_soci" + ? "The account still has the Socio role." + : "The account doesn't have the Socio role."} + + ) : null} + {numberDiffers ? ( + + Microsoft 365 has number {socio.accountNumber} on the account. Change number sets both again. + + ) : null} + {missing.length && !socio.endedOn ? ( + Missing: {missing.join(", ")}. + ) : null} +
+ ) : null} + +
+
+ + + + + + +
+ + + +
+ + + {socio.activity.length === 0 ? ( + + ) : ( + + {socio.activity.map((entry) => { + const actor = actorName(entry.actor, people) + return ( + } + title={ + + {actor} {entry.text} + + } + meta={formatDateTime(new Date(entry.at))} + /> + ) + })} + + )} + +
+
+ {canWrite ? ( + + ) : null} + {canWrite && !deleted ? ( + + ) : null} + + ) +} diff --git a/src/lib/format.ts b/src/lib/format.ts index 853ed0f2..c25b0383 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -32,6 +32,8 @@ const IRREGULAR_PLURALS = new Map([ ["edition", "editions"], ["membership", "memberships"], ["person", "people"], + ["socio", "soci"], + ["active socio", "active soci"], ["reply", "replies"], ["entry", "entries"], ]) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 4d436d2c..14a17ee5 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -34,7 +34,6 @@ import { Route as DashboardTelegramCapoRouteImport } from './routes/dashboard/te import { Route as DashboardReportsResolvedRouteImport } from './routes/dashboard/reports/resolved' import { Route as DashboardReportsGroupLinksRouteImport } from './routes/dashboard/reports/group-links' import { Route as DashboardPeopleReceiptsRouteImport } from './routes/dashboard/people/receipts' -import { Route as DashboardPeopleMembersRouteImport } from './routes/dashboard/people/members' import { Route as DashboardPeopleLinkingRouteImport } from './routes/dashboard/people/linking' import { Route as DashboardPeopleAdminsRouteImport } from './routes/dashboard/people/admins' import { Route as DashboardPeoplePersonIdRouteImport } from './routes/dashboard/people/$personId' @@ -49,12 +48,14 @@ import { Route as DashboardAccountMeRouteImport } from './routes/dashboard/accou import { Route as DashboardWebGroupsByLabelIndexRouteImport } from './routes/dashboard/web/groups-by-label/index' import { Route as DashboardTelegramUsersIndexRouteImport } from './routes/dashboard/telegram/users/index' import { Route as DashboardPeopleTeamsIndexRouteImport } from './routes/dashboard/people/teams/index' +import { Route as DashboardPeopleSociIndexRouteImport } from './routes/dashboard/people/soci/index' import { Route as DashboardPeopleCensusIndexRouteImport } from './routes/dashboard/people/census/index' import { Route as DashboardEsignRequestsIndexRouteImport } from './routes/dashboard/esign/requests/index' import { Route as DashboardWebTagsTagRouteImport } from './routes/dashboard/web/tags/$tag' import { Route as DashboardWebGroupsByLabelSplatRouteImport } from './routes/dashboard/web/groups-by-label/$' import { Route as DashboardTelegramUsersUserIdRouteImport } from './routes/dashboard/telegram/users/$userId' import { Route as DashboardPeopleTeamsTeamIdRouteImport } from './routes/dashboard/people/teams/$teamId' +import { Route as DashboardPeopleSociNumberRouteImport } from './routes/dashboard/people/soci/$number' import { Route as DashboardPeopleCensusCampaignIdRouteImport } from './routes/dashboard/people/census/$campaignId' import { Route as DashboardEsignRequestsRequestIdRouteImport } from './routes/dashboard/esign/requests/$requestId' import { Route as ApiEsignAgentSplatRouteImport } from './routes/api/esign/agent/$' @@ -187,11 +188,6 @@ const DashboardPeopleReceiptsRoute = DashboardPeopleReceiptsRouteImport.update({ path: '/people/receipts', getParentRoute: () => DashboardRoute, } as any) -const DashboardPeopleMembersRoute = DashboardPeopleMembersRouteImport.update({ - id: '/people/members', - path: '/people/members', - getParentRoute: () => DashboardRoute, -} as any) const DashboardPeopleLinkingRoute = DashboardPeopleLinkingRouteImport.update({ id: '/people/linking', path: '/people/linking', @@ -265,6 +261,12 @@ const DashboardPeopleTeamsIndexRoute = path: '/people/teams/', getParentRoute: () => DashboardRoute, } as any) +const DashboardPeopleSociIndexRoute = + DashboardPeopleSociIndexRouteImport.update({ + id: '/people/soci/', + path: '/people/soci/', + getParentRoute: () => DashboardRoute, + } as any) const DashboardPeopleCensusIndexRoute = DashboardPeopleCensusIndexRouteImport.update({ id: '/people/census/', @@ -300,6 +302,12 @@ const DashboardPeopleTeamsTeamIdRoute = path: '/people/teams/$teamId', getParentRoute: () => DashboardRoute, } as any) +const DashboardPeopleSociNumberRoute = + DashboardPeopleSociNumberRouteImport.update({ + id: '/people/soci/$number', + path: '/people/soci/$number', + getParentRoute: () => DashboardRoute, + } as any) const DashboardPeopleCensusCampaignIdRoute = DashboardPeopleCensusCampaignIdRouteImport.update({ id: '/people/census/$campaignId', @@ -340,7 +348,6 @@ export interface FileRoutesByFullPath { '/dashboard/people/$personId': typeof DashboardPeoplePersonIdRoute '/dashboard/people/admins': typeof DashboardPeopleAdminsRoute '/dashboard/people/linking': typeof DashboardPeopleLinkingRoute - '/dashboard/people/members': typeof DashboardPeopleMembersRoute '/dashboard/people/receipts': typeof DashboardPeopleReceiptsRoute '/dashboard/reports/group-links': typeof DashboardReportsGroupLinksRoute '/dashboard/reports/resolved': typeof DashboardReportsResolvedRoute @@ -359,12 +366,14 @@ export interface FileRoutesByFullPath { '/api/esign/agent/$': typeof ApiEsignAgentSplatRoute '/dashboard/esign/requests/$requestId': typeof DashboardEsignRequestsRequestIdRoute '/dashboard/people/census/$campaignId': typeof DashboardPeopleCensusCampaignIdRoute + '/dashboard/people/soci/$number': typeof DashboardPeopleSociNumberRoute '/dashboard/people/teams/$teamId': typeof DashboardPeopleTeamsTeamIdRoute '/dashboard/telegram/users/$userId': typeof DashboardTelegramUsersUserIdRoute '/dashboard/web/groups-by-label/$': typeof DashboardWebGroupsByLabelSplatRoute '/dashboard/web/tags/$tag': typeof DashboardWebTagsTagRoute '/dashboard/esign/requests/': typeof DashboardEsignRequestsIndexRoute '/dashboard/people/census/': typeof DashboardPeopleCensusIndexRoute + '/dashboard/people/soci/': typeof DashboardPeopleSociIndexRoute '/dashboard/people/teams/': typeof DashboardPeopleTeamsIndexRoute '/dashboard/telegram/users/': typeof DashboardTelegramUsersIndexRoute '/dashboard/web/groups-by-label/': typeof DashboardWebGroupsByLabelIndexRoute @@ -390,7 +399,6 @@ export interface FileRoutesByTo { '/dashboard/people/$personId': typeof DashboardPeoplePersonIdRoute '/dashboard/people/admins': typeof DashboardPeopleAdminsRoute '/dashboard/people/linking': typeof DashboardPeopleLinkingRoute - '/dashboard/people/members': typeof DashboardPeopleMembersRoute '/dashboard/people/receipts': typeof DashboardPeopleReceiptsRoute '/dashboard/reports/group-links': typeof DashboardReportsGroupLinksRoute '/dashboard/reports/resolved': typeof DashboardReportsResolvedRoute @@ -409,12 +417,14 @@ export interface FileRoutesByTo { '/api/esign/agent/$': typeof ApiEsignAgentSplatRoute '/dashboard/esign/requests/$requestId': typeof DashboardEsignRequestsRequestIdRoute '/dashboard/people/census/$campaignId': typeof DashboardPeopleCensusCampaignIdRoute + '/dashboard/people/soci/$number': typeof DashboardPeopleSociNumberRoute '/dashboard/people/teams/$teamId': typeof DashboardPeopleTeamsTeamIdRoute '/dashboard/telegram/users/$userId': typeof DashboardTelegramUsersUserIdRoute '/dashboard/web/groups-by-label/$': typeof DashboardWebGroupsByLabelSplatRoute '/dashboard/web/tags/$tag': typeof DashboardWebTagsTagRoute '/dashboard/esign/requests': typeof DashboardEsignRequestsIndexRoute '/dashboard/people/census': typeof DashboardPeopleCensusIndexRoute + '/dashboard/people/soci': typeof DashboardPeopleSociIndexRoute '/dashboard/people/teams': typeof DashboardPeopleTeamsIndexRoute '/dashboard/telegram/users': typeof DashboardTelegramUsersIndexRoute '/dashboard/web/groups-by-label': typeof DashboardWebGroupsByLabelIndexRoute @@ -442,7 +452,6 @@ export interface FileRoutesById { '/dashboard/people/$personId': typeof DashboardPeoplePersonIdRoute '/dashboard/people/admins': typeof DashboardPeopleAdminsRoute '/dashboard/people/linking': typeof DashboardPeopleLinkingRoute - '/dashboard/people/members': typeof DashboardPeopleMembersRoute '/dashboard/people/receipts': typeof DashboardPeopleReceiptsRoute '/dashboard/reports/group-links': typeof DashboardReportsGroupLinksRoute '/dashboard/reports/resolved': typeof DashboardReportsResolvedRoute @@ -461,12 +470,14 @@ export interface FileRoutesById { '/api/esign/agent/$': typeof ApiEsignAgentSplatRoute '/dashboard/esign/requests/$requestId': typeof DashboardEsignRequestsRequestIdRoute '/dashboard/people/census/$campaignId': typeof DashboardPeopleCensusCampaignIdRoute + '/dashboard/people/soci/$number': typeof DashboardPeopleSociNumberRoute '/dashboard/people/teams/$teamId': typeof DashboardPeopleTeamsTeamIdRoute '/dashboard/telegram/users/$userId': typeof DashboardTelegramUsersUserIdRoute '/dashboard/web/groups-by-label/$': typeof DashboardWebGroupsByLabelSplatRoute '/dashboard/web/tags/$tag': typeof DashboardWebTagsTagRoute '/dashboard/esign/requests/': typeof DashboardEsignRequestsIndexRoute '/dashboard/people/census/': typeof DashboardPeopleCensusIndexRoute + '/dashboard/people/soci/': typeof DashboardPeopleSociIndexRoute '/dashboard/people/teams/': typeof DashboardPeopleTeamsIndexRoute '/dashboard/telegram/users/': typeof DashboardTelegramUsersIndexRoute '/dashboard/web/groups-by-label/': typeof DashboardWebGroupsByLabelIndexRoute @@ -495,7 +506,6 @@ export interface FileRouteTypes { | '/dashboard/people/$personId' | '/dashboard/people/admins' | '/dashboard/people/linking' - | '/dashboard/people/members' | '/dashboard/people/receipts' | '/dashboard/reports/group-links' | '/dashboard/reports/resolved' @@ -514,12 +524,14 @@ export interface FileRouteTypes { | '/api/esign/agent/$' | '/dashboard/esign/requests/$requestId' | '/dashboard/people/census/$campaignId' + | '/dashboard/people/soci/$number' | '/dashboard/people/teams/$teamId' | '/dashboard/telegram/users/$userId' | '/dashboard/web/groups-by-label/$' | '/dashboard/web/tags/$tag' | '/dashboard/esign/requests/' | '/dashboard/people/census/' + | '/dashboard/people/soci/' | '/dashboard/people/teams/' | '/dashboard/telegram/users/' | '/dashboard/web/groups-by-label/' @@ -545,7 +557,6 @@ export interface FileRouteTypes { | '/dashboard/people/$personId' | '/dashboard/people/admins' | '/dashboard/people/linking' - | '/dashboard/people/members' | '/dashboard/people/receipts' | '/dashboard/reports/group-links' | '/dashboard/reports/resolved' @@ -564,12 +575,14 @@ export interface FileRouteTypes { | '/api/esign/agent/$' | '/dashboard/esign/requests/$requestId' | '/dashboard/people/census/$campaignId' + | '/dashboard/people/soci/$number' | '/dashboard/people/teams/$teamId' | '/dashboard/telegram/users/$userId' | '/dashboard/web/groups-by-label/$' | '/dashboard/web/tags/$tag' | '/dashboard/esign/requests' | '/dashboard/people/census' + | '/dashboard/people/soci' | '/dashboard/people/teams' | '/dashboard/telegram/users' | '/dashboard/web/groups-by-label' @@ -596,7 +609,6 @@ export interface FileRouteTypes { | '/dashboard/people/$personId' | '/dashboard/people/admins' | '/dashboard/people/linking' - | '/dashboard/people/members' | '/dashboard/people/receipts' | '/dashboard/reports/group-links' | '/dashboard/reports/resolved' @@ -615,12 +627,14 @@ export interface FileRouteTypes { | '/api/esign/agent/$' | '/dashboard/esign/requests/$requestId' | '/dashboard/people/census/$campaignId' + | '/dashboard/people/soci/$number' | '/dashboard/people/teams/$teamId' | '/dashboard/telegram/users/$userId' | '/dashboard/web/groups-by-label/$' | '/dashboard/web/tags/$tag' | '/dashboard/esign/requests/' | '/dashboard/people/census/' + | '/dashboard/people/soci/' | '/dashboard/people/teams/' | '/dashboard/telegram/users/' | '/dashboard/web/groups-by-label/' @@ -815,13 +829,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardPeopleReceiptsRouteImport parentRoute: typeof DashboardRoute } - '/dashboard/people/members': { - id: '/dashboard/people/members' - path: '/people/members' - fullPath: '/dashboard/people/members' - preLoaderRoute: typeof DashboardPeopleMembersRouteImport - parentRoute: typeof DashboardRoute - } '/dashboard/people/linking': { id: '/dashboard/people/linking' path: '/people/linking' @@ -920,6 +927,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardPeopleTeamsIndexRouteImport parentRoute: typeof DashboardRoute } + '/dashboard/people/soci/': { + id: '/dashboard/people/soci/' + path: '/people/soci' + fullPath: '/dashboard/people/soci/' + preLoaderRoute: typeof DashboardPeopleSociIndexRouteImport + parentRoute: typeof DashboardRoute + } '/dashboard/people/census/': { id: '/dashboard/people/census/' path: '/people/census' @@ -962,6 +976,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardPeopleTeamsTeamIdRouteImport parentRoute: typeof DashboardRoute } + '/dashboard/people/soci/$number': { + id: '/dashboard/people/soci/$number' + path: '/people/soci/$number' + fullPath: '/dashboard/people/soci/$number' + preLoaderRoute: typeof DashboardPeopleSociNumberRouteImport + parentRoute: typeof DashboardRoute + } '/dashboard/people/census/$campaignId': { id: '/dashboard/people/census/$campaignId' path: '/people/census/$campaignId' @@ -1028,7 +1049,6 @@ interface DashboardRouteChildren { DashboardPeoplePersonIdRoute: typeof DashboardPeoplePersonIdRoute DashboardPeopleAdminsRoute: typeof DashboardPeopleAdminsRoute DashboardPeopleLinkingRoute: typeof DashboardPeopleLinkingRoute - DashboardPeopleMembersRoute: typeof DashboardPeopleMembersRoute DashboardPeopleReceiptsRoute: typeof DashboardPeopleReceiptsRoute DashboardReportsGroupLinksRoute: typeof DashboardReportsGroupLinksRoute DashboardReportsResolvedRoute: typeof DashboardReportsResolvedRoute @@ -1040,10 +1060,12 @@ interface DashboardRouteChildren { DashboardPeopleIndexRoute: typeof DashboardPeopleIndexRoute DashboardEsignRequestsRequestIdRoute: typeof DashboardEsignRequestsRequestIdRoute DashboardPeopleCensusCampaignIdRoute: typeof DashboardPeopleCensusCampaignIdRoute + DashboardPeopleSociNumberRoute: typeof DashboardPeopleSociNumberRoute DashboardPeopleTeamsTeamIdRoute: typeof DashboardPeopleTeamsTeamIdRoute DashboardTelegramUsersUserIdRoute: typeof DashboardTelegramUsersUserIdRoute DashboardEsignRequestsIndexRoute: typeof DashboardEsignRequestsIndexRoute DashboardPeopleCensusIndexRoute: typeof DashboardPeopleCensusIndexRoute + DashboardPeopleSociIndexRoute: typeof DashboardPeopleSociIndexRoute DashboardPeopleTeamsIndexRoute: typeof DashboardPeopleTeamsIndexRoute DashboardTelegramUsersIndexRoute: typeof DashboardTelegramUsersIndexRoute } @@ -1062,7 +1084,6 @@ const DashboardRouteChildren: DashboardRouteChildren = { DashboardPeoplePersonIdRoute: DashboardPeoplePersonIdRoute, DashboardPeopleAdminsRoute: DashboardPeopleAdminsRoute, DashboardPeopleLinkingRoute: DashboardPeopleLinkingRoute, - DashboardPeopleMembersRoute: DashboardPeopleMembersRoute, DashboardPeopleReceiptsRoute: DashboardPeopleReceiptsRoute, DashboardReportsGroupLinksRoute: DashboardReportsGroupLinksRoute, DashboardReportsResolvedRoute: DashboardReportsResolvedRoute, @@ -1074,10 +1095,12 @@ const DashboardRouteChildren: DashboardRouteChildren = { DashboardPeopleIndexRoute: DashboardPeopleIndexRoute, DashboardEsignRequestsRequestIdRoute: DashboardEsignRequestsRequestIdRoute, DashboardPeopleCensusCampaignIdRoute: DashboardPeopleCensusCampaignIdRoute, + DashboardPeopleSociNumberRoute: DashboardPeopleSociNumberRoute, DashboardPeopleTeamsTeamIdRoute: DashboardPeopleTeamsTeamIdRoute, DashboardTelegramUsersUserIdRoute: DashboardTelegramUsersUserIdRoute, DashboardEsignRequestsIndexRoute: DashboardEsignRequestsIndexRoute, DashboardPeopleCensusIndexRoute: DashboardPeopleCensusIndexRoute, + DashboardPeopleSociIndexRoute: DashboardPeopleSociIndexRoute, DashboardPeopleTeamsIndexRoute: DashboardPeopleTeamsIndexRoute, DashboardTelegramUsersIndexRoute: DashboardTelegramUsersIndexRoute, } diff --git a/src/routes/dashboard/azure/members.tsx b/src/routes/dashboard/azure/members.tsx index bbf2a3d3..6454a118 100644 --- a/src/routes/dashboard/azure/members.tsx +++ b/src/routes/dashboard/azure/members.tsx @@ -8,13 +8,9 @@ import { hasPermission } from "@/server/permissions" export const Route = createFileRoute("/dashboard/azure/members")({ beforeLoad: ({ context }) => { - if ( - !hasPermission(context.permissions, "azure:members:read") && - !hasPermission(context.permissions, "azure:members:create") - ) - throw redirect({ to: "/unauthorized" }) + if (!hasPermission(context.permissions, "azure:members:read")) throw redirect({ to: "/unauthorized" }) }, - loader: ({ context }) => (hasPermission(context.permissions, "azure:members:read") ? getAzureMembers() : null), + loader: () => getAzureMembers(), pendingComponent: () => ( <> diff --git a/src/routes/dashboard/people/members.tsx b/src/routes/dashboard/people/members.tsx deleted file mode 100644 index 00bb91ee..00000000 --- a/src/routes/dashboard/people/members.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router" - -import { MembersPage } from "@/features/people/members-page" - -// An unconnected mockup (Phase 2, #144): no loader, nothing is read from the backend. -export const Route = createFileRoute("/dashboard/people/members")({ - component: MembersPage, -}) diff --git a/src/routes/dashboard/people/soci/$number.tsx b/src/routes/dashboard/people/soci/$number.tsx new file mode 100644 index 00000000..7c2f1c7a --- /dev/null +++ b/src/routes/dashboard/people/soci/$number.tsx @@ -0,0 +1,44 @@ +import { createFileRoute, notFound } from "@tanstack/react-router" + +import { RecordSkeleton } from "@/components/primitives" +import { PageBar, PageContent } from "@/components/shell" +import { getPeople } from "@/features/people/people.functions" +import { getSociList, getSocio } from "@/features/soci/soci.functions" +import { SocioPage } from "@/features/soci/socio-page" +import { hasPermission } from "@/server/permissions" + +export const Route = createFileRoute("/dashboard/people/soci/$number")({ + // People records name who did what (the backend records account IDs), when the viewer may read them. + loader: async ({ params, context }) => { + if (!/^[1-9]\d{0,5}$/.test(params.number)) throw notFound() + const [socio, register, people] = await Promise.all([ + getSocio({ data: { number: Number(params.number) } }), + getSociList(), + hasPermission(context.permissions, "people:read") + ? getPeople().catch((error) => { + console.error(error) + return [] + }) + : [], + ]) + return { socio, register: register.members, people } + }, + pendingComponent: SocioPending, + component: SocioRoute, +}) + +function SocioPending() { + return ( + <> + + + + + + ) +} + +function SocioRoute() { + const { socio, register, people } = Route.useLoaderData() + return +} diff --git a/src/routes/dashboard/people/soci/index.tsx b/src/routes/dashboard/people/soci/index.tsx new file mode 100644 index 00000000..5f6f8f57 --- /dev/null +++ b/src/routes/dashboard/people/soci/index.tsx @@ -0,0 +1,24 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { TableSkeleton } from "@/components/primitives" +import { PageBar, PageContent } from "@/components/shell" +import { RegisterPage } from "@/features/soci/register-page" +import { getSociRegister } from "@/features/soci/soci.functions" + +export const Route = createFileRoute("/dashboard/people/soci/")({ + loader: () => getSociRegister(), + pendingComponent: () => ( + <> + + + + + + ), + component: RegisterRoute, +}) + +function RegisterRoute() { + const { register, unregistered } = Route.useLoaderData() + return +} diff --git a/src/server/auth.middleware.ts b/src/server/auth.middleware.ts index b9aa26a5..4ab7adfd 100644 --- a/src/server/auth.middleware.ts +++ b/src/server/auth.middleware.ts @@ -157,6 +157,25 @@ export const peopleLinkMiddleware = createMiddleware({ type: "function" }) return next() }) +/** People › Soci: the Libro Soci, its bookkeeping data and the Soci group's accounts without a record. */ +export const sociReadMiddleware = createMiddleware({ type: "function" }) + .middleware([adminMiddleware]) + .server(({ next, context }) => { + requirePermission(context.permissions, "soci:read") + return next() + }) + +/** + * People › Soci: admit a socio (a new Microsoft 365 account in Soci, then the record), register an account already in + * Soci, edit the bookkeeping data and change a member number. + */ +export const sociWriteMiddleware = createMiddleware({ type: "function" }) + .middleware([adminMiddleware]) + .server(({ next, context }) => { + requirePermission(context.permissions, "soci:write") + return next() + }) + /** Telegram grants: create and interrupt. */ export const grantsWriteMiddleware = createMiddleware({ type: "function" }) .middleware([adminMiddleware]) @@ -213,22 +232,6 @@ export const azureMembersReadMiddleware = createMiddleware({ type: "function" }) return next() }) -/** Microsoft 365: create a new member through the backend's fixed workflow. */ -export const azureMembersCreateMiddleware = createMiddleware({ type: "function" }) - .middleware([adminMiddleware]) - .server(({ next, context }) => { - requirePermission(context.permissions, "azure:members:create") - return next() - }) - -/** Microsoft 365: change the member number of an existing user, optionally emailing them. */ -export const azureMembersWriteMiddleware = createMiddleware({ type: "function" }) - .middleware([adminMiddleware]) - .server(({ next, context }) => { - requirePermission(context.permissions, "azure:members:write") - return next() - }) - /** Microsoft 365: list the groups and their members. */ export const azureGroupsReadMiddleware = createMiddleware({ type: "function" }) .middleware([adminMiddleware]) diff --git a/src/server/permissions.ts b/src/server/permissions.ts index 855807e5..7d19004d 100644 --- a/src/server/permissions.ts +++ b/src/server/permissions.ts @@ -15,14 +15,14 @@ export const PERMISSIONS = [ "web:content:write", "web:reports:manage", "azure:members:read", - "azure:members:create", - "azure:members:write", "azure:groups:read", "people:self", "people:read", "people:write", "people:appoint", "people:link", + "soci:read", + "soci:write", "esign:sign", "esign:requests:manage", "esign:drives:manage", diff --git a/tests/azure-member-number.test.mjs b/tests/azure-member-number.test.mjs deleted file mode 100644 index 7e2ca8fd..00000000 --- a/tests/azure-member-number.test.mjs +++ /dev/null @@ -1,44 +0,0 @@ -import assert from "node:assert/strict" -import { test } from "node:test" - -import { azureEmailSchema } from "../src/features/azure/email.ts" -import { isMemberNumber, reconcileMemberNumbers } from "../src/features/azure/member-number.ts" - -void test("a stale directory refresh keeps the saved member number", () => { - const saved = new Map([["alan", "2042"]]) - const stale = [{ id: "alan", employeeId: "1002" }] - - assert.equal(reconcileMemberNumbers(saved, stale).get("alan"), "2042") - assert.equal(reconcileMemberNumbers(saved, null).get("alan"), "2042") -}) - -void test("a confirmed member number stops masking subsequent directory changes", () => { - const saved = new Map([ - ["alan", "2042"], - ["grace", "2043"], - ]) - const refreshed = [ - { id: "alan", employeeId: "2042" }, - { id: "grace", employeeId: null }, - ] - - const remaining = reconcileMemberNumbers(saved, refreshed) - assert.equal(remaining.has("alan"), false) - assert.equal(remaining.get("grace"), "2043") - assert.equal(saved.get("alan"), "2042") - const externalEdit = [{ id: "alan", employeeId: "2044" }] - assert.equal(remaining.get("alan") ?? externalEdit[0].employeeId, "2044") -}) - -void test("member numbers must be positive integers the API can represent exactly", () => { - for (const valid of ["1", "2042", "9007199254740991"]) assert.equal(isMemberNumber(valid), true) - for (const invalid of ["", "0", "01", "-1", "1.2", "9007199254740992", "999999999999999999999"]) - assert.equal(isMemberNumber(invalid), false) -}) - -void test("member notification email validation matches the endpoint Zod schema", () => { - for (const valid of ["a@x.com", "first.last@example.org", "user+tag@sub.domain.net"]) - assert.equal(azureEmailSchema.safeParse(valid).success, true, valid) - for (const invalid of ["a..b@x.com", "a@x..com"]) - assert.equal(azureEmailSchema.safeParse(invalid).success, false, invalid) -}) diff --git a/tests/people-navigation.test.mjs b/tests/people-navigation.test.mjs index ba0c2448..2bbb8452 100644 --- a/tests/people-navigation.test.mjs +++ b/tests/people-navigation.test.mjs @@ -30,7 +30,6 @@ void test("the People mockups and the remaining pages open with people:read", as "/dashboard/people/admins", "/dashboard/people/linking", // Unconnected mockups (Phase 2 and 3): gated by people:read, not by the deleted member and fee permissions. - "/dashboard/people/members", "/dashboard/people/receipts", "/dashboard/people/census", "/dashboard/people/census/mock-2026", @@ -44,6 +43,42 @@ void test("the People mockups and the remaining pages open with people:read", as assert.equal(canOpenPage(matchPath("/dashboard/account/me"), ["people:self"], roles), true) }) +void test("People's Soci pages open with soci:read, under the Soci group", async () => { + const { canOpenPage, isDeepPage, matchPath, sectionFor, serviceById, serviceFor } = await nav() + for (const path of ["/dashboard/people/soci", "/dashboard/people/soci/344"]) { + const match = matchPath(path) + assert.equal(serviceFor(match).id, "people", path) + assert.equal(sectionFor(match).id, "soci", path) + assert.equal(canOpenPage(match, ["admin:access", "soci:read"], roles), true, path) + assert.equal(canOpenPage(match, ["admin:access", "people:read"], roles), false, path) + assert.equal(canOpenPage(match, ["soci:read"], roles), false, path) + } + const socio = matchPath("/dashboard/people/soci/344") + assert.equal(socio.kind, "people-socio") + assert.equal(socio.number, 344) + assert.equal(isDeepPage(matchPath("/dashboard/people/soci/344")), true) + assert.equal(matchPath("/dashboard/people/soci/0344").kind, "not-found") + assert.equal(matchPath("/dashboard/people/members").kind, "not-found") + // Groups are contiguous: the panel draws each title once, above its first section. + const groups = serviceById("people").sections.map((section) => section.group) + assert.equal([...new Set(groups)].join(), "Volunteers,Soci") + assert.equal(groups.join(), groups.toSorted((a, b) => groups.indexOf(a) - groups.indexOf(b)).join()) + assert.equal( + serviceById("people") + .sections.filter((section) => section.group === "Volunteers") + .map((section) => section.id) + .join(), + "overview,admins,teams,census,linking" + ) +}) + +void test("Microsoft 365 › Members needs azure:members:read alone", async () => { + const { canOpenPage, matchPath } = await nav() + const members = matchPath("/dashboard/azure/members") + assert.equal(canOpenPage(members, ["admin:access", "azure:members:read"], roles), true) + assert.equal(canOpenPage(members, ["admin:access", "azure:members:create"], roles), false) +}) + void test("eSign keeps its routes, permission gates and counts", async () => { const { canOpenPage, isRailService, matchPath, sectionPending, serviceById, serviceFor } = await nav() for (const path of ["/dashboard/esign/inbox", "/dashboard/esign/sign"]) { diff --git a/tests/server-security.test.mjs b/tests/server-security.test.mjs index 71ba3846..e3defe11 100644 --- a/tests/server-security.test.mjs +++ b/tests/server-security.test.mjs @@ -225,6 +225,8 @@ test("dashboard server functions attach their scoped authorization middleware", "src/features/people/people-invites.functions.ts", "src/features/people/people-link.functions.ts", "src/features/people/people-invite-landing.functions.ts", + "src/features/soci/soci.functions.ts", + "src/features/soci/soci-write.functions.ts", ] for (const file of adminFunctionFiles) { @@ -248,8 +250,6 @@ test("dashboard mutations enforce their exact write scope", async () => { // Mutations that join two domains list every middleware they need. const combinedScopes = {} const entries = [ - ["azureMembersCreateMiddleware", ["src/features/azure/azure.functions.ts#createAzureMember"]], - ["azureMembersWriteMiddleware", ["src/features/azure/azure.functions.ts#setAzureMemberNumber"]], ["esignSignWriteMiddleware", ["src/features/esign/esign.functions.ts"]], ["esignRequestsManageMiddleware", ["src/features/esign/requests.functions.ts"]], ["esignDrivesManageMiddleware", ["src/features/esign/drives.functions.ts"]], @@ -276,6 +276,7 @@ test("dashboard mutations enforce their exact write scope", async () => { ], ["peopleLinkMiddleware", ["src/features/people/people-link.functions.ts"]], ["peopleInviteMiddleware", ["src/features/people/people-invite-landing.functions.ts"]], + ["sociWriteMiddleware", ["src/features/soci/soci-write.functions.ts"]], ].map(([expectedMiddleware, files]) => ({ expectedMiddleware, files })) // "file#name" scopes an entry to one mutation, for files whose mutations need different permissions. @@ -307,7 +308,7 @@ test("dashboard mutations enforce their exact write scope", async () => { test("dashboard mutation controls use their server's write scope", async () => { const scopes = { - "src/features/azure/members-page.tsx": ["azure:members:create", "azure:members:write"], + "src/features/azure/members-page.tsx": "soci:read", "src/features/esign/requests-page.tsx": "esign:requests:manage", "src/features/esign/request-page.tsx": "esign:requests:manage", "src/features/esign/drives-page.tsx": "esign:drives:manage", @@ -335,6 +336,8 @@ test("dashboard mutation controls use their server's write scope", async () => { "src/features/people/teams-page.tsx": ["people:read", "people:write", "people:appoint"], "src/features/people/linking-page.tsx": ["people:write", "people:link"], "src/features/people/my-area-page.tsx": "people:read", + "src/features/soci/register-page.tsx": "soci:write", + "src/features/soci/socio-page.tsx": "soci:write", } const directory = new URL("../src", import.meta.url).pathname const checked = new Set() @@ -373,8 +376,7 @@ test("event handlers integrate protected server-function redirects with the rout "src/components/telegram/create-grant-dialog.tsx": ["createTelegramGrant", "findTelegramUser"], "src/features/associations/associations-page.tsx": ["createAssociation", "editAssociation", "deleteAssociation"], "src/features/associations/association-links-dialog.tsx": ["editAssociationLinks"], - "src/features/azure/member-dialog.tsx": ["createAzureMember"], - "src/features/azure/member-number-dialog.tsx": ["setAzureMemberNumber"], + "src/features/soci/socio-dialogs.tsx": ["admitSocio", "registerSocio", "updateSocio", "setSocioNumber"], "src/features/people/person-dialog.tsx": ["createPerson", "updatePerson"], "src/features/people/notes-dialog.tsx": ["setPersonNote"], "src/features/people/account-action-dialog.tsx": ["sendPersonInvite", "revokePersonInvite"], @@ -666,7 +668,7 @@ test("every caught runtime error is written to the console", async () => { assert.deepEqual(failures, [], `Caught errors must be logged at: ${failures.join(", ")}`) }) -void test("the Azure directory read has its own permission boundary", async () => { +void test("the Azure directory is read-only, behind its own permission", async () => { const source = await readFile(new URL("../src/features/azure/azure.functions.ts", import.meta.url), "utf8") const read = exportedServerFunctions(source, "azure.functions.ts").find((fn) => fn.name === "getAzureMembers") assert.ok(read) @@ -675,8 +677,14 @@ void test("the Azure directory read has its own permission boundary", async () = const middleware = await readFile(new URL("../src/server/auth.middleware.ts", import.meta.url), "utf8") const readMiddleware = middleware.slice( middleware.indexOf("export const azureMembersReadMiddleware"), - middleware.indexOf("export const azureMembersCreateMiddleware") + middleware.indexOf("export const azureGroupsReadMiddleware") ) assert.match(readMiddleware, /middleware\(\[adminMiddleware\]\)/) assert.match(readMiddleware, /requirePermission\(context.permissions, "azure:members:read"\)/) + // Admitting and renumbering soci moved to People › Soci: nothing in the dashboard writes to the directory. + assert.deepEqual( + exportedServerFunctions(source, "azure.functions.ts").filter((fn) => fn.isPost), + [] + ) + assert.doesNotMatch(middleware, /azureMembers(Create|Write)Middleware/) }) diff --git a/tests/soci.test.mjs b/tests/soci.test.mjs new file mode 100644 index 00000000..6d6fb03d --- /dev/null +++ b/tests/soci.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict" +import { test } from "node:test" + +import { checkCharacter, codiceFiscaleProblem, matchesBirthDate } from "../src/features/soci/codice-fiscale.ts" +import { isMemberNumber } from "../src/features/soci/member-number.ts" +import { + admitInputSchema, + codiceFiscaleSchema, + registerInputSchema, + updateInputSchema, +} from "../src/features/soci/soci.contract.ts" + +const details = { + firstName: "Mario", + lastName: "Rossi", + alias: "", + codiceFiscale: "rssmra80a01h501u", + birthDate: "1980-01-01", + birthPlace: "Roma", + residence: { street: "Via Roma 1", postalCode: "20133", city: "Milano", province: "mi", country: "it" }, +} +const admission = { + account: { firstName: "Mario", lastName: "Rossi", mailbox: { kind: "standard" }, sendEmailTo: "mario@example.org" }, + number: 344, + details, + admittedOn: "2026-11-09", +} + +void test("member numbers are positive integers up to the backend's six digits", () => { + for (const valid of ["1", "344", "999999"]) assert.equal(isMemberNumber(valid), true, valid) + for (const invalid of ["", "0", "0344", "-1", "1.2", "1000000"]) assert.equal(isMemberNumber(invalid), false, invalid) +}) + +void test("the codice fiscale is checked like the backend: format, omocodia, check character, birth date", () => { + assert.equal(codiceFiscaleProblem("RSSMRA80A01H501U"), null) + const omocodic = `RSSMRA80A01H50M${checkCharacter("RSSMRA80A01H50M")}` + assert.equal(codiceFiscaleProblem(omocodic), null) + assert.equal(matchesBirthDate(omocodic, "1980-01-01"), true) + assert.equal(codiceFiscaleProblem("RSSMRA80A01H501X"), "check") + assert.equal(codiceFiscaleProblem("RSSMRA80A01H501"), "format") + assert.equal(codiceFiscaleSchema.parse(" rssmra80a01 h501u "), "RSSMRA80A01H501U") +}) + +void test("admitting needs every detail but the alias, normalized as the backend stores it", () => { + const parsed = admitInputSchema.parse(admission) + assert.equal(parsed.details.codiceFiscale, "RSSMRA80A01H501U") + assert.deepEqual(parsed.details.residence, { + street: "Via Roma 1", + postalCode: "20133", + city: "Milano", + province: "MI", + country: "IT", + }) + assert.equal(parsed.details.alias, null) + for (const missing of ["birthPlace", "codiceFiscale", "birthDate"]) + assert.equal(admitInputSchema.safeParse({ ...admission, details: { ...details, [missing]: "" } }).success, false) + const noProvince = { ...details, residence: { ...details.residence, province: "" } } + assert.equal(admitInputSchema.safeParse({ ...admission, details: noProvince }).success, false) + const wrongDate = { ...details, birthDate: "1980-01-02" } + assert.equal(admitInputSchema.safeParse({ ...admission, details: wrongDate }).success, false) +}) + +void test("registering and editing need only the legal name", () => { + const minimal = { firstName: "Alan", lastName: "Turing" } + const registered = registerInputSchema.parse({ + entraObjectId: "acc-alan", + details: minimal, + admittedOn: "2025-03-14", + }) + assert.equal(registered.details.codiceFiscale, null) + assert.equal(registered.details.residence.street, null) + const empty = { + ...minimal, + codiceFiscale: "", + birthDate: "", + birthPlace: "", + residence: { street: "", postalCode: "", city: "", province: "", country: "" }, + } + assert.equal(updateInputSchema.safeParse({ number: 41, details: empty, admittedOn: "2025-10-13" }).success, true) + assert.equal( + updateInputSchema.safeParse({ number: 41, details: { lastName: "Gallo" }, admittedOn: "2025-10-13" }).success, + false + ) +}) From ba8c43dde02b25823aaf76dbd54a71ddcc4a95eb Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Tue, 10 Nov 2026 12:20:58 +0100 Subject: [PATCH 2/3] fix(soci): stop Admit on a bad member number, show account-name errors, and guard Register's edits - Admit no longer submits when the member number isn't a plain positive integer or is already taken (`34a` used to admit 34). - Account first/last name errors from the contract show on those fields instead of being dropped. - Register existing socio asks before discarding edits, and only then. Its admission date is the account's creation day in Rome, matching the hint, and the country starts empty. - The number-mismatch alert no longer suggests Change number can restore the record's number. - The socio page loads the register only for viewers with `soci:write`, who use it in Edit. - Docs: Soci in AGENTS.md's middleware list and the permission matrix; the Member register mockup removed from design.md, permissions.md and fees.md. --- AGENTS.md | 2 +- docs/design.md | 15 +++++----- docs/people/fees.md | 4 +-- docs/people/permissions.md | 20 ++++++++------ src/features/people/people-format.ts | 7 ++++- src/features/soci/soci.functions.ts | 2 +- src/features/soci/socio-dialogs.tsx | 29 ++++++++++++++++---- src/features/soci/socio-form.tsx | 13 +++++++-- src/features/soci/socio-page.tsx | 3 +- src/routes/dashboard/people/soci/$number.tsx | 7 +++-- tests/soci.test.mjs | 10 +++++++ 11 files changed, 79 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c5e721a3..79959e6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ What the app is and how to run it: [`README.md`](README.md). UI rules: [`docs/de `src/start.ts` validates the configured Origin on all state-changing requests. There is no Better Auth proxy. - **Server functions** attach `adminMiddleware` for reads, and their exact permission middleware for mutations: grants, Telegram groups, WhatsApp groups, labels, website content, reports, People (write, appoint, link, team), and - Microsoft 365 member creation and member-number changes. + Soci (`soci:write`: admit, register, edit, change number). Soci reads use `sociReadMiddleware` (`soci:read`). The backend independently checks every procedure. `context.backend` forwards a bearer token, never cookies. - **Features** (`src/features//`): pages, dialogs, validation and `*.functions.ts` server functions. - **Microsoft 365**: read the member directory with `azure:members:read`, and list groups and their members with diff --git a/docs/design.md b/docs/design.md index 076a2a40..77b4ae57 100644 --- a/docs/design.md +++ b/docs/design.md @@ -93,8 +93,8 @@ the Account page. **Panel** (240px, 200px below 1100px). Rendered only for services with two or more sections (Telegram, Microsoft 365, Web, Reports); WhatsApp, Overview and Account have none. A service may group its sections (`group` on `Section`): the panel draws each group's title, 11px uppercase and subtle, above its first visible section, and the mobile pills only -keep the order. People groups Volunteers (Overview, Admins, Teams, Census, Linking) and Soci (Register, then Fees and -Deletion pending). A 64px header with the service icon and title, then 38px items: +keep the order. People groups Volunteers (Overview, Admins, Teams, Census, Linking) and Soci (Register, and the Receipts +mockup until Fees replaces it; Deletion pending). A 64px header with the service icon and title, then 38px items: the current one has a muted fill, an accent icon and a 3px accent bar; on deep pages the parent section keeps the fill and accent icon without the bar (`data-ancestor`). The only counts are the open reports next to Reports › Open and the documents to sign next to Signatures › To sign, each as a small accent pill. @@ -590,14 +590,15 @@ Rules that the templates above don't imply. request.". - **Account**: permissions are grouped by area (Dashboard, Telegram, WhatsApp, group labels, Microsoft 365, Web) with the service icon. -- **People**: one record per person whether or not they have an account; Overview, Member register, Admins, Teams, - Census and Linking all open the same record (`/dashboard/people/$personId`). Account status always shows a badge, +- **People**: one record per person whether or not they have an account; Overview, Admins, Teams, Census and + Linking all open the same record (`/dashboard/people/$personId`). Account status always shows a badge, Not linked included. Mobile list cards replace unlabelled column values with one line of role chips or status badges in the name cell. Dialogs that need fields and are destructive (End admin role, End membership, I'm leaving) use `FormDialog` with `tone="danger"`; plain confirmations stay `ConfirmDialog`. -- **Member register**: status segments scope the table; Number leads on desktop, the name on mobile. Add member from a - record opens the register's dialog via `?add=P-…`. Export confirms before downloading. Without `people:read`, - register/receipt rows have no full-record links; the register loads its own restricted projection. +- **Soci › Register** (`docs/people/soci.md`): the Libro Soci, one record per admission. Status segments (Active / + Ended / All) scope the table; Number leads on desktop, the name on mobile. Alerts above the table list the Soci + group's accounts without a record (with Register) and each kind of mismatch. Rows open the socio page + (`/dashboard/people/soci/$number`), a deep page with Edit and Change number. - **Fees and receipts** (`docs/people/fees.md`): the register's first row action follows the fee (Review receipt, Send reminder, or an empty slot); its header keeps one outline action (Send reminders), with Export register as a boxed icon. People › Receipts is a queue with the pipeline as segments and the officer's to-sign count in the panel. diff --git a/docs/people/fees.md b/docs/people/fees.md index 3f5ac634..8acd9917 100644 --- a/docs/people/fees.md +++ b/docs/people/fees.md @@ -1,8 +1,8 @@ # People area: fees and receipts > [!IMPORTANT] -> **This describes an unconnected mockup.** Since #138, People › Member register, People › Receipts and My area's -> fee card render static sample data, make no backend call and disable every action. The procedures, permissions +> **This describes an unconnected mockup.** Since #138, People › Receipts and My area's fee card render static +> sample data (the Member register mockup was replaced by Soci › Register in #155), make no backend call and disable every action. The procedures, permissions > (`people:fees:write`, `people:receipts:sign`) and the eSign receipt bridge below are removed. Soci, fees and receipts > will be rebuilt from scratch on Microsoft 365 members in Phase 2 (#144); this file stays as the design reference. > diff --git a/docs/people/permissions.md b/docs/people/permissions.md index ce50d0a9..9786c1e8 100644 --- a/docs/people/permissions.md +++ b/docs/people/permissions.md @@ -5,15 +5,17 @@ Only My area, an active Capo Admin's scope and a Team Lead's own teams work with count only once PoliNetwork Auth holds them (the record is linked and the role applied). “Team member” means a volunteer in a team. `people:members:read`, `people:members:write`, `people:fees:write` and `people:receipts:sign` were removed in #138. -| Viewer / extra permissions | Pages | Actions | -| ---------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| Volunteer: `people:self`, P-0021 | My area, Sign-in & security | Read own record and roles | -| Team Lead: `people:self`, P-0030 | Above + own team(s) under People | Add team members, edit team fields, end ordinary team memberships; never appoint or end a Lead without `people:appoint` + admin access | -| Capo Admin: `people:self`, P-0003 | My area + Telegram › Capo Admin | Read-only list of the admins who have them as Capo Admin of reference | -| `people:read` | People overview, person records, Admins, Teams, Linking | Read contacts and notes; no editing, linking or appointments | -| `people:link` | Link account and Unlink on records and Overview | Search the IdP's accounts, link one by hand, unlink (Auth revokes the roles first) | -| `people:read` (mockups) | Member register, Receipts, Census; the fee card in My area | None: unconnected mockups with sample data (#138), to be rebuilt in Phase 2 (#144) and Phase 3 (#145) | -| Master Admin: all explicit permissions, P-0007 | All permitted sections | All People mutations | +| Viewer / extra permissions | Pages | Actions | +| ---------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Volunteer: `people:self`, P-0021 | My area, Sign-in & security | Read own record and roles | +| Team Lead: `people:self`, P-0030 | Above + own team(s) under People | Add team members, edit team fields, end ordinary team memberships; never appoint or end a Lead without `people:appoint` + admin access | +| Capo Admin: `people:self`, P-0003 | My area + Telegram › Capo Admin | Read-only list of the admins who have them as Capo Admin of reference | +| `people:read` | People overview, person records, Admins, Teams, Linking | Read contacts and notes; no editing, linking or appointments | +| `people:link` | Link account and Unlink on records and Overview | Search the IdP's accounts, link one by hand, unlink (Auth revokes the roles first) | +| `soci:read` | Soci › Register, socio pages | Read the Libro Soci, its activity and the Soci group's accounts without a record | +| `soci:write` | Same | Admit socio, Register existing socio, Edit, Change number | +| `people:read` (mockups) | Receipts, Census; the fee card in My area | None: unconnected mockups with sample data (#138), to be rebuilt in Phase 2 (#144) and Phase 3 (#145) | +| Master Admin: all explicit permissions, P-0007 | All permitted sections | All People mutations | `people:write` enables record, admin-role, team and invite mutations on readable pages; `people:appoint` enables Capo/Lead appointments and ending those roles; `people:link` enables linking by hand and diff --git a/src/features/people/people-format.ts b/src/features/people/people-format.ts index fa906507..cd7c8571 100644 --- a/src/features/people/people-format.ts +++ b/src/features/people/people-format.ts @@ -128,7 +128,12 @@ export function formatDay(value: string) { * than this): date inputs and the roles Add person starts with use it. */ export function todayIso() { - return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(new Date()) + return romeDay(new Date()) +} + +/** The Europe/Rome calendar day of an instant, as an ISO date: `2025-08-31T23:30Z` is `2025-09-01`. */ +export function romeDay(instant: Date | string) { + return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(new Date(instant)) } export function isExpired(date: string) { diff --git a/src/features/soci/soci.functions.ts b/src/features/soci/soci.functions.ts index 552ae3f0..d95df39d 100644 --- a/src/features/soci/soci.functions.ts +++ b/src/features/soci/soci.functions.ts @@ -26,7 +26,7 @@ export const getSocio = createServerFn({ method: "GET" }) .validator(numberInputSchema) .handler(async ({ data, context }) => (await sociApi(context)).get(data)) -/** The register alone, without reading the Soci group: the socio page checks codici fiscali against it. */ +/** The register without the unregistered accounts: Edit on the socio page checks codici fiscali against it. */ export const getSociList = createServerFn({ method: "GET" }) .middleware([sociReadMiddleware]) .handler(async ({ context }) => (await sociApi(context)).list()) diff --git a/src/features/soci/socio-dialogs.tsx b/src/features/soci/socio-dialogs.tsx index 87079865..aa0c14b2 100644 --- a/src/features/soci/socio-dialogs.tsx +++ b/src/features/soci/socio-dialogs.tsx @@ -17,7 +17,7 @@ import { } from "@/components/primitives" import { Checkbox } from "@/components/ui/checkbox" import { Input } from "@/components/ui/input" -import { formatDay, todayIso } from "@/features/people/people-format" +import { formatDay, romeDay, todayIso } from "@/features/people/people-format" import { errorMessage, useRefreshAfter } from "@/features/people/people-hooks" import { cn } from "@/lib/utils" @@ -188,6 +188,7 @@ export function AdmitSocioDialog({ open, onOpenChange, register }: DialogProps & setSubmitted(true) const parsed = admitInputSchema.safeParse(input()) setErrors(fieldErrors(parsed.error)) + if (!isMemberNumber(number) || takenNumber) return if (!parsed.success || localPart === null || sameAddress || detailsBlocked(details, register)) return // The welcome email carries the new account's credentials, so it normally goes to a personal address. if (isPoliNetworkAddress(recipient)) { @@ -212,20 +213,29 @@ export function AdmitSocioDialog({ open, onOpenChange, register }: DialogProps & >
- + setAccountName({ firstName: event.target.value })} className={cn("h-9", fieldControl)} /> - + setAccountName({ lastName: event.target.value })} className={cn("h-9", fieldControl)} /> @@ -376,11 +386,13 @@ export function RegisterSocioDialog({ const [errors, setErrors] = useState({}) const fixedNumber = account?.employeeId && isMemberNumber(account.employeeId) ? account.employeeId : null + // Nothing but the name is known yet, so the country starts empty too. function prefill(next: Unregistered | null): DetailsDraft { - return { ...EMPTY_DETAILS, firstName: next?.givenName ?? "", lastName: next?.surname ?? "" } + return { ...EMPTY_DETAILS, country: "", firstName: next?.givenName ?? "", lastName: next?.surname ?? "" } } + // The day in Rome, as the hint shows it: the fee year turns on that calendar. function suggestedDate(next: Unregistered | null) { - return next?.createdDateTime ? next.createdDateTime.slice(0, 10) : todayIso() + return next?.createdDateTime ? romeDay(next.createdDateTime) : todayIso() } function choose(id: string) { const next = accounts.find((candidate) => candidate.entraObjectId === id) ?? null @@ -426,7 +438,12 @@ export function RegisterSocioDialog({ title="Register existing socio" description="For an account already in Soci without a record. Fields left empty can be completed later." noun="registration" - dirty={account !== null && accounts.length > 1} + dirty={ + account !== null && + (JSON.stringify(details) !== JSON.stringify(prefill(account)) || + admittedOn !== suggestedDate(account) || + number !== "") + } canSubmit={account !== null} submitLabel="Register socio" onSubmit={submit} diff --git a/src/features/soci/socio-form.tsx b/src/features/soci/socio-form.tsx index 2d29c8b5..9226982a 100644 --- a/src/features/soci/socio-form.tsx +++ b/src/features/soci/socio-form.tsx @@ -89,15 +89,24 @@ const FIELD_KEYS = [ "account", "number", "admittedOn", + "accountFirstName", + "accountLastName", ] as const type FieldKey = (typeof FIELD_KEYS)[number] export type FieldErrors = { [Key in FieldKey]?: string } -/** The first message per field from a failed parse of a contract input; `details.residence.city` becomes `city`. */ +/** + * The first message per field from a failed parse of a contract input: `details.residence.city` becomes `city`, and + * `account.firstName` becomes `accountFirstName`, apart from the legal name. + */ export function fieldErrors(error: z.ZodError | undefined) { const errors: FieldErrors = {} for (const issue of error?.issues ?? []) { - const name = issue.path.find((part) => part !== "details" && part !== "residence") + const [head, next] = issue.path + const name = + head === "account" && (next === "firstName" || next === "lastName") + ? `account${next === "firstName" ? "FirstName" : "LastName"}` + : issue.path.find((part) => part !== "details" && part !== "residence") const key = FIELD_KEYS.find((candidate) => candidate === name) if (key && !errors[key]) errors[key] = issue.message.startsWith("Invalid input") ? "Fill this in." : issue.message } diff --git a/src/features/soci/socio-page.tsx b/src/features/soci/socio-page.tsx index 4b3af0ec..d424461a 100644 --- a/src/features/soci/socio-page.tsx +++ b/src/features/soci/socio-page.tsx @@ -135,7 +135,8 @@ export function SocioPage({ ) : null} {numberDiffers ? ( - Microsoft 365 has number {socio.accountNumber} on the account. Change number sets both again. + Microsoft 365 has number {socio.accountNumber} on the account, not {formatMemberNumber(socio.number)}. + Correct it in Microsoft 365, or use Change number to give both a new number. ) : null} {missing.length && !socio.endedOn ? ( diff --git a/src/routes/dashboard/people/soci/$number.tsx b/src/routes/dashboard/people/soci/$number.tsx index 7c2f1c7a..3a0ea7d1 100644 --- a/src/routes/dashboard/people/soci/$number.tsx +++ b/src/routes/dashboard/people/soci/$number.tsx @@ -8,12 +8,13 @@ import { SocioPage } from "@/features/soci/socio-page" import { hasPermission } from "@/server/permissions" export const Route = createFileRoute("/dashboard/people/soci/$number")({ - // People records name who did what (the backend records account IDs), when the viewer may read them. + // People records name who did what (the backend records account IDs), when the viewer may read them. The register, + // which Edit checks codici fiscali against, is loaded only for those who may edit. loader: async ({ params, context }) => { if (!/^[1-9]\d{0,5}$/.test(params.number)) throw notFound() const [socio, register, people] = await Promise.all([ getSocio({ data: { number: Number(params.number) } }), - getSociList(), + hasPermission(context.permissions, "soci:write") ? getSociList().then((list) => list.members) : [], hasPermission(context.permissions, "people:read") ? getPeople().catch((error) => { console.error(error) @@ -21,7 +22,7 @@ export const Route = createFileRoute("/dashboard/people/soci/$number")({ }) : [], ]) - return { socio, register: register.members, people } + return { socio, register, people } }, pendingComponent: SocioPending, component: SocioRoute, diff --git a/tests/soci.test.mjs b/tests/soci.test.mjs index 6d6fb03d..e50a6c29 100644 --- a/tests/soci.test.mjs +++ b/tests/soci.test.mjs @@ -82,3 +82,13 @@ void test("registering and editing need only the legal name", () => { false ) }) + +void test("an account name the backend refuses is reported on the account's field, not the legal name", () => { + const parsed = admitInputSchema.safeParse({ ...admission, account: { ...admission.account, lastName: "O’Brien" } }) + assert.equal(parsed.success, false) + // `fieldErrors` in socio-form.tsx shows this path on the account's Last name field. + assert.deepEqual( + parsed.error.issues.map((issue) => issue.path.join(".")), + ["account.lastName"] + ) +}) From 2782bcd19fdb9ccba98a8ce6a81aee2f2a7cbfaa Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Tue, 10 Nov 2026 12:39:29 +0100 Subject: [PATCH 3/3] fix(soci): show the contract's welcome-email rejection on Admit's email field An address like `a@b.c` passed the dialog's pattern but not `z.email()`, so Admit did nothing with no message. `fieldErrors` now maps `account.sendEmailTo` to `accountEmail`, and the field shows "Enter a valid email address." --- src/features/soci/socio-dialogs.tsx | 3 ++- src/features/soci/socio-form.tsx | 15 +++++++++++---- tests/soci.test.mjs | 10 ++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/features/soci/socio-dialogs.tsx b/src/features/soci/socio-dialogs.tsx index aa0c14b2..55631402 100644 --- a/src/features/soci/socio-dialogs.tsx +++ b/src/features/soci/socio-dialogs.tsx @@ -125,8 +125,9 @@ export function AdmitSocioDialog({ open, onOpenChange, register }: DialogProps & : submitted && localPart === null ? "Use lowercase letters and digits, with single dots or hyphens between them." : undefined + // `errors.accountEmail`: the contract's stricter check, e.g. `a@b.c` has no real top-level domain. const emailError = submitted - ? !EMAIL_PATTERN.test(recipient) + ? !EMAIL_PATTERN.test(recipient) || errors.accountEmail ? "Enter a valid email address." : sameAddress ? "This is the address being created and has no mailbox yet. Use the person's personal email." diff --git a/src/features/soci/socio-form.tsx b/src/features/soci/socio-form.tsx index 9226982a..86d3a244 100644 --- a/src/features/soci/socio-form.tsx +++ b/src/features/soci/socio-form.tsx @@ -91,22 +91,29 @@ const FIELD_KEYS = [ "admittedOn", "accountFirstName", "accountLastName", + "accountEmail", ] as const type FieldKey = (typeof FIELD_KEYS)[number] export type FieldErrors = { [Key in FieldKey]?: string } /** * The first message per field from a failed parse of a contract input: `details.residence.city` becomes `city`, and - * `account.firstName` becomes `accountFirstName`, apart from the legal name. + * `account.firstName` becomes `accountFirstName` (and `account.sendEmailTo` `accountEmail`), apart from the legal name. */ export function fieldErrors(error: z.ZodError | undefined) { const errors: FieldErrors = {} for (const issue of error?.issues ?? []) { const [head, next] = issue.path const name = - head === "account" && (next === "firstName" || next === "lastName") - ? `account${next === "firstName" ? "FirstName" : "LastName"}` - : issue.path.find((part) => part !== "details" && part !== "residence") + head !== "account" + ? issue.path.find((part) => part !== "details" && part !== "residence") + : next === "firstName" + ? "accountFirstName" + : next === "lastName" + ? "accountLastName" + : next === "sendEmailTo" + ? "accountEmail" + : "account" const key = FIELD_KEYS.find((candidate) => candidate === name) if (key && !errors[key]) errors[key] = issue.message.startsWith("Invalid input") ? "Fill this in." : issue.message } diff --git a/tests/soci.test.mjs b/tests/soci.test.mjs index e50a6c29..fc41d5e6 100644 --- a/tests/soci.test.mjs +++ b/tests/soci.test.mjs @@ -92,3 +92,13 @@ void test("an account name the backend refuses is reported on the account's fiel ["account.lastName"] ) }) + +void test("an email the contract refuses is reported on the welcome email field", () => { + const parsed = admitInputSchema.safeParse({ ...admission, account: { ...admission.account, sendEmailTo: "a@b.c" } }) + assert.equal(parsed.success, false) + // `fieldErrors` maps this path to `accountEmail`, which the Admit dialog shows on the email field. + assert.deepEqual( + parsed.error.issues.map((issue) => issue.path.join(".")), + ["account.sendEmailTo"] + ) +})