Add password resets, change password, sign-out, a Branches link and moving staff
Gaps found while writing the user manual: - Forgotten passwords: owners and managers can create a one-time password reset link from Staff (managers for their own cashiers). It expires after 24 hours; the old password works until it is used, and using it signs the account out everywhere. Reset and invite links share the invite page, which now says which it is. An owner who forgets their password gets a link from the platform operator (Platform console, "Owner password link"). The login page's "Forgot password?" explains this. - Change password: any signed-in user, from the dashboard, the till or the platform console. Needs the current password; other devices are signed out. - Sign out on the dashboard and the platform console (the till already had it), through one shared helper that also clears the till's cached session. - A Branches link on the owner's dashboard; the page was only reachable by typing its address. - Owners can move a manager or cashier to another branch from Staff. A moved manager stops being the old branch's manager and becomes the new branch's if it has none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user