feat(portal): refine account identity controls
CI / validate (push) Successful in 4m51s
Release / release (push) Successful in 6m36s

This commit is contained in:
dmg
2026-08-01 18:06:30 -04:00
parent 86c87153b4
commit 19a5d04178
17 changed files with 357 additions and 43 deletions
+78 -7
View File
@@ -6,20 +6,56 @@ import { and, eq, isNull, ne } from "drizzle-orm";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { recordUserEvent } from "@/lib/audit"; import { recordUserEvent } from "@/lib/audit";
import { db } from "@/lib/database"; import { db } from "@/lib/database";
import { hasDiscordNicknameConfirmation } from "@/lib/dashboard-change-confirmation";
import { requireCurrentUser } from "@/lib/auth/user-session"; import { requireCurrentUser } from "@/lib/auth/user-session";
import { checkAccountAdditionNetwork, toAuditIpData } from "@/lib/ip-intelligence"; import { checkAccountAdditionNetwork, toAuditIpData } from "@/lib/ip-intelligence";
import { logger } from "@/lib/logger";
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/; const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
export async function updateFirstName(formData: FormData) { export async function updateFirstName(formData: FormData) {
const user = await requireCurrentUser(); const user = await requireCurrentUser();
const firstName = String(formData.get("firstName") ?? "").trim(); const firstName = String(formData.get("firstName") ?? "").trim();
const confirmed = hasDiscordNicknameConfirmation(formData);
if (firstName.length < 1 || firstName.length > 50 || /[\u0000-\u001f\u007f]/.test(firstName)) { if (firstName.length < 1 || firstName.length > 50 || /[\u0000-\u001f\u007f]/.test(firstName)) {
redirect("/account?error=invalid-name"); redirect("/account?error=invalid-name");
} }
await db.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id));
const [primary] = await db.select({ username: minecraftAccounts.username }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)),
).limit(1);
if (!primary) redirect("/account?error=nickname-not-configured");
if (!confirmed) redirect(`/account?pendingName=${encodeURIComponent(firstName)}`);
const guildId = process.env.DISCORD_GUILD_ID?.trim();
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
if (!guildId || !botToken) redirect("/account?error=nickname-not-configured");
const nickname = formatDiscordNickname(firstName, primary.username);
try {
await db.transaction(async (tx) => {
await tx.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id));
await updateGuildNickname({
guildId,
discordUserId: user.discordUserId,
nickname,
botToken,
});
});
} catch (error) {
logger.error(
{ err: error, event: "account.first_name_update_failed" },
"Failed to update the user name and Discord nickname",
);
redirect(`/account?error=nickname-update-failed&pendingName=${encodeURIComponent(firstName)}`);
}
await recordUserEvent(user, "games.minecraft.account-manager.user.first-name.updated", { firstName }); await recordUserEvent(user, "games.minecraft.account-manager.user.first-name.updated", { firstName });
redirect("/account?confirmNickname=1"); await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", {
nickname,
operation: "update-first-name",
});
redirect("/account?nicknameUpdated=1");
} }
export async function addMinecraftAccount(formData: FormData) { export async function addMinecraftAccount(formData: FormData) {
@@ -75,23 +111,58 @@ export async function addMinecraftAccount(formData: FormData) {
export async function setPrimaryAccount(formData: FormData) { export async function setPrimaryAccount(formData: FormData) {
const user = await requireCurrentUser(); const user = await requireCurrentUser();
const accountId = String(formData.get("accountId") ?? ""); const accountId = String(formData.get("accountId") ?? "");
const confirmed = hasDiscordNicknameConfirmation(formData);
const changed = await db.transaction(async (tx) => { const [requestedAccount] = await db.select({ id: minecraftAccounts.id, username: minecraftAccounts.username }).from(minecraftAccounts).where(
const [account] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)), and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
).limit(1); ).limit(1);
if (!requestedAccount) redirect("/account?error=unknown-account");
if (!user.firstName) redirect("/account?error=nickname-not-configured");
if (!confirmed) redirect(`/account?pendingPrimary=${encodeURIComponent(requestedAccount.id)}`);
const guildId = process.env.DISCORD_GUILD_ID?.trim();
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
if (!guildId || !botToken) redirect("/account?error=nickname-not-configured");
const nickname = formatDiscordNickname(user.firstName, requestedAccount.username);
let changed = false;
try {
changed = await db.transaction(async (tx) => {
const [account] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.id, requestedAccount.id), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
).limit(1);
if (!account) return false; if (!account) return false;
await tx.update(minecraftAccounts).set({ isPrimary: false, updatedAt: new Date() }).where( await tx.update(minecraftAccounts).set({ isPrimary: false, updatedAt: new Date() }).where(
and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)), and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
); );
await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id)); await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id));
await updateGuildNickname({
guildId,
discordUserId: user.discordUserId,
nickname,
botToken,
});
return true; return true;
}); });
} catch (error) {
logger.error(
{ err: error, event: "account.primary_update_failed" },
"Failed to update the primary account and Discord nickname",
);
redirect(`/account?error=nickname-update-failed&pendingPrimary=${encodeURIComponent(requestedAccount.id)}`);
}
if (!changed) redirect("/account?error=unknown-account"); if (!changed) redirect("/account?error=unknown-account");
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.primary-changed", { accountId }); await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.primary-changed", {
redirect("/account?confirmNickname=1"); accountId: requestedAccount.id,
nickname,
});
await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", {
nickname,
operation: "set-primary-account",
});
redirect("/account?nicknameUpdated=1");
} }
export async function removeMinecraftAccount(formData: FormData) { export async function removeMinecraftAccount(formData: FormData) {
+77 -16
View File
@@ -4,6 +4,7 @@ import { and, desc, eq, isNull } from "drizzle-orm";
import { logout } from "@/app/auth/actions"; import { logout } from "@/app/auth/actions";
import { db } from "@/lib/database"; import { db } from "@/lib/database";
import { requireCurrentUser } from "@/lib/auth/user-session"; import { requireCurrentUser } from "@/lib/auth/user-session";
import { groupAccessAddresses } from "@/lib/access-address-groups";
import { intelligenceSummary } from "@/lib/event-ip-summary"; import { intelligenceSummary } from "@/lib/event-ip-summary";
import { import {
addMinecraftAccount, addMinecraftAccount,
@@ -13,6 +14,10 @@ import {
updateFirstName, updateFirstName,
} from "./actions"; } from "./actions";
function queryValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
const errorMessages: Record<string, string> = { const errorMessages: Record<string, string> = {
"invalid-name": "Enter a valid name between 1 and 50 characters.", "invalid-name": "Enter a valid name between 1 and 50 characters.",
"invalid-username": "Java usernames use 316 letters, numbers, or underscores.", "invalid-username": "Java usernames use 316 letters, numbers, or underscores.",
@@ -27,10 +32,12 @@ const errorMessages: Record<string, string> = {
export default async function AccountPage({ export default async function AccountPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<Record<string, string | undefined>>; searchParams: Promise<Record<string, string | string[] | undefined>>;
}) { }) {
const user = await requireCurrentUser("/account"); const user = await requireCurrentUser("/account");
const query = await searchParams; const query = await searchParams;
const error = queryValue(query.error);
const unverified = queryValue(query.unverified);
const [accounts, observations] = await Promise.all([ const [accounts, observations] = await Promise.all([
db db
.select() .select()
@@ -50,12 +57,31 @@ export default async function AccountPage({
.leftJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress)) .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(20), .limit(100),
]); ]);
const addressGroups = groupAccessAddresses(observations);
const primary = accounts.find((account) => account.isPrimary); const primary = accounts.find((account) => account.isPrimary);
const desiredNickname = user.firstName && primary const desiredNickname = user.firstName && primary
? formatDiscordNickname(user.firstName, primary.username) ? formatDiscordNickname(user.firstName, primary.username)
: null; : null;
const pendingName = queryValue(query.pendingName)?.trim();
const pendingPrimaryId = queryValue(query.pendingPrimary);
const pendingPrimary = accounts.find((account) => account.id === pendingPrimaryId);
const pendingChange = pendingName && pendingName.length <= 50 && primary
? {
kind: "name" as const,
label: `Change your name to ${pendingName}`,
nickname: formatDiscordNickname(pendingName, primary.username),
firstName: pendingName,
}
: pendingPrimary && user.firstName
? {
kind: "primary" as const,
label: `Make ${pendingPrimary.username} your primary account`,
nickname: formatDiscordNickname(user.firstName, pendingPrimary.username),
accountId: pendingPrimary.id,
}
: null;
return ( return (
<main className="min-h-screen bg-canvas px-6 py-10 text-ink sm:py-16"> <main className="min-h-screen bg-canvas px-6 py-10 text-ink sm:py-16">
@@ -68,14 +94,35 @@ export default async function AccountPage({
<form action={logout}><button className="font-mono text-xs font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Sign out</button></form> <form action={logout}><button className="font-mono text-xs font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Sign out</button></form>
</header> </header>
{query.error && ( {error && (
<p className="mt-8 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent"> <p className="mt-8 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent">
{errorMessages[query.error] ?? "The requested change could not be completed."} {errorMessages[error] ?? "The requested change could not be completed."}
</p> </p>
)} )}
{query.nicknameUpdated && <p className="mt-8 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider">Discord nickname updated</p>} {queryValue(query.nicknameUpdated) && <p className="mt-8 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider">Profile and Discord nickname updated</p>}
{query.confirmNickname && desiredNickname && ( {pendingChange && (
<section className="mt-8 border border-accent bg-panel p-6 shadow-[6px_6px_0_var(--color-accent)] sm:flex sm:items-center sm:justify-between sm:gap-8">
<div>
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Review linked identity change</p>
<h2 className="mt-3 font-display text-2xl font-black uppercase">{pendingChange.label}</h2>
<p className="mt-3 text-sm leading-6 text-muted">Nothing changes until you confirm. This will also update your Discord nickname to:</p>
<p className="mt-2 font-display text-2xl font-black">{pendingChange.nickname}</p>
</div>
<div className="mt-6 flex flex-wrap items-center gap-4 sm:mt-0 sm:justify-end">
<form action={pendingChange.kind === "name" ? updateFirstName : setPrimaryAccount}>
{pendingChange.kind === "name"
? <input name="firstName" type="hidden" value={pendingChange.firstName} />
: <input name="accountId" type="hidden" value={pendingChange.accountId} />}
<input name="confirmDiscordNickname" type="hidden" value="yes" />
<button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Confirm both changes</button>
</form>
<a className="font-mono text-[10px] font-bold uppercase underline underline-offset-4" href="/account">Cancel</a>
</div>
</section>
)}
{queryValue(query.confirmNickname) && desiredNickname && (
<section className="mt-8 border border-accent bg-panel p-6 shadow-[6px_6px_0_var(--color-accent)] sm:flex sm:items-center sm:justify-between sm:gap-8"> <section className="mt-8 border border-accent bg-panel p-6 shadow-[6px_6px_0_var(--color-accent)] sm:flex sm:items-center sm:justify-between sm:gap-8">
<div> <div>
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Confirm Discord change</p> <p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Confirm Discord change</p>
@@ -108,7 +155,7 @@ export default async function AccountPage({
<p className="mt-2 break-all font-mono text-[10px] text-muted">{account.minecraftUuid ?? "UUID will be learned at game login"}</p> <p className="mt-2 break-all font-mono text-[10px] text-muted">{account.minecraftUuid ?? "UUID will be learned at game login"}</p>
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
{!account.isPrimary && <form action={setPrimaryAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Make primary</button></form>} {!account.isPrimary && <form action={setPrimaryAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Review primary change</button></form>}
<form action={removeMinecraftAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider text-accent underline underline-offset-4" type="submit">Remove</button></form> <form action={removeMinecraftAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider text-accent underline underline-offset-4" type="submit">Remove</button></form>
</div> </div>
</article> </article>
@@ -116,11 +163,11 @@ export default async function AccountPage({
{accounts.length === 0 && <p className="py-8 text-muted">No active Minecraft accounts. Add one before joining the server.</p>} {accounts.length === 0 && <p className="py-8 text-muted">No active Minecraft accounts. Add one before joining the server.</p>}
</div> </div>
{query.unverified ? ( {unverified ? (
<form action={addMinecraftAccount} className="border-l-2 border-accent bg-panel p-6"> <form action={addMinecraftAccount} className="border-l-2 border-accent bg-panel p-6">
<h3 className="font-display text-xl font-black uppercase">Mojang couldnt verify {query.unverified}</h3> <h3 className="font-display text-xl font-black uppercase">Mojang couldnt verify {unverified}</h3>
<p className="mt-2 text-sm leading-6 text-muted">Continue only if you are certain the spelling is correct.</p> <p className="mt-2 text-sm leading-6 text-muted">Continue only if you are certain the spelling is correct.</p>
<input name="username" type="hidden" value={query.unverified} /><input name="confirmUnverified" type="hidden" value="yes" /> <input name="username" type="hidden" value={unverified} /><input name="confirmUnverified" type="hidden" value="yes" />
<button className="mt-5 border border-ink bg-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Add anyway</button> <button className="mt-5 border border-ink bg-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Add anyway</button>
<a className="ml-5 font-mono text-[10px] font-bold uppercase underline" href="/account">Cancel</a> <a className="ml-5 font-mono text-[10px] font-bold uppercase underline" href="/account">Cancel</a>
</form> </form>
@@ -135,11 +182,24 @@ export default async function AccountPage({
<section> <section>
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Recent security activity</p> <p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Recent security activity</p>
<h2 className="mt-2 border-b border-line pb-4 font-display text-3xl font-black uppercase">Access addresses</h2> <h2 className="mt-2 border-b border-line pb-4 font-display text-3xl font-black uppercase">Access addresses</h2>
{observations.length ? ( {addressGroups.length ? (
<div className="divide-y divide-line font-mono text-xs"> <div className="divide-y divide-line font-mono text-xs">
{observations.map((observation) => { <p className="py-3 text-[10px] leading-5 text-muted">Similar IPv4 /24 and IPv6 /64 networks are grouped. Counts cover your 100 most recent observations.</p>
const summary = intelligenceSummary(observation.intelligence); {addressGroups.map((group) => {
return <div className="grid grid-cols-[1fr_auto] gap-4 py-4" key={observation.id}><div><p>{observation.ipAddress}</p><p className="mt-1 text-[10px] text-muted">{summary.location ?? "Location unavailable"} · {summary.classification ?? observation.classification}</p></div><span className="text-muted">{observation.source} · {observation.observedAt.toISOString()}</span></div>; const summary = intelligenceSummary(group.intelligence);
return (
<div className="grid gap-3 py-4 sm:grid-cols-[1fr_auto] sm:items-start" key={group.network}>
<div>
<div className="flex flex-wrap items-center gap-3">
<p className="font-bold">{group.network}</p>
<span className="border border-line px-2 py-1 text-[9px] uppercase tracking-wider text-muted">{group.count} {group.count === 1 ? "observation" : "observations"}</span>
</div>
<p className="mt-2 text-[10px] text-muted">Latest address {group.latestAddress}</p>
<p className="mt-1 text-[10px] text-muted">{summary.location ?? "Location unavailable"} · {summary.classification ?? group.classification}</p>
</div>
<span className="text-[10px] text-muted sm:text-right">{group.sources.join(" + ")}<br />Last seen {group.latestObservedAt.toISOString()}</span>
</div>
);
})} })}
</div> </div>
) : <p className="py-6 text-sm text-muted">No web or game access addresses have been recorded yet.</p>} ) : <p className="py-6 text-sm text-muted">No web or game access addresses have been recorded yet.</p>}
@@ -151,8 +211,9 @@ export default async function AccountPage({
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Profile</p> <p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Profile</p>
<label className="mt-5 block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="firstName">What we call you</label> <label className="mt-5 block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="firstName">What we call you</label>
<input className="mt-3 w-full border border-line bg-canvas px-4 py-3 outline-none focus:border-accent" defaultValue={user.firstName ?? ""} id="firstName" maxLength={50} name="firstName" required /> <input className="mt-3 w-full border border-line bg-canvas px-4 py-3 outline-none focus:border-accent" defaultValue={user.firstName ?? ""} id="firstName" maxLength={50} name="firstName" required />
{desiredNickname && <p className="mt-4 text-xs leading-5 text-muted">Discord preview: <strong className="text-ink">{desiredNickname}</strong></p>} {desiredNickname && <p className="mt-4 text-xs leading-5 text-muted">Current Discord nickname: <strong className="text-ink">{desiredNickname}</strong></p>}
<button className="mt-6 border border-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider" type="submit">Save name</button> <p className="mt-3 text-xs leading-5 text-muted">You will review the new Discord nickname before anything changes.</p>
<button className="mt-6 border border-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider" type="submit">Review name change</button>
</form> </form>
</aside> </aside>
</div> </div>
@@ -3,6 +3,7 @@ import { events, ipObservations, minecraftAccounts, users } from "@minecraft-acc
import { and, desc, eq, isNull, or } from "drizzle-orm"; import { and, desc, eq, 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 { db } from "@/lib/database"; import { db } from "@/lib/database";
import { eventIpSummary } from "@/lib/event-ip-summary"; import { eventIpSummary } from "@/lib/event-ip-summary";
import { import {
@@ -60,8 +61,11 @@ export default async function AdminUserPage({
.from(ipObservations) .from(ipObservations)
.where(eq(ipObservations.userId, user.id)) .where(eq(ipObservations.userId, user.id))
.orderBy(desc(ipObservations.observedAt)) .orderBy(desc(ipObservations.observedAt))
.limit(20), .limit(100),
]); ]);
const addressGroups = groupAccessAddresses(
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)
@@ -162,9 +166,19 @@ export default async function AdminUserPage({
<section className="border border-line bg-panel p-6"> <section className="border border-line bg-panel p-6">
<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>
<div className="mt-4 divide-y divide-line"> <div className="mt-4 divide-y divide-line">
{observations.map((observation) => <div className="py-3" key={observation.id}><p className="font-mono text-xs">{observation.ipAddress}</p><p className="mt-1 font-mono text-[9px] text-muted">{observation.source} · {observation.observedAt.toISOString()}</p></div>)} {addressGroups.map((group) => (
{!observations.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>} <div className="py-3" key={group.network}>
<div className="flex items-center justify-between gap-3">
<p className="font-mono text-xs font-bold">{group.network}</p>
<span className="font-mono text-[9px] text-muted">×{group.count}</span>
</div>
<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>
</div>
))}
{!addressGroups.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>}
</div> </div>
</section> </section>
</aside> </aside>
+5 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { SiteFooter } from "@/components/site-footer";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -10,7 +11,10 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) { export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
return ( return (
<html lang="en"> <html lang="en">
<body>{children}</body> <body className="flex min-h-screen flex-col">
<div className="flex-1">{children}</div>
<SiteFooter />
</body>
</html> </html>
); );
} }
-4
View File
@@ -84,10 +84,6 @@ export default async function HomePage({
</aside> </aside>
</section> </section>
<footer className="flex flex-col gap-3 border-t border-line pt-5 font-mono text-[10px] uppercase tracking-[0.18em] text-muted sm:flex-row sm:items-center sm:justify-between">
<span>Java Edition only</span>
<span>Unknown players are denied by default</span>
</footer>
</div> </div>
</main> </main>
); );
@@ -0,0 +1,13 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { SiteFooter } from "./site-footer";
describe("SiteFooter", () => {
it("credits DMG Games with the requested sponsor link", () => {
const markup = renderToStaticMarkup(<SiteFooter />);
expect(markup).toContain("Social Minecraft is sponsored by");
expect(markup).toContain('href="https://dmg.games"');
expect(markup).toContain("DMG Games.");
});
});
+15
View File
@@ -0,0 +1,15 @@
export function SiteFooter() {
return (
<footer className="border-t border-line bg-panel px-6 py-6 text-center font-mono text-[10px] uppercase tracking-[0.16em] text-muted">
Social Minecraft is sponsored by{" "}
<a
className="font-bold text-ink underline decoration-accent underline-offset-4 transition-colors hover:text-accent"
href="https://dmg.games"
rel="noreferrer"
target="_blank"
>
DMG Games.
</a>
</footer>
);
}
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { groupAccessAddresses } from "./access-address-groups";
describe("groupAccessAddresses", () => {
it("collapses repeated observations from the same network into one recent summary", () => {
const groups = groupAccessAddresses([
{ id: "old", ipAddress: "198.51.100.21", source: "web", classification: "clear", observedAt: new Date("2026-08-01T10:00:00Z"), intelligence: null },
{ id: "new", ipAddress: "198.51.100.240", source: "game", classification: "clear", observedAt: new Date("2026-08-01T12:00:00Z"), intelligence: { provider: "proxycheck" } },
{ id: "other", ipAddress: "203.0.113.9", source: "web", classification: "vpn", observedAt: new Date("2026-08-01T11:00:00Z"), intelligence: null },
]);
expect(groups).toHaveLength(2);
expect(groups[0]).toMatchObject({
network: "198.51.100.0/24",
latestAddress: "198.51.100.240",
sources: ["game", "web"],
count: 2,
classification: "clear",
intelligence: { provider: "proxycheck" },
});
expect(groups[0]?.latestObservedAt.toISOString()).toBe("2026-08-01T12:00:00.000Z");
});
});
+60
View File
@@ -0,0 +1,60 @@
import { addressGroup } from "@minecraft-account-manager/network";
type AccessObservation = {
id: string;
ipAddress: string;
source: string;
classification: string;
observedAt: Date;
intelligence: Record<string, unknown> | null;
};
export type AccessAddressGroup = {
network: string;
latestAddress: string;
sources: string[];
count: number;
firstObservedAt: Date;
latestObservedAt: Date;
classification: string;
intelligence: Record<string, unknown> | null;
};
export function groupAccessAddresses(observations: AccessObservation[]) {
const groups = new Map<string, AccessAddressGroup & { sourceSet: Set<string> }>();
for (const observation of observations) {
const network = addressGroup(observation.ipAddress);
const existing = groups.get(network);
if (!existing) {
groups.set(network, {
network,
latestAddress: observation.ipAddress,
sources: [],
sourceSet: new Set([observation.source]),
count: 1,
firstObservedAt: observation.observedAt,
latestObservedAt: observation.observedAt,
classification: observation.classification,
intelligence: observation.intelligence,
});
continue;
}
existing.count += 1;
existing.sourceSet.add(observation.source);
if (observation.observedAt < existing.firstObservedAt) {
existing.firstObservedAt = observation.observedAt;
}
if (observation.observedAt > existing.latestObservedAt) {
existing.latestAddress = observation.ipAddress;
existing.latestObservedAt = observation.observedAt;
existing.classification = observation.classification;
existing.intelligence = observation.intelligence;
}
}
return [...groups.values()]
.map(({ sourceSet, ...group }) => ({ ...group, sources: [...sourceSet].sort() }))
.sort((left, right) => right.latestObservedAt.getTime() - left.latestObservedAt.getTime());
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { hasDiscordNicknameConfirmation } from "./dashboard-change-confirmation";
describe("dashboard identity change confirmation", () => {
it("accepts only the explicit Discord nickname confirmation value", () => {
expect(hasDiscordNicknameConfirmation(new FormData())).toBe(false);
const declined = new FormData();
declined.set("confirmDiscordNickname", "no");
expect(hasDiscordNicknameConfirmation(declined)).toBe(false);
const confirmed = new FormData();
confirmed.set("confirmDiscordNickname", "yes");
expect(hasDiscordNicknameConfirmation(confirmed)).toBe(true);
});
});
@@ -0,0 +1,3 @@
export function hasDiscordNicknameConfirmation(formData: FormData) {
return formData.get("confirmDiscordNickname") === "yes";
}
+1
View File
@@ -2,6 +2,7 @@
## 2026-08-01 ## 2026-08-01
* **Refine**: Group repeated access networks, confirm linked Discord nickname changes before mutation, and add DMG Games sponsorship attribution.
* **Extend**: Add shared Pino logging with credential redaction and actionable web and Discord runtime diagnostics. * **Extend**: Add shared Pino logging with credential redaction and actionable web and Discord runtime diagnostics.
* **Fix**: Build magic-link redirects from the configured public portal URL instead of the reverse proxy's internal request origin. * **Fix**: Build magic-link redirects from the configured public portal URL instead of the reverse proxy's internal request origin.
* **Verify**: Confirmed `v1.1.1` left all pre-existing `latest` digests unchanged while publishing versioned artifacts. * **Verify**: Confirmed `v1.1.1` left all pre-existing `latest` digests unchanged while publishing versioned artifacts.
+3 -1
View File
@@ -3,7 +3,7 @@ type: User Story
title: Enter the account portal through Discord title: Enter the account portal through Discord
description: Direct visitors are guided to the configured Discord community and its account commands. description: Direct visitors are guided to the configured Discord community and its account commands.
tags: [player, portal, discord, onboarding] tags: [player, portal, discord, onboarding]
timestamp: 2026-08-01T18:43:58Z timestamp: 2026-08-01T22:04:17Z
story_id: US-001 story_id: US-001
status: verified status: verified
--- ---
@@ -18,11 +18,13 @@ As a prospective player, I want the portal to direct me to the community Discord
- [x] Given a configured invite URL, when the visitor selects the join action, then the Discord invite opens in a new browser context. - [x] Given a configured invite URL, when the visitor selects the join action, then the Discord invite opens in a new browser context.
- [x] Given a configured guild ID, when the visitor selects the app action, then a `discord://` guild link is opened. - [x] Given a configured guild ID, when the visitor selects the app action, then a `discord://` guild link is opened.
- [x] Given an unauthenticated protected-page request, when authorization fails, then the visitor returns to the portal with prominent Discord instructions. - [x] Given an unauthenticated protected-page request, when authorization fails, then the visitor returns to the portal with prominent Discord instructions.
- [x] Every portal page credits Social Minecraft sponsorship by DMG Games and links to `https://dmg.games`.
# Implementation # Implementation
- [`apps/web/src/app/page.tsx`](../apps/web/src/app/page.tsx) - [`apps/web/src/app/page.tsx`](../apps/web/src/app/page.tsx)
- [`apps/web/src/lib/auth/user-session.ts`](../apps/web/src/lib/auth/user-session.ts) - [`apps/web/src/lib/auth/user-session.ts`](../apps/web/src/lib/auth/user-session.ts)
- [`apps/web/src/components/site-footer.tsx`](../apps/web/src/components/site-footer.tsx)
- Configuration: `DISCORD_GUILD_ID`, `DISCORD_INVITE_URL` - Configuration: `DISCORD_GUILD_ID`, `DISCORD_INVITE_URL`
# Validation # Validation
+3 -3
View File
@@ -3,7 +3,7 @@ type: User Story
title: Manage linked accounts from the dashboard title: Manage linked accounts from the dashboard
description: Authenticated users maintain their profile and active Java Edition accounts. description: Authenticated users maintain their profile and active Java Edition accounts.
tags: [player, dashboard, minecraft, profile] tags: [player, dashboard, minecraft, profile]
timestamp: 2026-08-01T18:43:58Z timestamp: 2026-08-01T22:04:17Z
story_id: US-005 story_id: US-005
status: verified status: verified
--- ---
@@ -20,7 +20,7 @@ As a registered player, I want to manage my profile and linked Minecraft account
- [x] The user can soft-remove an active account. - [x] The user can soft-remove an active account.
- [x] The user can choose exactly one active primary account. - [x] The user can choose exactly one active primary account.
- [x] Removing a primary account promotes another active account when one exists. - [x] Removing a primary account promotes another active account when one exists.
- [x] Name and primary changes show the expected Discord nickname and require confirmation. - [x] Name and primary changes preview the expected Discord nickname and require explicit confirmation before either profile mutation occurs.
- [x] The dashboard shows recent portal and game IP observations with classification and available location. - [x] The dashboard shows recent portal and game IP observations with classification and available location.
- [x] The user can revoke the current session by signing out. - [x] The user can revoke the current session by signing out.
@@ -32,7 +32,7 @@ As a registered player, I want to manage my profile and linked Minecraft account
# Validation # Validation
Server actions verify the current session and constrain every account lookup by the authenticated user ID. Server actions verify the current session, constrain every account lookup by the authenticated user ID, and require the explicit Discord confirmation field before name or primary-account mutations. Confirmation parsing is covered by [`apps/web/src/lib/dashboard-change-confirmation.test.ts`](../apps/web/src/lib/dashboard-change-confirmation.test.ts).
# Related Stories # Related Stories
+4 -1
View File
@@ -3,7 +3,7 @@ type: User Story
title: Enrich portal and game login IPs title: Enrich portal and game login IPs
description: Login audit events include cached approximate location and network intelligence from ProxyCheck.io. description: Login audit events include cached approximate location and network intelligence from ProxyCheck.io.
tags: [security, network, audit, proxycheck] tags: [security, network, audit, proxycheck]
timestamp: 2026-08-01T18:43:58Z timestamp: 2026-08-01T22:04:17Z
story_id: US-007 story_id: US-007
status: verified status: verified
--- ---
@@ -22,6 +22,7 @@ As an operator, I want portal and registered game logins enriched with network c
- [x] Unknown game accounts do not trigger paid ProxyCheck lookups. - [x] Unknown game accounts do not trigger paid ProxyCheck lookups.
- [x] Login events and IP observations retain the available classification and approximate location. - [x] Login events and IP observations retain the available classification and approximate location.
- [x] Users and administrators can see available location and classification in audit views. - [x] Users and administrators can see available location and classification in audit views.
- [x] Repeated access observations are summarized by IPv4 /24 or IPv6 /64 network with counts, sources, and latest activity.
# Implementation # Implementation
@@ -34,6 +35,8 @@ As an operator, I want portal and registered game logins enriched with network c
- [`packages/network/test/proxycheck.test.ts`](../packages/network/test/proxycheck.test.ts) - [`packages/network/test/proxycheck.test.ts`](../packages/network/test/proxycheck.test.ts)
- [`packages/network/test/client-ip.test.ts`](../packages/network/test/client-ip.test.ts) - [`packages/network/test/client-ip.test.ts`](../packages/network/test/client-ip.test.ts)
- [`packages/network/test/address-groups.test.ts`](../packages/network/test/address-groups.test.ts)
- [`apps/web/src/lib/access-address-groups.test.ts`](../apps/web/src/lib/access-address-groups.test.ts)
# Related Stories # Related Stories
+20
View File
@@ -118,6 +118,26 @@ export class ProxyCheckProvider implements IpIntelligenceProvider {
} }
} }
export function addressGroup(ipAddress: string) {
const hostAddress = ipAddress.split("/", 1)[0] ?? ipAddress;
if (!isIP(hostAddress)) return ipAddress;
let address = ipaddr.parse(hostAddress);
if (address instanceof ipaddr.IPv6 && address.isIPv4MappedAddress()) {
address = address.toIPv4Address();
}
const prefixLength = address.kind() === "ipv4" ? 24 : 64;
const bytes = address.toByteArray();
for (let bit = prefixLength; bit < bytes.length * 8; bit += 1) {
const byteIndex = Math.floor(bit / 8);
const bitMask = 1 << (7 - (bit % 8));
bytes[byteIndex] = (bytes[byteIndex] ?? 0) & ~bitMask;
}
return `${ipaddr.fromByteArray(bytes).toString()}/${prefixLength}`;
}
export function isPublicIp(ipAddress: string) { export function isPublicIp(ipAddress: string) {
if (!isIP(ipAddress)) return false; if (!isIP(ipAddress)) return false;
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { addressGroup } from "../src/index";
describe("access address groups", () => {
it("groups nearby IPv4 and IPv6 addresses by their stable network prefix", () => {
expect(addressGroup("198.51.100.21")).toBe("198.51.100.0/24");
expect(addressGroup("198.51.100.240")).toBe("198.51.100.0/24");
expect(addressGroup("198.51.100.99/32")).toBe("198.51.100.0/24");
expect(addressGroup("2001:db8:abcd:1234:1111::1")).toBe("2001:db8:abcd:1234::/64");
expect(addressGroup("2001:db8:abcd:1234:ffff::9")).toBe("2001:db8:abcd:1234::/64");
});
});