diff --git a/docs/screenshots/azure/member-number-email.png b/docs/screenshots/azure/member-number-email.png new file mode 100644 index 0000000..602ca3b Binary files /dev/null and b/docs/screenshots/azure/member-number-email.png differ diff --git a/docs/screenshots/azure/welcome-email.png b/docs/screenshots/azure/welcome-email.png new file mode 100644 index 0000000..2f4ba1d Binary files /dev/null and b/docs/screenshots/azure/welcome-email.png differ 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/errors.ts b/src/azure/errors.ts new file mode 100644 index 0000000..09e430e --- /dev/null +++ b/src/azure/errors.ts @@ -0,0 +1,14 @@ +import type { MemberSummary } from "./types" + +export class IncompleteMemberError extends Error { + constructor( + readonly member: MemberSummary, + cause: unknown + ) { + super( + `Account ${member.mail} (${member.id}) was created, but setup and cleanup failed. Contact IT before trying again.`, + { cause } + ) + this.name = "IncompleteMemberError" + } +} diff --git a/src/azure/functions/members.ts b/src/azure/functions/members.ts index 41ec310..6e7536f 100644 --- a/src/azure/functions/members.ts +++ b/src/azure/functions/members.ts @@ -2,7 +2,9 @@ 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 { IncompleteMemberError } from "../errors" +import { MAIL_DOMAIN } from "../mailbox" +import type { CreatedMember, CreateMemberInput, MemberSummary, ParsedUser, User } from "../types" const GruppoSociID = "1c68dbb8-4ac3-4569-a886-283b5a825cbd" const Licenses = { @@ -13,22 +15,41 @@ const Licenses = { } as const const FlippedLicenses = Object.fromEntries(Object.entries(Licenses).map(([key, value]) => [value, key])) +type Page = { value: T[]; "@odata.nextLink"?: string } + +async function allPages(client: ReturnType, first: Page, advanced = false): Promise { + const items = [...first.value] + let next = first["@odata.nextLink"] + while (next) { + const request = client.api(next) + if (advanced) request.header("ConsistencyLevel", "eventual") + const page: Page = await request.get() + items.push(...page.value) + next = page["@odata.nextLink"] + } + return items +} + export async function getMembers(): Promise { try { const client = getAzureClient() - const allPolinetworkUsers: User[] = await client - .api(`/users`) - .header("ConsistencyLevel", "eventual") - .select(["displayName", "mail", "givenName", "surname", "id", "assignedLicenses", "employeeId"]) - .filter("endswith(mail, '@polinetwork.org')") - .get() - .then((r) => r.value) + const allPolinetworkUsers = await allPages( + client, + await client + .api(`/users`) + .header("ConsistencyLevel", "eventual") + .count(true) + .select(["displayName", "mail", "givenName", "surname", "id", "assignedLicenses", "employeeId"]) + .filter("endswith(mail, '@polinetwork.org') or endswith(userPrincipalName, '@polinetwork.org')") + .get(), + true + ) - const members: { id: string }[] = await client - .api(`/groups/${GruppoSociID}/members`) - .select(["id"]) - .get() - .then((r) => r.value) + const members = await allPages<{ id: string }>( + client, + await client.api(`/groups/${GruppoSociID}/members`).select(["id"]).get() + ) + const memberIds = new Set(members.map((member) => member.id)) logger.debug({ count: members.length }, "[Azure Graph API] Get members") @@ -36,16 +57,61 @@ export async function getMembers(): Promise { ({ assignedLicenses, ...u }) => ({ ...u, - isMember: members.findIndex((m) => m.id === u.id) !== -1, + isMember: memberIds.has(u.id), assignedLicensesIds: assignedLicenses.map((al) => (al.skuId ? FlippedLicenses[al.skuId] : "UNKNOWN")), }) satisfies ParsedUser ) } catch (error) { logger.error({ error }, "[Azure Graph API] Could not get users") - return [] + throw error + } +} + +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") + .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 +124,25 @@ 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, + givenName: firstName, + surname: lastName, + usageLocation: "IT", + employeeId: assocNumber.toString(), + mailNickname: localPart, userPrincipalName: mail, passwordProfile: { forceChangePasswordNextSignIn: true, @@ -77,25 +150,31 @@ export async function createMember({ firstName, lastName, assocNumber }: CreateM }, }) - // set firstName and lastName - await withRetry(() => - client.api(`/users/${id}`).patch({ - givenName: firstName, - surname: lastName, - usageLocation: "IT", - employeeId: assocNumber.toString(), - }) - ) - - // add to group Soci - await withRetry(() => - client.api(`/groups/${GruppoSociID}/members/$ref`).post({ - "@odata.id": `https://graph.microsoft.com/v1.0/directoryObjects/${id}`, - }) - ) - - // assign licenses - await manageLicenses(id, ["OFFICE_365", "POWER_AUTOMATE"], []) + try { + await withRetry(() => + client.api(`/groups/${GruppoSociID}/members/$ref`).post({ + "@odata.id": `https://graph.microsoft.com/v1.0/directoryObjects/${id}`, + }) + ) + await manageLicenses(id, ["OFFICE_365", "POWER_AUTOMATE"], []) + } catch (error) { + try { + await withRetry(async () => { + try { + await client.api(`/users/${id}`).delete() + } catch (cleanupError) { + if ((cleanupError as { statusCode?: number }).statusCode !== 404) throw cleanupError + } + }) + } catch (cleanupError) { + logger.error({ error: cleanupError, memberId: id }, "[AZURE] incomplete account cleanup failed") + throw new IncompleteMemberError( + { id, displayName: `${firstName} ${lastName}`, givenName: firstName, mail, employeeId: assocNumber.toString() }, + error + ) + } + throw error + } return { firstName, @@ -135,6 +214,6 @@ export async function manageLicenses( })), removeLicenses: removeLicenses.map((l) => Licenses[l]), }) - ).catch((e) => logger.error({ error: e }, "[Azure Graph API] members:manageLicenses ERROR")) + ) logger.info("[Azure Graph API] members:manageLicenses OK") } 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 65f445e..4603670 100644 --- a/src/emails/mailer.tsx +++ b/src/emails/mailer.tsx @@ -4,6 +4,7 @@ import type { JSX } from "react" import { sendEmail as azureSendEmail, type EmailAttachment } 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 SignatureRequestEmail from "./templates/signature-request" import SignedDocumentEmail from "./templates/signed-document" @@ -64,6 +65,28 @@ 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, + + ) +} + export async function sendSignatureRequestEmail( toAddress: string, props: { signerName: string; requestedBy: string; title: string; role: string | null; url: string } diff --git a/src/emails/templates/member-number-changed.tsx b/src/emails/templates/member-number-changed.tsx new file mode 100644 index 0000000..d819898 --- /dev/null +++ b/src/emails/templates/member-number-changed.tsx @@ -0,0 +1,93 @@ +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 PoliNetwork member number is now {props.assocNum} + {props.previousAssocNum ? <> (previously number {props.previousAssocNum}) : null}. +
+ + {props.email ? ( + <> + Microsoft 365 account + +
+ {props.email} +
+ + ) : null} + + + For help, contact IT on{" "} + + Telegram + {" "} + or at{" "} + + it@polinetwork.org + + + + ~ Lorenzo Corallo, Team Lead IT +
+
+ +