From 8ad6729b58f59df3d6b445a7f72293ce0a983ac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?= Date: Tue, 13 Oct 2026 13:59:31 +0200 Subject: [PATCH 1/6] feat(azure): let the dashboard change member numbers and choose the new address setAssocNumber leaves the legacy path: it now requires azure:members:write for dashboard users (token callers only), refuses a number another user already holds, audits who changed what, and can email the member their new number. create accepts a mailbox choice: the standard firstname.lastname address (now without accents, apostrophes or spaces) or a custom local part on polinetwork.org. It refuses an address or member number already in use, and a welcome email addressed to the mailbox being created. Callers that omit the choice keep the standard address. --- src/azure/directory.ts | 12 +- src/azure/functions/members.ts | 62 +++++- src/azure/mailbox.ts | 35 ++++ src/azure/mock-directory.ts | 27 ++- src/azure/types.ts | 11 ++ src/emails/mailer.tsx | 23 +++ .../templates/member-number-changed.tsx | 98 +++++++++ src/routers/azure/members.ts | 86 ++++++-- tests/azure-router.test.ts | 187 ++++++++++++++++-- tests/idp-router.test.ts | 12 +- 10 files changed, 511 insertions(+), 42 deletions(-) create mode 100644 src/azure/mailbox.ts create mode 100644 src/emails/templates/member-number-changed.tsx diff --git a/src/azure/directory.ts b/src/azure/directory.ts index 093c7f3..6db3c51 100644 --- a/src/azure/directory.ts +++ b/src/azure/directory.ts @@ -1,12 +1,22 @@ import { logger } from "@/logger" import { azureCredentials } from "./config" import { addGroupMember, getAllGroups, removeGroupMember } from "./functions/groups" -import { createMember, getMembers, setMemberNumber } from "./functions/members" +import { + createMember, + findByMail, + findByMemberNumber, + getMember, + getMembers, + setMemberNumber, +} from "./functions/members" import { createMockAzureDirectory } from "./mock-directory" import type { AzureDirectory } from "./types" const graphAzureDirectory: AzureDirectory = { getMembers, + getMember, + findByMemberNumber, + findByMail, setMemberNumber, createMember, getAllGroups, diff --git a/src/azure/functions/members.ts b/src/azure/functions/members.ts index 41ec310..479b783 100644 --- a/src/azure/functions/members.ts +++ b/src/azure/functions/members.ts @@ -2,7 +2,8 @@ import { logger } from "@/logger" import { generatePassword } from "@/utils/password" import { withRetry } from "@/utils/wait" import { getAzureClient } from "../client" -import type { CreatedMember, CreateMemberInput, ParsedUser, User } from "../types" +import { MAIL_DOMAIN } from "../mailbox" +import type { CreatedMember, CreateMemberInput, MemberSummary, ParsedUser, User } from "../types" const GruppoSociID = "1c68dbb8-4ac3-4569-a886-283b5a825cbd" const Licenses = { @@ -46,6 +47,53 @@ export async function getMembers(): Promise { } } +const SUMMARY_FIELDS = ["id", "displayName", "givenName", "mail", "employeeId"] + +function toSummary(user: Partial & { id: string }): MemberSummary { + return { + id: user.id, + displayName: user.displayName ?? null, + givenName: user.givenName ?? null, + mail: user.mail ?? null, + employeeId: user.employeeId ?? null, + } +} + +export async function getMember(userId: string): Promise { + try { + const user = await getAzureClient() + .api(`/users/${encodeURIComponent(userId)}`) + .select(SUMMARY_FIELDS) + .get() + return toSummary(user) + } catch (error) { + if ((error as { statusCode?: number }).statusCode === 404) return null + throw error + } +} + +/** Callers pass only validated values (integers, `localPartSchema` addresses), so the filter needs no escaping. */ +async function findOne(filter: string): Promise { + const users: MemberSummary[] = await getAzureClient() + .api("/users") + .header("ConsistencyLevel", "eventual") + .count(true) + .filter(filter) + .select(SUMMARY_FIELDS) + .top(1) + .get() + .then((r) => r.value) + return users[0] ? toSummary(users[0]) : null +} + +export async function findByMemberNumber(assocNumber: number) { + return findOne(`employeeId eq '${assocNumber}'`) +} + +export async function findByMail(mail: string) { + return findOne(`userPrincipalName eq '${mail}' or mail eq '${mail}'`) +} + export async function setMemberNumber(userId: string, assocNumber: number) { try { const client = getAzureClient() @@ -58,18 +106,22 @@ export async function setMemberNumber(userId: string, assocNumber: number) { } } -export async function createMember({ firstName, lastName, assocNumber }: CreateMemberInput): Promise { +export async function createMember({ + firstName, + lastName, + assocNumber, + localPart, +}: CreateMemberInput): Promise { const client = getAzureClient() // TODO: separate steps and add better error handling, maybe with neverthrow const password = generatePassword() - const mailNickname = `${firstName.replaceAll(" ", "")}.${lastName.replaceAll(" ", "")}`.toLowerCase() - const mail = `${mailNickname}@polinetwork.org` + const mail = `${localPart}@${MAIL_DOMAIN}` // create user const { id }: { id: string } = await client.api("/users").post({ accountEnabled: true, displayName: `${firstName} ${lastName}`, - mailNickname, + mailNickname: localPart, userPrincipalName: mail, passwordProfile: { forceChangePasswordNextSignIn: true, diff --git a/src/azure/mailbox.ts b/src/azure/mailbox.ts new file mode 100644 index 0000000..06260b1 --- /dev/null +++ b/src/azure/mailbox.ts @@ -0,0 +1,35 @@ +import z from "zod" + +export const MAIL_DOMAIN = "polinetwork.org" + +const LOCAL_PART = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/ + +/** The part before `@polinetwork.org`: lowercase letters, digits, and single dots or hyphens between them. */ +export const localPartSchema = z + .string() + .trim() + .toLowerCase() + .max(64) + .regex(LOCAL_PART, "Only lowercase letters, digits, dots and hyphens") + +export const mailboxSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("standard") }), + z.object({ kind: z.literal("custom"), localPart: localPartSchema }), +]) + +export type MailboxChoice = z.infer + +/** Accents and apostrophes are dropped and spaces removed: "Niccolò", "D'Amico" -> "niccolo.damico". */ +function namePart(name: string) { + return name.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 LOCAL_PART.test(localPart) && localPart.length <= 64 ? localPart : null +} + +export function mailboxLocalPart(firstName: string, lastName: string, mailbox: MailboxChoice) { + return mailbox.kind === "custom" ? mailbox.localPart : standardLocalPart(firstName, lastName) +} diff --git a/src/azure/mock-directory.ts b/src/azure/mock-directory.ts index 80b4b75..75a2bb9 100644 --- a/src/azure/mock-directory.ts +++ b/src/azure/mock-directory.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto" import { generatePassword } from "@/utils/password" -import type { AzureDirectory, ParsedGroup, ParsedUser } from "./types" +import { MAIL_DOMAIN } from "./mailbox" +import type { AzureDirectory, MemberSummary, ParsedGroup, ParsedUser } from "./types" const ASSOCIATION_GROUP_ID = "mock-group-association-members" @@ -65,6 +66,10 @@ function cloneMembers(members: ParsedUser[]): ParsedUser[] { return members.map((member) => ({ ...member, assignedLicensesIds: [...member.assignedLicensesIds] })) } +function summary({ id, displayName, givenName, mail, employeeId }: ParsedUser): MemberSummary { + return { id, displayName, givenName, mail, employeeId } +} + function cloneGroups(groups: ParsedGroup[]): ParsedGroup[] { return groups.map((group) => ({ ...group, members: group.members.map((member) => ({ ...member })) })) } @@ -78,6 +83,21 @@ export function createMockAzureDirectory(): AzureDirectory { return cloneMembers(members) }, + async getMember(userId) { + const member = members.find((candidate) => candidate.id === userId) + return member ? summary(member) : null + }, + + async findByMemberNumber(assocNumber) { + const member = members.find((candidate) => candidate.employeeId === assocNumber.toString()) + return member ? summary(member) : null + }, + + async findByMail(mail) { + const member = members.find((candidate) => candidate.mail === mail) + return member ? summary(member) : null + }, + async setMemberNumber(userId, assocNumber) { const member = members.find((candidate) => candidate.id === userId) if (!member) return { error: `Azure member ${userId} was not found in the local directory` } @@ -86,9 +106,8 @@ export function createMockAzureDirectory(): AzureDirectory { return { error: null } }, - async createMember({ firstName, lastName, assocNumber }) { - const mailNickname = `${firstName.replaceAll(" ", "")}.${lastName.replaceAll(" ", "")}`.toLowerCase() - const mail = `${mailNickname}@polinetwork.org` + async createMember({ firstName, lastName, assocNumber, localPart }) { + const mail = `${localPart}@${MAIL_DOMAIN}` if (members.some((member) => member.mail === mail)) { throw new Error(`Azure member ${mail} already exists in the local directory`) } diff --git a/src/azure/types.ts b/src/azure/types.ts index bf06ae6..60021d4 100644 --- a/src/azure/types.ts +++ b/src/azure/types.ts @@ -47,8 +47,13 @@ export type CreateMemberInput = { firstName: string lastName: string assocNumber: number + /** The part before `@polinetwork.org`, already validated. */ + localPart: string } +/** The directory fields the member number workflow reads. */ +export type MemberSummary = Pick + export type CreatedMember = { firstName: string lastName: string @@ -59,6 +64,12 @@ export type CreatedMember = { export type AzureDirectory = { getMembers(): Promise + /** Null when no user has this id. */ + getMember(userId: string): Promise + /** Any user whose `employeeId` is this number. */ + findByMemberNumber(assocNumber: number): Promise + /** Any user whose sign-in name or mail is this address. */ + findByMail(mail: string): Promise setMemberNumber(userId: string, assocNumber: number): Promise<{ error: string | null }> createMember(input: CreateMemberInput): Promise getAllGroups(): Promise diff --git a/src/emails/mailer.tsx b/src/emails/mailer.tsx index 2dfb4f7..564aee0 100644 --- a/src/emails/mailer.tsx +++ b/src/emails/mailer.tsx @@ -4,6 +4,7 @@ import type { JSX } from "react" import { sendEmail as azureSendEmail } from "@/azure/functions/emails" import { env } from "@/env" import { logger } from "@/logger" +import MemberNumberChanged from "./templates/member-number-changed" import OtpEmail from "./templates/otp" import Welcome from "./templates/welcome" @@ -51,3 +52,25 @@ export async function sendWelcomeEmail( /> ) } + +export async function sendMemberNumberChangedEmail( + toAddress: string, + memberInfo: { + firstName: string | null + assocNumber: number + previousAssocNumber: string | null + email: string | null + } +) { + const subject = makeSubject("Your PoliNetwork member number has changed") + return sendEmail( + toAddress, + subject, + + ) +} diff --git a/src/emails/templates/member-number-changed.tsx b/src/emails/templates/member-number-changed.tsx new file mode 100644 index 0000000..784a827 --- /dev/null +++ b/src/emails/templates/member-number-changed.tsx @@ -0,0 +1,98 @@ +import { + Body, + CodeInline, + Container, + Head, + Heading, + Html, + Link, + Preview, + pixelBasedPreset, + Section, + Tailwind, + Text, +} from "@react-email/components" +import { Footer } from "../components/footer" +import { Logo } from "../components/logo" +import type { EmailTemplate } from "../types" + +type Props = { + firstName: string | null + assocNum: number + previousAssocNum: string | null + email: string | null +} + +const MemberNumberChangedEmail: EmailTemplate = (props) => { + return ( + + + + Your PoliNetwork member number is now {props.assocNum.toString()} + + + + + {/* Main Content Container */} + + Your member number has changed + + {/* Content Section */} +
+ + {props.firstName ? ( + <> + Hi {props.firstName}, + + ) : ( + "Hi," + )} +
your membership of PoliNetwork APS is now registered + with number {props.assocNum} + {props.previousAssocNum ? <> (previously number {props.previousAssocNum}) : null}. +
+ + {props.email ? ( + <> + + Nothing else changes: you keep signing in to your Office 365 account with the same address. + + +
+ {props.email} +
+ + ) : null} + + + Please use the new number from now on, for example in renewals and official communications. + + + + If you think this is a mistake or have some questions, please contact me on{" "} + + Telegram + + + + + ~ Lorenzo Corallo, Gestione Progetti Innovativi e Supporto Tecnico (IT) + +
+
+ +