Add password resets, change password, sign-out, a Branches link and moving staff

Gaps found while writing the user manual:

- Forgotten passwords: owners and managers can create a one-time
  password reset link from Staff (managers for their own cashiers). It
  expires after 24 hours; the old password works until it is used, and
  using it signs the account out everywhere. Reset and invite links share
  the invite page, which now says which it is. An owner who forgets their
  password gets a link from the platform operator (Platform console,
  "Owner password link"). The login page's "Forgot password?" explains
  this.
- Change password: any signed-in user, from the dashboard, the till or
  the platform console. Needs the current password; other devices are
  signed out.
- Sign out on the dashboard and the platform console (the till already
  had it), through one shared helper that also clears the till's cached
  session.
- A Branches link on the owner's dashboard; the page was only reachable
  by typing its address.
- Owners can move a manager or cashier to another branch from Staff. A
  moved manager stops being the old branch's manager and becomes the new
  branch's if it has none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 13:57:04 +03:00
parent 418ad36b02
commit 6f8a5fe969
18 changed files with 582 additions and 47 deletions
+59
View File
@@ -6,6 +6,7 @@ import { apiFetch, ApiError } from "@/lib/api";
import { AuthUser } from "@/lib/types";
import { useToast } from "@/components/ui/Toast";
import { SkeletonList } from "@/components/ui/Skeleton";
import AccountLinks from "@/components/AccountLinks";
type TierId = "STARTER" | "GROWTH" | "ENTERPRISE";
type SubStatus = "TRIALING" | "ACTIVE" | "PAST_DUE" | "SUSPENDED";
@@ -63,6 +64,7 @@ export default function PlatformPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<string | null>(null);
const [ownerLink, setOwnerLink] = useState<{ business: string; name: string; email: string; url: string } | null>(null);
useEffect(() => {
(async () => {
@@ -107,6 +109,23 @@ export default function PlatformPage() {
setSavingId(null);
}
// A one-time password link for a business's owner (an owner who forgot
// their password has nobody inside the business who can reset it).
async function ownerReset(b: BizRow) {
if (!window.confirm(`Create a password link for the owner of ${b.name}? Send it only to the owner, by a channel you know is theirs.`)) {
return;
}
try {
const r = await apiFetch<{ inviteUrl: string; email: string; name: string }>(
"/api/platform/businesses/" + b.id + "/owner-reset",
{ method: "POST" }
);
setOwnerLink({ business: b.name, name: r.name, email: r.email, url: r.inviteUrl });
} catch (err) {
show(err instanceof ApiError ? err.message : "Could not create the link", "error");
}
}
if (!ready) {
return (
<main className="min-h-screen bg-depth-950 pb-12">
@@ -130,6 +149,12 @@ export default function PlatformPage() {
Every business on your platform, across all tenants. Change plans, extend billing periods,
and suspend accounts here.
</p>
<div className="mt-3 flex items-center gap-4">
<a href="/admin/dashboard" className="text-sm text-sand-200/70 underline underline-offset-2 hover:text-white">
Your business dashboard
</a>
<AccountLinks />
</div>
</div>
</header>
@@ -144,6 +169,28 @@ export default function PlatformPage() {
</div>
)}
{ownerLink && (
<div className="rounded-xl2 border border-confirm-500/30 bg-confirm-500/10 p-4 text-sm">
<p className="font-medium text-white">
Password link for {ownerLink.name} ({ownerLink.email}), owner of {ownerLink.business}
</p>
<p className="mt-1 text-xs text-white/60">
It works once and expires within 24 hours (7 days if they never set a password). Whoever opens it can take
over the account: send it only to the owner, by a channel you know is theirs.
</p>
<input
readOnly
value={ownerLink.url}
onFocus={(e) => e.target.select()}
aria-label="Owner password link"
className="mt-3 w-full rounded-lg border border-white/20 bg-depth-900 px-3 py-2 font-mono text-xs text-white"
/>
<button onClick={() => setOwnerLink(null)} className="mt-2 text-xs text-white/60 underline">
Done
</button>
</div>
)}
{loading ? (
<SkeletonList rows={4} />
) : rows.length === 0 ? (
@@ -158,6 +205,7 @@ export default function PlatformPage() {
b={b}
saving={savingId === b.id}
onPatch={patch}
onOwnerReset={ownerReset}
/>
))}
</div>
@@ -180,10 +228,12 @@ function BusinessCard({
b,
saving,
onPatch,
onOwnerReset,
}: {
b: BizRow;
saving: boolean;
onPatch: (id: string, update: Record<string, unknown>, label: string) => void;
onOwnerReset: (b: BizRow) => void;
}) {
const overBranches = b.maxBranches !== null && b.branchCount > b.maxBranches;
const overUsers = b.maxUsers !== null && b.userCount > b.maxUsers;
@@ -287,6 +337,15 @@ function BusinessCard({
Reactivate
</button>
)}
{b.owner && (
<button
type="button"
onClick={() => onOwnerReset(b)}
className="tap-target rounded-xl2 border border-white/20 px-4 text-sm font-medium text-white/80"
>
Owner password link
</button>
)}
</div>
</div>
);