From 6f8a5fe969557f1ff15b1c87a62697d417db11d3 Mon Sep 17 00:00:00 2001 From: denisireri Date: Tue, 13 Oct 2026 13:57:04 +0300 Subject: [PATCH] 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 --- src/app/account/password/page.tsx | 109 ++++++++++++++ src/app/admin/dashboard/page.tsx | 10 ++ src/app/admin/staff/page.tsx | 76 ++++++++-- src/app/api/auth/password/route.ts | 45 ++++++ src/app/api/invites/[token]/route.ts | 12 +- .../businesses/[id]/owner-reset/route.ts | 37 +++++ src/app/api/users/[id]/invite/route.ts | 25 ++-- src/app/api/users/[id]/route.ts | 7 + src/app/api/users/route.ts | 4 +- src/app/invite/[token]/page.tsx | 8 +- src/app/login/page.tsx | 11 +- src/app/platform/page.tsx | 59 ++++++++ src/app/terminal/page.tsx | 21 +-- src/components/AccountLinks.tsx | 26 ++++ src/lib/server/invites.ts | 18 ++- src/lib/server/models/User.ts | 11 +- src/lib/signOut.ts | 16 +++ tests/account.test.ts | 134 ++++++++++++++++++ 18 files changed, 582 insertions(+), 47 deletions(-) create mode 100644 src/app/account/password/page.tsx create mode 100644 src/app/api/auth/password/route.ts create mode 100644 src/app/api/platform/businesses/[id]/owner-reset/route.ts create mode 100644 src/components/AccountLinks.tsx create mode 100644 src/lib/signOut.ts create mode 100644 tests/account.test.ts diff --git a/src/app/account/password/page.tsx b/src/app/account/password/page.tsx new file mode 100644 index 0000000..9c7994f --- /dev/null +++ b/src/app/account/password/page.tsx @@ -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(null); + const [current, setCurrent] = useState(""); + const [next, setNext] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(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 ( +
+
+

Change password

+ {me &&

{me.email}

} + + {done ? ( +
+

+ Your password is changed. Any other phone or computer signed in to your account has been signed out. +

+ +
+ ) : ( +
+
+ + setCurrent(e.target.value)} className={inputClass} /> +
+
+ + setNext(e.target.value)} className={inputClass} /> +
+
+ + setConfirm(e.target.value)} className={inputClass} /> +
+ {error && ( +

+ {error} +

+ )} + + +
+ )} +
+
+ ); +} diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/dashboard/page.tsx index 2fd3ee1..3cf0022 100644 --- a/src/app/admin/dashboard/page.tsx +++ b/src/app/admin/dashboard/page.tsx @@ -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(); @@ -129,6 +130,10 @@ export default function AdminDashboardPage() { ? `${me.branchName} Dashboard` : "Branch Dashboard"} +

{me.name}

+
+ +
Stock + {me.role === "SUPER_ADMIN" && ( + + Branches + + )} Products diff --git a/src/app/admin/staff/page.tsx b/src/app/admin/staff/page.tsx index 86e1e55..0b9d11e 100644 --- a/src/app/admin/staff/page.tsx +++ b/src/app/admin/staff/page.tsx @@ -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 && (
-

Send this link to {inviteLink.name} so they can set their password.

-

It works once and expires in 7 days. Anyone with the link can claim the account, so send it only to them.

+

+ {inviteLink.purpose === "reset" + ? `Send this password reset link to ${inviteLink.name}.` + : `Send this link to ${inviteLink.name} so they can set their password.`} +

+

+ 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. +

{ const branchName = branches.find((b) => b._id === s.branchId)?.branchName; return ( -
  • +
  • {s.name} @@ -276,16 +305,33 @@ export default function StaffPage() { {branchName ? " · " + branchName : ""}

    -
    +
    {s.role.replace("_", " ")} - {canManage(s) && s.isActive && s.invitePending && ( + {isSuperAdmin && canManage(s) && s.isActive && s.role !== "SUPER_ADMIN" && branches.length > 1 && ( + + )} + {canManage(s) && s.isActive && ( )} {canManage(s) && ( diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts new file mode 100644 index 0000000..5cf4232 --- /dev/null +++ b/src/app/api/auth/password/route.ts @@ -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 }); +} diff --git a/src/app/api/invites/[token]/route.ts b/src/app/api/invites/[token]/route.ts index 07aaf9d..9e592c8 100644 --- a/src/app/api/invites/[token]/route.ts +++ b/src/app/api/invites/[token]/route.ts @@ -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 }); } diff --git a/src/app/api/platform/businesses/[id]/owner-reset/route.ts b/src/app/api/platform/businesses/[id]/owner-reset/route.ts new file mode 100644 index 0000000..eb775a1 --- /dev/null +++ b/src/app/api/platform/businesses/[id]/owner-reset/route.ts @@ -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 }); +} diff --git a/src/app/api/users/[id]/invite/route.ts b/src/app/api/users/[id]/invite/route.ts index 1d8cdc1..24117f3 100644 --- a/src/app/api/users/[id]/invite/route.ts +++ b/src/app/api/users/[id]/invite/route.ts @@ -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 }); } diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts index c054ce2..3583161 100644 --- a/src/app/api/users/[id]/route.ts +++ b/src/app/api/users/[id]/route.ts @@ -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(); diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index 3513a6b..0ff242b 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -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) })), }); } diff --git a/src/app/invite/[token]/page.tsx b/src/app/invite/[token]/page.tsx index b625d68..a0fd665 100644 --- a/src/app/invite/[token]/page.tsx +++ b/src/app/invite/[token]/page.tsx @@ -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() { ) : ( <>

    {invite.businessName ?? "Vessel"}

    -

    Welcome, {invite.name}

    +

    + {invite.purpose === "reset" ? `Reset your password, ${invite.name}` : `Welcome, ${invite.name}`} +

    - Choose a password. You will sign in with {invite.email} 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 {invite.email} and this password.

    diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 2fc66c2..a42eb51 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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(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 - Forgot password? +
    + {showForgot && ( +

    + 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. +

    + )} {invited && !error && (

    Your password is set. Sign in with your email and new password. diff --git a/src/app/platform/page.tsx b/src/app/platform/page.tsx index 0723b18..3a89908 100644 --- a/src/app/platform/page.tsx +++ b/src/app/platform/page.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [savingId, setSavingId] = useState(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 (

    @@ -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.

    +
    @@ -144,6 +169,28 @@ export default function PlatformPage() {
    )} + {ownerLink && ( +
    +

    + Password link for {ownerLink.name} ({ownerLink.email}), owner of {ownerLink.business} +

    +

    + 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. +

    + 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" + /> + +
    + )} + {loading ? ( ) : rows.length === 0 ? ( @@ -158,6 +205,7 @@ export default function PlatformPage() { b={b} saving={savingId === b.id} onPatch={patch} + onOwnerReset={ownerReset} /> ))}
  • @@ -180,10 +228,12 @@ function BusinessCard({ b, saving, onPatch, + onOwnerReset, }: { b: BizRow; saving: boolean; onPatch: (id: string, update: Record, 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 )} + {b.owner && ( + + )}
    ); diff --git a/src/app/terminal/page.tsx b/src/app/terminal/page.tsx index 5a63abf..bae7b05 100644 --- a/src/app/terminal/page.tsx +++ b/src/app/terminal/page.tsx @@ -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"} - +
    + + +
    diff --git a/src/components/AccountLinks.tsx b/src/components/AccountLinks.tsx new file mode 100644 index 0000000..011d0ae --- /dev/null +++ b/src/components/AccountLinks.tsx @@ -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 ( +
    + + Change password + + +
    + ); +} diff --git a/src/lib/server/invites.ts b/src/lib/server/invites.ts index 3d82336..357f2c4 100644 --- a/src/lib/server/invites.ts +++ b/src/lib/server/invites.ts @@ -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 { 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): 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}`; } diff --git a/src/lib/server/models/User.ts b/src/lib/server/models/User.ts index 8f9baf7..6164bd9 100644 --- a/src/lib/server/models/User.ts +++ b/src/lib/server/models/User.ts @@ -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( 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 } ); diff --git a/src/lib/signOut.ts b/src/lib/signOut.ts new file mode 100644 index 0000000..7832b59 --- /dev/null +++ b/src/lib/signOut.ts @@ -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 { + try { + await apiFetch("/api/auth/logout", { method: "POST" }); + } catch { + // Offline or already signed out. + } + clearSession(); +} diff --git a/tests/account.test.ts b/tests/account.test.ts new file mode 100644 index 0000000..c1f4aff --- /dev/null +++ b/tests/account.test.ts @@ -0,0 +1,134 @@ +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); + }); +}); -- 2.52.0