feat(admin): show group schedule status
CI / validate (push) Successful in 6m9s
Release / release (push) Successful in 7m51s

This commit is contained in:
dmg
2026-08-02 14:26:24 -04:00
parent c131465ff5
commit 6fa33c9f7b
6 changed files with 26 additions and 6 deletions
@@ -112,7 +112,7 @@ export default async function GroupPage({
<PolicyDetail description="Controls whether members can connect to Minecraft. Disabled access always overrides the schedule." label="Minecraft access"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="Minecraft access" returnLocation="detail" /></PolicyDetail> <PolicyDetail description="Controls whether members can connect to Minecraft. Disabled access always overrides the schedule." label="Minecraft access"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="Minecraft access" returnLocation="detail" /></PolicyDetail>
<PolicyDetail description="Allows confirmed VPN, proxy, and Tor connections after access and schedule checks pass." 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> <PolicyDetail description="Allows confirmed VPN, proxy, and Tor connections after access and schedule checks pass." 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>
</div> </div>
<div className="mt-7 border-t border-line pt-6"> <div className="mt-7 scroll-mt-6 border-t border-line pt-6" id="group-schedule">
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
<div className="max-w-2xl"><h3 className="font-mono text-xs font-bold uppercase">Weekly access schedule</h3><p className="mt-2 text-xs leading-5 text-muted">When Minecraft access is enabled, members may log in only during these recurring UTC windows. Existing sessions are not disconnected when a window ends.</p><div className="mt-4"><GroupScheduleSummary windows={accessWindows} /></div></div> <div className="max-w-2xl"><h3 className="font-mono text-xs font-bold uppercase">Weekly access schedule</h3><p className="mt-2 text-xs leading-5 text-muted">When Minecraft access is enabled, members may log in only during these recurring UTC windows. Existing sessions are not disconnected when a window ends.</p><div className="mt-4"><GroupScheduleSummary windows={accessWindows} /></div></div>
<AdminModalForm action={replaceGroupSchedule} description={`Replace the complete weekly access schedule for ${group.name}. Minecraft access must still be enabled.`} submitLabel="Save schedule" title={`Schedule ${group.name}`} triggerClassName="shrink-0 border border-ink px-4 py-3 font-mono text-[10px] font-bold uppercase tracking-wider" triggerLabel="Edit schedule"> <AdminModalForm action={replaceGroupSchedule} description={`Replace the complete weekly access schedule for ${group.name}. Minecraft access must still be enabled.`} submitLabel="Save schedule" title={`Schedule ${group.name}`} triggerClassName="shrink-0 border border-ink px-4 py-3 font-mono text-[10px] font-bold uppercase tracking-wider" triggerLabel="Edit schedule">
@@ -1,10 +1,11 @@
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database"; import { groupAccessWindows, groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { asc, count, 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 { AdminModalForm } from "@/components/admin-modal-form";
import { GroupPolicyControl } from "@/components/group-policy-control"; import { GroupPolicyControl } from "@/components/group-policy-control";
import { db } from "@/lib/database"; import { db } from "@/lib/database";
import { effectiveGroupMemberCount } from "@/lib/group-management"; import { effectiveGroupMemberCount } from "@/lib/group-management";
import { groupScheduleStatus } from "@/lib/group-schedule";
import { createGroup, setGroupAccess, setGroupAnonymizedNetworkAccess } from "./actions"; import { createGroup, setGroupAccess, setGroupAnonymizedNetworkAccess } from "./actions";
const errors: Record<string, string> = { const errors: Record<string, string> = {
@@ -26,11 +27,15 @@ 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], scheduleCounts] = 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({ count: count() }).from(users), db.select({ count: count() }).from(users),
db.select({ groupId: groupAccessWindows.groupId, count: count() })
.from(groupAccessWindows)
.groupBy(groupAccessWindows.groupId),
]); ]);
const scheduleCountByGroup = new Map(scheduleCounts.map((schedule) => [schedule.groupId, Number(schedule.count)]));
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="flex flex-col gap-6 border-b border-line pb-8 sm:flex-row sm:items-end sm:justify-between"> <header className="flex flex-col gap-6 border-b border-line pb-8 sm:flex-row sm:items-end sm:justify-between">
@@ -60,10 +65,10 @@ export default async function GroupsPage({ searchParams }: { searchParams: Promi
{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.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>}
<div className="mt-9 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]"> <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"> <table className="w-full min-w-[880px] border-collapse text-left">
<caption className="sr-only">Access groups and their effective policies</caption> <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"> <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> <tr><th className="p-4" scope="col">Name</th><th className="p-4" scope="col">Minecraft access</th><th className="p-4" scope="col">Schedule</th><th className="p-4" scope="col">VPN access</th><th className="p-4 text-right" scope="col">Users</th></tr>
</thead> </thead>
<tbody className="divide-y divide-line"> <tbody className="divide-y divide-line">
{allGroups.map((group) => { {allGroups.map((group) => {
@@ -72,6 +77,7 @@ export default async function GroupsPage({ searchParams }: { searchParams: Promi
memberships.map((membership) => membership.groupId), memberships.map((membership) => membership.groupId),
group, group,
); );
const scheduleStatus = groupScheduleStatus(scheduleCountByGroup.get(group.id) ?? 0);
return ( return (
<tr className="transition-colors hover:bg-canvas/60" key={group.id}> <tr className="transition-colors hover:bg-canvas/60" key={group.id}>
<th className="p-4 text-left" scope="row"> <th className="p-4 text-left" scope="row">
@@ -79,6 +85,7 @@ export default async function GroupsPage({ searchParams }: { searchParams: Promi
{group.isDefault && <span className="ml-3 bg-ink px-2 py-1 font-mono text-[8px] font-bold uppercase text-canvas">Default</span>} {group.isDefault && <span className="ml-3 bg-ink px-2 py-1 font-mono text-[8px] font-bold uppercase text-canvas">Default</span>}
</th> </th>
<td className="p-4"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberCount} policy="Minecraft access" returnLocation="list" /></td> <td className="p-4"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberCount} policy="Minecraft access" returnLocation="list" /></td>
<td className="p-4"><Link aria-label={`${scheduleStatus}. Edit schedule for ${group.name}`} className={`font-mono text-[10px] font-bold uppercase underline underline-offset-4 ${scheduleStatus === "Unrestricted" ? "text-muted" : "text-accent"}`} href={`/admin/groups/${group.id}#group-schedule`}>{scheduleStatus}</Link></td>
<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> <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>
<td className="p-4 text-right font-mono text-sm font-bold">{memberCount}</td> <td className="p-4 text-right font-mono text-sm font-bold">{memberCount}</td>
</tr> </tr>
+7
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
evaluateGroupSchedule, evaluateGroupSchedule,
groupScheduleStatus,
localWindowToUtc, localWindowToUtc,
parseScheduleWindows, parseScheduleWindows,
utcWindowToLocal, utcWindowToLocal,
@@ -13,6 +14,12 @@ const fridayEvening: WeeklyAccessWindow = {
}; };
describe("weekly group access schedules", () => { describe("weekly group access schedules", () => {
it("summarizes whether a group has configured windows", () => {
expect(groupScheduleStatus(0)).toBe("Unrestricted");
expect(groupScheduleStatus(1)).toBe("1 window");
expect(groupScheduleStatus(3)).toBe("3 windows");
});
it("allows an enabled group at any time when no schedule is configured", () => { it("allows an enabled group at any time when no schedule is configured", () => {
expect(evaluateGroupSchedule([], new Date("2026-08-07T19:00:00Z"))).toEqual({ expect(evaluateGroupSchedule([], new Date("2026-08-07T19:00:00Z"))).toEqual({
allowed: true, allowed: true,
+5
View File
@@ -1,6 +1,11 @@
export const MINUTES_PER_WEEK = 7 * 24 * 60; export const MINUTES_PER_WEEK = 7 * 24 * 60;
const MAX_WINDOWS = 50; const MAX_WINDOWS = 50;
export function groupScheduleStatus(windowCount: number) {
if (windowCount <= 0) return "Unrestricted";
return `${windowCount} ${windowCount === 1 ? "window" : "windows"}`;
}
export interface WeeklyAccessWindow { export interface WeeklyAccessWindow {
startMinuteOfWeek: number; startMinuteOfWeek: number;
endMinuteOfWeek: number; endMinuteOfWeek: number;
+1 -1
View File
@@ -14,7 +14,7 @@ As an administrator, I want a concise group policy table and focused group detai
# Acceptance Criteria # 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] The main Groups page lists name, Minecraft access, schedule status, 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] 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] 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] Add group opens an accessible modal asking for name, description, Minecraft access, and VPN/proxy/Tor access.
+1
View File
@@ -16,6 +16,7 @@ As an administrator, I want an enabled group to have recurring access windows, s
- [x] A group can have zero or more recurring weekly access windows stored and evaluated in UTC. - [x] A group can have zero or more recurring weekly access windows stored and evaluated in UTC.
- [x] The browser shows each UTC window's current equivalent in the administrator's local timezone while clearly identifying UTC as authoritative. - [x] The browser shows each UTC window's current equivalent in the administrator's local timezone while clearly identifying UTC as authoritative.
- [x] The Groups table identifies unrestricted groups and the configured window count, linking each status to schedule management.
- [x] Administrators can add and remove multiple windows, including windows that cross the end of the UTC week. - [x] Administrators can add and remove multiple windows, including windows that cross the end of the UTC week.
- [x] Window starts are inclusive and window ends are exclusive. - [x] Window starts are inclusive and window ends are exclusive.
- [x] No configured windows preserve unrestricted scheduling behavior while Minecraft access is enabled. - [x] No configured windows preserve unrestricted scheduling behavior while Minecraft access is enabled.