diff --git a/AGENTS.md b/AGENTS.md
index 58f329bc..79959e6a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,17 +11,13 @@ 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 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..77b4ae57 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, 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.
@@ -587,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/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 (
-
-
- {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}
/>
)
}
/>
- )}
+
+ {addressError ??
+ (mailbox === "standard"
+ ? "Built from the first and last name, without accents, apostrophes or spaces."
+ : "Lowercase letters, digits, dots and hyphens.")}
+
+ {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, not {formatMemberNumber(socio.number)}.
+ Correct it in Microsoft 365, or use Change number to give both a new number.
+
+ ) : null}
+ {missing.length && !socio.endedOn ? (
+ Missing: {missing.join(", ")}.
+ ) : null}
+