feat(admin): streamline group management
This commit is contained in:
@@ -77,7 +77,7 @@ The token is displayed once and stored only as a SHA-256 hash.
|
|||||||
- PostgreSQL and Drizzle ORM
|
- PostgreSQL and Drizzle ORM
|
||||||
- Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role
|
- Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role
|
||||||
- Admin user search, account management, event exploration, DAU and confirmed-connection metrics, toggleable Natural Earth/OpenStreetMap user-location views, and automatic Discord nickname synchronization
|
- Admin user search, account management, event exploration, DAU and confirmed-connection metrics, toggleable Natural Earth/OpenStreetMap user-location views, and automatic Discord nickname synchronization
|
||||||
- Exclusive group admission: unassigned users fall back to protected `everyone`, and only the effective group's access and VPN/proxy/Tor exception settings apply
|
- Exclusive group admission: unassigned users fall back to protected `everyone`, and administrators manage effective membership, access, and VPN/proxy/Tor exceptions through confirmed group workflows
|
||||||
- Deployment-managed Discord guild ID and invite URL
|
- Deployment-managed Discord guild ID and invite URL
|
||||||
- discord.js bot with `/register` and `/account`
|
- discord.js bot with `/register` and `/account`
|
||||||
- Java Edition online-mode accounts only
|
- Java Edition online-mode accounts only
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/d3-geo": "^3.1.1",
|
"@types/d3-geo": "^3.1.1",
|
||||||
"@types/leaflet": "^1.9.22",
|
"@types/leaflet": "^1.9.22",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.3",
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
"@types/topojson-client": "^3.1.5",
|
"@types/topojson-client": "^3.1.5",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^16.2.1",
|
"eslint-config-next": "^16.2.1",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.1.0"
|
"vitest": "^4.1.0"
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
import { groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||||
import { asc, eq } from "drizzle-orm";
|
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { AdminModalForm } from "@/components/admin-modal-form";
|
||||||
|
import { AdminUserTable } from "@/components/admin-user-table";
|
||||||
|
import { GroupPolicyControl } from "@/components/group-policy-control";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
import { addGroupMember, assignDefaultGroup, deleteGroup, removeGroupMember, setGroupAccess, setGroupAnonymizedNetworkAccess } from "../actions";
|
import { isEffectiveGroupMember } from "@/lib/group-management";
|
||||||
|
import { assignUserGroupFromRegistry } from "../../users/actions";
|
||||||
|
import { deleteGroup, setGroupAccess, setGroupAnonymizedNetworkAccess, updateGroupDetails } from "../actions";
|
||||||
|
|
||||||
const savedMessages: Record<string, string> = {
|
const savedMessages: Record<string, string> = {
|
||||||
created: "Group created with access disabled.",
|
created: "Group created.",
|
||||||
access: "Group access policy updated.",
|
details: "Group details updated.",
|
||||||
"network-access": "Group VPN, proxy, and Tor policy updated.",
|
access: "Minecraft access policy updated.",
|
||||||
"member-added": "User assigned to the group.",
|
"network-access": "VPN, proxy, and Tor policy updated.",
|
||||||
"member-removed": "User returned to the default group.",
|
group: "Member group updated.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorMessages: Record<string, string> = {
|
||||||
|
"invalid-group": "Enter a valid name and a description of no more than 500 characters.",
|
||||||
|
"duplicate-group": "A group with that name already exists.",
|
||||||
|
"invalid-group-assignment": "The user or destination group no longer exists. No membership change was applied.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -20,31 +32,43 @@ export default async function GroupPage({
|
|||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ groupId: string }>;
|
params: Promise<{ groupId: string }>;
|
||||||
searchParams: Promise<{ saved?: string }>;
|
searchParams: Promise<{ error?: string; saved?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { groupId } = await params;
|
const { groupId } = await params;
|
||||||
const query = await searchParams;
|
const query = await searchParams;
|
||||||
const [group] = await db.select().from(groups).where(eq(groups.id, groupId)).limit(1);
|
const [group] = await db.select().from(groups).where(eq(groups.id, groupId)).limit(1);
|
||||||
if (!group) notFound();
|
if (!group) notFound();
|
||||||
|
|
||||||
const [allUsers, memberships] = await Promise.all([
|
const [allUsers, allGroups, memberships] = await Promise.all([
|
||||||
db.select({
|
db.select({
|
||||||
id: users.id,
|
id: users.id,
|
||||||
firstName: users.firstName,
|
firstName: users.firstName,
|
||||||
discordUsername: users.discordUsername,
|
discordUsername: users.discordUsername,
|
||||||
discordGlobalName: users.discordGlobalName,
|
discordGlobalName: users.discordGlobalName,
|
||||||
discordUserId: users.discordUserId,
|
discordUserId: users.discordUserId,
|
||||||
}).from(users).orderBy(asc(users.discordUsername)),
|
onboardingCompletedAt: users.onboardingCompletedAt,
|
||||||
db.select({
|
primaryUsername: minecraftAccounts.username,
|
||||||
userId: userGroupMemberships.userId,
|
accountCount: sql<number>`(
|
||||||
groupId: userGroupMemberships.groupId,
|
select count(*)::int from ${minecraftAccounts} account_count
|
||||||
groupName: groups.name,
|
where account_count.user_id = ${users.id}
|
||||||
}).from(userGroupMemberships).innerJoin(groups, eq(groups.id, userGroupMemberships.groupId)),
|
and account_count.deleted_at is null
|
||||||
|
)`,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.leftJoin(minecraftAccounts, and(
|
||||||
|
eq(minecraftAccounts.userId, users.id),
|
||||||
|
eq(minecraftAccounts.isPrimary, true),
|
||||||
|
isNull(minecraftAccounts.deletedAt),
|
||||||
|
))
|
||||||
|
.orderBy(users.firstName, users.discordUsername),
|
||||||
|
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault })
|
||||||
|
.from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
|
||||||
|
db.select({ userId: userGroupMemberships.userId, groupId: userGroupMemberships.groupId })
|
||||||
|
.from(userGroupMemberships),
|
||||||
]);
|
]);
|
||||||
const assignmentByUser = new Map(memberships.map((membership) => [membership.userId, membership]));
|
const assignmentByUser = Object.fromEntries(memberships.map((membership) => [membership.userId, membership.groupId]));
|
||||||
const memberCount = group.isDefault
|
const memberUsers = allUsers.filter((user) => isEffectiveGroupMember(user.id, assignmentByUser, group));
|
||||||
? allUsers.length - assignmentByUser.size
|
const returnTo = `/admin/groups/${group.id}`;
|
||||||
: memberships.filter((membership) => membership.groupId === group.id).length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-6 py-12">
|
<main className="mx-auto max-w-6xl px-6 py-12">
|
||||||
@@ -53,83 +77,57 @@ export default async function GroupPage({
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Access group</p>
|
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Access group</p>
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-3"><h1 className="font-display text-5xl font-black uppercase sm:text-7xl">{group.name}</h1>{group.isDefault && <span className="bg-ink px-3 py-2 font-mono text-[9px] font-bold uppercase text-canvas">Default</span>}</div>
|
<div className="mt-4 flex flex-wrap items-center gap-3"><h1 className="font-display text-5xl font-black uppercase sm:text-7xl">{group.name}</h1>{group.isDefault && <span className="bg-ink px-3 py-2 font-mono text-[9px] font-bold uppercase text-canvas">Default</span>}</div>
|
||||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted">{group.description ?? "No description."}</p>
|
<p className="mt-3 max-w-2xl whitespace-pre-line text-sm leading-6 text-muted">{group.description ?? "No description."}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-6 sm:grid-cols-2">
|
<AdminModalForm
|
||||||
<form action={setGroupAccess} className="border-l-2 border-accent pl-5">
|
action={updateGroupDetails}
|
||||||
|
description={group.isDefault ? "Update the protected default group's description. Its name remains everyone." : "Update the administrator-facing name and description. The internal slug remains stable."}
|
||||||
|
submitLabel="Save details"
|
||||||
|
title={`Edit ${group.name}`}
|
||||||
|
triggerClassName="border border-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider"
|
||||||
|
triggerLabel="Edit group"
|
||||||
|
>
|
||||||
<input name="groupId" type="hidden" value={group.id} />
|
<input name="groupId" type="hidden" value={group.id} />
|
||||||
<input name="accessEnabled" type="hidden" value={group.accessEnabled ? "no" : "yes"} />
|
<div className="space-y-5">
|
||||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Minecraft admission</p>
|
<label className="block text-sm font-bold">Name<input className="mt-2 w-full border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent read-only:cursor-not-allowed read-only:text-muted" defaultValue={group.name} maxLength={50} name="name" readOnly={group.isDefault} required /></label>
|
||||||
<p className="mt-2 font-display text-2xl font-black uppercase">{group.accessEnabled ? "Allowed" : "Denied"}</p>
|
<label className="block text-sm font-bold">Description<textarea className="mt-2 min-h-32 w-full resize-y border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" defaultValue={group.description ?? ""} maxLength={500} name="description" /></label>
|
||||||
<button className="mt-3 font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Turn access {group.accessEnabled ? "off" : "on"}</button>
|
|
||||||
</form>
|
|
||||||
<form action={setGroupAnonymizedNetworkAccess} className="border-l-2 border-accent pl-5">
|
|
||||||
<input name="groupId" type="hidden" value={group.id} />
|
|
||||||
<input name="anonymizedNetworksAllowed" type="hidden" value={group.anonymizedNetworksAllowed ? "no" : "yes"} />
|
|
||||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">VPN / proxy / Tor</p>
|
|
||||||
<p className="mt-2 font-display text-2xl font-black uppercase">{group.anonymizedNetworksAllowed ? "Allowed" : "Denied"}</p>
|
|
||||||
<button className="mt-3 font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Turn exceptions {group.anonymizedNetworksAllowed ? "off" : "on"}</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
</AdminModalForm>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{query.saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider" role="status">{savedMessages[query.saved] ?? "Group updated."}</p>}
|
{query.saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">{savedMessages[query.saved] ?? "Group updated."}</p>}
|
||||||
|
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errorMessages[query.error] ?? "The group operation failed."}</p>}
|
||||||
|
|
||||||
<section className="mt-10">
|
<section aria-labelledby="group-policy-heading" className="mt-10 border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
||||||
<div className="flex items-end justify-between border-b border-line pb-4">
|
<div className="border-b border-line pb-4"><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Admission controls</p><h2 className="mt-2 font-display text-3xl font-black uppercase" id="group-policy-heading">Group policies</h2></div>
|
||||||
<div><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Membership</p><h2 className="mt-2 font-display text-3xl font-black uppercase">Registered users</h2></div>
|
<div className="mt-6 grid gap-6 sm:grid-cols-2">
|
||||||
<span className="font-mono text-xs text-muted">{memberCount} members</span>
|
<PolicyDetail description="Controls whether members can connect to Minecraft." label="Minecraft access"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="Minecraft access" returnLocation="detail" /></PolicyDetail>
|
||||||
</div>
|
<PolicyDetail description="Allows confirmed VPN, proxy, and Tor connections." label="VPN / proxy / Tor"><GroupPolicyControl action={setGroupAnonymizedNetworkAccess} enabled={group.anonymizedNetworksAllowed} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="VPN / proxy / Tor" returnLocation="detail" /></PolicyDetail>
|
||||||
{group.isDefault && <p className="border-b border-line bg-panel px-5 py-4 text-sm text-muted">Users belong to <strong className="text-ink">everyone</strong> only while they have no explicit group assignment.</p>}
|
|
||||||
<div className="divide-y divide-line">
|
|
||||||
{allUsers.map((user) => {
|
|
||||||
const assignment = assignmentByUser.get(user.id);
|
|
||||||
const isMember = group.isDefault ? !assignment : assignment?.groupId === group.id;
|
|
||||||
return (
|
|
||||||
<article className="grid gap-4 py-5 sm:grid-cols-[1fr_auto] sm:items-center" key={user.id}>
|
|
||||||
<div>
|
|
||||||
<Link className="font-mono text-sm font-bold underline decoration-line underline-offset-4 hover:decoration-accent" href={`/admin/users/${user.id}`}>{user.firstName ?? user.discordGlobalName ?? user.discordUsername}</Link>
|
|
||||||
<p className="mt-1 font-mono text-[10px] text-muted">@{user.discordUsername} · {user.discordUserId}</p>
|
|
||||||
{!isMember && assignment && <p className="mt-1 text-xs text-muted">Currently assigned to {assignment.groupName}</p>}
|
|
||||||
</div>
|
|
||||||
{isMember ? (
|
|
||||||
group.isDefault ? <span className="font-mono text-[9px] font-bold uppercase text-muted">Default assignment</span> : (
|
|
||||||
<form action={removeGroupMember}>
|
|
||||||
<input name="groupId" type="hidden" value={group.id} />
|
|
||||||
<input name="userId" type="hidden" value={user.id} />
|
|
||||||
<button className="font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Return to everyone</button>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<form action={group.isDefault ? assignDefaultGroup : addGroupMember}>
|
|
||||||
<input name="groupId" type="hidden" value={group.id} />
|
|
||||||
<input name="userId" type="hidden" value={user.id} />
|
|
||||||
<button className="font-mono text-[9px] font-bold uppercase text-ink underline underline-offset-4" type="submit">Move to {group.name}</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{!allUsers.length && <p className="py-8 text-sm text-muted">No registered users yet.</p>}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-10" aria-labelledby="group-members-heading">
|
||||||
|
<div className="flex items-end justify-between border-b border-line pb-4">
|
||||||
|
<div><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Effective membership</p><h2 className="mt-2 font-display text-3xl font-black uppercase" id="group-members-heading">Members</h2></div>
|
||||||
|
<span className="font-mono text-xs text-muted">{memberUsers.length} {memberUsers.length === 1 ? "member" : "members"}</span>
|
||||||
|
</div>
|
||||||
|
<p className="border-x border-line bg-panel px-5 py-4 text-sm text-muted">{group.isDefault ? <>These users have no explicit assignment and therefore use <strong className="text-ink">everyone</strong>.</> : <>Choose another group to move a member, or choose <strong className="text-ink">everyone</strong> to remove the member from {group.name}. Every change requires confirmation.</>}</p>
|
||||||
|
<div className="mt-5"><AdminUserTable action={assignUserGroupFromRegistry} assignmentByUser={assignmentByUser} emptyMessage="This group has no effective members." groups={allGroups} returnTo={returnTo} users={memberUsers} /></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{!group.isDefault && (
|
{!group.isDefault && (
|
||||||
<section className="mt-12 border border-accent bg-panel p-6">
|
<section className="mt-12 flex flex-col gap-5 border border-accent bg-panel p-6 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Danger zone</p>
|
<div><p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Danger zone</p><h2 className="mt-2 font-display text-2xl font-black uppercase">Delete {group.name}</h2><p className="mt-2 max-w-2xl text-sm leading-6 text-muted">All {memberUsers.length} effective {memberUsers.length === 1 ? "member" : "members"} will return to everyone.</p></div>
|
||||||
<h2 className="mt-3 font-display text-2xl font-black uppercase">Delete {group.name}</h2>
|
<AdminModalForm action={deleteGroup} description={`Permanently delete ${group.name} and return ${memberUsers.length} ${memberUsers.length === 1 ? "member" : "members"} to everyone. This cannot be undone.`} intent="danger" submitLabel="Delete group" title={`Delete ${group.name}?`} triggerClassName="bg-accent px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" triggerLabel="Delete group">
|
||||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted">Deleting this group returns its {memberCount} {memberCount === 1 ? "member" : "members"} to the protected default group. This cannot be undone.</p>
|
|
||||||
<details className="mt-5">
|
|
||||||
<summary className="w-fit cursor-pointer font-mono text-[10px] font-bold uppercase text-accent underline underline-offset-4">Review deletion</summary>
|
|
||||||
<form action={deleteGroup} className="mt-4 flex flex-wrap items-center gap-4">
|
|
||||||
<input name="groupId" type="hidden" value={group.id} />
|
<input name="groupId" type="hidden" value={group.id} />
|
||||||
<input name="confirmDelete" type="hidden" value="yes" />
|
<input name="confirmDelete" type="hidden" value="yes" />
|
||||||
<button className="bg-accent px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Delete group permanently</button>
|
</AdminModalForm>
|
||||||
<span className="text-xs text-muted">Members will use everyone immediately.</span>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PolicyDetail({ children, description, label }: { children: ReactNode; description: string; label: string }) {
|
||||||
|
return <div className="flex items-center justify-between gap-5 border-l-2 border-accent pl-5"><div><h3 className="font-mono text-xs font-bold uppercase">{label}</h3><p className="mt-2 text-xs leading-5 text-muted">{description}</p></div>{children}</div>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,179 +1,178 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { events, groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
import { events, groups, userGroupMemberships } from "@minecraft-account-manager/database";
|
||||||
import { getClientIp } from "@minecraft-account-manager/network";
|
import { getClientIp } from "@minecraft-account-manager/network";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, ne, sql } from "drizzle-orm";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { recordAdminSubjectEvent } from "@/lib/audit";
|
|
||||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
|
import { editableGroupName, groupSlug, validateGroupDetails } from "@/lib/group-management";
|
||||||
|
|
||||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
||||||
|
type Admin = Awaited<ReturnType<typeof requireAdminSession>>;
|
||||||
|
type ReturnLocation = "list" | "detail";
|
||||||
|
|
||||||
function groupPath(groupId: string, query?: string) {
|
function groupPath(groupId: string, query?: string) {
|
||||||
return `/admin/groups/${encodeURIComponent(groupId)}${query ? `?${query}` : ""}`;
|
return `/admin/groups/${encodeURIComponent(groupId)}${query ? `?${query}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function returnLocation(formData: FormData): ReturnLocation {
|
||||||
|
return formData.get("returnLocation") === "list" ? "list" : "detail";
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationPath(groupId: string, location: ReturnLocation, query: string) {
|
||||||
|
return location === "list" ? `/admin/groups?${query}` : groupPath(groupId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function auditContext() {
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
return getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditData(admin: Admin, data: Record<string, unknown>) {
|
||||||
|
return { ...data, adminEmail: admin.email, adminName: admin.name };
|
||||||
|
}
|
||||||
|
|
||||||
export async function createGroup(formData: FormData) {
|
export async function createGroup(formData: FormData) {
|
||||||
const admin = await requireAdminSession();
|
const admin = await requireAdminSession();
|
||||||
const name = String(formData.get("name") ?? "").trim();
|
const details = validateGroupDetails(formData.get("name"), formData.get("description"));
|
||||||
const slug = String(formData.get("slug") ?? "").trim().toLowerCase();
|
if (!details) redirect("/admin/groups?error=invalid-group");
|
||||||
const description = String(formData.get("description") ?? "").trim();
|
const accessEnabled = formData.get("accessEnabled") === "yes";
|
||||||
if (name.length < 1 || name.length > 50 || !SLUG_PATTERN.test(slug) || slug.length > 50 || description.length > 500) {
|
const anonymizedNetworksAllowed = formData.get("anonymizedNetworksAllowed") === "yes";
|
||||||
redirect("/admin/groups?error=invalid-group");
|
const ipAddress = await auditContext();
|
||||||
}
|
|
||||||
|
|
||||||
let group: { id: string } | undefined;
|
let created: { id: string } | null = null;
|
||||||
try {
|
try {
|
||||||
[group] = await db.insert(groups).values({
|
created = await db.transaction(async (tx) => {
|
||||||
name,
|
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('minecraft-account-manager-group-identity'))`);
|
||||||
|
const [duplicate] = await tx.select({ id: groups.id }).from(groups)
|
||||||
|
.where(sql`lower(${groups.name}) = lower(${details.name})`).limit(1);
|
||||||
|
if (duplicate) return null;
|
||||||
|
const existing = await tx.select({ slug: groups.slug }).from(groups);
|
||||||
|
const slug = groupSlug(details.name, new Set(existing.map((group) => group.slug.toLowerCase())));
|
||||||
|
const [group] = await tx.insert(groups).values({
|
||||||
|
name: details.name,
|
||||||
slug,
|
slug,
|
||||||
description: description || null,
|
description: details.description || null,
|
||||||
accessEnabled: false,
|
accessEnabled,
|
||||||
anonymizedNetworksAllowed: false,
|
anonymizedNetworksAllowed,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
}).returning({ id: groups.id });
|
}).returning({ id: groups.id });
|
||||||
} catch {
|
if (!group) throw new Error("Group insert returned no row");
|
||||||
redirect("/admin/groups?error=duplicate-group");
|
await tx.insert(events).values({
|
||||||
}
|
id: randomUUID(),
|
||||||
if (!group) redirect("/admin/groups?error=create-failed");
|
source: "/web/admin",
|
||||||
|
type: "games.minecraft.account-manager.group.created",
|
||||||
await recordAdminSubjectEvent(admin, `group/${group.id}`, "games.minecraft.account-manager.group.created", {
|
subject: `group/${group.id}`,
|
||||||
name,
|
time: new Date(),
|
||||||
|
data: auditData(admin, {
|
||||||
|
name: details.name,
|
||||||
slug,
|
slug,
|
||||||
accessEnabled: false,
|
|
||||||
anonymizedNetworksAllowed: false,
|
|
||||||
});
|
|
||||||
redirect(groupPath(group.id, "saved=created"));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setGroupAccess(formData: FormData) {
|
|
||||||
const admin = await requireAdminSession();
|
|
||||||
const groupId = String(formData.get("groupId") ?? "");
|
|
||||||
const accessEnabled = formData.get("accessEnabled") === "yes";
|
|
||||||
if (!UUID_PATTERN.test(groupId)) redirect("/admin/groups?error=unknown-group");
|
|
||||||
|
|
||||||
const [group] = await db.update(groups).set({ accessEnabled, updatedAt: new Date() })
|
|
||||||
.where(eq(groups.id, groupId)).returning({ id: groups.id, name: groups.name });
|
|
||||||
if (!group) redirect("/admin/groups?error=unknown-group");
|
|
||||||
|
|
||||||
await recordAdminSubjectEvent(admin, `group/${group.id}`, "games.minecraft.account-manager.group.access-updated", {
|
|
||||||
name: group.name,
|
|
||||||
accessEnabled,
|
accessEnabled,
|
||||||
|
anonymizedNetworksAllowed,
|
||||||
|
}),
|
||||||
|
ipAddress: ipAddress ?? null,
|
||||||
});
|
});
|
||||||
redirect(groupPath(group.id, "saved=access"));
|
return group;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
redirect("/admin/groups?error=create-failed");
|
||||||
|
}
|
||||||
|
if (!created) redirect("/admin/groups?error=duplicate-group");
|
||||||
|
redirect(groupPath(created.id, "saved=created"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setGroupAnonymizedNetworkAccess(formData: FormData) {
|
export async function updateGroupDetails(formData: FormData) {
|
||||||
const admin = await requireAdminSession();
|
const admin = await requireAdminSession();
|
||||||
const groupId = String(formData.get("groupId") ?? "");
|
const groupId = String(formData.get("groupId") ?? "");
|
||||||
const anonymizedNetworksAllowed = formData.get("anonymizedNetworksAllowed") === "yes";
|
const details = validateGroupDetails(formData.get("name"), formData.get("description"));
|
||||||
|
if (!UUID_PATTERN.test(groupId) || !details) redirect(operationPath(groupId, "detail", "error=invalid-group"));
|
||||||
|
const ipAddress = await auditContext();
|
||||||
|
|
||||||
|
const result = await db.transaction(async (tx) => {
|
||||||
|
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('minecraft-account-manager-group-identity'))`);
|
||||||
|
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
|
||||||
|
const [current] = await tx.select().from(groups).where(eq(groups.id, groupId)).limit(1);
|
||||||
|
if (!current) return "missing" as const;
|
||||||
|
const name = editableGroupName(current.name, current.isDefault, details.name);
|
||||||
|
if (!current.isDefault) {
|
||||||
|
const [duplicate] = await tx.select({ id: groups.id }).from(groups)
|
||||||
|
.where(and(sql`lower(${groups.name}) = lower(${name})`, ne(groups.id, current.id))).limit(1);
|
||||||
|
if (duplicate) return "duplicate" as const;
|
||||||
|
}
|
||||||
|
const [updated] = await tx.update(groups).set({ name, description: details.description || null, updatedAt: new Date() })
|
||||||
|
.where(eq(groups.id, current.id)).returning({ id: groups.id });
|
||||||
|
if (!updated) return "missing" as const;
|
||||||
|
await tx.insert(events).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
source: "/web/admin",
|
||||||
|
type: "games.minecraft.account-manager.group.details-updated",
|
||||||
|
subject: `group/${current.id}`,
|
||||||
|
time: new Date(),
|
||||||
|
data: auditData(admin, {
|
||||||
|
previousName: current.name,
|
||||||
|
name,
|
||||||
|
previousDescription: current.description,
|
||||||
|
description: details.description || null,
|
||||||
|
}),
|
||||||
|
ipAddress: ipAddress ?? null,
|
||||||
|
});
|
||||||
|
return "updated" as const;
|
||||||
|
});
|
||||||
|
if (result === "missing") redirect("/admin/groups?error=unknown-group");
|
||||||
|
if (result === "duplicate") redirect(groupPath(groupId, "error=duplicate-group"));
|
||||||
|
redirect(groupPath(groupId, "saved=details"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateGroupPolicy(
|
||||||
|
formData: FormData,
|
||||||
|
policy: "access" | "anonymized-networks",
|
||||||
|
) {
|
||||||
|
const admin = await requireAdminSession();
|
||||||
|
const groupId = String(formData.get("groupId") ?? "");
|
||||||
|
const location = returnLocation(formData);
|
||||||
if (!UUID_PATTERN.test(groupId)) redirect("/admin/groups?error=unknown-group");
|
if (!UUID_PATTERN.test(groupId)) redirect("/admin/groups?error=unknown-group");
|
||||||
const requestHeaders = await headers();
|
const enabled = formData.get("enabled") === "yes";
|
||||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
const ipAddress = await auditContext();
|
||||||
|
|
||||||
const group = await db.transaction(async (tx) => {
|
const group = await db.transaction(async (tx) => {
|
||||||
const [updated] = await tx.update(groups).set({ anonymizedNetworksAllowed, updatedAt: new Date() })
|
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
|
||||||
.where(eq(groups.id, groupId)).returning({ id: groups.id, name: groups.name });
|
const [current] = await tx.select().from(groups).where(eq(groups.id, groupId)).limit(1);
|
||||||
|
if (!current) return null;
|
||||||
|
const update = policy === "access" ? { accessEnabled: enabled } : { anonymizedNetworksAllowed: enabled };
|
||||||
|
const [updated] = await tx.update(groups).set({ ...update, updatedAt: new Date() })
|
||||||
|
.where(eq(groups.id, current.id)).returning({ id: groups.id });
|
||||||
if (!updated) return null;
|
if (!updated) return null;
|
||||||
await tx.insert(events).values({
|
await tx.insert(events).values({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
source: "/web/admin",
|
source: "/web/admin",
|
||||||
type: "games.minecraft.account-manager.group.anonymized-network-access-updated",
|
type: policy === "access"
|
||||||
subject: `group/${updated.id}`,
|
? "games.minecraft.account-manager.group.access-updated"
|
||||||
|
: "games.minecraft.account-manager.group.anonymized-network-access-updated",
|
||||||
|
subject: `group/${current.id}`,
|
||||||
time: new Date(),
|
time: new Date(),
|
||||||
data: { name: updated.name, anonymizedNetworksAllowed, adminEmail: admin.email, adminName: admin.name },
|
data: auditData(admin, {
|
||||||
|
name: current.name,
|
||||||
|
previousEnabled: policy === "access" ? current.accessEnabled : current.anonymizedNetworksAllowed,
|
||||||
|
enabled,
|
||||||
|
}),
|
||||||
ipAddress: ipAddress ?? null,
|
ipAddress: ipAddress ?? null,
|
||||||
});
|
});
|
||||||
return updated;
|
return current;
|
||||||
});
|
});
|
||||||
if (!group) redirect("/admin/groups?error=unknown-group");
|
if (!group) redirect("/admin/groups?error=unknown-group");
|
||||||
redirect(groupPath(group.id, "saved=network-access"));
|
redirect(operationPath(group.id, location, `saved=${policy === "access" ? "access" : "network-access"}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addGroupMember(formData: FormData) {
|
export async function setGroupAccess(formData: FormData) {
|
||||||
const admin = await requireAdminSession();
|
return updateGroupPolicy(formData, "access");
|
||||||
const groupId = String(formData.get("groupId") ?? "");
|
|
||||||
const userId = String(formData.get("userId") ?? "");
|
|
||||||
if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership");
|
|
||||||
|
|
||||||
const [[group], [user]] = await Promise.all([
|
|
||||||
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
|
|
||||||
db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
|
|
||||||
]);
|
|
||||||
if (!group || !user || group.isDefault) redirect("/admin/groups?error=invalid-membership");
|
|
||||||
|
|
||||||
const previousGroup = await db.transaction(async (tx) => {
|
|
||||||
const [previous] = await tx
|
|
||||||
.select({ id: groups.id, name: groups.name })
|
|
||||||
.from(userGroupMemberships)
|
|
||||||
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
|
|
||||||
.where(eq(userGroupMemberships.userId, user.id))
|
|
||||||
.limit(1);
|
|
||||||
await tx.delete(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id));
|
|
||||||
await tx.insert(userGroupMemberships).values({ groupId: group.id, userId: user.id });
|
|
||||||
return previous ?? null;
|
|
||||||
});
|
|
||||||
await recordAdminSubjectEvent(admin, `user/${user.id}`, "games.minecraft.account-manager.group.assignment-updated", {
|
|
||||||
groupId: group.id,
|
|
||||||
groupName: group.name,
|
|
||||||
previousGroupId: previousGroup?.id ?? null,
|
|
||||||
previousGroupName: previousGroup?.name ?? "everyone",
|
|
||||||
});
|
|
||||||
redirect(groupPath(group.id, "saved=member-added"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeGroupMember(formData: FormData) {
|
export async function setGroupAnonymizedNetworkAccess(formData: FormData) {
|
||||||
const admin = await requireAdminSession();
|
return updateGroupPolicy(formData, "anonymized-networks");
|
||||||
const groupId = String(formData.get("groupId") ?? "");
|
|
||||||
const userId = String(formData.get("userId") ?? "");
|
|
||||||
if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership");
|
|
||||||
|
|
||||||
const [group] = await db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault })
|
|
||||||
.from(groups).where(eq(groups.id, groupId)).limit(1);
|
|
||||||
if (!group || group.isDefault) redirect("/admin/groups?error=invalid-membership");
|
|
||||||
|
|
||||||
await db.delete(userGroupMemberships).where(and(
|
|
||||||
eq(userGroupMemberships.groupId, group.id),
|
|
||||||
eq(userGroupMemberships.userId, userId),
|
|
||||||
));
|
|
||||||
await recordAdminSubjectEvent(admin, `user/${userId}`, "games.minecraft.account-manager.group.assignment-removed", {
|
|
||||||
groupId: group.id,
|
|
||||||
groupName: group.name,
|
|
||||||
fallbackGroup: "everyone",
|
|
||||||
});
|
|
||||||
redirect(groupPath(group.id, "saved=member-removed"));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function assignDefaultGroup(formData: FormData) {
|
|
||||||
const admin = await requireAdminSession();
|
|
||||||
const groupId = String(formData.get("groupId") ?? "");
|
|
||||||
const userId = String(formData.get("userId") ?? "");
|
|
||||||
if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership");
|
|
||||||
|
|
||||||
const [[defaultGroup], [user]] = await Promise.all([
|
|
||||||
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
|
|
||||||
db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
|
|
||||||
]);
|
|
||||||
if (!defaultGroup?.isDefault || !user) redirect("/admin/groups?error=invalid-membership");
|
|
||||||
|
|
||||||
const [previous] = await db
|
|
||||||
.select({ id: groups.id, name: groups.name })
|
|
||||||
.from(userGroupMemberships)
|
|
||||||
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
|
|
||||||
.where(eq(userGroupMemberships.userId, user.id))
|
|
||||||
.limit(1);
|
|
||||||
await db.delete(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id));
|
|
||||||
await recordAdminSubjectEvent(admin, `user/${user.id}`, "games.minecraft.account-manager.group.assignment-updated", {
|
|
||||||
groupId: defaultGroup.id,
|
|
||||||
groupName: defaultGroup.name,
|
|
||||||
previousGroupId: previous?.id ?? null,
|
|
||||||
previousGroupName: previous?.name ?? null,
|
|
||||||
});
|
|
||||||
redirect(groupPath(defaultGroup.id, "saved=member-added"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteGroup(formData: FormData) {
|
export async function deleteGroup(formData: FormData) {
|
||||||
@@ -181,40 +180,32 @@ export async function deleteGroup(formData: FormData) {
|
|||||||
const groupId = String(formData.get("groupId") ?? "");
|
const groupId = String(formData.get("groupId") ?? "");
|
||||||
const confirmed = formData.get("confirmDelete") === "yes";
|
const confirmed = formData.get("confirmDelete") === "yes";
|
||||||
if (!UUID_PATTERN.test(groupId) || !confirmed) redirect("/admin/groups?error=invalid-delete");
|
if (!UUID_PATTERN.test(groupId) || !confirmed) redirect("/admin/groups?error=invalid-delete");
|
||||||
const requestHeaders = await headers();
|
const ipAddress = await auditContext();
|
||||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
|
||||||
|
|
||||||
const deleted = await db.transaction(async (tx) => {
|
const deleted = await db.transaction(async (tx) => {
|
||||||
const [group] = await tx
|
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('minecraft-account-manager-group-membership'))`);
|
||||||
.select({ id: groups.id, name: groups.name, slug: groups.slug, isDefault: groups.isDefault })
|
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
|
||||||
.from(groups)
|
const [group] = await tx.select().from(groups).where(eq(groups.id, groupId)).limit(1);
|
||||||
.where(eq(groups.id, groupId))
|
|
||||||
.limit(1);
|
|
||||||
if (!group || group.isDefault) return null;
|
if (!group || group.isDefault) return null;
|
||||||
const members = await tx
|
const members = await tx.select({ userId: userGroupMemberships.userId })
|
||||||
.select({ userId: userGroupMemberships.userId })
|
.from(userGroupMemberships).where(eq(userGroupMemberships.groupId, group.id));
|
||||||
.from(userGroupMemberships)
|
|
||||||
.where(eq(userGroupMemberships.groupId, group.id));
|
|
||||||
await tx.insert(events).values({
|
await tx.insert(events).values({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
source: "/web/admin",
|
source: "/web/admin",
|
||||||
type: "games.minecraft.account-manager.group.deleted",
|
type: "games.minecraft.account-manager.group.deleted",
|
||||||
subject: `group/${group.id}`,
|
subject: `group/${group.id}`,
|
||||||
time: new Date(),
|
time: new Date(),
|
||||||
data: {
|
data: auditData(admin, {
|
||||||
name: group.name,
|
name: group.name,
|
||||||
slug: group.slug,
|
slug: group.slug,
|
||||||
affectedUsers: members.length,
|
affectedUsers: members.length,
|
||||||
fallbackGroup: "everyone",
|
fallbackGroup: "everyone",
|
||||||
adminEmail: admin.email,
|
}),
|
||||||
adminName: admin.name,
|
|
||||||
},
|
|
||||||
ipAddress: ipAddress ?? null,
|
ipAddress: ipAddress ?? null,
|
||||||
});
|
});
|
||||||
await tx.delete(groups).where(eq(groups.id, group.id));
|
await tx.delete(groups).where(eq(groups.id, group.id));
|
||||||
return group;
|
return group;
|
||||||
});
|
});
|
||||||
if (!deleted) redirect("/admin/groups?error=protected-group");
|
if (!deleted) redirect("/admin/groups?error=protected-group");
|
||||||
|
|
||||||
redirect("/admin/groups?saved=deleted");
|
redirect("/admin/groups?saved=deleted");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +1,102 @@
|
|||||||
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||||
import { asc, desc } from "drizzle-orm";
|
import { asc, count, desc } from "drizzle-orm";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { AdminModalForm } from "@/components/admin-modal-form";
|
||||||
|
import { GroupPolicyControl } from "@/components/group-policy-control";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
import { createGroup, setGroupAccess } from "./actions";
|
import { effectiveGroupMemberCount } from "@/lib/group-management";
|
||||||
|
import { createGroup, setGroupAccess, setGroupAnonymizedNetworkAccess } from "./actions";
|
||||||
|
|
||||||
const errors: Record<string, string> = {
|
const errors: Record<string, string> = {
|
||||||
"invalid-group": "Enter a name and a lowercase slug containing letters, numbers, or hyphens.",
|
"invalid-group": "Enter a group name and an optional description of no more than 500 characters.",
|
||||||
"duplicate-group": "That group slug already exists.",
|
"duplicate-group": "A group with that name already exists.",
|
||||||
"create-failed": "The group could not be created.",
|
"create-failed": "The group could not be created.",
|
||||||
"unknown-group": "That group no longer exists.",
|
"unknown-group": "That group no longer exists.",
|
||||||
"invalid-membership": "That membership change was invalid.",
|
|
||||||
"invalid-delete": "Confirm the group deletion before continuing.",
|
"invalid-delete": "Confirm the group deletion before continuing.",
|
||||||
"protected-group": "The protected default group cannot be deleted.",
|
"protected-group": "The protected default group cannot be deleted.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const savedMessages: Record<string, string> = {
|
||||||
|
deleted: "Group deleted. Its former members now use the default group.",
|
||||||
|
access: "Minecraft access policy updated.",
|
||||||
|
"network-access": "VPN, proxy, and Tor policy updated.",
|
||||||
|
};
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function GroupsPage({ searchParams }: { searchParams: Promise<{ error?: string; saved?: string }> }) {
|
export default async function GroupsPage({ searchParams }: { searchParams: Promise<{ error?: string; saved?: string }> }) {
|
||||||
const query = await searchParams;
|
const query = await searchParams;
|
||||||
const [allGroups, memberships, registeredUsers] = await Promise.all([
|
const [allGroups, memberships, [registeredUsers]] = await Promise.all([
|
||||||
db.select().from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
|
db.select().from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
|
||||||
db.select({ groupId: userGroupMemberships.groupId }).from(userGroupMemberships),
|
db.select({ groupId: userGroupMemberships.groupId }).from(userGroupMemberships),
|
||||||
db.select({ id: users.id }).from(users),
|
db.select({ count: count() }).from(users),
|
||||||
]);
|
]);
|
||||||
const membershipCounts = new Map<string, number>();
|
|
||||||
for (const membership of memberships) {
|
|
||||||
membershipCounts.set(membership.groupId, (membershipCounts.get(membership.groupId) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
const explicitlyAssignedUsers = memberships.length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||||
<header className="border-b border-line pb-8">
|
<header className="flex flex-col gap-6 border-b border-line pb-8 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Admission policy</p>
|
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Admission policy</p>
|
||||||
<h1 className="mt-4 font-display text-5xl font-black uppercase">Access groups</h1>
|
<h1 className="mt-4 font-display text-5xl font-black uppercase">Access groups</h1>
|
||||||
<p className="mt-5 max-w-2xl leading-7 text-muted">Each user has one effective group. Users without an explicit assignment fall back to <strong className="text-ink">everyone</strong>; Minecraft admission follows only that group’s access setting.</p>
|
<p className="mt-5 max-w-2xl leading-7 text-muted">One effective group controls Minecraft and VPN access. Every policy change asks for confirmation before it applies.</p>
|
||||||
|
</div>
|
||||||
|
<AdminModalForm
|
||||||
|
action={createGroup}
|
||||||
|
description="Create a named access group. Both policies start denied unless you explicitly enable them below."
|
||||||
|
submitLabel="Create group"
|
||||||
|
title="Add access group"
|
||||||
|
triggerClassName="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas"
|
||||||
|
triggerLabel="Add group"
|
||||||
|
>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<label className="block text-sm font-bold">Name<input autoComplete="off" className="mt-2 w-full border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" maxLength={50} name="name" required /></label>
|
||||||
|
<label className="block text-sm font-bold">Description<textarea className="mt-2 min-h-28 w-full resize-y border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" maxLength={500} name="description" /></label>
|
||||||
|
<PolicyCheckbox description="Allow members to connect to Minecraft." label="Minecraft access" name="accessEnabled" />
|
||||||
|
<PolicyCheckbox description="Allow confirmed VPN, proxy, and Tor connections." label="VPN / proxy / Tor exception" name="anonymizedNetworksAllowed" />
|
||||||
|
</div>
|
||||||
|
</AdminModalForm>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errors[query.error] ?? "The group operation failed."}</p>}
|
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errors[query.error] ?? "The group operation failed."}</p>}
|
||||||
{query.saved === "deleted" && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">Group deleted. Its former members now use the default group.</p>}
|
{query.saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">{savedMessages[query.saved] ?? "Group updated."}</p>}
|
||||||
|
|
||||||
<section className="mt-10 grid gap-5 md:grid-cols-2">
|
<div className="mt-9 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||||
|
<table className="w-full min-w-[760px] border-collapse text-left">
|
||||||
|
<caption className="sr-only">Access groups and their effective policies</caption>
|
||||||
|
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
|
||||||
|
<tr><th className="p-4" scope="col">Name</th><th className="p-4" scope="col">Minecraft access</th><th className="p-4" scope="col">VPN access</th><th className="p-4 text-right" scope="col">Users</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line">
|
||||||
{allGroups.map((group) => {
|
{allGroups.map((group) => {
|
||||||
const memberCount = group.isDefault ? registeredUsers.length - explicitlyAssignedUsers : membershipCounts.get(group.id) ?? 0;
|
const memberCount = effectiveGroupMemberCount(
|
||||||
|
Number(registeredUsers?.count ?? 0),
|
||||||
|
memberships.map((membership) => membership.groupId),
|
||||||
|
group,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<article className="border border-line bg-panel p-6 shadow-[5px_5px_0_var(--color-shadow)]" key={group.id}>
|
<tr className="transition-colors hover:bg-canvas/60" key={group.id}>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<th className="p-4 text-left" scope="row">
|
||||||
<div>
|
<Link className="font-display text-xl font-black uppercase underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/groups/${group.id}`}>{group.name}</Link>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{group.isDefault && <span className="ml-3 bg-ink px-2 py-1 font-mono text-[8px] font-bold uppercase text-canvas">Default</span>}
|
||||||
<h2 className="font-display text-2xl font-black uppercase">{group.name}</h2>
|
</th>
|
||||||
{group.isDefault && <span className="bg-ink px-2 py-1 font-mono text-[9px] font-bold uppercase text-canvas">Default</span>}
|
<td className="p-4"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberCount} policy="Minecraft access" returnLocation="list" /></td>
|
||||||
</div>
|
<td className="p-4"><GroupPolicyControl action={setGroupAnonymizedNetworkAccess} enabled={group.anonymizedNetworksAllowed} groupId={group.id} groupName={group.name} memberCount={memberCount} policy="VPN / proxy / Tor" returnLocation="list" /></td>
|
||||||
<p className="mt-1 font-mono text-[10px] text-muted">{group.slug} · {memberCount} members</p>
|
<td className="p-4 text-right font-mono text-sm font-bold">{memberCount}</td>
|
||||||
</div>
|
</tr>
|
||||||
<div className="flex flex-col items-end gap-2"><span className={`px-3 py-2 font-mono text-[9px] font-bold uppercase ${group.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{group.accessEnabled ? "Access on" : "Access off"}</span><span className="font-mono text-[9px] font-bold uppercase text-muted">VPN {group.anonymizedNetworksAllowed ? "allowed" : "denied"}</span></div>
|
|
||||||
</div>
|
|
||||||
<p className="mt-4 min-h-12 text-sm leading-6 text-muted">{group.description ?? "No description."}</p>
|
|
||||||
<div className="mt-5 flex items-center justify-between gap-4 border-t border-line pt-4">
|
|
||||||
<Link className="font-mono text-[10px] font-bold uppercase underline underline-offset-4" href={`/admin/groups/${group.id}`}>Manage members</Link>
|
|
||||||
<form action={setGroupAccess}>
|
|
||||||
<input name="groupId" type="hidden" value={group.id} />
|
|
||||||
<input name="accessEnabled" type="hidden" value={group.accessEnabled ? "no" : "yes"} />
|
|
||||||
<button className="font-mono text-[10px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Turn access {group.accessEnabled ? "off" : "on"}</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</section>
|
</tbody>
|
||||||
|
</table>
|
||||||
<form action={createGroup} className="mt-12 border border-line bg-panel p-7 shadow-[8px_8px_0_var(--color-shadow)]">
|
|
||||||
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Create a group</p>
|
|
||||||
<div className="mt-5 grid gap-5 sm:grid-cols-2">
|
|
||||||
<label className="text-sm font-bold">Name<input className="mt-2 w-full border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" maxLength={50} name="name" required /></label>
|
|
||||||
<label className="text-sm font-bold">Slug<input className="mt-2 w-full border border-line bg-canvas px-4 py-3 font-mono font-normal outline-none focus:border-accent" maxLength={50} name="slug" pattern="[a-z0-9]+(?:-[a-z0-9]+)*" placeholder="ops" required /></label>
|
|
||||||
</div>
|
</div>
|
||||||
<label className="mt-5 block text-sm font-bold">Description<textarea className="mt-2 min-h-24 w-full border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" maxLength={500} name="description" /></label>
|
<p className="mt-4 text-xs leading-5 text-muted">Users without an explicit assignment count toward <strong className="text-ink">everyone</strong>.</p>
|
||||||
<p className="mt-4 text-xs text-muted">New groups start with Minecraft access and VPN/proxy/Tor exceptions disabled.</p>
|
|
||||||
<button className="mt-6 border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Create group</button>
|
|
||||||
</form>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PolicyCheckbox({ description, label, name }: { description: string; label: string; name: string }) {
|
||||||
|
return (
|
||||||
|
<label className="flex cursor-pointer items-start justify-between gap-4 border border-line bg-canvas p-4">
|
||||||
|
<span><span className="block font-mono text-xs font-bold uppercase">{label}</span><span className="mt-1 block text-xs leading-5 text-muted">{description}</span></span>
|
||||||
|
<input className="mt-1 size-5 accent-[var(--color-accent)]" name={name} type="checkbox" value="yes" />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { recordAdminEvent } from "@/lib/audit";
|
import { recordAdminEvent } from "@/lib/audit";
|
||||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
|
import { adminGroupReturnPath } from "@/lib/group-management";
|
||||||
|
|
||||||
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
|
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
|
||||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
@@ -22,12 +23,6 @@ function userPath(userId: string, query?: string) {
|
|||||||
return `/admin/users/${encodeURIComponent(userId)}${query ? `?${query}` : ""}`;
|
return `/admin/users/${encodeURIComponent(userId)}${query ? `?${query}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function userRegistryPath(search: string, result: "saved=group" | "error=invalid-group-assignment") {
|
|
||||||
const query = new URLSearchParams(result);
|
|
||||||
if (search) query.set("q", search);
|
|
||||||
return `/admin/users?${query.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function targetUser(userId: string) {
|
async function targetUser(userId: string) {
|
||||||
if (!UUID_PATTERN.test(userId)) return null;
|
if (!UUID_PATTERN.test(userId)) return null;
|
||||||
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||||
@@ -85,20 +80,26 @@ export async function assignUserGroupFromRegistry(formData: FormData) {
|
|||||||
const admin = await requireAdminSession();
|
const admin = await requireAdminSession();
|
||||||
const userId = String(formData.get("userId") ?? "");
|
const userId = String(formData.get("userId") ?? "");
|
||||||
const groupId = String(formData.get("groupId") ?? "");
|
const groupId = String(formData.get("groupId") ?? "");
|
||||||
const search = String(formData.get("search") ?? "").trim().slice(0, 100);
|
const returnTo = formData.get("returnTo");
|
||||||
if (!UUID_PATTERN.test(userId) || !UUID_PATTERN.test(groupId)) redirect(userRegistryPath(search, "error=invalid-group-assignment"));
|
if (!UUID_PATTERN.test(userId) || !UUID_PATTERN.test(groupId)) redirect(adminGroupReturnPath(returnTo, "error=invalid-group-assignment"));
|
||||||
|
|
||||||
const [[user], [targetGroup]] = await Promise.all([
|
|
||||||
db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
|
|
||||||
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
|
|
||||||
]);
|
|
||||||
if (!user || !targetGroup) redirect(userRegistryPath(search, "error=invalid-group-assignment"));
|
|
||||||
|
|
||||||
const requestHeaders = await headers();
|
const requestHeaders = await headers();
|
||||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||||
try {
|
try {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.execute(sql`select ${users.id} from ${users} where ${users.id} = ${user.id} for update`);
|
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('minecraft-account-manager-group-membership'))`);
|
||||||
|
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
|
||||||
|
await tx.execute(sql`select ${users.id} from ${users} where ${users.id} = ${userId} for update`);
|
||||||
|
const [[user], [targetGroup]] = await Promise.all([
|
||||||
|
tx.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
|
||||||
|
tx.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
|
||||||
|
]);
|
||||||
|
if (!user || !targetGroup) throw new Error("User or destination group no longer exists");
|
||||||
|
const [membership] = await tx.select({ groupId: userGroupMemberships.groupId })
|
||||||
|
.from(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id)).limit(1);
|
||||||
|
if (membership && membership.groupId !== targetGroup.id) {
|
||||||
|
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${membership.groupId} for update`);
|
||||||
|
}
|
||||||
const [previous] = await tx.select({ id: groups.id, name: groups.name })
|
const [previous] = await tx.select({ id: groups.id, name: groups.name })
|
||||||
.from(userGroupMemberships)
|
.from(userGroupMemberships)
|
||||||
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
|
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
|
||||||
@@ -131,9 +132,9 @@ export async function assignUserGroupFromRegistry(formData: FormData) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
redirect(userRegistryPath(search, "error=invalid-group-assignment"));
|
redirect(adminGroupReturnPath(returnTo, "error=invalid-group-assignment"));
|
||||||
}
|
}
|
||||||
redirect(userRegistryPath(search, "saved=group"));
|
redirect(adminGroupReturnPath(returnTo, "saved=group"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUserName(formData: FormData) {
|
export async function updateUserName(formData: FormData) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
import { groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||||
import { and, asc, desc, eq, ilike, isNull, or, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, ilike, isNull, or, sql } from "drizzle-orm";
|
||||||
import Link from "next/link";
|
import { AdminUserTable } from "@/components/admin-user-table";
|
||||||
import { UserGroupSelect } from "@/components/user-group-select";
|
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
import { assignUserGroupFromRegistry } from "./actions";
|
import { assignUserGroupFromRegistry } from "./actions";
|
||||||
|
|
||||||
@@ -66,8 +65,8 @@ export default async function AdminUsersPage({
|
|||||||
db.select({ userId: userGroupMemberships.userId, groupId: userGroupMemberships.groupId })
|
db.select({ userId: userGroupMemberships.userId, groupId: userGroupMemberships.groupId })
|
||||||
.from(userGroupMemberships),
|
.from(userGroupMemberships),
|
||||||
]);
|
]);
|
||||||
const defaultGroup = allGroups.find((group) => group.isDefault);
|
const assignmentByUser = Object.fromEntries(memberships.map((membership) => [membership.userId, membership.groupId]));
|
||||||
const groupByUser = new Map(memberships.map((membership) => [membership.userId, membership.groupId]));
|
const returnTo = `/admin/users${search ? `?${new URLSearchParams({ q: search }).toString()}` : ""}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||||
@@ -92,26 +91,8 @@ export default async function AdminUsersPage({
|
|||||||
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{query.error === "invalid-group-assignment" ? "The user or group no longer exists. No group change was applied." : "The requested user could not be found."}</p>}
|
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{query.error === "invalid-group-assignment" ? "The user or group no longer exists. No group change was applied." : "The requested user could not be found."}</p>}
|
||||||
{query.saved === "group" && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">User group updated.</p>}
|
{query.saved === "group" && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">User group updated.</p>}
|
||||||
|
|
||||||
<div className="mt-8 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
<div className="mt-8">
|
||||||
<table className="w-full min-w-[900px] border-collapse text-left">
|
<AdminUserTable action={assignUserGroupFromRegistry} assignmentByUser={assignmentByUser} emptyMessage="No users match that search." groups={allGroups} returnTo={returnTo} users={results} />
|
||||||
<caption className="sr-only">Registered portal users</caption>
|
|
||||||
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
|
|
||||||
<tr><th className="p-4" scope="col">User</th><th className="p-4" scope="col">Discord</th><th className="p-4" scope="col">Primary</th><th className="p-4" scope="col">Accounts</th><th className="p-4" scope="col">Group</th><th className="p-4" scope="col">Status</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-line">
|
|
||||||
{results.map((user) => (
|
|
||||||
<tr className="transition-colors hover:bg-canvas/60" key={user.id}>
|
|
||||||
<th className="p-4 text-left" scope="row"><Link className="font-display text-lg font-black underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/users/${user.id}`}>{user.firstName ?? "Name needed"}</Link></th>
|
|
||||||
<td className="p-4"><div className="font-mono text-xs font-bold">{user.discordGlobalName ?? user.discordUsername}</div><div className="mt-1 font-mono text-[10px] text-muted">@{user.discordUsername}</div><div className="mt-1 font-mono text-[9px] text-muted">{user.discordUserId}</div></td>
|
|
||||||
<td className="p-4 font-mono text-xs">{user.primaryUsername ?? "—"}</td>
|
|
||||||
<td className="p-4 font-mono text-xs">{user.accountCount}</td>
|
|
||||||
<td className="p-4">{defaultGroup ? <UserGroupSelect action={assignUserGroupFromRegistry} effectiveGroupId={groupByUser.get(user.id) ?? defaultGroup.id} groups={allGroups} search={search} userId={user.id} userLabel={user.firstName ?? user.discordUsername} /> : <span className="text-xs text-accent">Default group missing</span>}</td>
|
|
||||||
<td className="p-4"><span className={`border px-2 py-1 font-mono text-[9px] uppercase tracking-wider ${user.onboardingCompletedAt ? "border-line text-muted" : "border-accent text-accent"}`}>{user.onboardingCompletedAt ? "Ready" : "Onboarding"}</span></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{!results.length && <tr><td className="p-8 text-muted" colSpan={6}>No users match that search.</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 font-mono text-[9px] uppercase tracking-widest text-muted">Showing up to 100 users</p>
|
<p className="mt-4 font-mono text-[9px] uppercase tracking-widest text-muted">Showing up to 100 users</p>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { AdminModalForm } from "./admin-modal-form";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
HTMLDialogElement.prototype.showModal = function showModal() { this.open = true; };
|
||||||
|
HTMLDialogElement.prototype.close = function close() {
|
||||||
|
this.open = false;
|
||||||
|
this.dispatchEvent(new Event("close"));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AdminModalForm", () => {
|
||||||
|
it("renders an accessible trigger, labelled dialog, cancellation, and pending-capable submit control", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<AdminModalForm
|
||||||
|
action={async () => undefined}
|
||||||
|
description="Review this policy change before applying it."
|
||||||
|
submitLabel="Apply policy"
|
||||||
|
title="Change access policy"
|
||||||
|
triggerLabel="Change"
|
||||||
|
>
|
||||||
|
<input name="groupId" type="hidden" value="group-one" />
|
||||||
|
</AdminModalForm>,
|
||||||
|
);
|
||||||
|
expect(markup).toContain("Change access policy");
|
||||||
|
expect(markup).toContain("Review this policy change before applying it.");
|
||||||
|
expect(markup).toContain("<dialog");
|
||||||
|
expect(markup).toContain("aria-haspopup=\"dialog\"");
|
||||||
|
expect(markup).toContain("Cancel");
|
||||||
|
expect(markup).toContain("Apply policy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens, cancels, and prevents dismissal while the action is pending", async () => {
|
||||||
|
let finishAction!: () => void;
|
||||||
|
const action = vi.fn(() => new Promise<void>((resolve) => { finishAction = resolve; }));
|
||||||
|
render(<AdminModalForm action={action} description="Confirm it." submitLabel="Apply policy" title="Change access policy" triggerLabel="Change" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Change" }));
|
||||||
|
const dialog = screen.getByRole("dialog") as HTMLDialogElement;
|
||||||
|
expect(dialog.open).toBe(true);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply policy" }));
|
||||||
|
await waitFor(() => expect(action).toHaveBeenCalledOnce());
|
||||||
|
expect((screen.getByRole("button", { name: "Change" }) as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
|
||||||
|
const cancelEvent = new Event("cancel", { bubbles: false, cancelable: true });
|
||||||
|
dialog.dispatchEvent(cancelEvent);
|
||||||
|
expect(cancelEvent.defaultPrevented).toBe(true);
|
||||||
|
expect(dialog.open).toBe(true);
|
||||||
|
|
||||||
|
finishAction();
|
||||||
|
await waitFor(() => expect((screen.getByRole("button", { name: "Change" }) as HTMLButtonElement).disabled).toBe(false));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(dialog.open).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode, RefObject } from "react";
|
||||||
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
import { useFormStatus } from "react-dom";
|
||||||
|
|
||||||
|
export function AdminModalForm({
|
||||||
|
action,
|
||||||
|
children,
|
||||||
|
description,
|
||||||
|
intent = "default",
|
||||||
|
submitLabel,
|
||||||
|
title,
|
||||||
|
triggerClassName,
|
||||||
|
triggerLabel,
|
||||||
|
triggerPressed,
|
||||||
|
}: {
|
||||||
|
action: (formData: FormData) => Promise<void>;
|
||||||
|
children?: ReactNode;
|
||||||
|
description: string;
|
||||||
|
intent?: "default" | "danger";
|
||||||
|
submitLabel: string;
|
||||||
|
title: string;
|
||||||
|
triggerClassName?: string;
|
||||||
|
triggerLabel: string;
|
||||||
|
triggerPressed?: boolean;
|
||||||
|
}) {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
const descriptionId = useId();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-pressed={triggerPressed}
|
||||||
|
className={triggerClassName ?? "font-mono text-[10px] font-bold uppercase underline underline-offset-4"}
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={() => dialogRef.current?.showModal()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{triggerLabel}
|
||||||
|
</button>
|
||||||
|
<dialog
|
||||||
|
aria-describedby={descriptionId}
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
className="admin-modal m-auto w-[min(92vw,36rem)] border border-ink bg-panel p-0 text-ink shadow-[10px_10px_0_var(--color-shadow)] backdrop:bg-ink/70"
|
||||||
|
onCancel={(event) => { if (submitting) event.preventDefault(); }}
|
||||||
|
ref={dialogRef}
|
||||||
|
>
|
||||||
|
<form action={action} className="p-6 sm:p-8" onSubmit={() => setSubmitting(true)}>
|
||||||
|
<p className="font-mono text-[9px] font-bold uppercase tracking-[0.2em] text-accent">Confirm operation</p>
|
||||||
|
<h2 className="mt-3 font-display text-3xl font-black uppercase" id={titleId}>{title}</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-muted" id={descriptionId}>{description}</p>
|
||||||
|
{children && <div className="mt-6">{children}</div>}
|
||||||
|
<ModalActions dialogRef={dialogRef} intent={intent} onPendingChange={setSubmitting} submitLabel={submitLabel} />
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalActions({
|
||||||
|
dialogRef,
|
||||||
|
intent,
|
||||||
|
onPendingChange,
|
||||||
|
submitLabel,
|
||||||
|
}: {
|
||||||
|
dialogRef: RefObject<HTMLDialogElement | null>;
|
||||||
|
intent: "default" | "danger";
|
||||||
|
onPendingChange: (pending: boolean) => void;
|
||||||
|
submitLabel: string;
|
||||||
|
}) {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
const observedPending = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (pending) {
|
||||||
|
observedPending.current = true;
|
||||||
|
onPendingChange(true);
|
||||||
|
} else if (observedPending.current) {
|
||||||
|
observedPending.current = false;
|
||||||
|
onPendingChange(false);
|
||||||
|
}
|
||||||
|
}, [onPendingChange, pending]);
|
||||||
|
return (
|
||||||
|
<div className="mt-8 flex flex-wrap justify-end gap-3 border-t border-line pt-5">
|
||||||
|
<button
|
||||||
|
className="border border-line px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider disabled:opacity-50"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => dialogRef.current?.close()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas disabled:cursor-wait disabled:opacity-60 ${intent === "danger" ? "bg-accent" : "bg-ink"}`}
|
||||||
|
disabled={pending}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{pending ? "Applying…" : submitLabel}
|
||||||
|
</button>
|
||||||
|
<span aria-live="polite" className="sr-only">{pending ? "Operation in progress." : ""}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { AdminUserTable } from "./admin-user-table";
|
||||||
|
|
||||||
|
describe("AdminUserTable", () => {
|
||||||
|
it("renders reusable identity and confirmed group controls", () => {
|
||||||
|
const markup = renderToStaticMarkup(<AdminUserTable
|
||||||
|
action={async () => undefined}
|
||||||
|
assignmentByUser={{ user1: "ops" }}
|
||||||
|
emptyMessage="No members."
|
||||||
|
groups={[{ id: "everyone", name: "everyone", isDefault: true }, { id: "ops", name: "Ops", isDefault: false }]}
|
||||||
|
returnTo="/admin/groups/11111111-1111-4111-8111-111111111111"
|
||||||
|
users={[{
|
||||||
|
id: "user1",
|
||||||
|
firstName: "Alex",
|
||||||
|
discordUsername: "alex",
|
||||||
|
discordGlobalName: "Alex Global",
|
||||||
|
discordUserId: "123",
|
||||||
|
onboardingCompletedAt: new Date("2026-08-01T00:00:00Z"),
|
||||||
|
primaryUsername: "AlexMC",
|
||||||
|
accountCount: 2,
|
||||||
|
}]}
|
||||||
|
/>);
|
||||||
|
expect(markup).toContain("Alex Global");
|
||||||
|
expect(markup).toContain("AlexMC");
|
||||||
|
expect(markup).toContain("Accounts");
|
||||||
|
expect(markup).toContain("Group for Alex");
|
||||||
|
expect(markup).toContain("Confirm move");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { UserGroupSelect } from "./user-group-select";
|
||||||
|
|
||||||
|
export interface AdminUserRow {
|
||||||
|
id: string;
|
||||||
|
firstName: string | null;
|
||||||
|
discordUsername: string;
|
||||||
|
discordGlobalName: string | null;
|
||||||
|
discordUserId: string;
|
||||||
|
onboardingCompletedAt: Date | null;
|
||||||
|
primaryUsername: string | null;
|
||||||
|
accountCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminUserTable({
|
||||||
|
action,
|
||||||
|
assignmentByUser,
|
||||||
|
emptyMessage,
|
||||||
|
groups,
|
||||||
|
returnTo,
|
||||||
|
users,
|
||||||
|
}: {
|
||||||
|
action: (formData: FormData) => Promise<void>;
|
||||||
|
assignmentByUser: Record<string, string>;
|
||||||
|
emptyMessage: string;
|
||||||
|
groups: Array<{ id: string; name: string; isDefault: boolean }>;
|
||||||
|
returnTo: string;
|
||||||
|
users: AdminUserRow[];
|
||||||
|
}) {
|
||||||
|
const defaultGroup = groups.find((group) => group.isDefault);
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||||
|
<table className="w-full min-w-[900px] border-collapse text-left">
|
||||||
|
<caption className="sr-only">Registered portal users and effective groups</caption>
|
||||||
|
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
|
||||||
|
<tr><th className="p-4" scope="col">User</th><th className="p-4" scope="col">Discord</th><th className="p-4" scope="col">Primary</th><th className="p-4" scope="col">Accounts</th><th className="p-4" scope="col">Group</th><th className="p-4" scope="col">Status</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line">
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr className="transition-colors hover:bg-canvas/60" key={user.id}>
|
||||||
|
<th className="p-4 text-left" scope="row"><Link className="font-display text-lg font-black underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/users/${user.id}`}>{user.firstName ?? "Name needed"}</Link></th>
|
||||||
|
<td className="p-4"><div className="font-mono text-xs font-bold">{user.discordGlobalName ?? user.discordUsername}</div><div className="mt-1 font-mono text-[10px] text-muted">@{user.discordUsername}</div><div className="mt-1 font-mono text-[9px] text-muted">{user.discordUserId}</div></td>
|
||||||
|
<td className="p-4 font-mono text-xs">{user.primaryUsername ?? "—"}</td>
|
||||||
|
<td className="p-4 font-mono text-xs">{user.accountCount}</td>
|
||||||
|
<td className="p-4">{defaultGroup ? <UserGroupSelect action={action} effectiveGroupId={assignmentByUser[user.id] ?? defaultGroup.id} groups={groups} returnTo={returnTo} userId={user.id} userLabel={user.firstName ?? user.discordUsername} /> : <span className="text-xs text-accent">Default group missing</span>}</td>
|
||||||
|
<td className="p-4"><span className={`border px-2 py-1 font-mono text-[9px] uppercase tracking-wider ${user.onboardingCompletedAt ? "border-line text-muted" : "border-accent text-accent"}`}>{user.onboardingCompletedAt ? "Ready" : "Onboarding"}</span></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!users.length && <tr><td className="p-8 text-muted" colSpan={6}>{emptyMessage}</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { AdminModalForm } from "./admin-modal-form";
|
||||||
|
|
||||||
|
export function GroupPolicyControl({
|
||||||
|
action,
|
||||||
|
enabled,
|
||||||
|
groupId,
|
||||||
|
groupName,
|
||||||
|
memberCount,
|
||||||
|
policy,
|
||||||
|
returnLocation,
|
||||||
|
}: {
|
||||||
|
action: (formData: FormData) => Promise<void>;
|
||||||
|
enabled: boolean;
|
||||||
|
groupId: string;
|
||||||
|
groupName: string;
|
||||||
|
memberCount: number;
|
||||||
|
policy: string;
|
||||||
|
returnLocation: "list" | "detail";
|
||||||
|
}) {
|
||||||
|
const nextState = enabled ? "deny" : "allow";
|
||||||
|
return (
|
||||||
|
<AdminModalForm
|
||||||
|
action={action}
|
||||||
|
description={`${nextState === "allow" ? "Allow" : "Deny"} ${policy.toLowerCase()} for ${memberCount} effective ${memberCount === 1 ? "member" : "members"} of ${groupName}.`}
|
||||||
|
submitLabel={`${nextState === "allow" ? "Allow" : "Deny"} access`}
|
||||||
|
title={`${nextState === "allow" ? "Allow" : "Deny"} ${policy}?`}
|
||||||
|
triggerClassName={`min-w-24 border px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider ${enabled ? "border-signal bg-signal text-ink" : "border-accent bg-transparent text-accent"}`}
|
||||||
|
triggerLabel={enabled ? "Allowed" : "Denied"}
|
||||||
|
triggerPressed={enabled}
|
||||||
|
>
|
||||||
|
<input name="groupId" type="hidden" value={groupId} />
|
||||||
|
<input name="enabled" type="hidden" value={enabled ? "no" : "yes"} />
|
||||||
|
<input name="returnLocation" type="hidden" value={returnLocation} />
|
||||||
|
</AdminModalForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,53 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { UserGroupSelect } from "./user-group-select";
|
import { UserGroupSelect } from "./user-group-select";
|
||||||
|
|
||||||
|
const groups = [{ id: "group-everyone", name: "everyone" }, { id: "group-ops", name: "Ops" }];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
HTMLDialogElement.prototype.showModal = function showModal() { this.open = true; };
|
||||||
|
HTMLDialogElement.prototype.close = function close() {
|
||||||
|
this.open = false;
|
||||||
|
this.dispatchEvent(new Event("close"));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe("UserGroupSelect", () => {
|
describe("UserGroupSelect", () => {
|
||||||
it("renders the effective group and preserves the active search", () => {
|
it("renders the effective group and preserves the return path", () => {
|
||||||
const markup = renderToStaticMarkup(<UserGroupSelect
|
const markup = renderToStaticMarkup(<UserGroupSelect
|
||||||
action={async () => undefined}
|
action={async () => undefined}
|
||||||
effectiveGroupId="group-ops"
|
effectiveGroupId="group-ops"
|
||||||
groups={[{ id: "group-everyone", name: "everyone" }, { id: "group-ops", name: "Ops" }]}
|
groups={groups}
|
||||||
search="alex smith"
|
returnTo="/admin/users?q=alex%20smith"
|
||||||
userId="user-one"
|
userId="user-one"
|
||||||
userLabel="Alex"
|
userLabel="Alex"
|
||||||
/>);
|
/>);
|
||||||
|
|
||||||
expect(markup).toContain('aria-label="Group for Alex"');
|
expect(markup).toContain('aria-label="Group for Alex"');
|
||||||
expect(markup).toContain('<option value="group-ops" selected="">Ops</option>');
|
expect(markup).toContain('<option value="group-ops" selected="">Ops</option>');
|
||||||
expect(markup).toContain('<input type="hidden" name="search" value="alex smith"/>');
|
expect(markup).toContain('<input type="hidden" name="returnTo" value="/admin/users?q=alex%20smith"/>');
|
||||||
expect(markup).toContain("Apply group");
|
expect(markup).toContain("Changing this selection opens a confirmation dialog.");
|
||||||
|
expect(markup).toContain("Confirm move");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires confirmation and restores the effective group when cancelled", () => {
|
||||||
|
render(<UserGroupSelect action={async () => undefined} effectiveGroupId="group-ops" groups={groups} returnTo="/admin/users" userId="user-one" userLabel="Alex" />);
|
||||||
|
const select = screen.getByRole("combobox", { name: "Group for Alex" }) as HTMLSelectElement;
|
||||||
|
|
||||||
|
fireEvent.change(select, { target: { value: "group-everyone" } });
|
||||||
|
const dialog = screen.getByRole("dialog") as HTMLDialogElement;
|
||||||
|
expect(dialog.open).toBe(true);
|
||||||
|
expect(select.value).toBe("group-everyone");
|
||||||
|
expect(select.disabled).toBe(true);
|
||||||
|
expect(screen.getByText(/from/).textContent).toContain("Ops");
|
||||||
|
expect(screen.getByText(/from/).textContent).toContain("everyone");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(dialog.open).toBe(false);
|
||||||
|
expect(select.value).toBe("group-ops");
|
||||||
|
expect(select.disabled).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,60 +1,99 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
import { useFormStatus } from "react-dom";
|
import { useFormStatus } from "react-dom";
|
||||||
|
|
||||||
export function UserGroupSelect({
|
export function UserGroupSelect({
|
||||||
action,
|
action,
|
||||||
effectiveGroupId,
|
effectiveGroupId,
|
||||||
groups,
|
groups,
|
||||||
search,
|
returnTo,
|
||||||
userId,
|
userId,
|
||||||
userLabel,
|
userLabel,
|
||||||
}: {
|
}: {
|
||||||
action: (formData: FormData) => Promise<void>;
|
action: (formData: FormData) => Promise<void>;
|
||||||
effectiveGroupId: string;
|
effectiveGroupId: string;
|
||||||
groups: Array<{ id: string; name: string }>;
|
groups: Array<{ id: string; name: string }>;
|
||||||
search: string;
|
returnTo: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
userLabel: string;
|
userLabel: string;
|
||||||
}) {
|
}) {
|
||||||
const helpId = `group-help-${userId}`;
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
return (
|
const titleId = useId();
|
||||||
<form action={action} className="flex items-center gap-2">
|
const descriptionId = useId();
|
||||||
<input name="userId" type="hidden" value={userId} />
|
const helpId = useId();
|
||||||
<input name="search" type="hidden" value={search} />
|
const [selectedGroupId, setSelectedGroupId] = useState(effectiveGroupId);
|
||||||
<span className="sr-only" id={helpId}>Changing this selection applies the group immediately.</span>
|
const [proposedGroupId, setProposedGroupId] = useState<string | null>(null);
|
||||||
<GroupSelectControl effectiveGroupId={effectiveGroupId} groups={groups} helpId={helpId} userLabel={userLabel} />
|
const [submitting, setSubmitting] = useState(false);
|
||||||
</form>
|
const currentGroup = groups.find((group) => group.id === effectiveGroupId);
|
||||||
);
|
const proposedGroup = groups.find((group) => group.id === proposedGroupId);
|
||||||
}
|
|
||||||
|
function resetSelection() {
|
||||||
|
setSelectedGroupId(effectiveGroupId);
|
||||||
|
setProposedGroupId(null);
|
||||||
|
}
|
||||||
|
|
||||||
function GroupSelectControl({
|
|
||||||
effectiveGroupId,
|
|
||||||
groups,
|
|
||||||
helpId,
|
|
||||||
userLabel,
|
|
||||||
}: {
|
|
||||||
effectiveGroupId: string;
|
|
||||||
groups: Array<{ id: string; name: string }>;
|
|
||||||
helpId: string;
|
|
||||||
userLabel: string;
|
|
||||||
}) {
|
|
||||||
const { pending } = useFormStatus();
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<span className="sr-only" id={helpId}>Changing this selection opens a confirmation dialog.</span>
|
||||||
<select
|
<select
|
||||||
aria-describedby={helpId}
|
aria-describedby={helpId}
|
||||||
aria-label={`Group for ${userLabel}`}
|
aria-label={`Group for ${userLabel}`}
|
||||||
className="max-w-44 border border-line bg-canvas px-3 py-2 font-mono text-xs outline-none focus:border-accent disabled:cursor-wait disabled:opacity-60"
|
className="max-w-44 border border-line bg-canvas px-3 py-2 font-mono text-xs outline-none focus:border-accent disabled:cursor-wait disabled:opacity-60"
|
||||||
defaultValue={effectiveGroupId}
|
disabled={proposedGroupId !== null || submitting}
|
||||||
disabled={pending}
|
onChange={(event) => {
|
||||||
name="groupId"
|
const nextGroupId = event.currentTarget.value;
|
||||||
onChange={(event) => event.currentTarget.form?.requestSubmit()}
|
if (nextGroupId === effectiveGroupId) return;
|
||||||
|
setSelectedGroupId(nextGroupId);
|
||||||
|
setProposedGroupId(nextGroupId);
|
||||||
|
dialogRef.current?.showModal();
|
||||||
|
}}
|
||||||
|
value={selectedGroupId}
|
||||||
>
|
>
|
||||||
{groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
|
{groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<span aria-live="polite" className="sr-only">{pending ? "Updating group." : ""}</span>
|
<dialog
|
||||||
<button className="sr-only focus:not-sr-only" disabled={pending} type="submit">Apply group</button>
|
aria-describedby={descriptionId}
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
className="admin-modal m-auto w-[min(92vw,34rem)] border border-ink bg-panel p-0 text-ink shadow-[10px_10px_0_var(--color-shadow)] backdrop:bg-ink/70"
|
||||||
|
onCancel={(event) => { if (submitting) event.preventDefault(); }}
|
||||||
|
onClose={() => { if (!submitting) resetSelection(); }}
|
||||||
|
ref={dialogRef}
|
||||||
|
>
|
||||||
|
<form action={action} className="p-6 sm:p-8" onSubmit={() => setSubmitting(true)}>
|
||||||
|
<input name="userId" type="hidden" value={userId} />
|
||||||
|
<input name="groupId" type="hidden" value={proposedGroupId ?? effectiveGroupId} />
|
||||||
|
<input name="returnTo" type="hidden" value={returnTo} />
|
||||||
|
<p className="font-mono text-[9px] font-bold uppercase tracking-[0.2em] text-accent">Confirm membership</p>
|
||||||
|
<h2 className="mt-3 font-display text-3xl font-black uppercase" id={titleId}>Move {userLabel}?</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-muted" id={descriptionId}>
|
||||||
|
Change the effective group from <strong className="text-ink">{currentGroup?.name ?? "unknown"}</strong> to <strong className="text-ink">{proposedGroup?.name ?? "unknown"}</strong>. Their access policy changes immediately.
|
||||||
|
</p>
|
||||||
|
<AssignmentActions dialogRef={dialogRef} onPendingChange={setSubmitting} />
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AssignmentActions({ dialogRef, onPendingChange }: { dialogRef: RefObject<HTMLDialogElement | null>; onPendingChange: (pending: boolean) => void }) {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
const observedPending = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (pending) {
|
||||||
|
observedPending.current = true;
|
||||||
|
onPendingChange(true);
|
||||||
|
} else if (observedPending.current) {
|
||||||
|
observedPending.current = false;
|
||||||
|
onPendingChange(false);
|
||||||
|
}
|
||||||
|
}, [onPendingChange, pending]);
|
||||||
|
return (
|
||||||
|
<div className="mt-8 flex justify-end gap-3 border-t border-line pt-5">
|
||||||
|
<button className="border border-line px-5 py-3 font-mono text-[10px] font-bold uppercase" disabled={pending} onClick={() => dialogRef.current?.close()} type="button">Cancel</button>
|
||||||
|
<button className="bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase text-canvas disabled:cursor-wait disabled:opacity-60" disabled={pending} type="submit">{pending ? "Moving…" : "Confirm move"}</button>
|
||||||
|
<span aria-live="polite" className="sr-only">{pending ? "Group change in progress." : ""}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ This OKF bundle is the product record for implemented and proposed behavior. Sto
|
|||||||
* [US-016 — Build and publish versioned releases](us-016-automated-releases.md) - Gitea Actions publish the Velocity JAR and web and migration images.
|
* [US-016 — Build and publish versioned releases](us-016-automated-releases.md) - Gitea Actions publish the Velocity JAR and web and migration images.
|
||||||
* [US-017 — Control admission with groups](us-017-group-access.md) - Each user has one effective group that explicitly controls Minecraft access.
|
* [US-017 — Control admission with groups](us-017-group-access.md) - Each user has one effective group that explicitly controls Minecraft access.
|
||||||
* [US-018 — Monitor community account activity](us-018-admin-dashboard.md) - Administrators review daily users, confirmed connections, locations, denials, and risky networks.
|
* [US-018 — Monitor community account activity](us-018-admin-dashboard.md) - Administrators review daily users, confirmed connections, locations, denials, and risky networks.
|
||||||
|
* [US-019 — Manage groups efficiently](us-019-admin-group-management.md) - Administrators manage group identity, policies, membership, and creation through focused confirmed workflows.
|
||||||
|
|
||||||
# Tracking
|
# Tracking
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## 2026-08-02
|
## 2026-08-02
|
||||||
|
|
||||||
|
* **Refine**: Replace admin group cards with a policy table, confirmed modal workflows, editable group details, and reusable effective-member management.
|
||||||
* **Add**: Provide Users-page group assignment, effective-group VPN/proxy/Tor exceptions for game admission, and independent configurable denial messages.
|
* **Add**: Provide Users-page group assignment, effective-group VPN/proxy/Tor exceptions for game admission, and independent configurable denial messages.
|
||||||
* **Fix**: Treat malformed ProxyCheck proxy signals as unknown and classify every authenticated Velocity login before identity resolution.
|
* **Fix**: Treat malformed ProxyCheck proxy signals as unknown and classify every authenticated Velocity login before identity resolution.
|
||||||
* **Fix**: Replace the dashboard's pre-enrichment network label with enriched company, ASN, connection type, Proxy/VPN status, and risk fields.
|
* **Fix**: Replace the dashboard's pre-enrichment network label with enriched company, ASN, connection type, Proxy/VPN status, and risk fields.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Manage users as an administrator
|
title: Manage users as an administrator
|
||||||
description: Authorized operators search users and maintain their names, linked accounts, primaries, and Discord nicknames.
|
description: Authorized operators search users and maintain their names, linked accounts, primaries, and Discord nicknames.
|
||||||
tags: [admin, users, minecraft, discord]
|
tags: [admin, users, minecraft, discord]
|
||||||
timestamp: 2026-08-02T14:12:43Z
|
timestamp: 2026-08-02T15:03:59Z
|
||||||
story_id: US-013
|
story_id: US-013
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -28,6 +28,10 @@ As an administrator, I want to manage a user's identity and Minecraft accounts,
|
|||||||
- [x] Selecting a group immediately applies the assignment; selecting `everyone` removes the explicit assignment.
|
- [x] Selecting a group immediately applies the assignment; selecting `everyone` removes the explicit assignment.
|
||||||
- [x] Group changes preserve the active user search and show accessible success or error feedback.
|
- [x] Group changes preserve the active user search and show accessible success or error feedback.
|
||||||
- [x] Registry assignment changes revalidate administrator authorization, user existence, and group existence, and audit the previous and new effective groups.
|
- [x] Registry assignment changes revalidate administrator authorization, user existence, and group existence, and audit the previous and new effective groups.
|
||||||
|
- [x] User rows and group-assignment controls are reusable between the Users registry and group-member details.
|
||||||
|
- [x] Group details show only the group's effective members with identity, Discord, primary-account, account-count, status, and group columns.
|
||||||
|
- [x] Changing a user's group requires modal confirmation and choosing `everyone` removes the explicit assignment.
|
||||||
|
- [x] Moving a member to another group removes that user from the current effective-member list after confirmation.
|
||||||
|
|
||||||
# Implementation
|
# Implementation
|
||||||
|
|
||||||
@@ -35,6 +39,7 @@ As an administrator, I want to manage a user's identity and Minecraft accounts,
|
|||||||
- [`apps/web/src/app/admin/(console)/users/[userId]/page.tsx`](../apps/web/src/app/admin/%28console%29/users/%5BuserId%5D/page.tsx)
|
- [`apps/web/src/app/admin/(console)/users/[userId]/page.tsx`](../apps/web/src/app/admin/%28console%29/users/%5BuserId%5D/page.tsx)
|
||||||
- [`apps/web/src/app/admin/(console)/users/actions.ts`](../apps/web/src/app/admin/%28console%29/users/actions.ts)
|
- [`apps/web/src/app/admin/(console)/users/actions.ts`](../apps/web/src/app/admin/%28console%29/users/actions.ts)
|
||||||
- [`apps/web/src/components/user-group-select.tsx`](../apps/web/src/components/user-group-select.tsx)
|
- [`apps/web/src/components/user-group-select.tsx`](../apps/web/src/components/user-group-select.tsx)
|
||||||
|
- [`apps/web/src/components/admin-user-table.tsx`](../apps/web/src/components/admin-user-table.tsx)
|
||||||
|
|
||||||
# Validation
|
# Validation
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Control Minecraft admission with groups
|
title: Control Minecraft admission with groups
|
||||||
description: Administrators assign users to groups and enable Minecraft access through explicit group policy.
|
description: Administrators assign users to groups and enable Minecraft access through explicit group policy.
|
||||||
tags: [admin, groups, authorization, velocity, security]
|
tags: [admin, groups, authorization, velocity, security]
|
||||||
timestamp: 2026-08-02T14:12:43Z
|
timestamp: 2026-08-02T15:03:59Z
|
||||||
story_id: US-017
|
story_id: US-017
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -29,6 +29,10 @@ As an administrator, I want to organize registered users into access groups, so
|
|||||||
- [x] Confirmed VPN, proxy, or Tor game connections are denied unless the user's single effective group allows anonymized networks.
|
- [x] Confirmed VPN, proxy, or Tor game connections are denied unless the user's single effective group allows anonymized networks.
|
||||||
- [x] Clear and hosting classifications are not denied by this group policy, and unavailable intelligence does not independently deny a registered player.
|
- [x] Clear and hosting classifications are not denied by this group policy, and unavailable intelligence does not independently deny a registered player.
|
||||||
- [x] VPN policy changes are authorized server-side and audited.
|
- [x] VPN policy changes are authorized server-side and audited.
|
||||||
|
- [x] Group creation can explicitly initialize Minecraft and VPN/proxy/Tor policies while retaining deny-by-default controls.
|
||||||
|
- [x] List and detail policy changes use the same confirmation workflow.
|
||||||
|
- [x] Effective member counts include unassigned users who fall back to `everyone`.
|
||||||
|
- [x] Group names and descriptions are validated and editable server-side.
|
||||||
|
|
||||||
# Implementation
|
# Implementation
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
type: User Story
|
||||||
|
title: Manage groups efficiently
|
||||||
|
description: Administrators use concise policy tables, focused group details, and confirmed modal workflows to manage access groups.
|
||||||
|
tags: [admin, groups, usability, authorization]
|
||||||
|
timestamp: 2026-08-02T15:03:59Z
|
||||||
|
story_id: US-019
|
||||||
|
status: verified
|
||||||
|
---
|
||||||
|
|
||||||
|
# User Story
|
||||||
|
|
||||||
|
As an administrator, I want a concise group policy table and focused group details, so that I can manage access without navigating cumbersome controls.
|
||||||
|
|
||||||
|
# Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] The main Groups page lists name, Minecraft access, VPN/proxy/Tor access, and effective member count with the default group first and remaining names ordered alphabetically.
|
||||||
|
- [x] Policy controls show their current state and require confirmation in an accessible modal before mutation.
|
||||||
|
- [x] Selecting a group name opens a detail page with its description, policies, and effective members.
|
||||||
|
- [x] Add group opens an accessible modal asking for name, description, Minecraft access, and VPN/proxy/Tor access.
|
||||||
|
- [x] New-group policies default to denied and can be enabled before creation.
|
||||||
|
- [x] Administrators manage only the display name; an internal collision-safe slug is generated automatically.
|
||||||
|
- [x] Administrators can edit group name and description; the protected `everyone` name remains fixed while its description remains editable.
|
||||||
|
- [x] Non-default groups can be deleted only after modal confirmation, returning all affected users to `everyone`.
|
||||||
|
- [x] Group identity, policy, creation, and deletion mutations commit atomically with their audit events.
|
||||||
|
- [x] Modal controls support keyboard operation, focus management, cancellation, and clear pending state.
|
||||||
|
|
||||||
|
# Implementation
|
||||||
|
|
||||||
|
- [`apps/web/src/app/admin/(console)/groups/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/page.tsx)
|
||||||
|
- [`apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/%5BgroupId%5D/page.tsx)
|
||||||
|
- [`apps/web/src/app/admin/(console)/groups/actions.ts`](../apps/web/src/app/admin/%28console%29/groups/actions.ts)
|
||||||
|
- [`apps/web/src/components/admin-modal-form.tsx`](../apps/web/src/components/admin-modal-form.tsx)
|
||||||
|
- [`apps/web/src/components/group-policy-control.tsx`](../apps/web/src/components/group-policy-control.tsx)
|
||||||
|
- [`apps/web/src/lib/group-management.ts`](../apps/web/src/lib/group-management.ts)
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
|
||||||
|
Native-dialog interaction and pending-state behavior are covered by [`apps/web/src/components/admin-modal-form.test.tsx`](../apps/web/src/components/admin-modal-form.test.tsx). Slug, return-path, protected-name, and effective-membership behavior are covered by [`apps/web/src/lib/group-management.test.ts`](../apps/web/src/lib/group-management.test.ts). TypeScript, lint, accessibility review, Semgrep, production build, and OKF validation pass.
|
||||||
|
|
||||||
|
# Related Stories
|
||||||
|
|
||||||
|
- [Manage users as an administrator](us-013-admin-user-management.md)
|
||||||
|
- [Control Minecraft admission with groups](us-017-group-access.md)
|
||||||
|
- [Preserve an audit trail](us-010-audit-events.md)
|
||||||
@@ -12,7 +12,8 @@ Player account management, administrator navigation, dashboard metrics and chart
|
|||||||
- Darkened the accent color so accent text reaches at least 4.5:1 contrast on both canvas and panel backgrounds.
|
- Darkened the accent color so accent text reaches at least 4.5:1 contrast on both canvas and panel backgrounds.
|
||||||
- Preserved reduced-motion behavior and disabled decorative cursor animation when requested.
|
- Preserved reduced-motion behavior and disabled decorative cursor animation when requested.
|
||||||
- Added labels or accessible names to search, Minecraft username, settings, group, and event-filter controls.
|
- Added labels or accessible names to search, Minecraft username, settings, group, and event-filter controls.
|
||||||
- Added per-user group dropdowns with immediate-change instructions, keyboard submission fallback, and live success or error feedback.
|
- Added reusable per-user group dropdowns that open labelled confirmation dialogs, restore the prior selection on cancellation, prevent dismissal while pending, and provide live progress and result feedback.
|
||||||
|
- Group creation, policy, editing, and deletion use native modal dialogs with keyboard cancellation, focus management, descriptive confirmation text, and disabled pending controls.
|
||||||
- Added `fieldset` and `legend` semantics to multi-select event-type filters.
|
- Added `fieldset` and `legend` semantics to multi-select event-type filters.
|
||||||
- Added table captions, column scopes, and row scopes to administrator data tables.
|
- Added table captions, column scopes, and row scopes to administrator data tables.
|
||||||
- Added `role=status` with polite announcements for successful nickname changes and `role=alert` with assertive announcements for errors.
|
- Added `role=status` with polite announcements for successful nickname changes and `role=alert` with assertive announcements for errors.
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
|
|||||||
- Velocity and its API fail closed.
|
- Velocity and its API fail closed.
|
||||||
- Registered players require an enabled effective group; explicit assignments replace rather than combine with the protected, disabled-by-default `everyone` fallback.
|
- Registered players require an enabled effective group; explicit assignments replace rather than combine with the protected, disabled-by-default `everyone` fallback.
|
||||||
- Confirmed VPN, proxy, and Tor game connections are denied unless that same effective group has an explicit exception; `everyone` and new groups default to no exception.
|
- Confirmed VPN, proxy, and Tor game connections are denied unless that same effective group has an explicit exception; `everyone` and new groups default to no exception.
|
||||||
- Group, VPN-policy, and membership mutations re-check the Keycloak administrator role server-side; registry assignments, VPN-policy changes, message settings, and destructive group deletion commit atomically with their audit events.
|
- Group, VPN-policy, identity, and membership mutations re-check the Keycloak administrator role server-side; registry assignments, policy changes, group edits, creation, deletion, and message settings commit atomically with their audit events.
|
||||||
|
- Group identity creation/rename and membership assignment/deletion use compatible PostgreSQL advisory and row locks to prevent duplicate names, stale audit records, or membership/deletion races. The protected default name and deletion restriction are enforced server-side.
|
||||||
- Every bearer-authenticated Velocity login uses cached IP intelligence before identity resolution, preventing account-creation races from bypassing network policy; malformed provider proxy signals classify as unknown.
|
- Every bearer-authenticated Velocity login uses cached IP intelligence before identity resolution, preventing account-creation races from bypassing network policy; malformed provider proxy signals classify as unknown.
|
||||||
- Event filters accept only event types already present in the ledger, and event detail routes remain role-protected.
|
- Event filters accept only event types already present in the ledger, and event detail routes remain role-protected.
|
||||||
- The administrator-only map defaults to bundled Natural Earth boundaries. OpenStreetMap tile requests begin only after an explicit operator opt-in; marker coordinates are not transmitted as data, but the requested tiles disclose the viewed geographic extent along with the administrator's IP and portal origin.
|
- The administrator-only map defaults to bundled Natural Earth boundaries. OpenStreetMap tile requests begin only after an explicit operator opt-in; marker coordinates are not transmitted as data, but the requested tiles disclose the viewed geographic extent along with the administrator's IP and portal origin.
|
||||||
|
|||||||
Generated
+688
@@ -54,6 +54,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/d3-geo": "^3.1.1",
|
"@types/d3-geo": "^3.1.1",
|
||||||
"@types/leaflet": "^1.9.22",
|
"@types/leaflet": "^1.9.22",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.3",
|
||||||
@@ -62,6 +63,7 @@
|
|||||||
"@types/topojson-client": "^3.1.5",
|
"@types/topojson-client": "^3.1.5",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^16.2.1",
|
"eslint-config-next": "^16.2.1",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.1.0"
|
"vitest": "^4.1.0"
|
||||||
@@ -80,6 +82,59 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
|
"version": "6.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz",
|
||||||
|
"integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@csstools/css-calc": "^3.2.1",
|
||||||
|
"@csstools/css-color-parser": "^4.1.9",
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0",
|
||||||
|
"lru-cache": "^11.5.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.13.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||||
|
"version": "11.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||||
|
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/dom-selector": {
|
||||||
|
"version": "8.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
|
||||||
|
"integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bidi-js": "^1.0.3",
|
||||||
|
"css-tree": "^3.2.1",
|
||||||
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
|
"lru-cache": "^11.5.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.13.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
|
||||||
|
"version": "11.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||||
|
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -329,6 +384,159 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@bramus/specificity": {
|
||||||
|
"version": "2.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||||
|
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-tree": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"specificity": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/color-helpers": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-calc": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-color-parser": {
|
||||||
|
"version": "4.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
|
||||||
|
"integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@csstools/color-helpers": "^6.1.0",
|
||||||
|
"@csstools/css-calc": "^3.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-parser-algorithms": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT-0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"css-tree": "^3.2.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"css-tree": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-tokenizer": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@discordjs/builders": {
|
"node_modules/@discordjs/builders": {
|
||||||
"version": "1.14.1",
|
"version": "1.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz",
|
||||||
@@ -1109,6 +1317,24 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@exodus/bytes": {
|
||||||
|
"version": "1.15.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
|
||||||
|
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@noble/hashes": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
@@ -2709,6 +2935,104 @@
|
|||||||
"tailwindcss": "4.3.3"
|
"tailwindcss": "4.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@testing-library/dom": {
|
||||||
|
"version": "10.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
|
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/code-frame": "^7.10.4",
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@types/aria-query": "^5.0.1",
|
||||||
|
"aria-query": "5.3.0",
|
||||||
|
"dom-accessibility-api": "^0.5.9",
|
||||||
|
"lz-string": "^1.5.0",
|
||||||
|
"picocolors": "1.1.1",
|
||||||
|
"pretty-format": "^27.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@testing-library/dom/node_modules/ansi-styles": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@testing-library/dom/node_modules/aria-query": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"dequal": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@testing-library/dom/node_modules/pretty-format": {
|
||||||
|
"version": "27.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||||
|
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1",
|
||||||
|
"ansi-styles": "^5.0.0",
|
||||||
|
"react-is": "^17.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@testing-library/dom/node_modules/react-is": {
|
||||||
|
"version": "17.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/@testing-library/react": {
|
||||||
|
"version": "16.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
|
||||||
|
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@testing-library/dom": "^10.0.0",
|
||||||
|
"@types/react": "^18.0.0 || ^19.0.0",
|
||||||
|
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.3",
|
"version": "0.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||||
@@ -2720,6 +3044,14 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/aria-query": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/@types/chai": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
@@ -3657,6 +3989,17 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansi-styles": {
|
"node_modules/ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
@@ -3941,6 +4284,16 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bidi-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.18",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
@@ -4183,6 +4536,20 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-tree": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mdn-data": "2.27.1",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -4221,6 +4588,35 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/data-urls": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-mimetype": "^5.0.0",
|
||||||
|
"whatwg-url": "^16.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/data-urls/node_modules/whatwg-url": {
|
||||||
|
"version": "16.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||||
|
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@exodus/bytes": "^1.11.0",
|
||||||
|
"tr46": "^6.0.0",
|
||||||
|
"webidl-conversions": "^8.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/data-view-buffer": {
|
"node_modules/data-view-buffer": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
||||||
@@ -4293,6 +4689,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js": {
|
||||||
|
"version": "10.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||||
|
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -4336,6 +4739,17 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dequal": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -4395,6 +4809,14 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-accessibility-api": {
|
||||||
|
"version": "0.5.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
|
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.4.2",
|
"version": "17.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||||
@@ -4591,6 +5013,19 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-abstract": {
|
"node_modules/es-abstract": {
|
||||||
"version": "1.24.2",
|
"version": "1.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
|
||||||
@@ -5751,6 +6186,19 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-encoding-sniffer": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@exodus/bytes": "^1.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -6107,6 +6555,13 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-potential-custom-element-name": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-regex": {
|
"node_modules/is-regex": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||||
@@ -6333,6 +6788,67 @@
|
|||||||
"js-yaml": "bin/js-yaml.js"
|
"js-yaml": "bin/js-yaml.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsdom": {
|
||||||
|
"version": "30.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
|
||||||
|
"integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asamuzakjp/css-color": "^6.0.5",
|
||||||
|
"@asamuzakjp/dom-selector": "^8.3.0",
|
||||||
|
"@bramus/specificity": "^2.4.2",
|
||||||
|
"@csstools/css-syntax-patches-for-csstree": "^1.1.7",
|
||||||
|
"@exodus/bytes": "^1.15.1",
|
||||||
|
"css-tree": "^3.2.1",
|
||||||
|
"data-urls": "^7.0.0",
|
||||||
|
"decimal.js": "^10.6.0",
|
||||||
|
"html-encoding-sniffer": "^6.0.0",
|
||||||
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
|
"lru-cache": "^11.5.2",
|
||||||
|
"parse5": "^8.0.1",
|
||||||
|
"saxes": "^6.0.0",
|
||||||
|
"symbol-tree": "^3.2.4",
|
||||||
|
"tough-cookie": "^6.0.2",
|
||||||
|
"undici": "^8.9.0",
|
||||||
|
"w3c-xmlserializer": "^5.0.0",
|
||||||
|
"webidl-conversions": "^8.0.1",
|
||||||
|
"whatwg-mimetype": "^5.0.0",
|
||||||
|
"whatwg-url": "^17.1.0",
|
||||||
|
"xml-name-validator": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"canvas": "^3.2.3"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"canvas": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jsdom/node_modules/lru-cache": {
|
||||||
|
"version": "11.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||||
|
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jsdom/node_modules/undici": {
|
||||||
|
"version": "8.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz",
|
||||||
|
"integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsesc": {
|
"node_modules/jsesc": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||||
@@ -6777,6 +7293,17 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lz-string": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"bin": {
|
||||||
|
"lz-string": "bin/bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-bytes.js": {
|
"node_modules/magic-bytes.js": {
|
||||||
"version": "1.13.1",
|
"version": "1.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.1.tgz",
|
||||||
@@ -6803,6 +7330,13 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mdn-data": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "CC0-1.0"
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -7297,6 +7831,19 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse5": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -7630,6 +8177,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.7",
|
"version": "2.0.0-next.7",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
||||||
@@ -7807,6 +8364,19 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/saxes": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"xmlchars": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v12.22.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -8310,6 +8880,13 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/symbol-tree": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||||
@@ -8424,6 +9001,26 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tldts": {
|
||||||
|
"version": "7.4.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz",
|
||||||
|
"integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tldts-core": "^7.4.10"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tldts": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tldts-core": {
|
||||||
|
"version": "7.4.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz",
|
||||||
|
"integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@@ -8451,6 +9048,32 @@
|
|||||||
"topoquantize": "bin/topoquantize"
|
"topoquantize": "bin/topoquantize"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tough-cookie": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tldts": "^7.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"punycode": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
@@ -9243,6 +9866,54 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-xmlserializer": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xml-name-validator": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-mimetype": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "17.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
|
||||||
|
"integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@exodus/bytes": "^1.15.1",
|
||||||
|
"tr46": "^6.0.0",
|
||||||
|
"webidl-conversions": "^8.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.14.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -9402,6 +10073,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml-name-validator": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlchars": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user