yiekheng c493101b60 feat(web): password policy, sign-out, dashboard isolation, activity tweaks
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>
2026-05-10 18:46:29 +08:00

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