Multi-fix batch from a rapid feedback round: - Password policy mirrors Facebook's documented rule (≥6 chars + mix of letters with numbers/symbols). Centralised in apps/web/src/lib/password-policy.ts; createUserAction, resetUserPasswordAction, the AddUser form, and the row Reset-password flow all use it. CLI scripts/set-password.ts inlines the same check so the bootstrap path stays consistent. - App shell adds a Sign-out button in both the desktop sidebar footer and the mobile drawer footer, with the signed-in username next to it. Layout passes username down alongside role. Theme toggle was removed from the shell per request — operators don't need it in the chrome. - Dashboard stats: getDashboardStats was running findMany on reminders with NO operator filter, so a brand-new user saw global counts from every tenant. Switched to an INNER JOIN on whatsapp_accounts so the card on / only counts this user's reminders. (Counts had been showing '1 / 1 / 3 / 5' to a fresh user — the cross-tenant leak the user flagged.) - /activity drops the All tab and the Clear-history button. Default filter is now Success when no ?filter= is set; Partial keeps fanning into Paused + Failed; Skipped still merges into Archived. - /settings drops the Display name row entirely and only shows the Role row to admins. Layout receives username so the shell can also surface it next to the Sign-out button. - Tests: password-policy.test.ts (11 cases), updated users.test.ts to use policy-compliant passwords + cover letters-only / digits-only rejection, sidebar-footer assertion swapped from theme-toggle to the new Sign-out + username markup. 453 tests green; typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
140 lines
4.6 KiB
TypeScript
140 lines
4.6 KiB
TypeScript
"use server";
|
|
|
|
import { eq } from "drizzle-orm";
|
|
import bcrypt from "bcryptjs";
|
|
import { revalidatePath } from "next/cache";
|
|
import { headers } from "next/headers";
|
|
import { operators } from "@cmbot/db";
|
|
import { db } from "@/lib/db";
|
|
import { requireAdmin } from "@/lib/auth";
|
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
import { validatePassword } from "@/lib/password-policy";
|
|
|
|
const MAX_FIELD_LEN = 256;
|
|
|
|
async function rateLimit(key: string): Promise<{ limited: boolean }> {
|
|
const h = await headers();
|
|
const ip =
|
|
h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
return checkRateLimit(`${key}:${ip}`, { max: 5, windowSec: 60 });
|
|
}
|
|
|
|
export type CreateUserResult =
|
|
| { ok: true; userId: string }
|
|
| { ok: false; error: string };
|
|
|
|
export async function createUserAction(input: {
|
|
username: string;
|
|
password: string;
|
|
role: "admin" | "user";
|
|
}): Promise<CreateUserResult> {
|
|
await requireAdmin();
|
|
const rl = await rateLimit("create-user");
|
|
if (rl.limited) return { ok: false, error: "Too many attempts. Try again later." };
|
|
const u = input.username.trim();
|
|
if (u.length < 3 || u.length > MAX_FIELD_LEN) {
|
|
return { ok: false, error: "Username must be 3..256 chars." };
|
|
}
|
|
const pwCheck = validatePassword(input.password);
|
|
if (!pwCheck.ok) return pwCheck;
|
|
if (input.role !== "admin" && input.role !== "user") {
|
|
return { ok: false, error: "Role must be admin or user." };
|
|
}
|
|
const hash = await bcrypt.hash(input.password, 12);
|
|
const [row] = await db
|
|
.insert(operators)
|
|
.values({
|
|
username: u,
|
|
passwordHash: hash,
|
|
displayName: u,
|
|
role: input.role,
|
|
defaultTimezone: "Asia/Kuala_Lumpur",
|
|
})
|
|
.returning({ id: operators.id });
|
|
revalidatePath("/settings/users");
|
|
return { ok: true, userId: row!.id };
|
|
}
|
|
|
|
export type SetRoleResult = { ok: true } | { ok: false; error: string };
|
|
|
|
export async function setUserRoleAction(input: {
|
|
userId: string;
|
|
role: "admin" | "user";
|
|
}): Promise<SetRoleResult> {
|
|
const me = await requireAdmin();
|
|
if (input.userId === me.id && input.role !== "admin") {
|
|
return { ok: false, error: "You can't demote your own account." };
|
|
}
|
|
const target = await db.query.operators.findFirst({
|
|
where: (o, { eq: dEq }) => dEq(o.id, input.userId),
|
|
});
|
|
if (!target) return { ok: false, error: "User not found." };
|
|
|
|
// If we're demoting an admin, make sure at least one admin remains.
|
|
if (target.role === "admin" && input.role !== "admin") {
|
|
const admins = await db.query.operators.findMany({
|
|
where: (o, { eq: dEq }) => dEq(o.role, "admin"),
|
|
});
|
|
if (admins.length <= 1) {
|
|
return { ok: false, error: "Can't demote the last admin. Promote another user first." };
|
|
}
|
|
}
|
|
|
|
await db
|
|
.update(operators)
|
|
.set({ role: input.role })
|
|
.where(eq(operators.id, input.userId));
|
|
revalidatePath("/settings/users");
|
|
return { ok: true };
|
|
}
|
|
|
|
export type DeleteUserResult = { ok: true } | { ok: false; error: string };
|
|
|
|
export async function deleteUserAction(input: {
|
|
userId: string;
|
|
}): Promise<DeleteUserResult> {
|
|
const me = await requireAdmin();
|
|
if (input.userId === me.id) {
|
|
return { ok: false, error: "You can't delete your own account." };
|
|
}
|
|
const target = await db.query.operators.findFirst({
|
|
where: (o, { eq: dEq }) => dEq(o.id, input.userId),
|
|
});
|
|
if (!target) return { ok: false, error: "User not found." };
|
|
if (target.role === "admin") {
|
|
const admins = await db.query.operators.findMany({
|
|
where: (o, { eq: dEq }) => dEq(o.role, "admin"),
|
|
});
|
|
if (admins.length <= 1) {
|
|
return { ok: false, error: "Can't delete the last admin. Promote another user first." };
|
|
}
|
|
}
|
|
await db.delete(operators).where(eq(operators.id, input.userId));
|
|
revalidatePath("/settings/users");
|
|
return { ok: true };
|
|
}
|
|
|
|
export type ResetPasswordResult = { ok: true } | { ok: false; error: string };
|
|
|
|
export async function resetUserPasswordAction(input: {
|
|
userId: string;
|
|
newPassword: string;
|
|
}): Promise<ResetPasswordResult> {
|
|
await requireAdmin();
|
|
const rl = await rateLimit("reset-password");
|
|
if (rl.limited) return { ok: false, error: "Too many attempts. Try again later." };
|
|
const pwCheck = validatePassword(input.newPassword);
|
|
if (!pwCheck.ok) return pwCheck;
|
|
const target = await db.query.operators.findFirst({
|
|
where: (o, { eq: dEq }) => dEq(o.id, input.userId),
|
|
});
|
|
if (!target) return { ok: false, error: "User not found." };
|
|
const hash = await bcrypt.hash(input.newPassword, 12);
|
|
await db
|
|
.update(operators)
|
|
.set({ passwordHash: hash })
|
|
.where(eq(operators.id, input.userId));
|
|
revalidatePath("/settings/users");
|
|
return { ok: true };
|
|
}
|