diff --git a/AGENTS.md b/AGENTS.md index 841644d9..5ca50938 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,9 +15,12 @@ What the app is and how to run it: [`README.md`](README.md). UI rules: [`docs/de 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. List groups with `azure:groups:read`; add or remove existing directory users - in a group with `azure:groups:write`; the add picker lists the directory, so it is disabled without - `azure:members:read`. Users and groups themselves cannot be edited from the dashboard. + 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 with `azure:groups:read`; add or remove existing directory users in a group with + `azure:groups:write`; the add picker lists the directory, so it is disabled without `azure:members:read`. Nothing + else about users or groups can be edited from the dashboard. - **Shared UI**: `src/components/shell`, `src/components/primitives`, `src/components/ui`. ## Conventions diff --git a/README.md b/README.md index efd41fdc..98cf072b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # PoliNetwork Admin The dashboard manages Telegram and WhatsApp groups, labels, Telegram users and grants, website content and reports. -Microsoft 365 supports a read-only member directory, creating a new association member through the backend's fixed -workflow, and group membership management. Directory access requires `azure:members:read`; creation independently -requires `azure:members:create`. Groups require `azure:groups:read`; adding and removing members `azure:groups:write`. +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 +group membership management. Directory access requires `azure:members:read`; creation independently requires +`azure:members:create`, and changing member IDs `azure:members:write`. Groups require `azure:groups:read`; adding and +removing members `azure:groups:write`. 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/screenshots/azure/create-custom.png b/docs/screenshots/azure/create-custom.png new file mode 100644 index 00000000..a4e2227b Binary files /dev/null and b/docs/screenshots/azure/create-custom.png differ diff --git a/docs/screenshots/azure/create-mail-failed.png b/docs/screenshots/azure/create-mail-failed.png new file mode 100644 index 00000000..413e9eb1 Binary files /dev/null and b/docs/screenshots/azure/create-mail-failed.png differ diff --git a/docs/screenshots/azure/create-recipient-confirm.png b/docs/screenshots/azure/create-recipient-confirm.png new file mode 100644 index 00000000..a5f6ce24 Binary files /dev/null and b/docs/screenshots/azure/create-recipient-confirm.png differ diff --git a/docs/screenshots/azure/create-standard.png b/docs/screenshots/azure/create-standard.png new file mode 100644 index 00000000..256a1c8f Binary files /dev/null and b/docs/screenshots/azure/create-standard.png differ diff --git a/docs/screenshots/azure/member-number-confirm.png b/docs/screenshots/azure/member-number-confirm.png new file mode 100644 index 00000000..da3e5888 Binary files /dev/null and b/docs/screenshots/azure/member-number-confirm.png differ diff --git a/docs/screenshots/azure/member-number-conflict.png b/docs/screenshots/azure/member-number-conflict.png new file mode 100644 index 00000000..f617e36f Binary files /dev/null and b/docs/screenshots/azure/member-number-conflict.png differ diff --git a/docs/screenshots/azure/member-number-dialog.png b/docs/screenshots/azure/member-number-dialog.png new file mode 100644 index 00000000..6342edfa Binary files /dev/null and b/docs/screenshots/azure/member-number-dialog.png differ diff --git a/docs/screenshots/azure/member-number-mobile.png b/docs/screenshots/azure/member-number-mobile.png new file mode 100644 index 00000000..b3f68117 Binary files /dev/null and b/docs/screenshots/azure/member-number-mobile.png differ diff --git a/docs/screenshots/azure/member-number-saved.png b/docs/screenshots/azure/member-number-saved.png new file mode 100644 index 00000000..8355a758 Binary files /dev/null and b/docs/screenshots/azure/member-number-saved.png differ diff --git a/src/features/azure/azure.functions.ts b/src/features/azure/azure.functions.ts index ecf8b291..cce1eb49 100644 --- a/src/features/azure/azure.functions.ts +++ b/src/features/azure/azure.functions.ts @@ -1,12 +1,21 @@ import { createServerFn } from "@tanstack/react-start" import { z } from "zod" -import { azureMembersCreateMiddleware, azureMembersReadMiddleware } from "@/server/auth.middleware" +import { + azureMembersCreateMiddleware, + azureMembersReadMiddleware, + azureMembersWriteMiddleware, +} from "@/server/auth.middleware" 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( @@ -15,10 +24,30 @@ export const createAzureMember = createServerFn({ method: "POST" }) lastName: z.string().trim().min(1), assocNumber: z.number().int().positive(), sendEmailTo: z.email(), + 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) throw new Error(result.error) + 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: z.email().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/mailbox.ts b/src/features/azure/mailbox.ts new file mode 100644 index 00000000..c2096979 --- /dev/null +++ b/src/features/azure/mailbox.ts @@ -0,0 +1,25 @@ +/** Mirrors the backend's `src/azure/mailbox.ts`, which builds and checks the address again. */ +export const MAIL_DOMAIN = "polinetwork.org" + +const LOCAL_PART = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/ + +export type MailboxKind = "standard" | "custom" + +export function isLocalPart(value: string) { + return value.length <= 64 && LOCAL_PART.test(value) +} + +/** Accents and apostrophes are dropped and spaces removed: "Niccolò", "D'Amico" -> "niccolo.damico". */ +function namePart(name: string) { + return name.trim().normalize("NFD").replace(/\p{M}/gu, "").replace(/['\s]/g, "").toLowerCase() +} + +/** `firstname.lastname`, or null when the name has letters an address cannot hold (e.g. "Łukasz"). */ +export function standardLocalPart(firstName: string, lastName: string): string | null { + const localPart = `${namePart(firstName)}.${namePart(lastName)}` + return isLocalPart(localPart) ? localPart : null +} + +export function isPoliNetworkAddress(email: string) { + return email.trim().toLowerCase().endsWith(`@${MAIL_DOMAIN}`) +} diff --git a/src/features/azure/member-dialog.tsx b/src/features/azure/member-dialog.tsx index a50c2e7d..8c07e194 100644 --- a/src/features/azure/member-dialog.tsx +++ b/src/features/azure/member-dialog.tsx @@ -1,119 +1,223 @@ import { useServerFn } from "@tanstack/react-start" +import { CircleAlert, TriangleAlert } from "lucide-react" import { useState } from "react" -import { fieldControl, fieldHintId, FormDialog, FormField } from "@/components/primitives" +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: () => Promise + 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." -function isMemberId(value: string) { - return /^[1-9]\d*$/.test(value) -} +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 ("This member number is already assigned.") are kept; anything else is generic. */ +/** 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. */ +/** 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 memberIdError = submitted && !isMemberId(memberId) ? MEMBER_ID_ERROR : undefined - const emailError = submitted && !isEmail(email.trim()) ? EMAIL_ERROR : undefined - const dirty = [firstName, lastName, email, memberId].some((value) => value.trim() !== "") - const canSubmit = firstName.trim() !== "" && lastName.trim() !== "" && email.trim() !== "" && memberId !== "" + 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 - async function submit() { - setSubmitted(true) - if (!isMemberId(memberId) || !isEmail(email.trim())) return - const assocNumber = Number.parseInt(memberId, 10) + 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 { - await createMember({ - data: { firstName: firstName.trim(), lastName: lastName.trim(), assocNumber, sendEmailTo: email.trim() }, + 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() + 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)} - /> - -
- + + 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}`} + + ) : ( +
setEmail(event.target.value)} - className={cn("h-9", fieldControl)} + aria-label="Address before @polinetwork.org" + aria-invalid={addressError ? true : undefined} + aria-describedby={fieldHintId("m365-member-address")} + onChange={(event) => 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.replace(/\D/g, ""))} + onChange={(event) => 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 new file mode 100644 index 00000000..ab9d1962 --- /dev/null +++ b/src/features/azure/member-number-dialog.tsx @@ -0,0 +1,182 @@ +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 { 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 /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) +} + +/** 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 new file mode 100644 index 00000000..db5b93cf --- /dev/null +++ b/src/features/azure/member-number.ts @@ -0,0 +1,15 @@ +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 e7c2721a..2fed1c64 100644 --- a/src/features/azure/members-page.tsx +++ b/src/features/azure/members-page.tsx @@ -1,6 +1,6 @@ import { useRouter } from "@tanstack/react-router" -import { Check, Plus, UsersRound } from "lucide-react" -import { useDeferredValue, useState } from "react" +import { Check, Hash, Plus, UsersRound } from "lucide-react" +import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react" import { buttonMotion, @@ -9,7 +9,9 @@ import { DataTable, type DataTableColumn, EmptyState, + IconButton, initialsOf, + RowActions, type TableSort, Unset, } from "@/components/primitives" @@ -21,6 +23,8 @@ 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" } @@ -133,6 +137,7 @@ const columns: DataTableColumn[] = [ export function AzureMembersPage({ members }: { members: AzureMember[] | null }) { const router = useRouter() const canCreate = useCan("azure:members:create") + const canWrite = useCan("azure:members:write") const [query, setQuery] = useState("") const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase()) const [membersOnly, setMembersOnly] = useState(false) @@ -141,7 +146,22 @@ export function AzureMembersPage({ members }: { members: AzureMember[] | null }) const [pageSize, setPageSize] = useState(20) const [dialogOpen, setDialogOpen] = useState(false) const [dialogKey, setDialogKey] = useState(0) - const shown = members ?? [] + /** 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 sorted = filtered.toSorted((a, b) => compareMembers(a, b, sort)) const rows = sorted.slice((page - 1) * pageSize, page * pageSize) @@ -149,14 +169,48 @@ export function AzureMembersPage({ members }: { members: AzureMember[] | null }) const allLicenses = shown.filter((member) => member.assignedLicensesIds.includes("OFFICE_365")).length const filteredView = deferredQuery !== "" || membersOnly - async function onSaved() { + 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.") } - appToast.success("Member created.") + 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() { @@ -237,6 +291,21 @@ export function AzureMembersPage({ members }: { members: AzureMember[] | null }) columns={columns} rows={rows} getRowId={(member) => member.id} + actions={ + canWrite + ? (member) => ( + + openNumberDialog(member)} + /> + + ) + : undefined + } + actionsWidth={60} sort={sort} onSort={(next) => { setSort(next) @@ -279,6 +348,16 @@ export function AzureMembersPage({ members }: { members: AzureMember[] | null }) {canCreate ? ( ) : null} + {canWrite && numberTarget ? ( + + ) : null} ) } diff --git a/src/server/auth.middleware.ts b/src/server/auth.middleware.ts index fb13dd82..2159753e 100644 --- a/src/server/auth.middleware.ts +++ b/src/server/auth.middleware.ts @@ -116,6 +116,14 @@ export const azureMembersCreateMiddleware = createMiddleware({ type: "function" 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 72cb27ef..2128393c 100644 --- a/src/server/permissions.ts +++ b/src/server/permissions.ts @@ -16,6 +16,7 @@ export const PERMISSIONS = [ "web:reports:manage", "azure:members:read", "azure:members:create", + "azure:members:write", "azure:groups:read", "azure:groups:write", ] as const diff --git a/tests/azure-member-number.test.mjs b/tests/azure-member-number.test.mjs new file mode 100644 index 00000000..c2b2c285 --- /dev/null +++ b/tests/azure-member-number.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict" +import { test } from "node:test" + +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) +}) diff --git a/tests/server-security.test.mjs b/tests/server-security.test.mjs index 5b62deaf..fc8ad783 100644 --- a/tests/server-security.test.mjs +++ b/tests/server-security.test.mjs @@ -192,7 +192,8 @@ test("dashboard server functions attach their scoped authorization middleware", test("dashboard mutations enforce their exact write scope", async () => { const entries = [ - ["azureMembersCreateMiddleware", ["src/features/azure/azure.functions.ts"]], + ["azureMembersCreateMiddleware", ["src/features/azure/azure.functions.ts#createAzureMember"]], + ["azureMembersWriteMiddleware", ["src/features/azure/azure.functions.ts#setAzureMemberNumber"]], ["azureGroupsWriteMiddleware", ["src/features/azure/groups.functions.ts"]], ["grantsWriteMiddleware", ["src/features/telegram/grants.functions.ts"]], ["telegramGroupsWriteMiddleware", ["src/features/telegram/groups.functions.ts"]], @@ -210,11 +211,18 @@ test("dashboard mutations enforce their exact write scope", async () => { ["reportsManageMiddleware", ["src/features/group-link-reports/reports.functions.ts"]], ].map(([expectedMiddleware, files]) => ({ expectedMiddleware, files })) + // "file#name" scopes an entry to one mutation, for files whose mutations need different permissions. + const scoped = new Map() + const mutationsOf = async (file) => { + const source = await readFile(new URL(`../${file}`, import.meta.url), "utf8") + return exportedServerFunctions(source, file).filter((serverFunction) => serverFunction.isPost) + } for (const { expectedMiddleware, files } of entries) { - for (const file of files) { - const source = await readFile(new URL(`../${file}`, import.meta.url), "utf8") - const mutations = exportedServerFunctions(source, file).filter((serverFunction) => serverFunction.isPost) - assert.ok(mutations.length > 0, `${file} must export at least one mutation`) + for (const entry of files) { + const [file, name] = entry.split("#") + const mutations = (await mutationsOf(file)).filter((mutation) => name === undefined || mutation.name === name) + assert.ok(mutations.length > 0, `${entry} must export at least one mutation`) + if (name !== undefined) scoped.set(file, [...(scoped.get(file) ?? []), name]) for (const mutation of mutations) { assert.deepEqual( mutation.middleware, @@ -224,12 +232,16 @@ test("dashboard mutations enforce their exact write scope", async () => { } } } + for (const [file, names] of scoped) { + for (const mutation of await mutationsOf(file)) + assert.ok(names.includes(mutation.name), `${file}:${mutation.name} needs a write-scope entry`) + } }) test("dashboard mutation controls use their server's write scope", async () => { const scopes = { "src/features/azure/groups-page.tsx": "azure:groups:write", - "src/features/azure/members-page.tsx": "azure:members:create", + "src/features/azure/members-page.tsx": ["azure:members:create", "azure:members:write"], "src/features/telegram/grants-page.tsx": "tg:grants:manage", "src/features/telegram/user-detail/profile.tsx": "tg:grants:manage", "src/features/telegram/groups-page.tsx": ["tg:groups:manage", "groups:labels:write"], @@ -285,6 +297,7 @@ test("event handlers integrate protected server-function redirects with the rout "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/azure/membership-dialog.tsx": ["addAzureGroupMember", "removeAzureGroupMember"], "src/features/faqs/faqs-page.tsx": ["addFAQ", "editFAQ", "deleteFAQ", "deleteFAQCategory"], "src/features/faqs/faq-category-dialog.tsx": ["addFAQCategory", "editFAQCategory"],