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:
33 files changed
+1348
-265
No files matched your search
@@ -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've been invited to join <span className="text-ink font-bold">{businessName}</span>. This link
|
||||
confirms it's really you — no password needed.
|
||||
You'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 & Sign In
|
||||
<Button type="button" className="mt-4 w-full" isLoading={isPending} onClick={sendCode}>
|
||||
Send Verification Code
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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" }] },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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'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'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't set a password yet. Email delivery isn't wired up — send them the link
|
||||
yourself.
|
||||
This person hasn't verified their email yet — an invitation email has been sent, and you can also
|
||||
share this link yourself as a backup.
|
||||
</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 & Permissions</h3>
|
||||
<div className="ml-1 flex items-center gap-2">
|
||||
<h3 className="text-muted text-[10px] font-bold tracking-widest uppercase">Role & 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"
|
||||
|
||||
Reference in new issue
Block a user