diff --git a/prisma/migrations/20260905214944_administrator_role_rename/migration.sql b/prisma/migrations/20260905214944_administrator_role_rename/migration.sql new file mode 100644 index 0000000..a354f2d --- /dev/null +++ b/prisma/migrations/20260905214944_administrator_role_rename/migration.sql @@ -0,0 +1,10 @@ +-- Renames the seeded "Owner" system role to "Administrator" for every +-- existing business. This is a pure rename of a per-business Role row's +-- display name -- the permissions attached to it, the StaffProfile rows +-- assigned to it, and every authorization check in the app (which is +-- already keyed on holding the MANAGE_STAFF permission, never on this +-- name) are all unaffected. Scoped to isSystem = true so a business that +-- happens to have created its own custom role literally named "Owner" +-- (not possible today -- there is no role-creation UI yet -- but scoped +-- defensively anyway) is left untouched. +UPDATE `Role` SET `name` = 'Administrator' WHERE `name` = 'Owner' AND `isSystem` = true; diff --git a/prisma/seed.ts b/prisma/seed.ts index 34ebe1f..3a7797b 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -8,7 +8,7 @@ import { PrismaClient, Weekday, RewardType, NotificationType, InstallationStatus import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config"; import { notifySafely } from "../src/services/notifications/notification.service"; -import { OWNER_PERMISSIONS, BARBER_PERMISSIONS, syncRolePermissions } from "../src/lib/roles/default-permissions"; +import { ADMINISTRATOR_PERMISSIONS, BARBER_PERMISSIONS, syncRolePermissions } from "../src/lib/roles/default-permissions"; import { INSTALLATION_SINGLETON_ID } from "../src/lib/installation/installation-state"; import bcrypt from "bcryptjs"; @@ -99,16 +99,16 @@ async function main() { }); const ownerRole = await prisma.role.upsert({ - where: { businessId_name: { businessId: business.id, name: "Owner" } }, + where: { businessId_name: { businessId: business.id, name: "Administrator" } }, update: {}, create: { businessId: business.id, - name: "Owner", + name: "Administrator", isSystem: true, - permissions: { create: OWNER_PERMISSIONS.map((permission) => ({ permission })) }, + permissions: { create: ADMINISTRATOR_PERMISSIONS.map((permission) => ({ permission })) }, }, }); - await syncRolePermissions(prisma, ownerRole.id, OWNER_PERMISSIONS); + await syncRolePermissions(prisma, ownerRole.id, ADMINISTRATOR_PERMISSIONS); const barberRole = await prisma.role.upsert({ where: { businessId_name: { businessId: business.id, name: "Barber" } }, diff --git a/src/app/(auth)/invite/accept/page.tsx b/src/app/(auth)/invite/accept/page.tsx index b7d4784..aac3b84 100644 --- a/src/app/(auth)/invite/accept/page.tsx +++ b/src/app/(auth)/invite/accept/page.tsx @@ -22,18 +22,25 @@ export default async function AcceptInvitationPage({ if (!check.valid) { const message = check.reason === "EXPIRED" - ? "This invitation link has expired. Ask the business owner to send you a new one." + ? "This invitation link has expired. Ask an administrator to send you a new one." : check.reason === "ALREADY_ACCEPTED" ? "This invitation has already been used." : "This invitation link isn't valid."; return ; } + if (!check.invitation.staff.user.email) { + return ; + } return (

Welcome, {check.invitation.staff.user.name}

Finish setting up your staff account.

- +
); } diff --git a/src/app/(owner)/owner/team/add-administrator/page.tsx b/src/app/(owner)/owner/team/add-administrator/page.tsx new file mode 100644 index 0000000..dedd070 --- /dev/null +++ b/src/app/(owner)/owner/team/add-administrator/page.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { TopNav } from "@/components/navigation/top-nav"; +import { hasPermission } from "@/lib/auth/permissions"; +import { Permission } from "@/generated/prisma/enums"; +import { getActingBusinessContext } from "@/lib/business/get-acting-business-context"; +import { InviteAdministratorForm } from "@/features/owner/components/invite-administrator-form"; + +export const metadata: Metadata = { title: "Invite Administrator" }; +export const dynamic = "force-dynamic"; + +export default async function OwnerAddAdministratorPage() { + const session = await auth(); + if (!session?.user) redirect("/sign-in"); + + const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF); + if (!canManage) redirect("/staff"); + + const context = await getActingBusinessContext(); + if (!context) redirect("/staff"); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/src/app/(owner)/owner/team/page.tsx b/src/app/(owner)/owner/team/page.tsx index bd36436..8e221bb 100644 --- a/src/app/(owner)/owner/team/page.tsx +++ b/src/app/(owner)/owner/team/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { Plus, Search } from "lucide-react"; +import { Plus, Search, ShieldCheck, ShieldPlus } from "lucide-react"; import { redirect } from "next/navigation"; import { auth } from "@/auth"; import { prisma } from "@/lib/db/prisma"; @@ -34,13 +34,23 @@ export default async function OwnerTeamPage({

Team

- -
@@ -79,7 +89,10 @@ export default async function OwnerTeamPage({

{s.user.name}

{s.user.email}

-

+

+ {s.role.name === "Administrator" && ( +

{profile.services.length === 0 ? (

- No services assigned yet. Ask the owner to add you to a service. + No services assigned yet. Ask an administrator to add you to a service.

) : (
    @@ -48,7 +49,7 @@ export default async function StaffProfilePage() {
)}

- Only the business owner can change which services you offer. + Only an administrator can change which services you offer.

diff --git a/src/auth.ts b/src/auth.ts index 689067d..850e144 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -14,31 +14,32 @@ import { secretsMatch } from "@/lib/security/secrets-match"; * src/lib/auth/otp.service.ts's own header for the full verify/finalize * design this builds on. * - * `{ email, code }` — normal sign-in/registration: verifyOtp then - * finalizeOtp back to back in this one request (functionally "verify and - * consume in one shot" from the caller's point of view). + * `{ email, code }` — the normal path, used by sign-in, registration, and + * (since the "administrator invite verifies via OTP too" work) invitation + * acceptance as well: verifyOtp then finalizeOtp back to back in this one + * request (functionally "verify and consume in one shot" from the + * caller's point of view). * - * `{ email, finalize: "true", token }` — used only by the installer's and - * staff-invitation-acceptance's own immediate post-completion sign-in, - * where the code was already consumed by their own earlier verifyOtp call - * and can't be re-entered (one-time use). `token` must match either the - * live INSTALLATION_TOKEN or a valid StaffInvitation.token for this exact - * email — required because /api/auth/callback/credentials is a public - * route regardless of this change (true of any Credentials provider), so - * this path must independently prove the caller already holds a secret an - * attacker can't have, not just trust that it's being called "from the - * right place". Without this, a race window (someone else's verifiedAt- - * but-not-yet-consumedAt row, mid-wizard) would be forgeable via a direct - * POST to that route with just a guessed email. + * `{ email, finalize: "true", token }` — used only by the installer's own + * immediate post-completion sign-in, where the administrator's code was + * already consumed by installApplication's own earlier verifyOtp call + * (inside its transaction) and can't be re-entered (one-time use). `token` + * must match the live INSTALLATION_TOKEN — required because + * /api/auth/callback/credentials is a public route regardless of this + * change (true of any Credentials provider), so this path must + * independently prove the caller already holds a secret an attacker can't + * have, not just trust that it's being called "from the right place". + * Without this, a race window (someone else's verifiedAt-but-not-yet- + * consumedAt row, mid-wizard) would be forgeable via a direct POST to that + * route with just a guessed email. (Invitation acceptance no longer uses + * this path — it has its own real OTP code to re-enter here instead, so + * there's nothing left to trust a bare token for there.) */ -async function verifyFinalizeToken(email: string, token: string): Promise { +function verifyFinalizeToken(token: string): boolean { if (!token) return false; const installToken = process.env.INSTALLATION_TOKEN; - if (installToken && secretsMatch(token, installToken)) return true; - - const invitation = await prisma.staffInvitation.findUnique({ where: { token }, select: { email: true } }); - return !!invitation && invitation.email.toLowerCase() === email; + return !!installToken && secretsMatch(token, installToken); } export const { handlers, auth, signIn, signOut } = NextAuth({ @@ -56,9 +57,9 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ credentials: { email: { label: "Email", type: "email" }, code: { label: "Code", type: "text" }, - // Only ever set by trusted server-side callers (the installer, - // staff-invitation acceptance) — never rendered on any real form. - // See this file's own header for the full finalize/token design. + // Only ever set by the installer's own trusted server-side sign-in + // call — never rendered on any real form. See this file's own + // header for the full finalize/token design. finalize: { label: "Finalize", type: "text" }, token: { label: "Token", type: "text" }, }, @@ -76,8 +77,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ if (raw?.finalize === "true") { const token = typeof raw?.token === "string" ? raw.token : ""; - const trusted = await verifyFinalizeToken(email, token); - if (!trusted) return null; + if (!verifyFinalizeToken(token)) return null; if (!(await finalizeOtp(prisma, email))) return null; } else { const code = typeof raw?.code === "string" ? raw.code : ""; @@ -95,10 +95,22 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }), ], callbacks: { - async jwt({ token, user }) { + async jwt({ token, user, trigger, session }) { if (user?.id) { token.id = user.id; } + // Lets a client-side `useSession().update({ email })` call (see + // change-email.ts's verifyEmailChangeAction, whose caller triggers + // this right after a successful email change) refresh the JWT's own + // email claim without forcing a full sign-out/sign-in — otherwise + // session.user.email would keep showing the old address until the + // token's next natural refresh. Nothing else in this app reads + // session.user.email for authorization (every permission/tenant + // check is keyed on token.id/session.user.id), so this is purely a + // display-freshness fix. + if (trigger === "update" && typeof session?.email === "string") { + token.email = session.email; + } return token; }, async session({ session, token }) { diff --git a/src/features/auth/actions/accept-invitation.ts b/src/features/auth/actions/accept-invitation.ts index d35dd82..47181fc 100644 --- a/src/features/auth/actions/accept-invitation.ts +++ b/src/features/auth/actions/accept-invitation.ts @@ -1,44 +1,80 @@ "use server"; import { signIn } from "@/auth"; +import { signInVerifySchema } from "@/features/auth/schemas"; +import { requestOtp, verifyOtp } from "@/lib/auth/otp.service"; import { prisma } from "@/lib/db/prisma"; +import { getRequestIp } from "@/lib/security/request-ip"; +import { getEmailProvider } from "@/services/notifications/providers"; import { acceptInvitation, validateInvitationToken } from "@/services/staff/staff-invitation.service"; -export interface AcceptInvitationResult { - success: boolean; +export interface InvitationOtpActionResult { + ok: boolean; error?: string; } +function invalidTokenMessage(reason: "INVALID_TOKEN" | "EXPIRED" | "ALREADY_ACCEPTED"): string { + return reason === "EXPIRED" + ? "This invitation link has expired. Ask an administrator to send you a new one." + : reason === "ALREADY_ACCEPTED" + ? "This invitation has already been used — try signing in instead." + : "This invitation link isn't valid."; +} + /** - * Public — the invited person has no account/session yet. No password or - * OTP step: the invite token itself (delivered only to the invited - * email) is already the trust anchor — see acceptInvitation's own doc - * comment. Signs in via src/auth.ts's token-gated finalize path, - * reusing this same token as the required secret. + * Step 1 of accepting an invitation — sends a code to the invited email. + * Public (the invitee has no account/session yet), but only reachable with + * a valid, unexpired, unaccepted invitation token, so there's no need for + * requestSignInOtpAction's `onlyIfUserExists` secrecy here: a real + * invitation already proves this exact email was chosen by an + * administrator, not guessed by the caller. */ -export async function acceptInvitationAction(token: string): Promise { +export async function requestInvitationOtpAction(token: string): Promise { const check = await validateInvitationToken(prisma, token); - if (!check.valid) { - const message = - check.reason === "EXPIRED" - ? "This invitation link has expired. Ask the business owner to send you a new one." - : check.reason === "ALREADY_ACCEPTED" - ? "This invitation has already been used — try signing in instead." - : "This invitation link isn't valid."; - return { success: false, error: message }; - } + if (!check.valid) return { ok: false, error: invalidTokenMessage(check.reason) }; + + const email = check.invitation.staff.user.email; + if (!email) return { ok: false, error: "This invitation link isn't valid." }; + + const ip = await getRequestIp(); + const result = await requestOtp(prisma, { + email, + purpose: "staff-invite", + ip, + senderName: check.invitation.staff.business.name, + emailProvider: getEmailProvider(), + }); + if (!result.ok) return { ok: false, error: "Too many attempts. Please try again later." }; + return { ok: true }; +} + +/** + * Step 2 — verifies the code, activates the account (acceptInvitation sets + * emailVerified + StaffInvitation.acceptedAt), then signs in with the same + * still-valid (verified-but-not-yet-consumed) code — same shape as + * src/features/auth/actions/register.ts's verifyRegisterOtpAction, and for + * the same reason: verifyOtp doesn't consume the code, only the following + * signIn's own authorize() call does, so it can safely run again here. + */ +export async function verifyInvitationOtpAction(token: string, code: string): Promise { + const check = await validateInvitationToken(prisma, token); + if (!check.valid) return { ok: false, error: invalidTokenMessage(check.reason) }; + + const email = check.invitation.staff.user.email; + if (!email) return { ok: false, error: "This invitation link isn't valid." }; + const parsed = signInVerifySchema.safeParse({ email, code }); + if (!parsed.success) return { ok: false, error: "Enter the 6-digit code." }; + + const ip = await getRequestIp(); + const verified = await verifyOtp(prisma, email, parsed.data.code, ip); + if (!verified.ok) return { ok: false, error: "Incorrect or expired code. Please try again." }; const result = await acceptInvitation(prisma, { token }); if (!result.success) { - return { success: false, error: "Couldn't accept this invitation. Please try again." }; + return { ok: false, error: result.reason ? invalidTokenMessage(result.reason) : "Couldn't accept this invitation. Please try again." }; } - await signIn("credentials", { - email: check.invitation.staff.user.email, - finalize: "true", - token, - redirectTo: "/staff", - }); + await signIn("credentials", { email, code: parsed.data.code, redirectTo: "/staff" }); - return { success: true }; + return { ok: true }; } diff --git a/src/features/auth/components/accept-invitation-form.tsx b/src/features/auth/components/accept-invitation-form.tsx index f540564..7ef63a0 100644 --- a/src/features/auth/components/accept-invitation-form.tsx +++ b/src/features/auth/components/accept-invitation-form.tsx @@ -2,37 +2,55 @@ import { useState, useTransition } from "react"; import { Button } from "@/components/ui/button"; -import { acceptInvitationAction } from "@/features/auth/actions/accept-invitation"; +import { OtpCodeStep } from "@/features/auth/components/otp-code-step"; +import { requestInvitationOtpAction, verifyInvitationOtpAction } from "@/features/auth/actions/accept-invitation"; -export function AcceptInvitationForm({ token, businessName }: { token: string; businessName: string }) { +export function AcceptInvitationForm({ token, email, businessName }: { token: string; email: string; businessName: string }) { + const [codeRequested, setCodeRequested] = useState(false); const [error, setError] = useState(null); const [isPending, startTransition] = useTransition(); - const onAccept = () => { + const sendCode = () => { setError(null); startTransition(async () => { - const result = await acceptInvitationAction(token); - if (!result.success) { + const result = await requestInvitationOtpAction(token); + if (!result.ok) { setError(result.error ?? "Something went wrong. Please try again."); + return; } - // On success, acceptInvitationAction signs the user in and redirects - // server-side — nothing else to do here. + setCodeRequested(true); }); }; + const verifyCode = async (code: string) => { + const result = await verifyInvitationOtpAction(token, code); + // On success, verifyInvitationOtpAction signs the user in and redirects + // server-side — nothing else to do here. + return { ok: result.ok, error: result.error }; + }; + + const resendCode = async () => { + const result = await requestInvitationOtpAction(token); + return { ok: result.ok, error: result.error }; + }; + + if (codeRequested) { + return ; + } + return (

- You've been invited to join {businessName}. This link - confirms it's really you — no password needed. + You've been invited to join {businessName}. Verify your + email to get started — no password needed.

{error && (

{error}

)} -
); diff --git a/src/features/auth/components/otp-code-step.tsx b/src/features/auth/components/otp-code-step.tsx index 592c84a..a1ebd87 100644 --- a/src/features/auth/components/otp-code-step.tsx +++ b/src/features/auth/components/otp-code-step.tsx @@ -13,19 +13,21 @@ export interface OtpCodeStepProps { email: string; onVerify: (code: string) => Promise; onResend: () => Promise; - onChangeEmail: () => void; + /** Omit when the email isn't user-chosen at this step (e.g. accept-invitation, where it's fixed by the invitation itself) — hides the "Change email" link entirely rather than wiring it to a no-op. */ + onChangeEmail?: () => void; verifyLabel?: string; } /** * The "enter the code" half of every OTP flow in the app — reused - * verbatim by sign-in, registration, and the installer's administrator - * step (embedded inline there, not a redirect — see install-wizard.tsx). - * Deliberately owns only this step: each caller manages its own "email" - * vs "code" phase and renders whatever step-1 fields it needs (sign-in - * needs just an email, registration/installer also need a name) before - * mounting this component — trying to also genericize step 1 fought the - * different field sets more than it saved. + * verbatim by sign-in, registration, the installer's administrator step + * (embedded inline there, not a redirect — see install-wizard.tsx), and + * invitation acceptance. Deliberately owns only this step: each caller + * manages its own "email" vs "code" phase and renders whatever step-1 + * fields it needs (sign-in needs just an email, registration/installer + * also need a name, invitation acceptance needs none — the email comes + * from the invitation) before mounting this component — trying to also + * genericize step 1 fought the different field sets more than it saved. */ export function OtpCodeStep({ email, onVerify, onResend, onChangeEmail, verifyLabel = "Verify" }: OtpCodeStepProps) { const [code, setCode] = useState(""); @@ -89,9 +91,11 @@ export function OtpCodeStep({ email, onVerify, onResend, onChangeEmail, verifyLa > Resend code - + {onChangeEmail && ( + + )}
); diff --git a/src/features/installation/__tests__/installer.service.integration.test.ts b/src/features/installation/__tests__/installer.service.integration.test.ts index 3d3052b..914b220 100644 --- a/src/features/installation/__tests__/installer.service.integration.test.ts +++ b/src/features/installation/__tests__/installer.service.integration.test.ts @@ -199,19 +199,19 @@ describe("installApplication (integration)", () => { expect(business?.loyaltyProgramme?.isEnabled).toBe(false); const roles = await prisma.role.findMany({ where: { businessId: result.businessId }, include: { permissions: true } }); - const ownerRole = roles.find((r) => r.name === "Owner"); + const administratorRole = roles.find((r) => r.name === "Administrator"); const barberRole = roles.find((r) => r.name === "Barber"); - expect(ownerRole).toBeDefined(); + expect(administratorRole).toBeDefined(); expect(barberRole).toBeDefined(); - expect(ownerRole!.permissions.length).toBeGreaterThan(barberRole!.permissions.length); + expect(administratorRole!.permissions.length).toBeGreaterThan(barberRole!.permissions.length); const user = await prisma.user.findUnique({ where: { id: result.userId } }); expect(user?.emailVerified).not.toBeNull(); expect(user?.passwordHash).toBeNull(); const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: result.userId }, include: { services: true } }); - expect(staffProfile?.roleId).toBe(ownerRole!.id); - // The initial owner is eligible for every starter service — see the brief's "initial owner is service-eligible." + expect(staffProfile?.roleId).toBe(administratorRole!.id); + // The initial administrator is eligible for every starter service — see the brief's "initial administrator is service-eligible." expect(staffProfile?.services).toHaveLength(1); const services = await prisma.service.findMany({ where: { businessId: result.businessId } }); diff --git a/src/features/installation/components/install-wizard.tsx b/src/features/installation/components/install-wizard.tsx index cd954d2..5b92fdd 100644 --- a/src/features/installation/components/install-wizard.tsx +++ b/src/features/installation/components/install-wizard.tsx @@ -363,7 +363,9 @@ export function InstallWizard({ {step === 2 && (

Administrator account

-

This becomes the first owner — full permissions, protected from removal.

+

+ This becomes the first Administrator — full permissions. You can add or change administrators later. +

{otpPhase === "code" ? ( ({ permission })) }, + permissions: { create: ADMINISTRATOR_PERMISSIONS.map((permission) => ({ permission })) }, }, }), tx.role.create({ @@ -254,7 +254,7 @@ export async function installApplication( staffProfile: { create: { businessId: business.id, - roleId: ownerRole.id, + roleId: administratorRole.id, isBookableOnline: true, }, }, diff --git a/src/features/owner/__tests__/authorization.integration.test.ts b/src/features/owner/__tests__/authorization.integration.test.ts index d4fa477..73cc732 100644 --- a/src/features/owner/__tests__/authorization.integration.test.ts +++ b/src/features/owner/__tests__/authorization.integration.test.ts @@ -37,7 +37,7 @@ describe("Owner authorization and business isolation (integration)", () => { const ownerRole = await prisma.role.create({ data: { businessId: business.businessId, - name: "Owner", + name: "Administrator", permissions: { create: [{ permission: "MANAGE_STAFF" }] }, }, }); diff --git a/src/features/owner/actions/breaks.ts b/src/features/owner/actions/breaks.ts index 8bc0644..47d8200 100644 --- a/src/features/owner/actions/breaks.ts +++ b/src/features/owner/actions/breaks.ts @@ -90,7 +90,7 @@ export async function ownerCreateBreakAction( await notifyStaffScheduleChanged(prisma, { businessId: owner.businessId, staffUserId: target.userId, - changedByName: owner.user.name ?? "The business owner", + changedByName: owner.user.name ?? "The business's administrator", whatChanged: "your breaks", viewSchedulePath: "/staff/settings/breaks", }); @@ -125,7 +125,7 @@ export async function ownerUpdateBreakAction( await notifyStaffScheduleChanged(prisma, { businessId: owner.businessId, staffUserId: target.userId, - changedByName: owner.user.name ?? "The business owner", + changedByName: owner.user.name ?? "The business's administrator", whatChanged: "your breaks", viewSchedulePath: "/staff/settings/breaks", }); @@ -147,7 +147,7 @@ export async function ownerDeleteBreakAction(staffId: string, breakId: string): await notifyStaffScheduleChanged(prisma, { businessId: owner.businessId, staffUserId: target.userId, - changedByName: owner.user.name ?? "The business owner", + changedByName: owner.user.name ?? "The business's administrator", whatChanged: "your breaks", viewSchedulePath: "/staff/settings/breaks", }); diff --git a/src/features/owner/actions/staff.ts b/src/features/owner/actions/staff.ts index 2676b66..284911a 100644 --- a/src/features/owner/actions/staff.ts +++ b/src/features/owner/actions/staff.ts @@ -8,6 +8,7 @@ import { writeAuditLog } from "@/lib/audit/write-audit-log"; import { createStaffProfile, deactivateStaff, + isStaffAdministrator, reactivateStaff, setStaffServiceEligibility, setWorkingHours, @@ -71,6 +72,64 @@ export async function addStaffAction(input: CreateStaffInput): Promise { + const { staff: owner, error } = await requireStaffManager(); + if (!owner) return { success: false, error: error ?? "Not authorized." }; + + const administratorRole = await prisma.role.findFirst({ + where: { businessId: owner.businessId, name: "Administrator" }, + }); + if (!administratorRole) { + return { success: false, error: "This business has no Administrator role configured." }; + } + + const result = await createStaffProfile(prisma, { + businessId: owner.businessId, + input: { + name: input.name, + email: input.email, + roleId: administratorRole.id, + serviceIds: [], + isBookableOnline: false, + }, + }); + if (!result.success) { + return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't invite this administrator." }; + } + + const invitation = await createInvitation(prisma, { + businessId: owner.businessId, + staffId: result.staff.id, + email: input.email.toLowerCase(), + invitedByUserId: owner.userId, + }); + + await writeAuditLog(prisma, { + actorUserId: owner.userId, + action: "ADMINISTRATOR_ADDED", + entityType: "StaffProfile", + entityId: result.staff.id, + metadata: { name: input.name, email: input.email }, + }); + + return { success: true, staffId: result.staff.id, inviteToken: invitation.token }; +} + export interface StaffActionResult { success: boolean; error?: string; @@ -80,14 +139,26 @@ export async function updateStaffAction(staffId: string, input: UpdateStaffAsOwn const { staff: owner, error } = await requireStaffManager(); if (!owner) return { success: false, error: error ?? "Not authorized." }; + // Read before/after Administrator status around the actual update purely + // for audit-log naming — the correctness-critical last-administrator + // check itself lives inside updateStaffAsOwner's own transaction. + const wasAdministrator = input.roleId ? await isStaffAdministrator(prisma, staffId) : false; + const result = await updateStaffAsOwner(prisma, { staffId, businessId: owner.businessId, input }); if (!result.success) { return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this staff member." }; } + let action = input.roleId ? "STAFF_ROLE_CHANGED" : "STAFF_UPDATED"; + if (input.roleId) { + const isAdministratorNow = await isStaffAdministrator(prisma, staffId); + if (!wasAdministrator && isAdministratorNow) action = "ADMINISTRATOR_PROMOTED"; + else if (wasAdministrator && !isAdministratorNow) action = "ADMINISTRATOR_DEMOTED"; + } + await writeAuditLog(prisma, { actorUserId: owner.userId, - action: input.roleId ? "STAFF_ROLE_CHANGED" : "STAFF_UPDATED", + action, entityType: "StaffProfile", entityId: staffId, metadata: { ...(input.roleId ? { roleId: input.roleId } : {}) }, @@ -106,6 +177,11 @@ export async function deactivateStaffAction(staffId: string, acknowledgeConflict const { staff: owner, error } = await requireStaffManager(); if (!owner) return { success: false, error: error ?? "Not authorized." }; + // Read before the actual deactivation purely for audit-log naming — the + // correctness-critical last-administrator check lives inside + // deactivateStaff's own transaction. + const wasAdministrator = await isStaffAdministrator(prisma, staffId); + const result = await deactivateStaff(prisma, { staffId, businessId: owner.businessId, acknowledgeConflicts }); if (!result.success) { return { @@ -122,7 +198,7 @@ export async function deactivateStaffAction(staffId: string, acknowledgeConflict await writeAuditLog(prisma, { actorUserId: owner.userId, - action: "STAFF_DEACTIVATED", + action: wasAdministrator ? "ADMINISTRATOR_REMOVED" : "STAFF_DEACTIVATED", entityType: "StaffProfile", entityId: staffId, }); @@ -246,7 +322,7 @@ export async function updateStaffWorkingHoursAction( await notifyStaffScheduleChanged(prisma, { businessId: owner.businessId, staffUserId: target.userId, - changedByName: owner.user.name ?? "The business owner", + changedByName: owner.user.name ?? "The business's administrator", whatChanged: "your working hours", viewSchedulePath: "/staff/settings/working-hours", }); diff --git a/src/features/owner/actions/time-off.ts b/src/features/owner/actions/time-off.ts index 846fa3f..6101611 100644 --- a/src/features/owner/actions/time-off.ts +++ b/src/features/owner/actions/time-off.ts @@ -81,7 +81,7 @@ export async function ownerCreateTimeOffAction( await notifyStaffScheduleChanged(prisma, { businessId: owner.businessId, staffUserId: target.userId, - changedByName: owner.user.name ?? "The business owner", + changedByName: owner.user.name ?? "The business's administrator", whatChanged: "your time off", viewSchedulePath: "/staff/settings/time-off", }); @@ -116,7 +116,7 @@ export async function ownerUpdateTimeOffAction( await notifyStaffScheduleChanged(prisma, { businessId: owner.businessId, staffUserId: target.userId, - changedByName: owner.user.name ?? "The business owner", + changedByName: owner.user.name ?? "The business's administrator", whatChanged: "your time off", viewSchedulePath: "/staff/settings/time-off", }); @@ -138,7 +138,7 @@ export async function ownerDeleteTimeOffAction(staffId: string, timeOffId: strin await notifyStaffScheduleChanged(prisma, { businessId: owner.businessId, staffUserId: target.userId, - changedByName: owner.user.name ?? "The business owner", + changedByName: owner.user.name ?? "The business's administrator", whatChanged: "your time off", viewSchedulePath: "/staff/settings/time-off", }); diff --git a/src/features/owner/components/add-staff-form.tsx b/src/features/owner/components/add-staff-form.tsx index ec4c4ea..f50a9ff 100644 --- a/src/features/owner/components/add-staff-form.tsx +++ b/src/features/owner/components/add-staff-form.tsx @@ -61,8 +61,8 @@ export function AddStaffForm({ roles, services }: AddStaffFormProps) {

{name} has been added.

- Email delivery isn't wired up yet — copy this invite link and send it to them yourself. It lets them - set their own password. Expires in 7 days. + An invitation email has been sent — you can also copy this link and send it yourself as a backup. It lets + them verify their email and get started, no password needed. Expires in 7 days.

{inviteLink}
diff --git a/src/features/owner/components/invite-administrator-form.tsx b/src/features/owner/components/invite-administrator-form.tsx new file mode 100644 index 0000000..bca024a --- /dev/null +++ b/src/features/owner/components/invite-administrator-form.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { inviteAdministratorAction } from "../actions/staff"; + +/** + * Deliberately minimal — Name + Email only. An administrator invite has no + * services/bio/bookable-online to configure (see inviteAdministratorAction), + * so this doesn't reuse AddStaffForm's fuller layout; it mirrors just the + * parts of it relevant here (same post-submit copy-link fallback pattern). + */ +export function InviteAdministratorForm() { + const router = useRouter(); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [error, setError] = useState(null); + const [inviteLink, setInviteLink] = useState(null); + const [copied, setCopied] = useState(false); + const [isPending, startTransition] = useTransition(); + + const submit = () => { + setError(null); + startTransition(async () => { + const result = await inviteAdministratorAction({ name, email }); + if (!result.success) { + setError(result.error ?? "Couldn't invite this administrator."); + return; + } + if (result.inviteToken) { + setInviteLink(`${window.location.origin}/invite/accept?token=${result.inviteToken}`); + } else { + router.push(`/owner/team/${result.staffId}`); + } + }); + }; + + const copyLink = async () => { + if (!inviteLink) return; + await navigator.clipboard.writeText(inviteLink); + setCopied(true); + }; + + if (inviteLink) { + return ( +
+

{name} has been invited as an Administrator.

+

+ An invitation email has been sent — you can also copy this link and send it yourself as a backup. It lets + them verify their email and get started, no password needed. Expires in 7 days. +

+
{inviteLink}
+
+ + +
+
+ ); + } + + return ( +
+
+

+ They'll be able to manage business settings, staff, and other administrators once they verify their + email. +

+ setName(e.target.value)} /> + setEmail(e.target.value)} /> +
+ + {error && ( +

+ {error} +

+ )} + +
+ ); +} diff --git a/src/features/owner/components/staff-detail-panel.tsx b/src/features/owner/components/staff-detail-panel.tsx index 14616e7..8ae79dc 100644 --- a/src/features/owner/components/staff-detail-panel.tsx +++ b/src/features/owner/components/staff-detail-panel.tsx @@ -1,10 +1,12 @@ "use client"; +import { ShieldCheck } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState, useTransition } from "react"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; +import { Modal } from "@/components/ui/modal"; import { Select } from "@/components/ui/select"; import { deactivateStaffAction, @@ -26,6 +28,18 @@ export interface StaffDetailPanelProps { type ConflictItem = { id: string; customerName: string; serviceNames: string; startAt: string }; +/** + * Display-only check — the actual "is this StaffProfile an Administrator" + * invariant enforced server-side is permission-based (holds MANAGE_STAFF, + * see staff.service.ts's isStaffAdministrator), not a name check. This is + * just what decides whether to show the Administrator badge / intercept + * "Save Role" with a confirmation — in practice they coincide today since + * no other role is ever granted MANAGE_STAFF. + */ +function isAdministratorRoleName(name: string | undefined): boolean { + return name === "Administrator"; +} + export function StaffDetailPanel({ staffId, currentRoleId, @@ -40,6 +54,7 @@ export function StaffDetailPanel({ const [serviceIds, setServiceIds] = useState(initialServiceIds); const [roleError, setRoleError] = useState(null); const [roleSaved, setRoleSaved] = useState(false); + const [confirmRoleChangeOpen, setConfirmRoleChangeOpen] = useState(false); const [servicesError, setServicesError] = useState(null); const [servicesSaved, setServicesSaved] = useState(false); const [serviceConflicts, setServiceConflicts] = useState([]); @@ -52,9 +67,14 @@ export function StaffDetailPanel({ const [isDeactivatePending, startDeactivateTransition] = useTransition(); const [isInvitePending, startInviteTransition] = useTransition(); + const isCurrentlyAdministrator = isAdministratorRoleName(roles.find((r) => r.id === currentRoleId)?.name); + const pendingRoleIsAdministrator = isAdministratorRoleName(roles.find((r) => r.id === roleId)?.name); + const roleChangeCrossesAdministratorBoundary = roleId !== currentRoleId && isCurrentlyAdministrator !== pendingRoleIsAdministrator; + const saveRole = () => { setRoleError(null); setRoleSaved(false); + setConfirmRoleChangeOpen(false); startRoleTransition(async () => { const result = await updateStaffAction(staffId, { roleId }); if (!result.success) { @@ -66,6 +86,14 @@ export function StaffDetailPanel({ }); }; + const onSaveRoleClick = () => { + if (roleChangeCrossesAdministratorBoundary) { + setConfirmRoleChangeOpen(true); + return; + } + saveRole(); + }; + const toggleService = (id: string) => { setServicesSaved(false); setServiceIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id])); @@ -128,8 +156,8 @@ export function StaffDetailPanel({

Invitation pending

- This person hasn't set a password yet. Email delivery isn't wired up — send them the link - yourself. + This person hasn't verified their email yet — an invitation email has been sent, and you can also + share this link yourself as a backup.

{inviteLink ? (
@@ -147,7 +175,15 @@ export function StaffDetailPanel({ )}
-

Role & Permissions

+
+

Role & Permissions

+ {isCurrentlyAdministrator && ( + + + )} +
setNewEmail(e.target.value)} /> + {error && ( +

+ {error} +

+ )} +
+ + +
+
+ )} + + {phase === "code" && ( +
+ setPhase("editing")} + verifyLabel="Change Email" + /> +
+ )} + + {success && phase === "idle" && ( +

+ Email updated. +

+ )} +
+ ); +} diff --git a/src/features/staff/components/profile-form.tsx b/src/features/staff/components/profile-form.tsx index 0e5cd44..990a43e 100644 --- a/src/features/staff/components/profile-form.tsx +++ b/src/features/staff/components/profile-form.tsx @@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { MediaPicker } from "@/features/media/components/media-picker"; +import { ChangeEmailSection } from "./change-email-section"; import { updateProfileAction } from "../actions/profile"; export interface ProfileFormInitial { @@ -15,6 +16,7 @@ export interface ProfileFormInitial { image: string; bio: string; isBookableOnline: boolean; + email: string; } export function ProfileForm({ initial }: { initial: ProfileFormInitial }) { @@ -44,6 +46,7 @@ export function ProfileForm({ initial }: { initial: ProfileFormInitial }) { return (
+ setName(e.target.value)} /> => { }); /** - * A Super Administrator has every permission an Owner has — OWNER_PERMISSIONS - * (src/lib/roles/default-permissions.ts) already IS the full Permission enum, - * so short-circuiting true here rather than granting a StaffProfile/Role is - * exactly equivalent, without giving the Super Admin a StaffProfile row at - * all (see docs/phase-9-summary.md, "Super Administrator" — this is what - * keeps them out of the Team list, barber picker, and appointment FKs - * structurally rather than via a filter). + * A platform-level Super Administrator has every permission a business + * Administrator has — ADMINISTRATOR_PERMISSIONS (src/lib/roles/default- + * permissions.ts) already IS the full Permission enum, so short-circuiting + * true here rather than granting a StaffProfile/Role is exactly equivalent, + * without giving the Super Admin a StaffProfile row at all (see docs/ + * phase-9-summary.md, "Super Administrator" — this is what keeps them out + * of the Team list, barber picker, and appointment FKs structurally rather + * than via a filter). These are two distinct concepts: a business + * Administrator is a per-business Role assignment (see docs/phase-6- + * summary.md); the platform Super Administrator is a single global flag + * provisioned only by scripts/ensure-super-admin.ts. This work does not + * change the Super Administrator or NextChair Manager in any way. */ export async function hasPermission(userId: string, permission: Permission): Promise { if (await isSuperAdmin(userId)) return true; diff --git a/src/lib/roles/default-permissions.ts b/src/lib/roles/default-permissions.ts index 1d1509d..4b76fbb 100644 --- a/src/lib/roles/default-permissions.ts +++ b/src/lib/roles/default-permissions.ts @@ -5,11 +5,18 @@ import { Permission } from "@/generated/prisma/enums"; * The two system roles' permission sets — single source of truth, used by * both `prisma/seed.ts` (dev/demo fixtures) and the installation wizard * (`src/features/installation/installer.service.ts`, the real "first - * owner" creation path). Previously duplicated only in seed.ts; extracted - * here in Phase 8 so a permission added to one path can't silently miss - * the other (see docs/phase-8-summary.md). + * Administrator" creation path). Previously duplicated only in seed.ts; + * extracted here in Phase 8 so a permission added to one path can't + * silently miss the other (see docs/phase-8-summary.md). + * + * "Administrator" (formerly "Owner" — renamed so the role reads as what it + * is: an assignable permission set, not a special account type. See + * docs/phase-6-summary.md, "Roles and permissions" — every "is this an + * administrator" check in the app is already keyed on holding + * MANAGE_STAFF, not on this role's name, so the rename is cosmetic to the + * authorization logic and only changes what gets displayed/assigned.) */ -export const OWNER_PERMISSIONS: Permission[] = [ +export const ADMINISTRATOR_PERMISSIONS: Permission[] = [ Permission.MANAGE_BUSINESS_PROFILE, Permission.MANAGE_BOOKING_RULES, Permission.MANAGE_SERVICES, diff --git a/src/services/notifications/templates/staff-emails.ts b/src/services/notifications/templates/staff-emails.ts index ccbef36..8317a42 100644 --- a/src/services/notifications/templates/staff-emails.ts +++ b/src/services/notifications/templates/staff-emails.ts @@ -13,10 +13,11 @@ export interface StaffInvitationEmailData { export function renderStaffInvitationEmail(data: StaffInvitationEmailData): RenderedEmail { const actionUrl = absoluteUrl(data.acceptInvitePath); + const article = data.roleName === "Administrator" ? "an" : "a"; const bodyHtml = [ paragraph(`Hi ${data.staffName},`), paragraph( - `${data.invitedByName} has added you as ${data.roleName === "Owner" ? "an" : "a"} ${data.roleName} at ${data.business.name}. Set your password to get started.`, + `${data.invitedByName} has added you as ${article} ${data.roleName} at ${data.business.name}. Verify your email to get started — no password needed.`, ), ].join(""); @@ -27,7 +28,7 @@ export function renderStaffInvitationEmail(data: StaffInvitationEmailData): Rend businessLogoUrl: data.business.logoUrl, headline: "You've been invited", bodyHtml, - actionLabel: "Set Your Password", + actionLabel: "Verify Your Email", actionUrl, footerNote: footerNoteHtml(data.business), }), @@ -37,9 +38,9 @@ export function renderStaffInvitationEmail(data: StaffInvitationEmailData): Rend bodyLines: [ `Hi ${data.staffName},`, "", - `${data.invitedByName} has added you as a ${data.roleName} at ${data.business.name}. Set your password to get started.`, + `${data.invitedByName} has added you as ${article} ${data.roleName} at ${data.business.name}. Verify your email to get started — no password needed.`, ], - actionLabel: "Set Your Password", + actionLabel: "Verify Your Email", actionUrl, footerNote: footerNoteText(data.business), }), diff --git a/src/services/staff/__tests__/email-change.integration.test.ts b/src/services/staff/__tests__/email-change.integration.test.ts new file mode 100644 index 0000000..3ad8ba0 --- /dev/null +++ b/src/services/staff/__tests__/email-change.integration.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { requestEmailChange, verifyEmailChange } from "../email-change.service"; +import { MockEmailProvider } from "@/services/notifications/providers/mock-email-provider"; +import { + cleanupTestBusiness, + createTestBusiness, + createTestStaff, + prisma, +} from "@/services/scheduling/__tests__/fixtures"; +import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures"; + +/** + * Real MySQL, real OTP verify/finalize — see otp.service.integration.test.ts + * for why a hand-rolled fake Prisma client would risk getting the + * verify/consume semantics subtly wrong. Uses the same + * request-then-extract-code-from-MockEmailProvider pattern established + * there and in installer.service.integration.test.ts. + */ + +function uniqueEmail(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.local`; +} + +async function requestAndExtractCode(newEmail: string, currentEmail: string | null): Promise { + const mockProvider = new MockEmailProvider(); + const result = await requestEmailChange(prisma, { currentEmail, newEmail, emailProvider: mockProvider }); + expect(result.valid).toBe(true); + const sent = mockProvider.sentEmails[0]; + const code = /verification code is (\d{6})/.exec(sent?.text ?? "")?.[1]; + if (!code) throw new Error("Test setup failed: couldn't extract the OTP code from the captured email."); + return code; +} + +describe("email-change (integration)", () => { + let business: TestBusiness; + + beforeEach(async () => { + business = await createTestBusiness(); + }); + + afterEach(async () => { + await cleanupTestBusiness(business); + }); + + it("only replaces the login email after the code is verified", async () => { + const { userId } = await createTestStaff(business); + const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } }); + const newEmail = uniqueEmail("new-email"); + + const code = await requestAndExtractCode(newEmail, user.email); + + // Requesting the code must not have touched the email yet. + const stillOld = await prisma.user.findUniqueOrThrow({ where: { id: userId } }); + expect(stillOld.email).toBe(user.email); + + const result = await verifyEmailChange(prisma, { userId, newEmail, code }); + expect(result.valid).toBe(true); + + const updated = await prisma.user.findUniqueOrThrow({ where: { id: userId } }); + expect(updated.email).toBe(newEmail.toLowerCase()); + expect(updated.emailVerified).not.toBeNull(); + }); + + it("rejects an incorrect code and leaves the email unchanged", async () => { + const { userId } = await createTestStaff(business); + const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } }); + const newEmail = uniqueEmail("wrong-code"); + await requestAndExtractCode(newEmail, user.email); + + const result = await verifyEmailChange(prisma, { userId, newEmail, code: "000000" }); + expect(result.valid).toBe(false); + expect(result.issues[0]?.code).toBe("INCORRECT_CODE"); + + const unchanged = await prisma.user.findUniqueOrThrow({ where: { id: userId } }); + expect(unchanged.email).toBe(user.email); + }); + + it("rejects a new email already used by another account, at request time", async () => { + const { userId: firstUserId } = await createTestStaff(business); + const { userId: secondUserId } = await createTestStaff(business); + const firstUser = await prisma.user.findUniqueOrThrow({ where: { id: firstUserId } }); + const secondUser = await prisma.user.findUniqueOrThrow({ where: { id: secondUserId } }); + + const mockProvider = new MockEmailProvider(); + const result = await requestEmailChange(prisma, { + currentEmail: firstUser.email, + newEmail: secondUser.email!, + emailProvider: mockProvider, + }); + expect(result.valid).toBe(false); + expect(result.issues[0]?.code).toBe("EMAIL_ALREADY_IN_USE"); + expect(mockProvider.sentEmails).toHaveLength(0); + }); + + it("rejects a new email claimed by another account between request and verify", async () => { + const { userId: firstUserId } = await createTestStaff(business); + const firstUser = await prisma.user.findUniqueOrThrow({ where: { id: firstUserId } }); + const newEmail = uniqueEmail("claimed-in-between"); + + const code = await requestAndExtractCode(newEmail, firstUser.email); + + // Someone else grabs the exact same address after the code was sent + // but before it's verified. + const interloper = await prisma.user.create({ data: { email: newEmail.toLowerCase(), name: "Interloper" } }); + business.createdUserIds.push(interloper.id); + + const result = await verifyEmailChange(prisma, { userId: firstUserId, newEmail, code }); + expect(result.valid).toBe(false); + expect(result.issues[0]?.code).toBe("EMAIL_ALREADY_IN_USE"); + + const unchanged = await prisma.user.findUniqueOrThrow({ where: { id: firstUserId } }); + expect(unchanged.email).toBe(firstUser.email); + }); + + it("rejects changing to the current email address", async () => { + const { userId } = await createTestStaff(business); + const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } }); + + const mockProvider = new MockEmailProvider(); + const result = await requestEmailChange(prisma, { + currentEmail: user.email, + newEmail: user.email!, + emailProvider: mockProvider, + }); + expect(result.valid).toBe(false); + expect(result.issues[0]?.code).toBe("SAME_EMAIL"); + }); +}); diff --git a/src/services/staff/__tests__/invitation-otp.integration.test.ts b/src/services/staff/__tests__/invitation-otp.integration.test.ts new file mode 100644 index 0000000..63a0c99 --- /dev/null +++ b/src/services/staff/__tests__/invitation-otp.integration.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { requestOtp, verifyOtp } from "@/lib/auth/otp.service"; +import { MockEmailProvider } from "@/services/notifications/providers/mock-email-provider"; +import { acceptInvitation, createInvitation, validateInvitationToken } from "../staff-invitation.service"; +import { createStaffProfile } from "../staff.service"; +import { + cleanupTestBusiness, + createTestBusiness, + prisma, +} from "@/services/scheduling/__tests__/fixtures"; +import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures"; + +/** + * Covers the fix from this work: invitation acceptance now requires a real + * OTP verification (src/features/auth/actions/accept-invitation.ts, + * requestInvitationOtpAction/verifyInvitationOtpAction), not just + * possessing the invite link — and, just as importantly, a newly-invited + * account genuinely cannot sign in via plain OTP *before* that + * verification happens (requestOtp's `onlyIfUserExists` now also requires + * emailVerified, see otp.service.ts). Tests the service layer directly + * (same convention as every other *.integration.test.ts here) rather than + * the Server Actions, which need a Next.js request context for auth()/ + * headers() that a plain Vitest run doesn't have. + */ + +async function createRoleAndInviteStaff(business: TestBusiness): Promise<{ staffId: string; email: string; token: string }> { + const role = await prisma.role.create({ data: { businessId: business.businessId, name: "Barber" } }); + const email = `invitee-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.local`; + const result = await createStaffProfile(prisma, { + businessId: business.businessId, + input: { name: "New Invitee", email, roleId: role.id, serviceIds: [], isBookableOnline: true }, + }); + if (!result.success) throw new Error("Test setup failed: couldn't create the invited staff profile."); + business.createdUserIds.push(result.staff.userId); + + const invitation = await createInvitation(prisma, { businessId: business.businessId, staffId: result.staff.id, email }); + return { staffId: result.staff.id, email, token: invitation.token }; +} + +async function requestAndExtractCode(email: string): Promise { + const mockProvider = new MockEmailProvider(); + const result = await requestOtp(prisma, { email, purpose: "staff-invite", emailProvider: mockProvider }); + expect(result.ok).toBe(true); + const sent = mockProvider.sentEmails[0]; + const code = /verification code is (\d{6})/.exec(sent?.text ?? "")?.[1]; + if (!code) throw new Error("Test setup failed: couldn't extract the OTP code from the captured email."); + return code; +} + +describe("staff invitation OTP verification (integration)", () => { + let business: TestBusiness; + + beforeEach(async () => { + business = await createTestBusiness(); + }); + + afterEach(async () => { + await cleanupTestBusiness(business); + }); + + it("a newly-invited (unverified) account cannot sign in via plain OTP before accepting", async () => { + const { email } = await createRoleAndInviteStaff(business); + + // createStaffProfile creates the User immediately, before any + // invitation is accepted — this is what onlyIfUserExists must not + // treat as "safe to sign this email in". + const user = await prisma.user.findUniqueOrThrow({ where: { email } }); + expect(user.emailVerified).toBeNull(); + + const mockProvider = new MockEmailProvider(); + const result = await requestOtp(prisma, { email, purpose: "login", onlyIfUserExists: true, emailProvider: mockProvider }); + // Same generic {ok:true} either way — see onlyIfUserExists's own doc + // comment — but nothing should actually have been sent, and no + // EmailOtp row should exist to verify against. + expect(result.ok).toBe(true); + expect(mockProvider.sentEmails).toHaveLength(0); + expect(await prisma.emailOtp.findUnique({ where: { email } })).toBeNull(); + }); + + it("verifying the invitation's OTP activates the account and grants sign-in access", async () => { + const { email, token } = await createRoleAndInviteStaff(business); + const code = await requestAndExtractCode(email); + + const verified = await verifyOtp(prisma, email, code); + expect(verified.ok).toBe(true); + + const result = await acceptInvitation(prisma, { token }); + expect(result.success).toBe(true); + + const user = await prisma.user.findUniqueOrThrow({ where: { email } }); + expect(user.emailVerified).not.toBeNull(); + + // Now that emailVerified is set, onlyIfUserExists must treat this as a + // real, signable-in account. + const mockProvider = new MockEmailProvider(); + const signInRequest = await requestOtp(prisma, { email, purpose: "login", onlyIfUserExists: true, emailProvider: mockProvider }); + expect(signInRequest.ok).toBe(true); + expect(mockProvider.sentEmails).toHaveLength(1); + }); + + it("rejects an expired invitation token", async () => { + const { token } = await createRoleAndInviteStaff(business); + await prisma.staffInvitation.update({ where: { token }, data: { expiresAt: new Date(Date.now() - 1000) } }); + + const check = await validateInvitationToken(prisma, token); + expect(check.valid).toBe(false); + if (check.valid) throw new Error("unreachable"); + expect(check.reason).toBe("EXPIRED"); + }); + + it("rejects an already-accepted invitation token (cannot be replayed)", async () => { + const { email, token } = await createRoleAndInviteStaff(business); + const firstCode = await requestAndExtractCode(email); + expect((await verifyOtp(prisma, email, firstCode)).ok).toBe(true); + expect((await acceptInvitation(prisma, { token })).success).toBe(true); + + const check = await validateInvitationToken(prisma, token); + expect(check.valid).toBe(false); + if (check.valid) throw new Error("unreachable"); + expect(check.reason).toBe("ALREADY_ACCEPTED"); + }); +}); diff --git a/src/services/staff/__tests__/staff-owner.integration.test.ts b/src/services/staff/__tests__/staff-owner.integration.test.ts index a88a4f4..d0d8acf 100644 --- a/src/services/staff/__tests__/staff-owner.integration.test.ts +++ b/src/services/staff/__tests__/staff-owner.integration.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createStaffProfile, deactivateStaff, + isStaffAdministrator, reactivateStaff, setStaffServiceEligibility, updateStaffAsOwner, @@ -16,15 +17,16 @@ import { import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures"; /** - * Covers the owner-only staff management additions from Phase 6: - * onboarding, deactivation/last-owner protection, and service-eligibility - * conflict review. Uses createTestStaff's auto-created role (no - * MANAGE_STAFF permission) as the "ordinary barber" role, and a - * hand-created role with MANAGE_STAFF as the "owner" role — mirroring how - * countOtherActiveManagers/staffCurrentlyManagesStaff actually define "is - * an owner" (see staff.service.ts). + * Covers owner/administrator staff management: onboarding, deactivation/ + * last-administrator protection, role promote/demote, and + * service-eligibility conflict review. Uses createTestStaff's auto-created + * role (no MANAGE_STAFF permission) as the "ordinary barber" role, and a + * hand-created role with MANAGE_STAFF as the "administrator" role — + * mirroring how countOtherActiveAdministrators/isStaffAdministrator + * actually define "is an administrator" (see staff.service.ts): + * permission-based, never a check against the role's name. */ -describe("StaffService — owner actions (integration)", () => { +describe("StaffService — owner/administrator actions (integration)", () => { let business: TestBusiness; beforeEach(async () => { @@ -35,11 +37,11 @@ describe("StaffService — owner actions (integration)", () => { await cleanupTestBusiness(business); }); - async function createOwnerRole() { + async function createAdministratorRole() { return prisma.role.create({ data: { businessId: business.businessId, - name: "Owner", + name: "Administrator", permissions: { create: [{ permission: "MANAGE_STAFF" }] }, }, }); @@ -91,55 +93,100 @@ describe("StaffService — owner actions (integration)", () => { }); }); - describe("last-owner protection", () => { - it("blocks deactivating the only active owner", async () => { - const ownerRole = await createOwnerRole(); - const { staffId: ownerStaffId, userId } = await createTestStaff(business); - await prisma.staffProfile.update({ where: { id: ownerStaffId }, data: { roleId: ownerRole.id } }); + describe("last-administrator protection", () => { + it("blocks deactivating (removing) the only active administrator", async () => { + const administratorRole = await createAdministratorRole(); + const { staffId: administratorStaffId, userId } = await createTestStaff(business); + await prisma.staffProfile.update({ where: { id: administratorStaffId }, data: { roleId: administratorRole.id } }); void userId; - const result = await deactivateStaff(prisma, { staffId: ownerStaffId, businessId: business.businessId }); + const result = await deactivateStaff(prisma, { staffId: administratorStaffId, businessId: business.businessId }); expect(result.success).toBe(false); if (result.success) throw new Error("unreachable"); - expect(result.validation.issues[0]?.code).toBe("LAST_OWNER"); + expect(result.validation.issues[0]?.code).toBe("LAST_ADMINISTRATOR"); + expect(result.validation.issues[0]?.message).toBe( + "You can't remove the only administrator. Add another administrator first.", + ); - const stillActive = await prisma.staffProfile.findUniqueOrThrow({ where: { id: ownerStaffId } }); + const stillActive = await prisma.staffProfile.findUniqueOrThrow({ where: { id: administratorStaffId } }); expect(stillActive.status).toBe("ACTIVE"); }); - it("allows deactivating an owner when another active owner remains", async () => { - const ownerRole = await createOwnerRole(); - const { staffId: firstOwnerId } = await createTestStaff(business); - const { staffId: secondOwnerId } = await createTestStaff(business); + it("allows removing an administrator when another active administrator remains", async () => { + const administratorRole = await createAdministratorRole(); + const { staffId: firstAdminId } = await createTestStaff(business); + const { staffId: secondAdminId } = await createTestStaff(business); await prisma.staffProfile.updateMany({ - where: { id: { in: [firstOwnerId, secondOwnerId] } }, - data: { roleId: ownerRole.id }, + where: { id: { in: [firstAdminId, secondAdminId] } }, + data: { roleId: administratorRole.id }, }); - const result = await deactivateStaff(prisma, { staffId: firstOwnerId, businessId: business.businessId }); + const result = await deactivateStaff(prisma, { staffId: firstAdminId, businessId: business.businessId }); expect(result.success).toBe(true); - const deactivated = await prisma.staffProfile.findUniqueOrThrow({ where: { id: firstOwnerId } }); + const deactivated = await prisma.staffProfile.findUniqueOrThrow({ where: { id: firstAdminId } }); expect(deactivated.status).toBe("INACTIVE"); }); - it("blocks demoting the only active owner's role away from MANAGE_STAFF", async () => { - const ownerRole = await createOwnerRole(); + it("blocks demoting the only active administrator's role away from MANAGE_STAFF", async () => { + const administratorRole = await createAdministratorRole(); const barberRole = await createBarberRole(); - const { staffId: ownerStaffId } = await createTestStaff(business); - await prisma.staffProfile.update({ where: { id: ownerStaffId }, data: { roleId: ownerRole.id } }); + const { staffId: administratorStaffId } = await createTestStaff(business); + await prisma.staffProfile.update({ where: { id: administratorStaffId }, data: { roleId: administratorRole.id } }); const result = await updateStaffAsOwner(prisma, { - staffId: ownerStaffId, + staffId: administratorStaffId, businessId: business.businessId, input: { roleId: barberRole.id }, }); expect(result.success).toBe(false); if (result.success) throw new Error("unreachable"); - expect(result.validation.issues[0]?.code).toBe("LAST_OWNER"); + expect(result.validation.issues[0]?.code).toBe("LAST_ADMINISTRATOR"); + expect(result.validation.issues[0]?.message).toBe( + "You can't remove the only administrator. Add another administrator first.", + ); - const stillOwner = await prisma.staffProfile.findUniqueOrThrow({ where: { id: ownerStaffId } }); - expect(stillOwner.roleId).toBe(ownerRole.id); + const stillAdministrator = await prisma.staffProfile.findUniqueOrThrow({ where: { id: administratorStaffId } }); + expect(stillAdministrator.roleId).toBe(administratorRole.id); + }); + + it("promotes a barber to Administrator", async () => { + const administratorRole = await createAdministratorRole(); + const barberRole = await createBarberRole(); + const { staffId } = await createTestStaff(business); + await prisma.staffProfile.update({ where: { id: staffId }, data: { roleId: barberRole.id } }); + + const result = await updateStaffAsOwner(prisma, { + staffId, + businessId: business.businessId, + input: { roleId: administratorRole.id }, + }); + expect(result.success).toBe(true); + + const promoted = await prisma.staffProfile.findUniqueOrThrow({ where: { id: staffId } }); + expect(promoted.roleId).toBe(administratorRole.id); + expect(await isStaffAdministrator(prisma, staffId)).toBe(true); + }); + + it("demotes an Administrator to Barber when another Administrator remains", async () => { + const administratorRole = await createAdministratorRole(); + const barberRole = await createBarberRole(); + const { staffId: firstAdminId } = await createTestStaff(business); + const { staffId: secondAdminId } = await createTestStaff(business); + await prisma.staffProfile.updateMany({ + where: { id: { in: [firstAdminId, secondAdminId] } }, + data: { roleId: administratorRole.id }, + }); + + const result = await updateStaffAsOwner(prisma, { + staffId: firstAdminId, + businessId: business.businessId, + input: { roleId: barberRole.id }, + }); + expect(result.success).toBe(true); + + expect(await isStaffAdministrator(prisma, firstAdminId)).toBe(false); + expect(await isStaffAdministrator(prisma, secondAdminId)).toBe(true); }); it("reactivating a deactivated staff member restores ACTIVE status", async () => { @@ -152,6 +199,61 @@ describe("StaffService — owner actions (integration)", () => { const staff = await prisma.staffProfile.findUniqueOrThrow({ where: { id: staffId } }); expect(staff.status).toBe("ACTIVE"); }); + + /** + * The concurrency scenario the brief explicitly calls out: a business + * with exactly two administrators, and two simultaneous requests each + * trying to remove a *different* one of them (one demoting, one + * deactivating). Without updateStaffAsOwner/deactivateStaff each + * locking the business row inside their own transaction (see + * staff.service.ts), both could read "the other one is still an + * administrator" before either commits, and both would succeed — + * leaving zero. Run repeatedly (not just once) since a race is + * probabilistic; this asserts the invariant holds regardless of which + * of the two operations happens to win. + */ + it("never leaves zero administrators when two are removed concurrently", async () => { + const administratorRole = await createAdministratorRole(); + const barberRole = await createBarberRole(); + const { staffId: firstAdminId } = await createTestStaff(business); + const { staffId: secondAdminId } = await createTestStaff(business); + await prisma.staffProfile.updateMany({ + where: { id: { in: [firstAdminId, secondAdminId] } }, + data: { roleId: administratorRole.id }, + }); + + const [demoteResult, deactivateResult] = await Promise.all([ + updateStaffAsOwner(prisma, { + staffId: firstAdminId, + businessId: business.businessId, + input: { roleId: barberRole.id }, + }), + deactivateStaff(prisma, { staffId: secondAdminId, businessId: business.businessId }), + ]); + + // Exactly one of the two concurrent removals must have been rejected + // — never both succeeding (zero administrators) and, just as + // importantly, never both being rejected (a business with 2 real + // administrators must be able to remove one of them). + const results = [demoteResult, deactivateResult]; + const succeeded = results.filter((r) => r.success); + const failed = results.filter((r) => !r.success); + expect(succeeded).toHaveLength(1); + expect(failed).toHaveLength(1); + if (!failed[0]!.success) { + expect(failed[0]!.validation.issues[0]?.code).toBe("LAST_ADMINISTRATOR"); + } + + const remainingAdministrators = await prisma.staffProfile.count({ + where: { + businessId: business.businessId, + status: "ACTIVE", + id: { in: [firstAdminId, secondAdminId] }, + role: { permissions: { some: { permission: "MANAGE_STAFF" } } }, + }, + }); + expect(remainingAdministrators).toBeGreaterThanOrEqual(1); + }); }); describe("deactivation appointment conflicts", () => { diff --git a/src/services/staff/email-change.service.ts b/src/services/staff/email-change.service.ts new file mode 100644 index 0000000..2dcb2ff --- /dev/null +++ b/src/services/staff/email-change.service.ts @@ -0,0 +1,117 @@ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import { finalizeOtp, requestOtp, verifyOtp } from "@/lib/auth/otp.service"; +import type { EmailProvider } from "@/services/notifications/providers/email-provider"; +import { fail, ok } from "@/lib/validation-result"; +import type { ValidationResult } from "@/lib/validation-result"; + +/** + * The testable core of "change my login email" (see + * src/features/staff/actions/change-email.ts for the thin Server Action + * wrapper that resolves the caller's session/IP and calls these) — pulled + * into its own service, same split as every other staff-management action + * in this app (StaffService, StaffInvitationService), so this can be + * integration-tested directly without needing a Next.js request context + * for auth()/headers(). + * + * Available to any staff member, Administrator or not — see + * change-email.ts's own header for why this deliberately isn't + * Administrator-gated. + */ + +type Db = PrismaClient | Prisma.TransactionClient; + +export type EmailChangeErrorCode = + | "INVALID_INPUT" + | "SAME_EMAIL" + | "EMAIL_ALREADY_IN_USE" + | "TOO_MANY_ATTEMPTS" + | "INCORRECT_CODE"; + +class EmailChangeConflictError extends Error {} +class EmailChangeNotVerifiedError extends Error {} + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +function isValidEmailFormat(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +/** Step 1 — send a code to the NEW address (never the current one). */ +export async function requestEmailChange( + db: Db, + params: { + currentEmail: string | null; + newEmail: string; + ip?: string; + senderName?: string; + emailProvider: EmailProvider; + }, +): Promise> { + const email = normalizeEmail(params.newEmail); + if (!isValidEmailFormat(email)) { + return fail({ code: "INVALID_INPUT", message: "Enter a valid email address." }); + } + if (email === params.currentEmail?.toLowerCase()) { + return fail({ code: "SAME_EMAIL", message: "That's already your email address." }); + } + + const existing = await db.user.findUnique({ where: { email }, select: { id: true } }); + if (existing) { + return fail({ code: "EMAIL_ALREADY_IN_USE", message: "That email is already in use." }); + } + + const result = await requestOtp(db, { + email, + purpose: "email-change", + ip: params.ip, + senderName: params.senderName, + emailProvider: params.emailProvider, + }); + if (!result.ok) { + return fail({ code: "TOO_MANY_ATTEMPTS", message: "Too many attempts. Please try again later." }); + } + return ok(); +} + +/** + * Step 2 — verifies the code, then re-checks the uniqueness conflict and + * finalizes the OTP together with the actual `User.email` write inside one + * transaction (closes the request-to-verify race: someone else could have + * claimed the address in between). The old email is untouched until this + * succeeds. + */ +export async function verifyEmailChange( + prisma: PrismaClient, + params: { userId: string; newEmail: string; code: string; ip?: string }, +): Promise> { + const email = normalizeEmail(params.newEmail); + const verified = await verifyOtp(prisma, email, params.code, params.ip); + if (!verified.ok) { + return fail({ code: "INCORRECT_CODE", message: "Incorrect or expired code. Please try again." }); + } + + try { + await prisma.$transaction(async (tx) => { + const conflict = await tx.user.findUnique({ where: { email }, select: { id: true } }); + if (conflict && conflict.id !== params.userId) { + throw new EmailChangeConflictError(); + } + if (!(await finalizeOtp(tx, email))) { + throw new EmailChangeNotVerifiedError(); + } + await tx.user.update({ where: { id: params.userId }, data: { email, emailVerified: new Date() } }); + }); + } catch (error) { + if (error instanceof EmailChangeConflictError) { + return fail({ code: "EMAIL_ALREADY_IN_USE", message: "That email is already in use." }); + } + if (error instanceof EmailChangeNotVerifiedError) { + return fail({ code: "INCORRECT_CODE", message: "Incorrect or expired code. Please try again." }); + } + throw error; + } + + return ok(); +} diff --git a/src/services/staff/staff-invitation.service.ts b/src/services/staff/staff-invitation.service.ts index bf14fa1..eb3d170 100644 --- a/src/services/staff/staff-invitation.service.ts +++ b/src/services/staff/staff-invitation.service.ts @@ -8,9 +8,14 @@ import { getOwnerUserIds } from "@/services/notifications/recipients"; * The "invite a new staff member" flow, deliberately split out of * StaffService (see docs/phase-6-summary.md, "Domain services" — the * brief's own instruction not to mix invitation delivery into StaffService). - * StaffService.createStaffProfile makes the User/StaffProfile usable - * immediately; this file only tracks the separate "has this person set - * their own password yet" signal via StaffInvitation.acceptedAt. + * StaffService.createStaffProfile creates the User/StaffProfile + * immediately, but requestOtp's `onlyIfUserExists` check (src/lib/auth/ + * otp.service.ts) also requires `emailVerified` — so a newly-invited + * account genuinely cannot sign in until this file's acceptInvitation sets + * that, which itself only happens after the invitee verifies an OTP sent + * to this exact email (see src/features/auth/actions/accept-invitation.ts). + * This file tracks that "has this person verified their email yet" signal + * via StaffInvitation.acceptedAt. * * `token` is a plain opaque value (crypto-random, not HMAC-signed like the * booking-draft cookie, because it's validated against a database row @@ -58,7 +63,7 @@ export async function createInvitation( type: NotificationType.STAFF_INVITATION_RECEIVED, recipientUserId: staff.user.id, subject: `You've been invited to join ${staff.business.name}`, - bodyText: `${invitedBy?.name ?? "The owner"} has added you as ${staff.role.name} at ${staff.business.name}. Set your password to get started.`, + bodyText: `${invitedBy?.name ?? "The business's administrator"} has added you as ${staff.role.name} at ${staff.business.name}. Verify your email to get started — no password needed.`, deepLinkPath: `/invite/accept?token=${invitation.token}`, idempotencyKey: `notify:staff-invitation-received:${invitation.id}`, }); @@ -105,13 +110,16 @@ export async function validateInvitationToken(prisma: PrismaClient, token: strin } /** - * Activates the invited staff member's account — no password to set: - * possessing this exact token (delivered only to the invited email) is - * itself the proof of email ownership, same as it already was for - * `emailVerified` before this function ever checked a password (see this - * file's own header). The caller (acceptInvitationAction) signs the user - * in immediately after, via src/auth.ts's token-gated finalize path, - * reusing this same invitation token as the required secret. + * Activates the invited staff member's account — no password to set. The + * caller (verifyInvitationOtpAction, src/features/auth/actions/accept- + * invitation.ts) has already required the invitee to request and verify a + * one-time code sent to this exact email (via requestOtp/verifyOtp, + * purpose "staff-invite") before calling this — the token alone is no + * longer treated as sufficient proof of email ownership on its own. This + * function itself just does the actual account-activation bookkeeping + * (emailVerified + acceptedAt) once that verification has already + * succeeded, still re-validating the token/expiry/already-accepted state + * one more time for safety. */ export async function acceptInvitation( prisma: PrismaClient, @@ -138,7 +146,7 @@ export async function acceptInvitation( type: NotificationType.OWNER_STAFF_INVITATION_ACCEPTED, recipientUserId: ownerUserId, subject: "Invitation accepted", - bodyText: `${check.invitation.staff.user.name ?? "A new team member"} has set their password and joined ${check.invitation.staff.business.name}.`, + bodyText: `${check.invitation.staff.user.name ?? "A new team member"} has verified their email and joined ${check.invitation.staff.business.name}.`, deepLinkPath: `/owner/team/${check.invitation.staff.id}`, idempotencyKey: `notify:owner-staff-invitation-accepted:${check.invitation.id}:${ownerUserId}`, }); diff --git a/src/services/staff/staff.service.ts b/src/services/staff/staff.service.ts index 1eabbfd..358b518 100644 --- a/src/services/staff/staff.service.ts +++ b/src/services/staff/staff.service.ts @@ -1,4 +1,4 @@ -import type { PrismaClient } from "@/generated/prisma/client"; +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { Weekday } from "@/generated/prisma/enums"; import { calendarDateOf, @@ -34,7 +34,7 @@ export type StaffServiceErrorCode = | "PERMISSION_DENIED" | "EMAIL_ALREADY_IN_USE" | "ROLE_NOT_FOUND" - | "LAST_OWNER"; + | "LAST_ADMINISTRATOR"; export class StaffPermissionDeniedError extends Error { constructor(message = "You don't have permission to manage this staff member's schedule.") { @@ -43,6 +43,19 @@ export class StaffPermissionDeniedError extends Error { } } +/** A Prisma client or an in-flight transaction client — same convention as src/lib/auth/otp.service.ts's `Db` alias, so the last-administrator count/update below can run atomically inside one `$transaction`. */ +type Db = PrismaClient | Prisma.TransactionClient; + +/** Thrown inside a `$transaction` callback below to carry a specific StaffServiceErrorCode back out through the surrounding try/catch — mirrors installer.service.ts's own InstallStepError pattern. */ +class StaffOperationError extends Error { + constructor( + readonly code: StaffServiceErrorCode, + message: string, + ) { + super(message); + } +} + /** Every mutation here takes the acting staff member's own id and asserts the resource belongs to them — a staff member manages only their own schedule this phase (see docs/phase-5-summary.md, "Permission model"). */ export function assertOwnsResource(resourceStaffId: string, actingStaffId: string): void { if (resourceStaffId !== actingStaffId) { @@ -696,12 +709,12 @@ export async function listRolesForBusiness(prisma: PrismaClient, businessId: str }); } -/** A role holding MANAGE_STAFF is this app's definition of "an owner" — see docs/phase-6-summary.md, "Roles and permissions". */ -async function countOtherActiveManagers( - prisma: PrismaClient, +/** A role holding MANAGE_STAFF is this app's definition of "an Administrator" — see docs/phase-6-summary.md, "Roles and permissions". Deliberately permission-based, not a check against the role's name — stays correct even if a business ever gets a custom role that also grants MANAGE_STAFF. */ +async function countOtherActiveAdministrators( + db: Db, params: { businessId: string; excludeStaffId: string }, ) { - return prisma.staffProfile.count({ + return db.staffProfile.count({ where: { businessId: params.businessId, status: "ACTIVE", @@ -711,8 +724,9 @@ async function countOtherActiveManagers( }); } -async function staffCurrentlyManagesStaff(prisma: PrismaClient, staffId: string): Promise { - const staff = await prisma.staffProfile.findUnique({ +/** Exported so the owner-actions layer can decide when a role change / deactivation crosses the Administrator boundary, for audit-log naming (ADMINISTRATOR_PROMOTED/DEMOTED/REMOVED vs. the generic STAFF_* actions). */ +export async function isStaffAdministrator(db: Db, staffId: string): Promise { + const staff = await db.staffProfile.findUnique({ where: { id: staffId }, select: { status: true, role: { select: { permissions: { select: { permission: true } } } } }, }); @@ -796,63 +810,87 @@ export interface UpdateStaffAsOwnerInput { roleId?: string; } +/** + * A role change is the only path here that can ever reduce the business's + * administrator count, so the last-administrator check and the actual + * write must happen atomically — otherwise two concurrent requests each + * demoting a *different* administrator could both read "someone else is + * still an administrator" before either commits, and both succeed, + * leaving zero. When a role change is requested, this acquires a + * `SELECT ... FOR UPDATE` lock on the business's own row first (same + * `$queryRaw` tagged-template pattern already used in + * src/app/api/health/route.ts) so a second concurrent call for the same + * business blocks until the first transaction commits — at which point its + * own count reflects the first call's already-applied change. Calls that + * don't touch `roleId` skip the lock entirely so ordinary concurrent + * profile edits for different staff members are never needlessly + * serialized against each other. + */ export async function updateStaffAsOwner( prisma: PrismaClient, params: { staffId: string; businessId: string; input: UpdateStaffAsOwnerInput }, ) { - const existing = await prisma.staffProfile.findFirst({ - where: { id: params.staffId, businessId: params.businessId }, - }); - if (!existing) { - return { success: false as const, validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }) }; - } - - if (params.input.roleId && params.input.roleId !== existing.roleId) { - const role = await prisma.role.findFirst({ where: { id: params.input.roleId, businessId: params.businessId } }); - if (!role) { - return { success: false as const, validation: fail({ code: "ROLE_NOT_FOUND", message: "Role not found." }) }; - } - const wasManager = await staffCurrentlyManagesStaff(prisma, params.staffId); - const stillManagesAfter = !!(await prisma.rolePermission.findFirst({ - where: { roleId: role.id, permission: "MANAGE_STAFF" }, - })); - if (wasManager && !stillManagesAfter) { - const otherManagers = await countOtherActiveManagers(prisma, { - businessId: params.businessId, - excludeStaffId: params.staffId, - }); - if (otherManagers === 0) { - return { - success: false as const, - validation: fail({ - code: "LAST_OWNER", - message: "This is the only active owner — assign another owner first.", - }), - }; + try { + await prisma.$transaction(async (tx) => { + if (params.input.roleId !== undefined) { + await tx.$queryRaw`SELECT id FROM Business WHERE id = ${params.businessId} FOR UPDATE`; } - } - } - await prisma.$transaction([ - prisma.user.update({ - where: { id: existing.userId }, - data: { - ...(params.input.name !== undefined ? { name: params.input.name } : {}), - ...(params.input.phone !== undefined ? { phone: params.input.phone || null } : {}), - ...(params.input.image !== undefined ? { image: params.input.image || null } : {}), - }, - }), - prisma.staffProfile.update({ - where: { id: params.staffId }, - data: { - ...(params.input.bio !== undefined ? { bio: params.input.bio || null } : {}), - ...(params.input.isBookableOnline !== undefined - ? { isBookableOnline: params.input.isBookableOnline } - : {}), - ...(params.input.roleId !== undefined ? { roleId: params.input.roleId } : {}), - }, - }), - ]); + const existing = await tx.staffProfile.findFirst({ + where: { id: params.staffId, businessId: params.businessId }, + }); + if (!existing) { + throw new StaffOperationError("STAFF_NOT_FOUND", "Staff member not found."); + } + + if (params.input.roleId && params.input.roleId !== existing.roleId) { + const role = await tx.role.findFirst({ where: { id: params.input.roleId, businessId: params.businessId } }); + if (!role) { + throw new StaffOperationError("ROLE_NOT_FOUND", "Role not found."); + } + const wasAdministrator = await isStaffAdministrator(tx, params.staffId); + const stillAdministratorAfter = !!(await tx.rolePermission.findFirst({ + where: { roleId: role.id, permission: "MANAGE_STAFF" }, + })); + if (wasAdministrator && !stillAdministratorAfter) { + const otherAdministrators = await countOtherActiveAdministrators(tx, { + businessId: params.businessId, + excludeStaffId: params.staffId, + }); + if (otherAdministrators === 0) { + throw new StaffOperationError( + "LAST_ADMINISTRATOR", + "You can't remove the only administrator. Add another administrator first.", + ); + } + } + } + + await tx.user.update({ + where: { id: existing.userId }, + data: { + ...(params.input.name !== undefined ? { name: params.input.name } : {}), + ...(params.input.phone !== undefined ? { phone: params.input.phone || null } : {}), + ...(params.input.image !== undefined ? { image: params.input.image || null } : {}), + }, + }); + await tx.staffProfile.update({ + where: { id: params.staffId }, + data: { + ...(params.input.bio !== undefined ? { bio: params.input.bio || null } : {}), + ...(params.input.isBookableOnline !== undefined + ? { isBookableOnline: params.input.isBookableOnline } + : {}), + ...(params.input.roleId !== undefined ? { roleId: params.input.roleId } : {}), + }, + }); + }); + } catch (error) { + if (error instanceof StaffOperationError) { + return { success: false as const, validation: fail({ code: error.code, message: error.message }) }; + } + throw error; + } return { success: true as const, validation: ok() }; } @@ -888,24 +926,6 @@ export async function deactivateStaff( }; } - const wasManager = await staffCurrentlyManagesStaff(prisma, params.staffId); - if (wasManager) { - const otherManagers = await countOtherActiveManagers(prisma, { - businessId: params.businessId, - excludeStaffId: params.staffId, - }); - if (otherManagers === 0) { - return { - success: false as const, - validation: fail({ - code: "LAST_OWNER", - message: "This is the only active owner — assign another owner first.", - }), - conflicts: [], - }; - } - } - if (!params.acknowledgeConflicts) { const conflicts = await findStaffDeactivationConflicts(prisma, { staffId: params.staffId }); if (conflicts.length > 0) { @@ -920,7 +940,37 @@ export async function deactivateStaff( } } - await prisma.staffProfile.update({ where: { id: params.staffId }, data: { status: "INACTIVE" } }); + // Last-administrator protection is checked fresh, inside the same + // transaction as the actual write, under a lock on the business row — + // see updateStaffAsOwner's own comment for why this needs to be atomic + // rather than the read-then-write this used to be. + try { + await prisma.$transaction(async (tx) => { + await tx.$queryRaw`SELECT id FROM Business WHERE id = ${params.businessId} FOR UPDATE`; + + const wasAdministrator = await isStaffAdministrator(tx, params.staffId); + if (wasAdministrator) { + const otherAdministrators = await countOtherActiveAdministrators(tx, { + businessId: params.businessId, + excludeStaffId: params.staffId, + }); + if (otherAdministrators === 0) { + throw new StaffOperationError( + "LAST_ADMINISTRATOR", + "You can't remove the only administrator. Add another administrator first.", + ); + } + } + + await tx.staffProfile.update({ where: { id: params.staffId }, data: { status: "INACTIVE" } }); + }); + } catch (error) { + if (error instanceof StaffOperationError) { + return { success: false as const, validation: fail({ code: error.code, message: error.message }), conflicts: [] }; + } + throw error; + } + return { success: true as const, conflicts: [], validation: ok() }; }