Compare commits
4 Commits
418ad36b02
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 34ea64c4d4 | |||
| d15273e2a6 | |||
| 5b7a2b847f | |||
| 6f8a5fe969 |
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { AuthUser } from "@/lib/types";
|
||||
|
||||
const inputClass =
|
||||
"tap-target w-full rounded-xl2 border border-depth-200 bg-white px-4 text-depth-900 shadow-sm transition focus:border-flow-500 focus:outline-none focus:ring-2 focus:ring-flow-500/25";
|
||||
|
||||
/** The signed-in user changes their own password. */
|
||||
export default function ChangePasswordPage() {
|
||||
const router = useRouter();
|
||||
const [me, setMe] = useState<AuthUser | null>(null);
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ user: AuthUser }>("/api/auth/me")
|
||||
.then((r) => setMe(r.user))
|
||||
.catch(() => router.replace("/login"));
|
||||
}, [router]);
|
||||
|
||||
const home = me?.role === "DELIVERY" ? "/terminal" : "/admin/dashboard";
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (next.length < 8) return setError("The new password must be at least 8 characters.");
|
||||
if (next !== confirm) return setError("The two new passwords do not match.");
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiFetch("/api/auth/password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ currentPassword: current, newPassword: next }),
|
||||
});
|
||||
setDone(true);
|
||||
setCurrent("");
|
||||
setNext("");
|
||||
setConfirm("");
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Could not change your password.");
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-sand-50 px-5 py-10">
|
||||
<div className="w-full max-w-md rounded-3xl bg-white p-6 shadow-soft">
|
||||
<h1 className="font-display text-2xl font-bold text-depth-900">Change password</h1>
|
||||
{me && <p className="mt-1 text-sm text-depth-600">{me.email}</p>}
|
||||
|
||||
{done ? (
|
||||
<div className="mt-6">
|
||||
<p className="rounded-lg bg-confirm-500/10 px-3 py-2 text-sm text-confirm-600">
|
||||
Your password is changed. Any other phone or computer signed in to your account has been signed out.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.replace(home)}
|
||||
className="tap-target mt-4 w-full rounded-full bg-gradient-to-r from-flow-500 to-flow-700 font-display font-semibold text-white"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="current-password" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||
Current password
|
||||
</label>
|
||||
<input id="current-password" type="password" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="new-password" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||
New password
|
||||
</label>
|
||||
<input id="new-password" type="password" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirm-password" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||
Confirm new password
|
||||
</label>
|
||||
<input id="confirm-password" type="password" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="rounded-lg bg-alert-500/10 px-3 py-2 text-sm text-alert-600">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="tap-target w-full rounded-full bg-gradient-to-r from-flow-500 to-flow-700 font-display font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{saving ? "Saving…" : "Change password"}
|
||||
</button>
|
||||
<button type="button" onClick={() => router.replace(home)} className="w-full text-sm text-depth-500 underline">
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import BranchRankingList from "@/components/admin/BranchRankingList";
|
||||
import ActivityFeedMatrix from "@/components/admin/ActivityFeedMatrix";
|
||||
import ReconciliationGrid from "@/components/admin/ReconciliationGrid";
|
||||
import StockSummaryCard from "@/components/admin/StockSummaryCard";
|
||||
import AccountLinks from "@/components/AccountLinks";
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const router = useRouter();
|
||||
@@ -112,7 +113,8 @@ export default function AdminDashboardPage() {
|
||||
}
|
||||
|
||||
const scope: "global" | "branch" = selectedBranchId === "ALL" ? "global" : "branch";
|
||||
const showNoBranchesNudge = me.role === "SUPER_ADMIN" && branchesLoaded && branches.length === 0;
|
||||
// The operator's own business is a placeholder; don't nudge it to set up branches.
|
||||
const showNoBranchesNudge = me.role === "SUPER_ADMIN" && !me.isPlatformAdmin && branchesLoaded && branches.length === 0;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-sand-50 pb-12">
|
||||
@@ -129,6 +131,13 @@ export default function AdminDashboardPage() {
|
||||
? `${me.branchName} Dashboard`
|
||||
: "Branch Dashboard"}
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-sand-200/60">
|
||||
{me.name}
|
||||
{me.isPlatformAdmin && " · Platform operator"}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<AccountLinks />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<GlobalSwitcher
|
||||
@@ -137,7 +146,7 @@ export default function AdminDashboardPage() {
|
||||
onChange={setSelectedBranchId}
|
||||
isSuperAdmin={me.role === "SUPER_ADMIN"}
|
||||
/>
|
||||
{me.isPlatformAdmin && (
|
||||
{me.isPlatformAdmin && (
|
||||
<Link href="/platform" className="tap-target flex items-center rounded-xl2 bg-gradient-to-r from-flow-500 to-flow-700 px-4 text-sm font-semibold text-depth-950 hover:opacity-90">
|
||||
Platform
|
||||
</Link>
|
||||
@@ -145,6 +154,11 @@ export default function AdminDashboardPage() {
|
||||
<Link href="/admin/stock" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
|
||||
Stock
|
||||
</Link>
|
||||
{me.role === "SUPER_ADMIN" && (
|
||||
<Link href="/admin/branches" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
|
||||
Branches
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/admin/products" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
|
||||
Products
|
||||
</Link>
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function StaffPage() {
|
||||
// Inviting is the default: the person sets their own password, so the
|
||||
// owner never has to know or pass on anyone's password.
|
||||
const [setPasswordNow, setSetPasswordNow] = useState(false);
|
||||
const [inviteLink, setInviteLink] = useState<{ name: string; url: string } | null>(null);
|
||||
const [inviteLink, setInviteLink] = useState<{ name: string; url: string; purpose: "invite" | "reset" } | null>(null);
|
||||
|
||||
const isSuperAdmin = me?.role === "SUPER_ADMIN";
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function StaffPage() {
|
||||
}),
|
||||
});
|
||||
show(name.trim() + " added as " + role.replace("_", " ").toLowerCase(), "success");
|
||||
setInviteLink(result.inviteUrl ? { name: name.trim(), url: result.inviteUrl } : null);
|
||||
setInviteLink(result.inviteUrl ? { name: name.trim(), url: result.inviteUrl, purpose: "invite" } : null);
|
||||
setName("");
|
||||
setBranchId("");
|
||||
setPhoneNumber("");
|
||||
@@ -121,13 +121,35 @@ export default function StaffPage() {
|
||||
return s.role === "DELIVERY" && s.branchId === me.branchId;
|
||||
}
|
||||
|
||||
async function newInviteLink(s: StaffMember) {
|
||||
// A new invite link while the person has no password yet; a password
|
||||
// reset link once they have one.
|
||||
async function newPasswordLink(s: StaffMember) {
|
||||
if (!s.invitePending && !window.confirm(`Create a password reset link for ${s.name}? Their current password keeps working until they use it.`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await apiFetch<{ inviteUrl: string }>("/api/users/" + s._id + "/invite", { method: "POST" });
|
||||
setInviteLink({ name: s.name, url: result.inviteUrl });
|
||||
show("New invite link created. The old link no longer works.", "success");
|
||||
const result = await apiFetch<{ inviteUrl: string; purpose: "invite" | "reset" }>("/api/users/" + s._id + "/invite", {
|
||||
method: "POST",
|
||||
});
|
||||
setInviteLink({ name: s.name, url: result.inviteUrl, purpose: result.purpose });
|
||||
show(result.purpose === "reset" ? "Reset link created" : "New invite link created. The old link no longer works.", "success");
|
||||
} catch (err) {
|
||||
show(err instanceof ApiError ? err.message : "Could not create an invite link", "error");
|
||||
show(err instanceof ApiError ? err.message : "Could not create a link", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Owner only: move a manager or cashier to another branch. They are signed
|
||||
// out and sign in again; sales already recorded stay with the old branch.
|
||||
async function moveToBranch(s: StaffMember, branchId: string) {
|
||||
const to = branches.find((b) => b._id === branchId);
|
||||
if (!to || branchId === s.branchId) return;
|
||||
if (!window.confirm(`Move ${s.name} to ${to.branchName}? They will be signed out and must sign in again.`)) return;
|
||||
try {
|
||||
await apiFetch("/api/users/" + s._id, { method: "PATCH", body: JSON.stringify({ branchId }) });
|
||||
show(`${s.name} moved to ${to.branchName}`, "success");
|
||||
await loadStaff();
|
||||
} catch (err) {
|
||||
show(err instanceof ApiError ? err.message : "Could not move this person", "error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,8 +241,15 @@ export default function StaffPage() {
|
||||
|
||||
{inviteLink && (
|
||||
<div className="rounded-xl2 border border-confirm-500/30 bg-confirm-500/10 p-4 text-sm text-depth-800">
|
||||
<p className="font-medium">Send this link to {inviteLink.name} so they can set their password.</p>
|
||||
<p className="mt-1 text-xs text-depth-500">It works once and expires in 7 days. Anyone with the link can claim the account, so send it only to them.</p>
|
||||
<p className="font-medium">
|
||||
{inviteLink.purpose === "reset"
|
||||
? `Send this password reset link to ${inviteLink.name}.`
|
||||
: `Send this link to ${inviteLink.name} so they can set their password.`}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-depth-500">
|
||||
It works once and expires in {inviteLink.purpose === "reset" ? "24 hours" : "7 days"}. Anyone with the link can
|
||||
take over the account, so send it only to them.
|
||||
</p>
|
||||
<input
|
||||
readOnly
|
||||
value={inviteLink.url}
|
||||
@@ -233,7 +262,7 @@ export default function StaffPage() {
|
||||
Copy link
|
||||
</button>
|
||||
<a
|
||||
href={`https://wa.me/?text=${encodeURIComponent(`Set your Vessel password here: ${inviteLink.url}`)}`}
|
||||
href={`https://wa.me/?text=${encodeURIComponent(`${inviteLink.purpose === "reset" ? "Reset" : "Set"} your Vessel password here: ${inviteLink.url}`)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-lg border border-depth-200 bg-white px-3 py-1.5 text-xs font-semibold text-depth-700"
|
||||
@@ -262,7 +291,7 @@ export default function StaffPage() {
|
||||
{staff.map((s) => {
|
||||
const branchName = branches.find((b) => b._id === s.branchId)?.branchName;
|
||||
return (
|
||||
<li key={s._id} className="flex items-center justify-between gap-3 px-4 py-3 animate-fade-up">
|
||||
<li key={s._id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 animate-fade-up">
|
||||
<div className={s.isActive ? "" : "opacity-50"}>
|
||||
<p className="font-medium text-depth-900">
|
||||
{s.name}
|
||||
@@ -276,16 +305,33 @@ export default function StaffPage() {
|
||||
{branchName ? " · " + branchName : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={"rounded-full px-2.5 py-1 text-xs font-medium " + roleBadgeStyles[s.role]}>
|
||||
{s.role.replace("_", " ")}
|
||||
</span>
|
||||
{canManage(s) && s.isActive && s.invitePending && (
|
||||
{isSuperAdmin && canManage(s) && s.isActive && s.role !== "SUPER_ADMIN" && branches.length > 1 && (
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => moveToBranch(s, e.target.value)}
|
||||
aria-label={`Move ${s.name} to another branch`}
|
||||
className="rounded-lg border border-depth-200 bg-white px-2 py-1 text-xs text-depth-700"
|
||||
>
|
||||
<option value="">Move to…</option>
|
||||
{branches
|
||||
.filter((b) => b._id !== s.branchId)
|
||||
.map((b) => (
|
||||
<option key={b._id} value={b._id}>
|
||||
{b.branchName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{canManage(s) && s.isActive && (
|
||||
<button
|
||||
onClick={() => newInviteLink(s)}
|
||||
onClick={() => newPasswordLink(s)}
|
||||
className="rounded-lg border border-depth-200 px-2.5 py-1 text-xs font-medium text-depth-700"
|
||||
>
|
||||
New link
|
||||
{s.invitePending ? "New link" : "Reset password"}
|
||||
</button>
|
||||
)}
|
||||
{canManage(s) && (
|
||||
|
||||
@@ -167,7 +167,9 @@ export async function POST(req: NextRequest) {
|
||||
phoneNumber: user.phoneNumber,
|
||||
email: user.email,
|
||||
},
|
||||
redirectTo: user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard",
|
||||
// Platform operators start on the console for every business, not on
|
||||
// their own (placeholder) business's dashboard.
|
||||
redirectTo: isPlatformAdmin(user) ? "/platform" : user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard",
|
||||
isPlatformAdmin: isPlatformAdmin(user),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { UserSession } from "@/lib/server/models/UserSession";
|
||||
import { authorize } from "@/lib/server/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const changeSchema = z.object({
|
||||
currentPassword: z.string().min(1, "Enter your current password"),
|
||||
newPassword: z.string().min(8, "The new password must be at least 8 characters"),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/password — the signed-in user changes their own password.
|
||||
* Every other device is signed out; this one stays signed in.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
await connectDb();
|
||||
const gate = authorize(req);
|
||||
if (!gate.ok) return gate.response;
|
||||
const auth = gate.auth;
|
||||
|
||||
const parsed = changeSchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||
}
|
||||
const { currentPassword, newPassword } = parsed.data;
|
||||
if (currentPassword === newPassword) {
|
||||
return NextResponse.json({ error: "Choose a password different from the current one" }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await User.findOne({ _id: auth.sub, businessId: auth.businessId }).select("+passwordHash");
|
||||
if (!user || !user.isActive) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
if (!(await user.comparePassword(currentPassword))) {
|
||||
return NextResponse.json({ error: "Your current password is not correct" }, { status: 400 });
|
||||
}
|
||||
|
||||
user.passwordHash = await bcrypt.hash(newPassword, 12);
|
||||
await user.save();
|
||||
await UserSession.deleteMany({ userId: user._id, deviceId: { $ne: auth.deviceId } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import bcrypt from "bcryptjs";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { Business } from "@/lib/server/models/Business";
|
||||
import { UserSession } from "@/lib/server/models/UserSession";
|
||||
import { hashInviteToken } from "@/lib/server/invites";
|
||||
import { crossTenant } from "@/lib/server/tenantGuard";
|
||||
|
||||
@@ -27,7 +28,12 @@ export async function GET(_req: NextRequest, { params }: { params: { token: stri
|
||||
const user = await findInvitee(params.token);
|
||||
if (!user) return NextResponse.json(INVALID, { status: 404 });
|
||||
const business = await Business.findById(user.businessId).select("name").lean();
|
||||
return NextResponse.json({ name: user.name, email: user.email, businessName: business?.name ?? null });
|
||||
return NextResponse.json({
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
businessName: business?.name ?? null,
|
||||
purpose: user.invitePurpose === "reset" ? "reset" : "invite",
|
||||
});
|
||||
}
|
||||
|
||||
const acceptSchema = z.object({ password: z.string().min(8, "Password must be at least 8 characters") });
|
||||
@@ -45,8 +51,12 @@ export async function POST(req: NextRequest, { params }: { params: { token: stri
|
||||
user.passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
||||
user.inviteTokenHash = null;
|
||||
user.inviteExpiresAt = null;
|
||||
user.invitePurpose = null;
|
||||
user.failedLoginAttempts = 0;
|
||||
user.lockedUntil = null;
|
||||
await user.save();
|
||||
// A new password ends every existing session, so whoever had the old one
|
||||
// (a lost phone, a former colleague) is signed out.
|
||||
await UserSession.deleteMany({ userId: user._id });
|
||||
return NextResponse.json({ ok: true, email: user.email });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isValidObjectId } from "mongoose";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { Business } from "@/lib/server/models/Business";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { requirePlatformAdmin } from "@/lib/server/platform";
|
||||
import { invitePending, issueInvite } from "@/lib/server/invites";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* POST /api/platform/businesses/:id/owner-reset — operator only. A one-time
|
||||
* password link for a business's owner of record: the way back in for an
|
||||
* owner who has forgotten their password (nobody inside the business can
|
||||
* reset the owner). Send the link to the owner yourself, by a channel you
|
||||
* know is theirs. Their current password keeps working until it is used.
|
||||
*/
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
await connectDb();
|
||||
const admin = await requirePlatformAdmin(req);
|
||||
if (!admin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
if (!isValidObjectId(params.id)) return NextResponse.json({ error: "Business not found" }, { status: 404 });
|
||||
|
||||
const business = await Business.findById(params.id).select("ownerId").lean();
|
||||
if (!business) return NextResponse.json({ error: "Business not found" }, { status: 404 });
|
||||
const owner = await User.findOne({ _id: business.ownerId, businessId: business._id });
|
||||
if (!owner) return NextResponse.json({ error: "This business has no owner account" }, { status: 404 });
|
||||
if (owner._id.toString() === admin.userId) {
|
||||
return NextResponse.json({ error: "Change your own password from Change password instead" }, { status: 409 });
|
||||
}
|
||||
if (!owner.isActive) return NextResponse.json({ error: "The owner's account is deactivated" }, { status: 409 });
|
||||
|
||||
const purpose = invitePending(owner) ? "invite" : "reset";
|
||||
const inviteUrl = issueInvite(owner, req, purpose);
|
||||
await owner.save();
|
||||
return NextResponse.json({ inviteUrl, purpose, email: owner.email, name: owner.name, expiresAt: owner.inviteExpiresAt });
|
||||
}
|
||||
@@ -3,14 +3,17 @@ import { isValidObjectId } from "mongoose";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { authorize, MANAGERS } from "@/lib/server/auth";
|
||||
import { issueInvite } from "@/lib/server/invites";
|
||||
import { invitePending, issueInvite } from "@/lib/server/invites";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* POST /api/users/:id/invite — a new invite link for someone who has not
|
||||
* accepted theirs yet (lost or expired). The previous link stops working.
|
||||
* Owners: anyone in the business. Managers: their own branch's cashiers.
|
||||
* POST /api/users/:id/invite — a one-time password link for a staff member:
|
||||
* - an INVITE link while they have not set a password yet (lost or expired invite);
|
||||
* - a RESET link once they have (forgotten password). Their current password
|
||||
* keeps working until the link is used; using it signs them out everywhere.
|
||||
* The previous link stops working either way.
|
||||
* Owners: anyone in the business but themselves. Managers: their own branch's cashiers.
|
||||
*/
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
await connectDb();
|
||||
@@ -23,13 +26,17 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
|
||||
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
|
||||
if (auth.role === "BRANCH_MANAGER" && !(user.role === "DELIVERY" && user.branchId?.toString() === auth.branchId)) {
|
||||
return NextResponse.json({ error: "Branch managers may only invite delivery staff in their own branch" }, { status: 403 });
|
||||
return NextResponse.json({ error: "Branch managers may only do this for delivery staff in their own branch" }, { status: 403 });
|
||||
}
|
||||
if (!user.inviteExpiresAt) {
|
||||
return NextResponse.json({ error: "This person has already set their password" }, { status: 409 });
|
||||
if (user._id.toString() === auth.sub) {
|
||||
return NextResponse.json({ error: "Change your own password from Change password instead" }, { status: 409 });
|
||||
}
|
||||
if (!user.isActive) {
|
||||
return NextResponse.json({ error: "Reactivate this account first" }, { status: 409 });
|
||||
}
|
||||
|
||||
const inviteUrl = issueInvite(user, req);
|
||||
const purpose = invitePending(user) ? "invite" : "reset";
|
||||
const inviteUrl = issueInvite(user, req, purpose);
|
||||
await user.save();
|
||||
return NextResponse.json({ inviteUrl, expiresAt: user.inviteExpiresAt });
|
||||
return NextResponse.json({ inviteUrl, purpose, expiresAt: user.inviteExpiresAt });
|
||||
}
|
||||
|
||||
@@ -69,7 +69,14 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
||||
}
|
||||
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId, ...ACTIVE_BRANCH }).lean();
|
||||
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||
const fromBranch = target.branchId;
|
||||
target.branchId = branch._id;
|
||||
// A moved manager stops being the old branch's manager, and becomes the
|
||||
// new branch's manager if it has none.
|
||||
if (target.role === "BRANCH_MANAGER" && fromBranch?.toString() !== branch._id.toString()) {
|
||||
await Branch.updateOne({ _id: fromBranch, businessId: auth.businessId, managerId: target._id }, { managerId: null });
|
||||
await Branch.updateOne({ _id: branch._id, businessId: auth.businessId, managerId: null }, { managerId: target._id });
|
||||
}
|
||||
}
|
||||
if (isActive !== undefined) target.isActive = isActive;
|
||||
await target.save();
|
||||
|
||||
@@ -8,7 +8,7 @@ import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||
import { objectId } from "@/lib/server/validation";
|
||||
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
||||
import { userLimitError, loadTier } from "@/lib/server/subscription";
|
||||
import { issueInvite, unusablePasswordHash } from "@/lib/server/invites";
|
||||
import { invitePending, issueInvite, unusablePasswordHash } from "@/lib/server/invites";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -26,7 +26,7 @@ export async function GET(req: NextRequest) {
|
||||
const users = await User.find(filter).select("-passwordHash -inviteTokenHash").sort({ name: 1 }).lean();
|
||||
// invitePending: the person has not set their password yet.
|
||||
return NextResponse.json({
|
||||
users: users.map((u) => ({ ...u, invitePending: !!u.inviteExpiresAt })),
|
||||
users: users.map((u) => ({ ...u, invitePending: invitePending(u) })),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ interface Invite {
|
||||
name: string;
|
||||
email: string;
|
||||
businessName: string | null;
|
||||
purpose: "invite" | "reset";
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
@@ -60,9 +61,12 @@ export default function InvitePage() {
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs uppercase tracking-wide text-flow-600">{invite.businessName ?? "Vessel"}</p>
|
||||
<h1 className="mt-1 font-display text-2xl font-bold text-depth-900">Welcome, {invite.name}</h1>
|
||||
<h1 className="mt-1 font-display text-2xl font-bold text-depth-900">
|
||||
{invite.purpose === "reset" ? `Reset your password, ${invite.name}` : `Welcome, ${invite.name}`}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-depth-600">
|
||||
Choose a password. You will sign in with <strong>{invite.email}</strong> and this password.
|
||||
{invite.purpose === "reset" ? "Choose a new password. Every device signed in to your account will be signed out." : "Choose a password."}{" "}
|
||||
You will sign in with <strong>{invite.email}</strong> and this password.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||
<div>
|
||||
|
||||
+10
-1
@@ -36,6 +36,7 @@ export default function LoginPage() {
|
||||
// Set when this email and password open accounts in more than one business.
|
||||
const [choices, setChoices] = useState<BusinessChoice[] | null>(null);
|
||||
const [invited, setInvited] = useState(false);
|
||||
const [showForgot, setShowForgot] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setInvited(new URLSearchParams(window.location.search).get("invited") === "1");
|
||||
@@ -226,9 +227,17 @@ export default function LoginPage() {
|
||||
/>
|
||||
Remember me
|
||||
</label>
|
||||
<span className="font-medium text-flow-600">Forgot password?</span>
|
||||
<button type="button" onClick={() => setShowForgot((v) => !v)} className="font-medium text-flow-600">
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForgot && (
|
||||
<p className="rounded-lg bg-sand-100 px-3 py-2 text-sm text-depth-700">
|
||||
Ask your branch manager or business owner for a password reset link (Staff page, Reset password). If
|
||||
you are the owner, contact Digital Chances support for one.
|
||||
</p>
|
||||
)}
|
||||
{invited && !error && (
|
||||
<p className="rounded-lg bg-confirm-500/10 px-3 py-2 text-sm text-confirm-600">
|
||||
Your password is set. Sign in with your email and new password.
|
||||
|
||||
+3
-1
@@ -12,7 +12,9 @@ export default function RootPage() {
|
||||
(async () => {
|
||||
try {
|
||||
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me");
|
||||
if (result.user.role === "DELIVERY") {
|
||||
if (result.user.isPlatformAdmin) {
|
||||
router.replace("/platform");
|
||||
} else if (result.user.role === "DELIVERY") {
|
||||
router.replace("/terminal");
|
||||
} else {
|
||||
router.replace("/admin/dashboard");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+11
-10
@@ -17,7 +17,8 @@ import {
|
||||
type QueuedSale,
|
||||
type QueueOwner,
|
||||
} from "@/lib/offlineQueue";
|
||||
import { clearSession, loadSession, saveSession, type StockSnapshot } from "@/lib/sessionCache";
|
||||
import { loadSession, saveSession, type StockSnapshot } from "@/lib/sessionCache";
|
||||
import { signOut } from "@/lib/signOut";
|
||||
import { MAX_LINES_PER_SALE } from "@/lib/syncProtocol";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import ProductGrid from "@/components/terminal/ProductGrid";
|
||||
@@ -392,12 +393,7 @@ export default function TerminalPage() {
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
try {
|
||||
await apiFetch("/api/auth/logout", { method: "POST" });
|
||||
} catch {
|
||||
// Offline: the cookies expire on their own; still clear this device.
|
||||
}
|
||||
clearSession();
|
||||
await signOut();
|
||||
router.replace("/login");
|
||||
}
|
||||
|
||||
@@ -451,9 +447,14 @@ export default function TerminalPage() {
|
||||
>
|
||||
{connected ? "● Online" : "● Offline"}
|
||||
</span>
|
||||
<button onClick={handleSignOut} className="text-xs text-sand-200/60 underline underline-offset-2">
|
||||
Sign out
|
||||
</button>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => router.push("/account/password")} className="text-xs text-sand-200/60 underline underline-offset-2">
|
||||
Password
|
||||
</button>
|
||||
<button onClick={handleSignOut} className="text-xs text-sand-200/60 underline underline-offset-2">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signOut } from "@/lib/signOut";
|
||||
|
||||
/** Change password + Sign out, for the dark page headers. */
|
||||
export default function AccountLinks() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="flex items-center gap-3 whitespace-nowrap text-sm">
|
||||
<Link href="/account/password" className="text-sand-200/70 underline underline-offset-2 hover:text-white">
|
||||
Change password
|
||||
</Link>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await signOut();
|
||||
router.replace("/login");
|
||||
}}
|
||||
className="text-sand-200/70 underline underline-offset-2 hover:text-white"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import type { NextRequest } from "next/server";
|
||||
import type { IUser } from "@/lib/server/models/User";
|
||||
|
||||
export const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
/** Reset links are shorter-lived: they can take over an account that is already in use. */
|
||||
export const RESET_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type LinkPurpose = "invite" | "reset";
|
||||
|
||||
export function hashInviteToken(token: string): string {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
@@ -14,14 +18,20 @@ export async function unusablePasswordHash(): Promise<string> {
|
||||
return bcrypt.hash(crypto.randomBytes(32).toString("hex"), 12);
|
||||
}
|
||||
|
||||
/** Whether the account is waiting for its first password (not a reset). */
|
||||
export function invitePending(user: Pick<IUser, "inviteExpiresAt" | "invitePurpose">): boolean {
|
||||
return !!user.inviteExpiresAt && user.invitePurpose !== "reset";
|
||||
}
|
||||
|
||||
/**
|
||||
* Give the user a fresh invite (any earlier link stops working) and return
|
||||
* the link to send them. The caller saves the user.
|
||||
* Give the user a fresh one-time password link (any earlier link stops
|
||||
* working) and return it to send them. The caller saves the user.
|
||||
*/
|
||||
export function issueInvite(user: IUser, req: NextRequest): string {
|
||||
export function issueInvite(user: IUser, req: NextRequest, purpose: LinkPurpose = "invite"): string {
|
||||
const token = crypto.randomBytes(32).toString("base64url");
|
||||
user.inviteTokenHash = hashInviteToken(token);
|
||||
user.inviteExpiresAt = new Date(Date.now() + INVITE_TTL_MS);
|
||||
user.inviteExpiresAt = new Date(Date.now() + (purpose === "reset" ? RESET_TTL_MS : INVITE_TTL_MS));
|
||||
user.invitePurpose = purpose;
|
||||
const origin = process.env.APP_URL?.replace(/\/$/, "") || req.nextUrl.origin;
|
||||
return `${origin}/invite/${token}`;
|
||||
}
|
||||
|
||||
@@ -25,11 +25,15 @@ export interface IUser extends Document {
|
||||
isActive: boolean;
|
||||
failedLoginAttempts: number;
|
||||
lockedUntil: Date | null;
|
||||
// Pending invite: the person sets their own password through a one-time
|
||||
// link. Only the SHA-256 of the token is stored. Until they accept, the
|
||||
// account has a random unknown password, so it cannot be signed into.
|
||||
// A one-time link through which the person sets their own password.
|
||||
// Only the SHA-256 of the token is stored.
|
||||
// invite: a new account; until accepted it has a random unknown password.
|
||||
// reset: a forgotten password; the old password keeps working until the
|
||||
// link is used, and using it signs the account out everywhere.
|
||||
// Rows from before invitePurpose existed have null, which means invite.
|
||||
inviteTokenHash: string | null;
|
||||
inviteExpiresAt: Date | null;
|
||||
invitePurpose: "invite" | "reset" | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -51,6 +55,7 @@ const userSchema = new Schema<IUser>(
|
||||
lockedUntil: { type: Date, default: null },
|
||||
inviteTokenHash: { type: String, default: null, index: { sparse: true } },
|
||||
inviteExpiresAt: { type: Date, default: null },
|
||||
invitePurpose: { type: String, enum: ["invite", "reset", null], default: null },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { clearSession } from "@/lib/sessionCache";
|
||||
|
||||
/**
|
||||
* End this device's session and forget the cached terminal session. Works
|
||||
* offline too: the server session then expires on its own, and this device
|
||||
* keeps nothing that would sign it back in.
|
||||
*/
|
||||
export async function signOut(): Promise<void> {
|
||||
try {
|
||||
await apiFetch("/api/auth/logout", { method: "POST" });
|
||||
} catch {
|
||||
// Offline or already signed out.
|
||||
}
|
||||
clearSession();
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { Branch } from "@/lib/server/models/Branch";
|
||||
import { UserSession } from "@/lib/server/models/UserSession";
|
||||
import * as invite from "@/app/api/users/[id]/invite/route";
|
||||
import * as invites from "@/app/api/invites/[token]/route";
|
||||
import * as users from "@/app/api/users/route";
|
||||
import * as userById from "@/app/api/users/[id]/route";
|
||||
import * as password from "@/app/api/auth/password/route";
|
||||
import * as login from "@/app/api/auth/login/route";
|
||||
import * as ownerReset from "@/app/api/platform/businesses/[id]/owner-reset/route";
|
||||
import { db, json, makeTenant, params, req, uuid, type Actor } from "./helpers";
|
||||
|
||||
const DEVICE = "00000000-0000-4000-8000-000000000001"; // the device tokenFor() signs in on
|
||||
|
||||
async function setPassword(a: Actor, pw: string) {
|
||||
await db(() => User.updateOne({ _id: a.id }, { passwordHash: bcrypt.hashSync(pw, 4) }));
|
||||
}
|
||||
const signIn = (a: Actor, pw: string) =>
|
||||
login.POST(req("POST", "/x", { body: { email: a.email, password: pw, deviceId: uuid() } }));
|
||||
const tokenOf = (url: string) => url.split("/invite/")[1];
|
||||
const sessions = (a: Actor) => db(() => UserSession.countDocuments({ userId: a.id }));
|
||||
|
||||
describe("password reset links", () => {
|
||||
it("lets a manager reset their cashier's forgotten password; the old one works until the link is used", async () => {
|
||||
const t = await makeTenant();
|
||||
await setPassword(t.cashierA, "old-password");
|
||||
await signIn(t.cashierA, "old-password"); // a session on some phone
|
||||
|
||||
const r = await json(await invite.POST(req("POST", "/x", { as: t.managerA }), params({ id: t.cashierA.id })));
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.purpose).toBe("reset");
|
||||
expect((await signIn(t.cashierA, "old-password")).status).toBe(200);
|
||||
|
||||
const page = await json(await invites.GET(req("GET", "/x"), params({ token: tokenOf(r.body.inviteUrl) })));
|
||||
expect(page.body.purpose).toBe("reset");
|
||||
|
||||
const accept = await invites.POST(req("POST", "/x", { body: { password: "new-password" } }), params({ token: tokenOf(r.body.inviteUrl) }));
|
||||
expect(accept.status).toBe(200);
|
||||
expect(await sessions(t.cashierA)).toBe(0); // every device signed out
|
||||
expect((await signIn(t.cashierA, "old-password")).status).toBe(401);
|
||||
expect((await signIn(t.cashierA, "new-password")).status).toBe(200);
|
||||
});
|
||||
|
||||
it("does not show a reset as a pending invite", async () => {
|
||||
const t = await makeTenant();
|
||||
await invite.POST(req("POST", "/x", { as: t.owner }), params({ id: t.cashierA.id }));
|
||||
const list = await json(await users.GET(req("GET", "/api/users", { as: t.owner })));
|
||||
expect(list.body.users.find((u: any) => u._id === t.cashierA.id).invitePending).toBe(false);
|
||||
});
|
||||
|
||||
it("expires a reset link after 24 hours", async () => {
|
||||
const t = await makeTenant();
|
||||
const r = await json(await invite.POST(req("POST", "/x", { as: t.owner }), params({ id: t.cashierA.id })));
|
||||
const user = await db(() => User.findById(t.cashierA.id).lean());
|
||||
const hours = (user!.inviteExpiresAt!.getTime() - Date.now()) / 3_600_000;
|
||||
expect(hours).toBeGreaterThan(23.9);
|
||||
expect(hours).toBeLessThanOrEqual(24);
|
||||
await db(() => User.updateOne({ _id: t.cashierA.id }, { inviteExpiresAt: new Date(Date.now() - 1000) }));
|
||||
expect((await invites.GET(req("GET", "/x"), params({ token: tokenOf(r.body.inviteUrl) }))).status).toBe(404);
|
||||
});
|
||||
|
||||
it("keeps managers to their own cashiers, and nobody resets themselves", async () => {
|
||||
const t = await makeTenant();
|
||||
const post = (as: Actor, id: string) => invite.POST(req("POST", "/x", { as }), params({ id }));
|
||||
expect((await post(t.managerA, t.cashierB.id)).status).toBe(403);
|
||||
expect((await post(t.managerA, t.owner.id)).status).toBe(403);
|
||||
expect((await post(t.owner, t.owner.id)).status).toBe(409);
|
||||
expect((await post(t.cashierA, t.cashierB.id)).status).toBe(403);
|
||||
});
|
||||
|
||||
it("lets the platform operator give a business owner a reset link, and nobody else", async () => {
|
||||
const t = await makeTenant("Customer Co");
|
||||
const home = await makeTenant("Operator Co");
|
||||
await db(() => User.updateOne({ _id: home.owner.id }, { email: "operator@aquaflow.test", platformAdmin: true }));
|
||||
const operator = { ...home.owner, email: "operator@aquaflow.test" };
|
||||
|
||||
const ok = await json(await ownerReset.POST(req("POST", "/x", { as: operator }), params({ id: t.businessId })));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toMatchObject({ purpose: "reset", email: t.owner.email.toLowerCase() });
|
||||
|
||||
expect((await ownerReset.POST(req("POST", "/x", { as: t.owner }), params({ id: t.businessId }))).status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("changing your own password", () => {
|
||||
it("needs the current password, then signs out every other device but this one", async () => {
|
||||
const t = await makeTenant();
|
||||
await setPassword(t.managerA, "current-pass");
|
||||
await signIn(t.managerA, "current-pass"); // another device
|
||||
await db(() => UserSession.create({ userId: t.managerA.id, deviceId: DEVICE, refreshTokenHash: "x", expiresAt: new Date(Date.now() + 86_400_000) }));
|
||||
expect(await sessions(t.managerA)).toBe(2);
|
||||
|
||||
const change = (currentPassword: string, newPassword: string) =>
|
||||
password.POST(req("POST", "/x", { as: t.managerA, body: { currentPassword, newPassword } }));
|
||||
expect((await change("wrong-pass", "brand-new-pass")).status).toBe(400);
|
||||
expect((await change("current-pass", "short")).status).toBe(400);
|
||||
expect((await change("current-pass", "brand-new-pass")).status).toBe(200);
|
||||
|
||||
expect(await sessions(t.managerA)).toBe(1);
|
||||
expect(await db(() => UserSession.exists({ userId: t.managerA.id, deviceId: DEVICE }))).toBeTruthy();
|
||||
expect((await signIn(t.managerA, "brand-new-pass")).status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moving staff between branches", () => {
|
||||
it("moves a manager, updates both branches' manager, and signs them out", async () => {
|
||||
const t = await makeTenant();
|
||||
await db(() => Branch.updateOne({ _id: t.branchA }, { managerId: t.managerA.id }));
|
||||
await db(() => UserSession.create({ userId: t.managerA.id, deviceId: uuid(), refreshTokenHash: "x", expiresAt: new Date(Date.now() + 86_400_000) }));
|
||||
|
||||
const res = await userById.PATCH(req("PATCH", "/x", { as: t.owner, body: { branchId: t.branchB } }), params({ id: t.managerA.id }));
|
||||
expect(res.status).toBe(200);
|
||||
const [a, b, mgr] = await Promise.all([
|
||||
db(() => Branch.findById(t.branchA).lean()),
|
||||
db(() => Branch.findById(t.branchB).lean()),
|
||||
db(() => User.findById(t.managerA.id).lean()),
|
||||
]);
|
||||
expect(a!.managerId).toBeNull();
|
||||
expect(b!.managerId!.toString()).toBe(t.managerA.id);
|
||||
expect(mgr!.branchId!.toString()).toBe(t.branchB);
|
||||
expect(await sessions(t.managerA)).toBe(0);
|
||||
});
|
||||
|
||||
it("only lets the owner move people, and only to an active branch", async () => {
|
||||
const t = await makeTenant();
|
||||
const move = (as: Actor, branchId: string) =>
|
||||
userById.PATCH(req("PATCH", "/x", { as, body: { branchId } }), params({ id: t.cashierA.id }));
|
||||
expect((await move(t.managerA, t.branchB)).status).toBe(403);
|
||||
await db(() => Branch.updateOne({ _id: t.branchB }, { isActive: false }));
|
||||
expect((await move(t.owner, t.branchB)).status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("where sign-in lands", () => {
|
||||
it("sends the platform operator to the platform console, owners to the dashboard, cashiers to the till", async () => {
|
||||
const t = await makeTenant();
|
||||
await Promise.all([setPassword(t.owner, "owner-pass"), setPassword(t.cashierA, "cashier-pass")]);
|
||||
expect((await json(await signIn(t.owner, "owner-pass"))).body.redirectTo).toBe("/admin/dashboard");
|
||||
expect((await json(await signIn(t.cashierA, "cashier-pass"))).body.redirectTo).toBe("/terminal");
|
||||
|
||||
await db(() => User.updateOne({ _id: t.owner.id }, { email: "operator@aquaflow.test", platformAdmin: true }));
|
||||
const operator = { ...t.owner, email: "operator@aquaflow.test" };
|
||||
expect((await json(await signIn(operator, "owner-pass"))).body.redirectTo).toBe("/platform");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user