Multi-administrator support
CI / ci (push) Canceled after 0s

1. Summary of changes
The installer-created account is no longer special. "Owner" is now literally renamed Administrator and behaves exactly like the existing role/permission system already allowed — the codebase turned out to already define "administrator" structurally (a StaffProfile whose role holds MANAGE_STAFF), not as a name or a business.ownerId-style field, and last-administrator protection already existed. The real work was: closing the gap between that and a full admin-management UI, fixing two OTP-migration bugs the new spec's requirements exposed, and making the existing protection transactional.

Businesses can now have any number of Administrators, added via Invite Administrator (name + email → OTP-verified before access), promoted/demoted via the existing staff role dropdown (now intercepted with a confirmation dialog when it crosses the Administrator boundary), or removed via the existing deactivate flow.
Two real bugs found and fixed, both required for the spec's "unverified email must not get access":
Invitation acceptance (/invite/accept) never actually verified an OTP — it silently relied on a sign-in path that would have failed for every real invitee. It now sends and verifies a real code, reusing the shared OtpCodeStep UI.
A newly-invited (not-yet-accepted) account could sign in via plain email OTP before ever touching the invite link, because sign-in only checked "does a User row exist," not whether it was verified. Fixed in the shared OTP service, benefiting every invitation, not just administrators'.
Last-administrator protection (already existed) is now transactional — a SELECT ... FOR UPDATE lock on the business row closes the race where two concurrent removals of two different administrators could both succeed.
Administrators (any staff member, in fact) can now change their own email — new address must be OTP-verified before it replaces the login email.
No hidden installer privilege: confirmed via code audit and a live install → the account is a plain Administrator-role staff member, provable identically via the Team UI as any later-added one.
NextChair Manager and platform Super Administrator are untouched.
2. Prisma/database changes
No schema changes. One data-only migration: prisma/migrations/20260905214944_administrator_role_rename — UPDATE Role SET name = 'Administrator' WHERE name = 'Owner' AND isSystem = true. Runs through the normal migration-deploy process; not something to run by hand.

3. How Administrator roles are represented
A per-business Role row named "Administrator" (was "Owner"), isSystem: true, holding every Permission — created identically at install and in dev seed data. This is what "Invite"/"Make Administrator" assign.
Whether a StaffProfile currently counts as an Administrator is permission-based (holds MANAGE_STAFF), not a name check — the same definition the codebase already used for last-owner protection (isStaffAdministrator in staff.service.ts). This is deliberately reused rather than introduced, and stays correct if a business ever gets a custom role that also grants MANAGE_STAFF.
No Business.adminId-style field anywhere — confirmed none existed before this work either.
4. How administrator invitations work
Reuses the existing StaffInvitation model and createStaffProfile/createInvitation machinery verbatim — a new inviteAdministratorAction just pre-selects the Administrator role and uses a minimal Name+Email form (invite-administrator-form.tsx). Acceptance (accept-invitation.ts) now requires a real OTP code sent to the invited email before the account activates — expiry (7 days), resend, and revoke are all the pre-existing invitation mechanics (revoke = the existing deactivate action, relabeled "Revoke Invitation" while pending).

5. How last-administrator protection is enforced
Server-side only, inside staff.service.ts's updateStaffAsOwner (role change) and deactivateStaff (remove), each wrapped in prisma.$transaction that first locks the business row (SELECT ... FOR UPDATE), then counts other active Administrators before allowing the change. Verified with a genuine concurrency test (two simultaneous removals against a 2-admin business) and manually in-browser (see below).

6. Installation flow changes
Only the role name and one line of wizard copy changed ("This becomes the first Administrator..."). No structural UX change — still collects name/email, verifies via OTP, creates/activates the account, assigns the role, exactly as before.

7. Files changed
New: the migration; email-change.service.ts + change-email.ts action + change-email-section.tsx; invite-administrator-form.tsx + add-administrator/page.tsx; 3 new integration test files.

Modified — core: staff.service.ts (transactional protection, isStaffAdministrator), staff-invitation.service.ts, otp.service.ts (onlyIfUserExists fix, email-change purpose), default-permissions.ts, permissions.ts, auth.ts (JWT email-refresh, simplified finalize path).

Modified — actions/UI: owner/actions/staff.ts, staff-detail-panel.tsx, team/page.tsx, add-staff-form.tsx, accept-invitation.ts/form/page, otp-code-step.tsx, profile-form.tsx, installer files, seed.ts, plus a terminology sweep ("owner" → "administrator" copy in breaks.ts/time-off.ts/staff-emails.ts/profile page).

8. Manual testing steps
Sign in as the seeded owner → /owner/team shows an Administrator badge and an "Invite Administrator" icon next to "+". Invite someone, accept via /invite/accept?token=... printed to the terminal (code also prints there) — they land signed in with full Manage access. Promote/demote any staff member via the role dropdown on their detail page — crossing the Administrator boundary shows a confirmation dialog. With only one Administrator left, attempting to demote or deactivate them shows "You can't remove the only administrator. Add another administrator first." Change your own email from Profile settings — the new address must be OTP-verified before it replaces the old one.

9. Deployment steps on the VPS
None beyond the normal deploy process — the migration is a standard Prisma migration that prisma migrate deploy (whatever already runs it there) will pick up automatically. No new environment variables. No changes to NextChair Manager or its authentication.

Verification performed: tsc/lint/unit — all clean; full test:integration — 219 passed (up from 207, all new); full test:e2e — same 36/37 as before this work (the 1 failure is a pre-existing, auth-unrelated flake unrelated to any of this); a full live manual pass covering every scenario above, including the last-administrator block and the email-change JWT-refresh behavior.
This commit is contained in:
Stefan Lazic committed 2026-09-05 23:32:54 +01:00
1 parent 94aa1b2f26
commit 973187dcc7
33 files changed
+1348 -265

No files matched your search

@@ -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;
+5 -5
View File
@@ -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" } },
+9 -2
View File
@@ -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 <InvalidInvitation message={message} />;
}
if (!check.invitation.staff.user.email) {
return <InvalidInvitation message="This invitation link isn't valid." />;
}
return (
<div>
<h1 className="mb-2 text-3xl font-bold tracking-tight">Welcome, {check.invitation.staff.user.name}</h1>
<p className="text-muted mb-10">Finish setting up your staff account.</p>
<AcceptInvitationForm token={token} businessName={check.invitation.staff.business.name} />
<AcceptInvitationForm
token={token}
email={check.invitation.staff.user.email}
businessName={check.invitation.staff.business.name}
/>
</div>
);
}
@@ -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 (
<div className="pb-12">
<TopNav title="Invite Administrator" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<InviteAdministratorForm />
</main>
</div>
);
}
+22 -9
View File
@@ -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({
<div className="px-6 pt-12 pb-12 lg:max-w-4xl">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight">Team</h1>
<Link
href="/owner/team/add"
className="bg-ink flex h-10 w-10 items-center justify-center rounded-full text-white"
aria-label="Add staff member"
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Link>
<div className="flex items-center gap-2">
<Link
href="/owner/team/add-administrator"
className="border-line text-ink flex h-10 w-10 items-center justify-center rounded-full border"
aria-label="Invite administrator"
title="Invite Administrator"
>
<ShieldPlus className="h-4 w-4" aria-hidden="true" />
</Link>
<Link
href="/owner/team/add"
className="bg-ink flex h-10 w-10 items-center justify-center rounded-full text-white"
aria-label="Add staff member"
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
</div>
<form action="/owner/team" method="GET" className="relative mb-4">
@@ -79,7 +89,10 @@ export default async function OwnerTeamPage({
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold">{s.user.name}</p>
<p className="text-muted truncate text-xs">{s.user.email}</p>
<p className="text-accent mt-0.5 text-[10px] font-bold tracking-widest uppercase">
<p className="text-accent mt-0.5 flex items-center gap-1 text-[10px] font-bold tracking-widest uppercase">
{s.role.name === "Administrator" && (
<ShieldCheck className="h-3 w-3 shrink-0" aria-hidden="true" />
)}
{s.role.name}
{s.status === "INACTIVE" && <span className="text-muted"> · Inactive</span>}
{s.status === "ACTIVE" && s.invitation && !s.invitation.acceptedAt && (
@@ -27,6 +27,7 @@ export default async function StaffProfilePage() {
image: profile.user.image ?? "",
bio: profile.bio ?? "",
isBookableOnline: profile.isBookableOnline,
email: profile.user.email ?? "",
}}
/>
@@ -36,7 +37,7 @@ export default async function StaffProfilePage() {
</p>
{profile.services.length === 0 ? (
<p className="text-muted rounded-md border border-line bg-white p-5 text-center text-sm">
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.
</p>
) : (
<ul className="divide-y divide-line rounded-md border border-line bg-white">
@@ -48,7 +49,7 @@ export default async function StaffProfilePage() {
</ul>
)}
<p className="text-muted mt-2 text-xs">
Only the business owner can change which services you offer.
Only an administrator can change which services you offer.
</p>
</div>
</main>
+38 -26
View File
@@ -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<boolean> {
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 }) {
+61 -25
View File
@@ -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<AcceptInvitationResult> {
export async function requestInvitationOtpAction(token: string): Promise<InvitationOtpActionResult> {
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<InvitationOtpActionResult> {
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 };
}
@@ -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<string | null>(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 <OtpCodeStep email={email} onVerify={verifyCode} onResend={resendCode} verifyLabel="Accept & Sign In" />;
}
return (
<div className="space-y-4">
<p className="text-muted text-sm">
You&apos;ve been invited to join <span className="text-ink font-bold">{businessName}</span>. This link
confirms it&apos;s really you — no password needed.
You&apos;ve been invited to join <span className="text-ink font-bold">{businessName}</span>. Verify your
email to get started — no password needed.
</p>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button type="button" className="mt-4 w-full" isLoading={isPending} onClick={onAccept}>
Accept &amp; Sign In
<Button type="button" className="mt-4 w-full" isLoading={isPending} onClick={sendCode}>
Send Verification Code
</Button>
</div>
);
+15 -11
View File
@@ -13,19 +13,21 @@ export interface OtpCodeStepProps {
email: string;
onVerify: (code: string) => Promise<OtpVerifyResult>;
onResend: () => Promise<OtpVerifyResult>;
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
</button>
<button type="button" onClick={onChangeEmail} className="text-muted font-bold underline">
Change email
</button>
{onChangeEmail && (
<button type="button" onClick={onChangeEmail} className="text-muted font-bold underline">
Change email
</button>
)}
</div>
</div>
);
@@ -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 } });
@@ -363,7 +363,9 @@ export function InstallWizard({
{step === 2 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Administrator account</h2>
<p className="text-muted text-sm">This becomes the first owner — full permissions, protected from removal.</p>
<p className="text-muted text-sm">
This becomes the first Administrator — full permissions. You can add or change administrators later.
</p>
{otpPhase === "code" ? (
<OtpCodeStep
@@ -2,7 +2,7 @@ import type { PrismaClient, Prisma } from "@/generated/prisma/client";
import { InstallationStatus, RewardType } from "@/generated/prisma/enums";
import { finalizeOtp } from "@/lib/auth/otp.service";
import { INSTALLATION_SINGLETON_ID } from "@/lib/installation/installation-state";
import { OWNER_PERMISSIONS, BARBER_PERMISSIONS } from "@/lib/roles/default-permissions";
import { ADMINISTRATOR_PERMISSIONS, BARBER_PERMISSIONS } from "@/lib/roles/default-permissions";
import { secretsMatch } from "@/lib/security/secrets-match";
import { validateBusinessProfileInput, validateOpeningHoursInput } from "@/services/business/business.service";
import { validateBookingRuleInput } from "@/services/scheduling/booking-rule.service";
@@ -224,13 +224,13 @@ export async function installApplication(
},
});
const [ownerRole] = await Promise.all([
const [administratorRole] = await Promise.all([
tx.role.create({
data: {
businessId: business.id,
name: "Owner",
name: "Administrator",
isSystem: true,
permissions: { create: OWNER_PERMISSIONS.map((permission) => ({ 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,
},
},
@@ -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" }] },
},
});
+3 -3
View File
@@ -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",
});
+79 -3
View File
@@ -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<AddStaffR
return { success: true, staffId: result.staff.id, inviteToken: invitation.token };
}
export interface InviteAdministratorResult {
success: boolean;
error?: string;
staffId?: string;
inviteToken?: string;
}
/**
* A thin, deliberately narrow variant of addStaffAction: a new person, not
* a role change, always assigned this business's Administrator role. Name
* + email only — the fields an administrator invite actually needs, unlike
* the full add-staff form (services, bio, bookable-online don't apply to a
* pure administrator). Reuses createStaffProfile/createInvitation exactly
* as-is, including createStaffProfile's own EMAIL_ALREADY_IN_USE check.
*/
export async function inviteAdministratorAction(input: { name: string; email: string }): Promise<InviteAdministratorResult> {
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",
});
+3 -3
View File
@@ -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",
});
@@ -61,8 +61,8 @@ export function AddStaffForm({ roles, services }: AddStaffFormProps) {
<div className="space-y-4 rounded-md border-line border bg-white p-6">
<p className="text-sm font-bold">{name} has been added.</p>
<p className="text-muted text-sm">
Email delivery isn&apos;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.
</p>
<div className="border-line bg-stone break-all rounded-lg border p-3 text-xs">{inviteLink}</div>
<div className="flex gap-3">
@@ -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<string | null>(null);
const [inviteLink, setInviteLink] = useState<string | null>(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 (
<div className="space-y-4 rounded-md border-line border bg-white p-6">
<p className="text-sm font-bold">{name} has been invited as an Administrator.</p>
<p className="text-muted text-sm">
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.
</p>
<div className="border-line bg-stone break-all rounded-lg border p-3 text-xs">{inviteLink}</div>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={copyLink}>
{copied ? "Copied" : "Copy Link"}
</Button>
<Button className="flex-1" onClick={() => router.push("/owner/team")}>
Done
</Button>
</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<p className="text-muted text-sm">
They&apos;ll be able to manage business settings, staff, and other administrators once they verify their
email.
</p>
<Input label="Name" required value={name} onChange={(e) => setName(e.target.value)} />
<Input label="Email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={submit}>
Invite Administrator
</Button>
</div>
);
}
@@ -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<string | null>(null);
const [roleSaved, setRoleSaved] = useState(false);
const [confirmRoleChangeOpen, setConfirmRoleChangeOpen] = useState(false);
const [servicesError, setServicesError] = useState<string | null>(null);
const [servicesSaved, setServicesSaved] = useState(false);
const [serviceConflicts, setServiceConflicts] = useState<ConflictItem[]>([]);
@@ -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({
<section className="rounded-md border-warning/30 bg-warning/10 border p-5">
<p className="text-warning-foreground text-sm font-bold">Invitation pending</p>
<p className="text-muted mt-1 text-xs">
This person hasn&apos;t set a password yet. Email delivery isn&apos;t wired up — send them the link
yourself.
This person hasn&apos;t verified their email yet — an invitation email has been sent, and you can also
share this link yourself as a backup.
</p>
{inviteLink ? (
<div className="mt-3 space-y-2">
@@ -147,7 +175,15 @@ export function StaffDetailPanel({
)}
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Role &amp; Permissions</h3>
<div className="ml-1 flex items-center gap-2">
<h3 className="text-muted text-[10px] font-bold tracking-widest uppercase">Role &amp; Permissions</h3>
{isCurrentlyAdministrator && (
<span className="bg-accent/10 text-accent inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold tracking-widest uppercase">
<ShieldCheck className="h-3 w-3 shrink-0" aria-hidden="true" />
Administrator
</span>
)}
</div>
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Select label="Role" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
{roles.map((r) => (
@@ -166,12 +202,39 @@ export function StaffDetailPanel({
Saved.
</p>
)}
<Button size="sm" isLoading={isRolePending} onClick={saveRole} disabled={roleId === currentRoleId}>
<Button size="sm" isLoading={isRolePending} onClick={onSaveRoleClick} disabled={roleId === currentRoleId}>
Save Role
</Button>
</div>
</section>
<Modal
open={confirmRoleChangeOpen}
onClose={() => setConfirmRoleChangeOpen(false)}
title={pendingRoleIsAdministrator ? "Make Administrator?" : "Remove administrator access?"}
>
<div className="space-y-4">
<p className="text-muted text-sm">
{pendingRoleIsAdministrator
? "This person will be able to manage business settings, staff, and other administrators."
: "This person will no longer be able to manage business settings, staff or other administrators."}
</p>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={() => setConfirmRoleChangeOpen(false)}>
Cancel
</Button>
<Button
variant={pendingRoleIsAdministrator ? "primary" : "destructive"}
className="flex-1"
isLoading={isRolePending}
onClick={saveRole}
>
Confirm
</Button>
</div>
</div>
</Modal>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Services Provided</h3>
<div className="rounded-md border-line space-y-3 border bg-white p-6">
@@ -280,7 +343,7 @@ export function StaffDetailPanel({
isLoading={isDeactivatePending}
onClick={() => deactivate(false)}
>
Deactivate Staff Member
{hasPendingInvitation ? "Revoke Invitation" : "Deactivate Staff Member"}
</Button>
</>
)}
@@ -0,0 +1,60 @@
"use server";
import { prisma } from "@/lib/db/prisma";
import { getRequestIp } from "@/lib/security/request-ip";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import { isStaffAdministrator } from "@/services/staff/staff.service";
import { requestEmailChange, verifyEmailChange } from "@/services/staff/email-change.service";
import { getEmailProvider } from "@/services/notifications/providers";
export interface EmailChangeActionResult {
ok: boolean;
error?: string;
}
/**
* Thin Server Action wrapper — the actual logic (validation, uniqueness
* checks, the OTP request/verify/finalize calls, the transactional email
* write) lives in email-change.service.ts, which is directly integration-
* tested without needing a Next.js request context for auth()/headers().
* Available to any signed-in staff member (Administrator or not) — see
* that file's own header for why this isn't Administrator-only.
*/
export async function requestEmailChangeAction(newEmail: string): Promise<EmailChangeActionResult> {
const staff = await getActingStaff();
if (!staff) return { ok: false, error: "You must be signed in as staff." };
const ip = await getRequestIp();
const result = await requestEmailChange(prisma, {
currentEmail: staff.user.email,
newEmail,
ip,
senderName: staff.business.name,
emailProvider: getEmailProvider(),
});
if (!result.valid) return { ok: false, error: result.issues[0]?.message ?? "Something went wrong." };
return { ok: true };
}
export async function verifyEmailChangeAction(newEmail: string, code: string): Promise<EmailChangeActionResult> {
const staff = await getActingStaff();
if (!staff) return { ok: false, error: "You must be signed in as staff." };
const oldEmail = staff.user.email;
const wasAdministrator = await isStaffAdministrator(prisma, staff.id);
const ip = await getRequestIp();
const result = await verifyEmailChange(prisma, { userId: staff.userId, newEmail, code, ip });
if (!result.valid) return { ok: false, error: result.issues[0]?.message ?? "Something went wrong." };
await writeAuditLog(prisma, {
actorUserId: staff.userId,
action: wasAdministrator ? "ADMINISTRATOR_EMAIL_CHANGED" : "STAFF_EMAIL_CHANGED",
entityType: "StaffProfile",
entityId: staff.id,
metadata: { oldEmail, newEmail: newEmail.trim().toLowerCase() },
});
return { ok: true };
}
@@ -0,0 +1,112 @@
"use client";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { OtpCodeStep } from "@/features/auth/components/otp-code-step";
import { requestEmailChangeAction, verifyEmailChangeAction } from "../actions/change-email";
type Phase = "idle" | "editing" | "code";
/**
* Self-service email change for any signed-in staff member (see
* change-email.ts's own header for why this isn't Administrator-only).
* The login email is only ever replaced after the new address is verified
* via OTP (verifyEmailChangeAction) — nothing here writes it earlier.
*/
export function ChangeEmailSection({ currentEmail }: { currentEmail: string }) {
const router = useRouter();
const { update: updateSession } = useSession();
const [phase, setPhase] = useState<Phase>("idle");
const [newEmail, setNewEmail] = useState("");
const [displayedEmail, setDisplayedEmail] = useState(currentEmail);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [isPending, setIsPending] = useState(false);
const sendCode = async () => {
setError(null);
setIsPending(true);
const result = await requestEmailChangeAction(newEmail);
setIsPending(false);
if (!result.ok) {
setError(result.error ?? "Something went wrong. Please try again.");
return;
}
setPhase("code");
};
const verifyCode = async (code: string) => {
const result = await verifyEmailChangeAction(newEmail, code);
if (result.ok) {
setDisplayedEmail(newEmail);
setSuccess(true);
setPhase("idle");
// Refresh the JWT's own email claim (see src/auth.ts's jwt callback)
// so this page — and anywhere else reading session.user.email —
// stops showing the old address without needing a fresh sign-in.
await updateSession({ email: newEmail });
router.refresh();
}
return { ok: result.ok, error: result.error };
};
const resendCode = async () => {
const result = await requestEmailChangeAction(newEmail);
return { ok: result.ok, error: result.error };
};
return (
<div className="rounded-md border border-line bg-white p-5">
<p className="text-muted mb-1 text-xs font-bold uppercase tracking-widest">Email</p>
{phase === "idle" && (
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-bold">{displayedEmail}</p>
<Button size="sm" variant="secondary" onClick={() => { setError(null); setSuccess(false); setNewEmail(""); setPhase("editing"); }}>
Change
</Button>
</div>
)}
{phase === "editing" && (
<div className="mt-3 space-y-3">
<Input label="New email address" type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} />
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<div className="flex gap-3">
<Button size="sm" variant="secondary" className="flex-1" onClick={() => setPhase("idle")}>
Cancel
</Button>
<Button size="sm" className="flex-1" isLoading={isPending} onClick={sendCode} disabled={!newEmail}>
Send Code
</Button>
</div>
</div>
)}
{phase === "code" && (
<div className="mt-3">
<OtpCodeStep
email={newEmail}
onVerify={verifyCode}
onResend={resendCode}
onChangeEmail={() => setPhase("editing")}
verifyLabel="Change Email"
/>
</div>
)}
{success && phase === "idle" && (
<p role="status" className="text-accent-foreground mt-2 text-sm font-medium">
Email updated.
</p>
)}
</div>
);
}
@@ -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 (
<div className="space-y-4">
<ChangeEmailSection currentEmail={initial.email} />
<Input label="Display name" value={name} onChange={(e) => setName(e.target.value)} />
<Input
label="Phone"
+14 -7
View File
@@ -55,7 +55,7 @@ const rateLimitBypassed = process.env.E2E_TEST_MODE === "true";
// the exact same reason (account/state enumeration via timing).
const DUMMY_HASH = bcrypt.hashSync("nextchair-otp-timing-safety-placeholder", BCRYPT_COST);
export type OtpPurpose = "login" | "register" | "install" | "staff-invite";
export type OtpPurpose = "login" | "register" | "install" | "staff-invite" | "email-change";
function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
@@ -76,10 +76,17 @@ export interface RequestOtpOptions {
senderName?: string;
/**
* Login-specific: only actually generates/sends a code if a User already
* exists for this email. The caller still gets the same {ok:true} result
* either way — this is what keeps "do not reveal whether an email
* address exists" true for the sign-in page without silently creating a
* dead OTP row (or emailing a stranger) for a typo'd/unregistered address.
* exists for this email *and* that email has been verified at least
* once. The caller still gets the same {ok:true} result either way —
* this is what keeps "do not reveal whether an email address exists"
* true for the sign-in page without silently creating a dead OTP row (or
* emailing a stranger) for a typo'd/unregistered address. Requiring
* emailVerified too (not just existence) is what stops a newly-invited-
* but-not-yet-accepted staff/administrator account from signing in
* before ever touching their invitation link — createStaffProfile
* creates the User row immediately, well before invitation acceptance
* sets emailVerified (see staff-invitation.service.ts's acceptInvitation
* and accept-invitation.ts).
*/
onlyIfUserExists?: boolean;
/** Required, not defaulted internally — see this file's own import comment for why. Real callers pass getEmailProvider(); tests pass a MockEmailProvider. */
@@ -105,9 +112,9 @@ export async function requestOtp(db: Db, options: RequestOtpOptions): Promise<Re
}
if (options.onlyIfUserExists) {
const user = await db.user.findUnique({ where: { email }, select: { id: true } });
const user = await db.user.findUnique({ where: { email }, select: { id: true, emailVerified: true } });
// Same generic {ok:true} either way — see this option's own doc comment.
if (!user) return { ok: true };
if (!user || !user.emailVerified) return { ok: true };
}
// ~1% of requests — see cleanupExpiredOtps's own comment.
+12 -7
View File
@@ -38,13 +38,18 @@ export const isSuperAdmin = cache(async (userId: string): Promise<boolean> => {
});
/**
* 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<boolean> {
if (await isSuperAdmin(userId)) return true;
+11 -4
View File
@@ -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,
@@ -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),
}),
@@ -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<string> {
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");
});
});
@@ -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<string> {
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");
});
});
@@ -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", () => {
+117
View File
@@ -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<ValidationResult<EmailChangeErrorCode>> {
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<ValidationResult<EmailChangeErrorCode>> {
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();
}
+20 -12
View File
@@ -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}`,
});
+128 -78
View File
@@ -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<boolean> {
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<boolean> {
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() };
}