Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e43db34402 | ||
|
|
20dfc58d63 | ||
|
|
6425a5056a | ||
|
|
6fa33c9f7b |
@@ -7,7 +7,6 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
@@ -116,9 +115,9 @@ jobs:
|
|||||||
apt-get install -y docker-ce-cli
|
apt-get install -y docker-ce-cli
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Gitea container registry
|
- name: Log in to Docker Hub
|
||||||
if: steps.release.outputs.created == 'true'
|
if: steps.release.outputs.created == 'true'
|
||||||
run: echo "${{ secrets.CONTAINER_REGISTRY_TOKEN }}" | docker login git.garvis.dev -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||||
|
|
||||||
- name: Build and push web image
|
- name: Build and push web image
|
||||||
if: steps.release.outputs.created == 'true'
|
if: steps.release.outputs.created == 'true'
|
||||||
@@ -129,9 +128,9 @@ jobs:
|
|||||||
--platform linux/amd64 \
|
--platform linux/amd64 \
|
||||||
--target runner \
|
--target runner \
|
||||||
--build-arg VERSION="$VERSION" \
|
--build-arg VERSION="$VERSION" \
|
||||||
-t "git.garvis.dev/dmg/minecraft-account-manager:${VERSION}" \
|
-t "dmgarvis/minecraft-account-manager:${VERSION}" \
|
||||||
.
|
.
|
||||||
docker push "git.garvis.dev/dmg/minecraft-account-manager:${VERSION}"
|
docker push "dmgarvis/minecraft-account-manager:${VERSION}"
|
||||||
|
|
||||||
- name: Build and push Discord bot image
|
- name: Build and push Discord bot image
|
||||||
if: steps.release.outputs.created == 'true'
|
if: steps.release.outputs.created == 'true'
|
||||||
@@ -142,9 +141,9 @@ jobs:
|
|||||||
--platform linux/amd64 \
|
--platform linux/amd64 \
|
||||||
--target bot \
|
--target bot \
|
||||||
--build-arg VERSION="$VERSION" \
|
--build-arg VERSION="$VERSION" \
|
||||||
-t "git.garvis.dev/dmg/minecraft-account-manager-bot:${VERSION}" \
|
-t "dmgarvis/minecraft-account-manager-bot:${VERSION}" \
|
||||||
.
|
.
|
||||||
docker push "git.garvis.dev/dmg/minecraft-account-manager-bot:${VERSION}"
|
docker push "dmgarvis/minecraft-account-manager-bot:${VERSION}"
|
||||||
|
|
||||||
- name: Build and push migration image
|
- name: Build and push migration image
|
||||||
if: steps.release.outputs.created == 'true'
|
if: steps.release.outputs.created == 'true'
|
||||||
@@ -155,9 +154,9 @@ jobs:
|
|||||||
--platform linux/amd64 \
|
--platform linux/amd64 \
|
||||||
--target migrate \
|
--target migrate \
|
||||||
--build-arg VERSION="$VERSION" \
|
--build-arg VERSION="$VERSION" \
|
||||||
-t "git.garvis.dev/dmg/minecraft-account-manager-migrate:${VERSION}" \
|
-t "dmgarvis/minecraft-account-manager-migrate:${VERSION}" \
|
||||||
.
|
.
|
||||||
docker push "git.garvis.dev/dmg/minecraft-account-manager-migrate:${VERSION}"
|
docker push "dmgarvis/minecraft-account-manager-migrate:${VERSION}"
|
||||||
|
|
||||||
- name: Create Gitea release and upload Velocity JAR
|
- name: Create Gitea release and upload Velocity JAR
|
||||||
if: steps.release.outputs.created == 'true'
|
if: steps.release.outputs.created == 'true'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
|||||||
import { UserWorldMap, type UserMapLocation } from "@/components/user-world-map";
|
import { UserWorldMap, type UserMapLocation } from "@/components/user-world-map";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
import { fillDailySeries, mergeRiskActivity, type DailyCount } from "@/lib/admin-metrics";
|
import { fillDailySeries, mergeRiskActivity, type DailyCount } from "@/lib/admin-metrics";
|
||||||
import { parseUserLocation, parseUserNetwork } from "@/lib/user-location-map";
|
import { MAP_LOCATION_CLASSIFICATIONS, parseUserLocation, parseUserNetwork } from "@/lib/user-location-map";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -58,6 +58,7 @@ export default async function AdminDashboardPage() {
|
|||||||
))
|
))
|
||||||
.where(and(
|
.where(and(
|
||||||
isNotNull(ipObservations.userId),
|
isNotNull(ipObservations.userId),
|
||||||
|
inArray(ipIntelligence.classification, MAP_LOCATION_CLASSIFICATIONS),
|
||||||
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'latitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'latitude')::double precision between -90 and 90 else false end`,
|
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'latitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'latitude')::double precision between -90 and 90 else false end`,
|
||||||
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'longitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'longitude')::double precision between -180 and 180 else false end`,
|
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'longitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'longitude')::double precision between -180 and 180 else false end`,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { resolveEffectiveGroup } from "@minecraft-account-manager/auth";
|
import { resolveEffectiveGroup } from "@minecraft-account-manager/auth";
|
||||||
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
|
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
|
||||||
import { events, groups, ipObservations, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
import { events, groups, ipIntelligence, ipObservations, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||||
import { and, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
import { and, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { groupAccessAddresses } from "@/lib/access-address-groups";
|
import { accessAddressDetails, groupAccessAddresses } from "@/lib/access-address-groups";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
import { discordIdentity } from "@/lib/discord-identity";
|
import { discordIdentity } from "@/lib/discord-identity";
|
||||||
import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters";
|
import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters";
|
||||||
@@ -80,8 +80,16 @@ export default async function AdminUserPage({
|
|||||||
.orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username),
|
.orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username),
|
||||||
recentEventsQuery,
|
recentEventsQuery,
|
||||||
db
|
db
|
||||||
.select()
|
.select({
|
||||||
|
id: ipObservations.id,
|
||||||
|
ipAddress: ipObservations.ipAddress,
|
||||||
|
source: ipObservations.source,
|
||||||
|
classification: ipObservations.classification,
|
||||||
|
observedAt: ipObservations.observedAt,
|
||||||
|
intelligence: ipIntelligence.rawResponse,
|
||||||
|
})
|
||||||
.from(ipObservations)
|
.from(ipObservations)
|
||||||
|
.leftJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
|
||||||
.where(eq(ipObservations.userId, user.id))
|
.where(eq(ipObservations.userId, user.id))
|
||||||
.orderBy(desc(ipObservations.observedAt))
|
.orderBy(desc(ipObservations.observedAt))
|
||||||
.limit(100),
|
.limit(100),
|
||||||
@@ -95,9 +103,7 @@ export default async function AdminUserPage({
|
|||||||
const explicitGroup = availableGroups.find((group) => !group.isDefault) ?? null;
|
const explicitGroup = availableGroups.find((group) => !group.isDefault) ?? null;
|
||||||
const defaultGroup = availableGroups.find((group) => group.isDefault) ?? null;
|
const defaultGroup = availableGroups.find((group) => group.isDefault) ?? null;
|
||||||
const effectiveGroup = resolveEffectiveGroup(explicitGroup, defaultGroup);
|
const effectiveGroup = resolveEffectiveGroup(explicitGroup, defaultGroup);
|
||||||
const addressGroups = groupAccessAddresses(
|
const addressGroups = groupAccessAddresses(observations);
|
||||||
observations.map((observation) => ({ ...observation, intelligence: null })),
|
|
||||||
);
|
|
||||||
const primary = accounts.find((account) => account.isPrimary);
|
const primary = accounts.find((account) => account.isPrimary);
|
||||||
const nickname = user.firstName
|
const nickname = user.firstName
|
||||||
? formatManagedDiscordNickname(user.firstName, primary?.username ?? null)
|
? formatManagedDiscordNickname(user.firstName, primary?.username ?? null)
|
||||||
@@ -215,7 +221,9 @@ export default async function AdminUserPage({
|
|||||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Recent addresses</p>
|
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Recent addresses</p>
|
||||||
<p className="mt-3 text-[10px] leading-5 text-muted">Grouped by IPv4 /24 or IPv6 /64 network across the 100 most recent observations.</p>
|
<p className="mt-3 text-[10px] leading-5 text-muted">Grouped by IPv4 /24 or IPv6 /64 network across the 100 most recent observations.</p>
|
||||||
<div className="mt-4 divide-y divide-line">
|
<div className="mt-4 divide-y divide-line">
|
||||||
{addressGroups.map((group) => (
|
{addressGroups.map((group) => {
|
||||||
|
const details = accessAddressDetails(group);
|
||||||
|
return (
|
||||||
<div className="py-3" key={group.network}>
|
<div className="py-3" key={group.network}>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<p className="font-mono text-xs font-bold">{group.network}</p>
|
<p className="font-mono text-xs font-bold">{group.network}</p>
|
||||||
@@ -223,8 +231,10 @@ export default async function AdminUserPage({
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-1 font-mono text-[9px] text-muted">{group.sources.join(" + ")} · {group.latestObservedAt.toISOString()}</p>
|
<p className="mt-1 font-mono text-[9px] text-muted">{group.sources.join(" + ")} · {group.latestObservedAt.toISOString()}</p>
|
||||||
<p className="mt-1 break-all font-mono text-[9px] text-muted">Latest {group.latestAddress}</p>
|
<p className="mt-1 break-all font-mono text-[9px] text-muted">Latest {group.latestAddress}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted">{details.location} · <span className="font-mono uppercase">{details.classification}</span></p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{!addressGroups.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>}
|
{!addressGroups.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { groupAccessAddresses } from "./access-address-groups";
|
import { accessAddressDetails, groupAccessAddresses } from "./access-address-groups";
|
||||||
|
|
||||||
describe("groupAccessAddresses", () => {
|
describe("groupAccessAddresses", () => {
|
||||||
it("collapses repeated observations from the same network into one recent summary", () => {
|
it("collapses repeated observations from the same network into one recent summary", () => {
|
||||||
@@ -20,4 +20,19 @@ describe("groupAccessAddresses", () => {
|
|||||||
});
|
});
|
||||||
expect(groups[0]?.latestObservedAt.toISOString()).toBe("2026-08-01T12:00:00.000Z");
|
expect(groups[0]?.latestObservedAt.toISOString()).toBe("2026-08-01T12:00:00.000Z");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("presents the latest enriched location and classification with observation fallbacks", () => {
|
||||||
|
expect(accessAddressDetails({
|
||||||
|
classification: "vpn",
|
||||||
|
intelligence: {
|
||||||
|
classification: "vpn",
|
||||||
|
location: { city: "Toronto", region: "Ontario", countryCode: "CA" },
|
||||||
|
},
|
||||||
|
})).toEqual({ location: "Toronto, Ontario, CA", classification: "vpn" });
|
||||||
|
|
||||||
|
expect(accessAddressDetails({ classification: "hosting", intelligence: null })).toEqual({
|
||||||
|
location: "Location unavailable",
|
||||||
|
classification: "hosting",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { addressGroup } from "@minecraft-account-manager/network";
|
import { addressGroup } from "@minecraft-account-manager/network";
|
||||||
|
import { intelligenceSummary } from "./event-ip-summary";
|
||||||
|
|
||||||
type AccessObservation = {
|
type AccessObservation = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -9,6 +10,14 @@ type AccessObservation = {
|
|||||||
intelligence: Record<string, unknown> | null;
|
intelligence: Record<string, unknown> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function accessAddressDetails(observation: Pick<AccessObservation, "classification" | "intelligence">) {
|
||||||
|
const summary = intelligenceSummary(observation.intelligence);
|
||||||
|
return {
|
||||||
|
location: summary.location ?? "Location unavailable",
|
||||||
|
classification: summary.classification ?? observation.classification,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type AccessAddressGroup = {
|
export type AccessAddressGroup = {
|
||||||
network: string;
|
network: string;
|
||||||
latestAddress: string;
|
latestAddress: string;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,7 +1,20 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { groupMapLocations, parseUserLocation, parseUserNetwork, projectWorldPoint } from "./user-location-map";
|
import {
|
||||||
|
MAP_LOCATION_CLASSIFICATIONS,
|
||||||
|
groupMapLocations,
|
||||||
|
parseUserLocation,
|
||||||
|
parseUserNetwork,
|
||||||
|
projectWorldPoint,
|
||||||
|
} from "./user-location-map";
|
||||||
|
|
||||||
describe("user location map", () => {
|
describe("user location map", () => {
|
||||||
|
it("allows only clear and hosting observations as map locations", () => {
|
||||||
|
expect(MAP_LOCATION_CLASSIFICATIONS).toEqual(["clear", "hosting"]);
|
||||||
|
expect(MAP_LOCATION_CLASSIFICATIONS).not.toContain("vpn");
|
||||||
|
expect(MAP_LOCATION_CLASSIFICATIONS).not.toContain("proxy");
|
||||||
|
expect(MAP_LOCATION_CLASSIFICATIONS).not.toContain("tor");
|
||||||
|
});
|
||||||
|
|
||||||
it("extracts a valid approximate location from cached IP intelligence", () => {
|
it("extracts a valid approximate location from cached IP intelligence", () => {
|
||||||
expect(parseUserLocation({
|
expect(parseUserLocation({
|
||||||
classification: "clear",
|
classification: "clear",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
type UnknownMap = Record<string, unknown>;
|
type UnknownMap = Record<string, unknown>;
|
||||||
|
|
||||||
|
export const MAP_LOCATION_CLASSIFICATIONS = ["clear", "hosting"] as const;
|
||||||
|
|
||||||
function objectValue(value: unknown): UnknownMap | null {
|
function objectValue(value: unknown): UnknownMap | null {
|
||||||
return value && typeof value === "object" && !Array.isArray(value)
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
? value as UnknownMap
|
? value as UnknownMap
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Design Update Log
|
# Design Update Log
|
||||||
|
|
||||||
|
## 2026-08-07
|
||||||
|
|
||||||
|
* **Extend**: Show each grouped recent address's latest approximate location and network classification on administrator user records.
|
||||||
|
* **Refine**: Select each admin map marker from the user's latest coordinate-bearing clear or hosting observation while keeping VPN, proxy, and Tor activity in the network-risk view.
|
||||||
|
|
||||||
## 2026-08-02
|
## 2026-08-02
|
||||||
|
|
||||||
* **Extend**: Add recurring UTC group-access windows, browser-local schedule editing, and validated static denial-message variables.
|
* **Extend**: Add recurring UTC group-access windows, browser-local schedule editing, and validated static denial-message variables.
|
||||||
|
|||||||
@@ -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-02T15:03:59Z
|
timestamp: 2026-08-07T23:02:04Z
|
||||||
story_id: US-013
|
story_id: US-013
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -17,6 +17,9 @@ As an administrator, I want to manage a user's identity and Minecraft accounts,
|
|||||||
- [x] Administrators can search by preferred name, Discord username or ID, Minecraft username, or UUID.
|
- [x] Administrators can search by preferred name, Discord username or ID, Minecraft username, or UUID.
|
||||||
- [x] Search results show onboarding state, primary username, and active account count.
|
- [x] Search results show onboarding state, primary username, and active account count.
|
||||||
- [x] A user detail view shows Discord display name, username, guild nickname, immutable ID, active accounts, groups, recent events, and recent IP observations.
|
- [x] A user detail view shows Discord display name, username, guild nickname, immutable ID, active accounts, groups, recent events, and recent IP observations.
|
||||||
|
- [x] Each grouped recent address shows the latest observation's approximate location and classification, including clear, VPN, proxy, Tor, hosting, and unknown classifications.
|
||||||
|
- [x] Missing IP enrichment is labelled as location unavailable and falls back to the stored observation classification.
|
||||||
|
- [x] Address groups use the enrichment associated with their latest observation.
|
||||||
- [x] Administrators can update the preferred name and synchronize Discord.
|
- [x] Administrators can update the preferred name and synchronize Discord.
|
||||||
- [x] Administrators can add Mojang-verified accounts or explicitly override an unverified username.
|
- [x] Administrators can add Mojang-verified accounts or explicitly override an unverified username.
|
||||||
- [x] Administrators can remove an account only after a visible confirmation step.
|
- [x] Administrators can remove an account only after a visible confirmation step.
|
||||||
@@ -43,7 +46,7 @@ As an administrator, I want to manage a user's identity and Minecraft accounts,
|
|||||||
|
|
||||||
# Validation
|
# Validation
|
||||||
|
|
||||||
Nickname fallback behavior is tested in [`packages/minecraft/test/minecraft.test.ts`](../packages/minecraft/test/minecraft.test.ts). Privileged routes pass TypeScript, lint, Semgrep, and production build checks.
|
Nickname fallback behavior is tested in [`packages/minecraft/test/minecraft.test.ts`](../packages/minecraft/test/minecraft.test.ts). Latest-observation enrichment and classification fallback are covered by [`apps/web/src/lib/access-address-groups.test.ts`](../apps/web/src/lib/access-address-groups.test.ts). The full 107-test suite, TypeScript, lint, OKF validation, and the production build pass.
|
||||||
|
|
||||||
# Related Stories
|
# Related Stories
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Monitor community account activity
|
title: Monitor community account activity
|
||||||
description: Administrators use a server-rendered dashboard to review daily activity, confirmed connections, locations, denials, and risky networks.
|
description: Administrators use a server-rendered dashboard to review daily activity, confirmed connections, locations, denials, and risky networks.
|
||||||
tags: [admin, dashboard, metrics, security, maps, ssr]
|
tags: [admin, dashboard, metrics, security, maps, ssr]
|
||||||
timestamp: 2026-08-02T12:05:27Z
|
timestamp: 2026-08-07T22:31:05Z
|
||||||
story_id: US-018
|
story_id: US-018
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -15,7 +15,9 @@ As an administrator, I want an operational dashboard of account and game activit
|
|||||||
# Acceptance Criteria
|
# Acceptance Criteria
|
||||||
|
|
||||||
- [x] The administrator landing page is a dashboard rather than a settings form.
|
- [x] The administrator landing page is a dashboard rather than a settings form.
|
||||||
- [x] A server-rendered Natural Earth overview plots each user's latest observation with valid approximate coordinates.
|
- [x] A server-rendered Natural Earth overview plots each user's latest non-anonymized observation with valid approximate coordinates, allowing clear and hosting classifications while excluding VPN, proxy, and Tor observations.
|
||||||
|
- [x] When a user's newest coordinate-bearing observation is VPN, proxy, or Tor, the map uses that user's older clear or hosting observation when one exists.
|
||||||
|
- [x] A user without a coordinate-bearing clear or hosting observation is counted as unavailable on the map.
|
||||||
- [x] Administrators can opt into a zoomable OpenStreetMap view without removing the default overview.
|
- [x] Administrators can opt into a zoomable OpenStreetMap view without removing the default overview.
|
||||||
- [x] OpenStreetMap tiles load only after the administrator selects the interactive view and retain required attribution.
|
- [x] OpenStreetMap tiles load only after the administrator selects the interactive view and retain required attribution.
|
||||||
- [x] Map markers show the managed Discord nickname on hover or keyboard focus, link to user records, and have an accessible text-table equivalent.
|
- [x] Map markers show the managed Discord nickname on hover or keyboard focus, link to user records, and have an accessible text-table equivalent.
|
||||||
@@ -31,7 +33,7 @@ As an administrator, I want an operational dashboard of account and game activit
|
|||||||
- [x] Monthly active users count distinct users observed through portal or game activity in the previous 30 days.
|
- [x] Monthly active users count distinct users observed through portal or game activity in the previous 30 days.
|
||||||
- [x] Monthly active Minecraft accounts count distinct accounts with a confirmed Velocity post-login connection in the previous 30 days.
|
- [x] Monthly active Minecraft accounts count distinct accounts with a confirmed Velocity post-login connection in the previous 30 days.
|
||||||
- [x] The dashboard shows login denials from the previous 24 hours.
|
- [x] The dashboard shows login denials from the previous 24 hours.
|
||||||
- [x] Recent VPN, proxy, and Tor observations use enriched ProxyCheck classifications, collapse repeated rows per user, and show counts, sources, and latest activity.
|
- [x] Recent VPN, proxy, and Tor observations remain available in the separate network-risk section when excluded from map-location selection.
|
||||||
- [x] The graph includes an accessible title, description, point labels, and textual values.
|
- [x] The graph includes an accessible title, description, point labels, and textual values.
|
||||||
- [x] Dashboard queries and initial rendering execute server-side; only the opt-in pan-and-zoom map hydrates client-side.
|
- [x] Dashboard queries and initial rendering execute server-side; only the opt-in pan-and-zoom map hydrates client-side.
|
||||||
- [x] Deployment-managed guild settings and denial messaging remain available on a dedicated settings page.
|
- [x] Deployment-managed guild settings and denial messaging remain available on a dedicated settings page.
|
||||||
@@ -48,8 +50,9 @@ As an administrator, I want an operational dashboard of account and game activit
|
|||||||
# Validation
|
# Validation
|
||||||
|
|
||||||
- Missing-day chart behavior and per-user VPN collapsing are covered by [`apps/web/src/lib/admin-metrics.test.ts`](../apps/web/src/lib/admin-metrics.test.ts).
|
- Missing-day chart behavior and per-user VPN collapsing are covered by [`apps/web/src/lib/admin-metrics.test.ts`](../apps/web/src/lib/admin-metrics.test.ts).
|
||||||
- Coordinate parsing, backward-compatible ProxyCheck network parsing, normalized location grouping, projection, count badges, complete grouped tooltips, linked markers, semantic network columns, text fallback, and attribution are covered by the user-world-map tests.
|
- Coordinate parsing, the clear/hosting map policy, backward-compatible ProxyCheck network parsing, normalized location grouping, projection, count badges, complete grouped tooltips, linked markers, semantic network columns, text fallback, and attribution are covered by the user-location and user-world-map tests.
|
||||||
- The Next.js production build reports the dashboard and database-backed console pages as dynamic server-rendered routes.
|
- The full test suite passes with 106 tests across 36 files; web type checking and lint pass.
|
||||||
|
- The Next.js production build succeeds and reports the dashboard and database-backed console pages as dynamic server-rendered routes.
|
||||||
|
|
||||||
# Related Stories
|
# Related Stories
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user