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); + }); +});