diff --git a/prisma/migrations/20260905214944_administrator_role_rename/migration.sql b/prisma/migrations/20260905214944_administrator_role_rename/migration.sql
new file mode 100644
index 0000000..a354f2d
--- /dev/null
+++ b/prisma/migrations/20260905214944_administrator_role_rename/migration.sql
@@ -0,0 +1,10 @@
+-- Renames the seeded "Owner" system role to "Administrator" for every
+-- existing business. This is a pure rename of a per-business Role row's
+-- display name -- the permissions attached to it, the StaffProfile rows
+-- assigned to it, and every authorization check in the app (which is
+-- already keyed on holding the MANAGE_STAFF permission, never on this
+-- name) are all unaffected. Scoped to isSystem = true so a business that
+-- happens to have created its own custom role literally named "Owner"
+-- (not possible today -- there is no role-creation UI yet -- but scoped
+-- defensively anyway) is left untouched.
+UPDATE `Role` SET `name` = 'Administrator' WHERE `name` = 'Owner' AND `isSystem` = true;
diff --git a/prisma/seed.ts b/prisma/seed.ts
index 34ebe1f..3a7797b 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -8,7 +8,7 @@ import { PrismaClient, Weekday, RewardType, NotificationType, InstallationStatus
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config";
import { notifySafely } from "../src/services/notifications/notification.service";
-import { OWNER_PERMISSIONS, BARBER_PERMISSIONS, syncRolePermissions } from "../src/lib/roles/default-permissions";
+import { ADMINISTRATOR_PERMISSIONS, BARBER_PERMISSIONS, syncRolePermissions } from "../src/lib/roles/default-permissions";
import { INSTALLATION_SINGLETON_ID } from "../src/lib/installation/installation-state";
import bcrypt from "bcryptjs";
@@ -99,16 +99,16 @@ async function main() {
});
const ownerRole = await prisma.role.upsert({
- where: { businessId_name: { businessId: business.id, name: "Owner" } },
+ where: { businessId_name: { businessId: business.id, name: "Administrator" } },
update: {},
create: {
businessId: business.id,
- name: "Owner",
+ name: "Administrator",
isSystem: true,
- permissions: { create: OWNER_PERMISSIONS.map((permission) => ({ permission })) },
+ permissions: { create: ADMINISTRATOR_PERMISSIONS.map((permission) => ({ permission })) },
},
});
- await syncRolePermissions(prisma, ownerRole.id, OWNER_PERMISSIONS);
+ await syncRolePermissions(prisma, ownerRole.id, ADMINISTRATOR_PERMISSIONS);
const barberRole = await prisma.role.upsert({
where: { businessId_name: { businessId: business.id, name: "Barber" } },
diff --git a/src/app/(auth)/invite/accept/page.tsx b/src/app/(auth)/invite/accept/page.tsx
index b7d4784..aac3b84 100644
--- a/src/app/(auth)/invite/accept/page.tsx
+++ b/src/app/(auth)/invite/accept/page.tsx
@@ -22,18 +22,25 @@ export default async function AcceptInvitationPage({
if (!check.valid) {
const message =
check.reason === "EXPIRED"
- ? "This invitation link has expired. Ask the business owner to send you a new one."
+ ? "This invitation link has expired. Ask an administrator to send you a new one."
: check.reason === "ALREADY_ACCEPTED"
? "This invitation has already been used."
: "This invitation link isn't valid.";
return ;
}
+ if (!check.invitation.staff.user.email) {
+ return ;
+ }
return (
Welcome, {check.invitation.staff.user.name}
Finish setting up your staff account.
-
+
);
}
diff --git a/src/app/(owner)/owner/team/add-administrator/page.tsx b/src/app/(owner)/owner/team/add-administrator/page.tsx
new file mode 100644
index 0000000..dedd070
--- /dev/null
+++ b/src/app/(owner)/owner/team/add-administrator/page.tsx
@@ -0,0 +1,31 @@
+import type { Metadata } from "next";
+import { redirect } from "next/navigation";
+import { auth } from "@/auth";
+import { TopNav } from "@/components/navigation/top-nav";
+import { hasPermission } from "@/lib/auth/permissions";
+import { Permission } from "@/generated/prisma/enums";
+import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
+import { InviteAdministratorForm } from "@/features/owner/components/invite-administrator-form";
+
+export const metadata: Metadata = { title: "Invite Administrator" };
+export const dynamic = "force-dynamic";
+
+export default async function OwnerAddAdministratorPage() {
+ const session = await auth();
+ if (!session?.user) redirect("/sign-in");
+
+ const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
+ if (!canManage) redirect("/staff");
+
+ const context = await getActingBusinessContext();
+ if (!context) redirect("/staff");
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(owner)/owner/team/page.tsx b/src/app/(owner)/owner/team/page.tsx
index bd36436..8e221bb 100644
--- a/src/app/(owner)/owner/team/page.tsx
+++ b/src/app/(owner)/owner/team/page.tsx
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
-import { Plus, Search } from "lucide-react";
+import { Plus, Search, ShieldCheck, ShieldPlus } from "lucide-react";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
@@ -34,13 +34,23 @@ export default async function OwnerTeamPage({
Team
-
-
-
+
+
+
+
+
+
+
+
);
diff --git a/src/features/installation/__tests__/installer.service.integration.test.ts b/src/features/installation/__tests__/installer.service.integration.test.ts
index 3d3052b..914b220 100644
--- a/src/features/installation/__tests__/installer.service.integration.test.ts
+++ b/src/features/installation/__tests__/installer.service.integration.test.ts
@@ -199,19 +199,19 @@ describe("installApplication (integration)", () => {
expect(business?.loyaltyProgramme?.isEnabled).toBe(false);
const roles = await prisma.role.findMany({ where: { businessId: result.businessId }, include: { permissions: true } });
- const ownerRole = roles.find((r) => r.name === "Owner");
+ const administratorRole = roles.find((r) => r.name === "Administrator");
const barberRole = roles.find((r) => r.name === "Barber");
- expect(ownerRole).toBeDefined();
+ expect(administratorRole).toBeDefined();
expect(barberRole).toBeDefined();
- expect(ownerRole!.permissions.length).toBeGreaterThan(barberRole!.permissions.length);
+ expect(administratorRole!.permissions.length).toBeGreaterThan(barberRole!.permissions.length);
const user = await prisma.user.findUnique({ where: { id: result.userId } });
expect(user?.emailVerified).not.toBeNull();
expect(user?.passwordHash).toBeNull();
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: result.userId }, include: { services: true } });
- expect(staffProfile?.roleId).toBe(ownerRole!.id);
- // The initial owner is eligible for every starter service — see the brief's "initial owner is service-eligible."
+ expect(staffProfile?.roleId).toBe(administratorRole!.id);
+ // The initial administrator is eligible for every starter service — see the brief's "initial administrator is service-eligible."
expect(staffProfile?.services).toHaveLength(1);
const services = await prisma.service.findMany({ where: { businessId: result.businessId } });
diff --git a/src/features/installation/components/install-wizard.tsx b/src/features/installation/components/install-wizard.tsx
index cd954d2..5b92fdd 100644
--- a/src/features/installation/components/install-wizard.tsx
+++ b/src/features/installation/components/install-wizard.tsx
@@ -363,7 +363,9 @@ export function InstallWizard({
{step === 2 && (
Administrator account
-
This becomes the first owner — full permissions, protected from removal.
+
+ This becomes the first Administrator — full permissions. You can add or change administrators later.
+
- Email delivery isn't wired up yet — copy this invite link and send it to them yourself. It lets them
- set their own password. Expires in 7 days.
+ An invitation email has been sent — you can also copy this link and send it yourself as a backup. It lets
+ them verify their email and get started, no password needed. Expires in 7 days.
{inviteLink}
diff --git a/src/features/owner/components/invite-administrator-form.tsx b/src/features/owner/components/invite-administrator-form.tsx
new file mode 100644
index 0000000..bca024a
--- /dev/null
+++ b/src/features/owner/components/invite-administrator-form.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useState, useTransition } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { inviteAdministratorAction } from "../actions/staff";
+
+/**
+ * Deliberately minimal — Name + Email only. An administrator invite has no
+ * services/bio/bookable-online to configure (see inviteAdministratorAction),
+ * so this doesn't reuse AddStaffForm's fuller layout; it mirrors just the
+ * parts of it relevant here (same post-submit copy-link fallback pattern).
+ */
+export function InviteAdministratorForm() {
+ const router = useRouter();
+ const [name, setName] = useState("");
+ const [email, setEmail] = useState("");
+ const [error, setError] = useState(null);
+ const [inviteLink, setInviteLink] = useState(null);
+ const [copied, setCopied] = useState(false);
+ const [isPending, startTransition] = useTransition();
+
+ const submit = () => {
+ setError(null);
+ startTransition(async () => {
+ const result = await inviteAdministratorAction({ name, email });
+ if (!result.success) {
+ setError(result.error ?? "Couldn't invite this administrator.");
+ return;
+ }
+ if (result.inviteToken) {
+ setInviteLink(`${window.location.origin}/invite/accept?token=${result.inviteToken}`);
+ } else {
+ router.push(`/owner/team/${result.staffId}`);
+ }
+ });
+ };
+
+ const copyLink = async () => {
+ if (!inviteLink) return;
+ await navigator.clipboard.writeText(inviteLink);
+ setCopied(true);
+ };
+
+ if (inviteLink) {
+ return (
+
+
{name} has been invited as an Administrator.
+
+ An invitation email has been sent — you can also copy this link and send it yourself as a backup. It lets
+ them verify their email and get started, no password needed. Expires in 7 days.
+
+
{inviteLink}
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ They'll be able to manage business settings, staff, and other administrators once they verify their
+ email.
+
+ );
+}
diff --git a/src/features/owner/components/staff-detail-panel.tsx b/src/features/owner/components/staff-detail-panel.tsx
index 14616e7..8ae79dc 100644
--- a/src/features/owner/components/staff-detail-panel.tsx
+++ b/src/features/owner/components/staff-detail-panel.tsx
@@ -1,10 +1,12 @@
"use client";
+import { ShieldCheck } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
+import { Modal } from "@/components/ui/modal";
import { Select } from "@/components/ui/select";
import {
deactivateStaffAction,
@@ -26,6 +28,18 @@ export interface StaffDetailPanelProps {
type ConflictItem = { id: string; customerName: string; serviceNames: string; startAt: string };
+/**
+ * Display-only check — the actual "is this StaffProfile an Administrator"
+ * invariant enforced server-side is permission-based (holds MANAGE_STAFF,
+ * see staff.service.ts's isStaffAdministrator), not a name check. This is
+ * just what decides whether to show the Administrator badge / intercept
+ * "Save Role" with a confirmation — in practice they coincide today since
+ * no other role is ever granted MANAGE_STAFF.
+ */
+function isAdministratorRoleName(name: string | undefined): boolean {
+ return name === "Administrator";
+}
+
export function StaffDetailPanel({
staffId,
currentRoleId,
@@ -40,6 +54,7 @@ export function StaffDetailPanel({
const [serviceIds, setServiceIds] = useState(initialServiceIds);
const [roleError, setRoleError] = useState(null);
const [roleSaved, setRoleSaved] = useState(false);
+ const [confirmRoleChangeOpen, setConfirmRoleChangeOpen] = useState(false);
const [servicesError, setServicesError] = useState(null);
const [servicesSaved, setServicesSaved] = useState(false);
const [serviceConflicts, setServiceConflicts] = useState([]);
@@ -52,9 +67,14 @@ export function StaffDetailPanel({
const [isDeactivatePending, startDeactivateTransition] = useTransition();
const [isInvitePending, startInviteTransition] = useTransition();
+ const isCurrentlyAdministrator = isAdministratorRoleName(roles.find((r) => r.id === currentRoleId)?.name);
+ const pendingRoleIsAdministrator = isAdministratorRoleName(roles.find((r) => r.id === roleId)?.name);
+ const roleChangeCrossesAdministratorBoundary = roleId !== currentRoleId && isCurrentlyAdministrator !== pendingRoleIsAdministrator;
+
const saveRole = () => {
setRoleError(null);
setRoleSaved(false);
+ setConfirmRoleChangeOpen(false);
startRoleTransition(async () => {
const result = await updateStaffAction(staffId, { roleId });
if (!result.success) {
@@ -66,6 +86,14 @@ export function StaffDetailPanel({
});
};
+ const onSaveRoleClick = () => {
+ if (roleChangeCrossesAdministratorBoundary) {
+ setConfirmRoleChangeOpen(true);
+ return;
+ }
+ saveRole();
+ };
+
const toggleService = (id: string) => {
setServicesSaved(false);
setServiceIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
@@ -128,8 +156,8 @@ export function StaffDetailPanel({
Invitation pending
- This person hasn't set a password yet. Email delivery isn't wired up — send them the link
- yourself.
+ This person hasn't verified their email yet — an invitation email has been sent, and you can also
+ share this link yourself as a backup.
{inviteLink ? (
@@ -147,7 +175,15 @@ export function StaffDetailPanel({
)}
-
+ {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."}
+