+ {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"],