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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user