feat(admin): streamline group management
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { adminGroupReturnPath, editableGroupName, effectiveGroupMemberCount, isEffectiveGroupMember, groupSlug, validateGroupDetails } from "./group-management";
|
||||
|
||||
describe("group management", () => {
|
||||
it("generates a collision-safe internal slug from the display name", () => {
|
||||
expect(groupSlug(" Trusted Öps Team! ", new Set(["trusted-ops-team", "trusted-ops-team-2"])))
|
||||
.toBe("trusted-ops-team-3");
|
||||
expect(groupSlug("🔥", new Set())).toBe("group");
|
||||
});
|
||||
|
||||
it("allows only local Users and group-detail return paths", () => {
|
||||
expect(adminGroupReturnPath("/admin/users?q=alex", "saved=group")).toBe("/admin/users?q=alex&saved=group");
|
||||
expect(adminGroupReturnPath("/admin/groups/11111111-1111-4111-8111-111111111111", "saved=group"))
|
||||
.toBe("/admin/groups/11111111-1111-4111-8111-111111111111?saved=group");
|
||||
expect(adminGroupReturnPath("https://evil.example/admin/users", "saved=group")).toBe("/admin/users?saved=group");
|
||||
expect(adminGroupReturnPath("/admin/settings", "error=invalid-group-assignment")).toBe("/admin/users?error=invalid-group-assignment");
|
||||
});
|
||||
|
||||
it("counts and filters explicit and default effective memberships", () => {
|
||||
const assignments = { one: "ops", two: "builders" };
|
||||
expect(effectiveGroupMemberCount(4, Object.values(assignments), { id: "everyone", isDefault: true })).toBe(2);
|
||||
expect(effectiveGroupMemberCount(4, Object.values(assignments), { id: "ops", isDefault: false })).toBe(1);
|
||||
expect(isEffectiveGroupMember("three", assignments, { id: "everyone", isDefault: true })).toBe(true);
|
||||
expect(isEffectiveGroupMember("one", assignments, { id: "ops", isDefault: false })).toBe(true);
|
||||
expect(isEffectiveGroupMember("two", assignments, { id: "ops", isDefault: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the protected default group name fixed", () => {
|
||||
expect(editableGroupName("everyone", true, "Renamed")).toBe("everyone");
|
||||
expect(editableGroupName("Ops", false, "Trusted hosts")).toBe("Trusted hosts");
|
||||
});
|
||||
|
||||
it("validates and normalizes editable group details", () => {
|
||||
expect(validateGroupDetails(" Trusted hosts ", " Can use managed VPNs. ")).toEqual({
|
||||
name: "Trusted hosts",
|
||||
description: "Can use managed VPNs.",
|
||||
});
|
||||
expect(validateGroupDetails("", "description")).toBeNull();
|
||||
expect(validateGroupDetails("bad\nname", "description")).toBeNull();
|
||||
expect(validateGroupDetails("Valid", "x".repeat(501))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
const NAME_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
||||
const TEXT_CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
|
||||
|
||||
export function validateGroupDetails(nameValue: unknown, descriptionValue: unknown) {
|
||||
const name = String(nameValue ?? "").trim();
|
||||
const description = String(descriptionValue ?? "").trim();
|
||||
if (
|
||||
name.length < 1 ||
|
||||
name.length > 50 ||
|
||||
NAME_CONTROL_CHARACTERS.test(name) ||
|
||||
description.length > 500 ||
|
||||
TEXT_CONTROL_CHARACTERS.test(description)
|
||||
) return null;
|
||||
return { name, description };
|
||||
}
|
||||
|
||||
export function adminGroupReturnPath(
|
||||
value: unknown,
|
||||
result: "saved=group" | "error=invalid-group-assignment",
|
||||
) {
|
||||
const requested = String(value ?? "");
|
||||
let pathname = "/admin/users";
|
||||
const parameters = new URLSearchParams();
|
||||
if (requested.startsWith("/")) {
|
||||
const url = new URL(requested, "http://internal");
|
||||
if (url.pathname === "/admin/users") {
|
||||
const search = url.searchParams.get("q")?.trim().slice(0, 100);
|
||||
if (search) parameters.set("q", search);
|
||||
} else if (/^\/admin\/groups\/[0-9a-f-]{36}$/i.test(url.pathname)) {
|
||||
pathname = url.pathname;
|
||||
}
|
||||
}
|
||||
const [key, resultValue] = result.split("=", 2) as ["saved" | "error", string];
|
||||
parameters.set(key, resultValue);
|
||||
return `${pathname}?${parameters.toString()}`;
|
||||
}
|
||||
|
||||
export function editableGroupName(currentName: string, isDefault: boolean, requestedName: string) {
|
||||
return isDefault ? currentName : requestedName;
|
||||
}
|
||||
|
||||
export function effectiveGroupMemberCount(
|
||||
totalUsers: number,
|
||||
assignedGroupIds: string[],
|
||||
group: { id: string; isDefault: boolean },
|
||||
) {
|
||||
return group.isDefault
|
||||
? Math.max(0, totalUsers - assignedGroupIds.length)
|
||||
: assignedGroupIds.filter((groupId) => groupId === group.id).length;
|
||||
}
|
||||
|
||||
export function isEffectiveGroupMember(
|
||||
userId: string,
|
||||
assignmentByUser: Record<string, string>,
|
||||
group: { id: string; isDefault: boolean },
|
||||
) {
|
||||
return group.isDefault ? !assignmentByUser[userId] : assignmentByUser[userId] === group.id;
|
||||
}
|
||||
|
||||
export function groupSlug(name: string, existingSlugs: Set<string>) {
|
||||
const normalized = name
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "group";
|
||||
const base = normalized.slice(0, 50).replace(/-+$/g, "") || "group";
|
||||
if (!existingSlugs.has(base)) return base;
|
||||
for (let suffix = 2; suffix < 10_000; suffix += 1) {
|
||||
const suffixText = `-${suffix}`;
|
||||
const candidate = `${base.slice(0, 50 - suffixText.length).replace(/-+$/g, "")}${suffixText}`;
|
||||
if (!existingSlugs.has(candidate)) return candidate;
|
||||
}
|
||||
throw new Error("Could not generate a unique group slug");
|
||||
}
|
||||
Reference in New Issue
Block a user