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
+85 -14
View File
@@ -6,20 +6,56 @@ import { and, eq, isNull, ne } from "drizzle-orm";
import { redirect } from "next/navigation";
import { recordUserEvent } from "@/lib/audit";
import { db } from "@/lib/database";
import { hasDiscordNicknameConfirmation } from "@/lib/dashboard-change-confirmation";
import { requireCurrentUser } from "@/lib/auth/user-session";
import { checkAccountAdditionNetwork, toAuditIpData } from "@/lib/ip-intelligence";
import { logger } from "@/lib/logger";
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
export async function updateFirstName(formData: FormData) {
const user = await requireCurrentUser();
const firstName = String(formData.get("firstName") ?? "").trim();
const confirmed = hasDiscordNicknameConfirmation(formData);
if (firstName.length < 1 || firstName.length > 50 || /[\u0000-\u001f\u007f]/.test(firstName)) {
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 });
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) {
@@ -75,23 +111,58 @@ export async function addMinecraftAccount(formData: FormData) {
export async function setPrimaryAccount(formData: FormData) {
const user = await requireCurrentUser();
const accountId = String(formData.get("accountId") ?? "");
const confirmed = hasDiscordNicknameConfirmation(formData);
const [requestedAccount] = await db.select({ id: minecraftAccounts.id, username: minecraftAccounts.username }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
).limit(1);
const changed = await db.transaction(async (tx) => {
const [account] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
).limit(1);
if (!account) return false;
if (!requestedAccount) redirect("/account?error=unknown-account");
if (!user.firstName) redirect("/account?error=nickname-not-configured");
if (!confirmed) redirect(`/account?pendingPrimary=${encodeURIComponent(requestedAccount.id)}`);
await tx.update(minecraftAccounts).set({ isPrimary: false, updatedAt: new Date() }).where(
and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
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;
await tx.update(minecraftAccounts).set({ isPrimary: false, updatedAt: new Date() }).where(
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 updateGuildNickname({
guildId,
discordUserId: user.discordUserId,
nickname,
botToken,
});
return true;
});
} catch (error) {
logger.error(
{ err: error, event: "account.primary_update_failed" },
"Failed to update the primary account and Discord nickname",
);
await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id));
return true;
});
redirect(`/account?error=nickname-update-failed&pendingPrimary=${encodeURIComponent(requestedAccount.id)}`);
}
if (!changed) redirect("/account?error=unknown-account");
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.primary-changed", { accountId });
redirect("/account?confirmNickname=1");
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.primary-changed", {
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) {
+77 -16
View File
@@ -4,6 +4,7 @@ import { and, desc, eq, isNull } from "drizzle-orm";
import { logout } from "@/app/auth/actions";
import { db } from "@/lib/database";
import { requireCurrentUser } from "@/lib/auth/user-session";
import { groupAccessAddresses } from "@/lib/access-address-groups";
import { intelligenceSummary } from "@/lib/event-ip-summary";
import {
addMinecraftAccount,
@@ -13,6 +14,10 @@ import {
updateFirstName,
} from "./actions";
function queryValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
const errorMessages: Record<string, string> = {
"invalid-name": "Enter a valid name between 1 and 50 characters.",
"invalid-username": "Java usernames use 316 letters, numbers, or underscores.",
@@ -27,10 +32,12 @@ const errorMessages: Record<string, string> = {
export default async function AccountPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | undefined>>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const user = await requireCurrentUser("/account");
const query = await searchParams;
const error = queryValue(query.error);
const unverified = queryValue(query.unverified);
const [accounts, observations] = await Promise.all([
db
.select()
@@ -50,12 +57,31 @@ export default async function AccountPage({
.leftJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
.where(eq(ipObservations.userId, user.id))
.orderBy(desc(ipObservations.observedAt))
.limit(20),
.limit(100),
]);
const addressGroups = groupAccessAddresses(observations);
const primary = accounts.find((account) => account.isPrimary);
const desiredNickname = user.firstName && primary
? formatDiscordNickname(user.firstName, primary.username)
: 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 (
<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>
</header>
{query.error && (
{error && (
<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>
)}
{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">
<div>
<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>
</div>
<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>
</div>
</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>}
</div>
{query.unverified ? (
{unverified ? (
<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>
<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>
<a className="ml-5 font-mono text-[10px] font-bold uppercase underline" href="/account">Cancel</a>
</form>
@@ -135,11 +182,24 @@ export default async function AccountPage({
<section>
<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>
{observations.length ? (
{addressGroups.length ? (
<div className="divide-y divide-line font-mono text-xs">
{observations.map((observation) => {
const summary = intelligenceSummary(observation.intelligence);
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>;
<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>
{addressGroups.map((group) => {
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>
) : <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>
<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 />
{desiredNickname && <p className="mt-4 text-xs leading-5 text-muted">Discord preview: <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>
{desiredNickname && <p className="mt-4 text-xs leading-5 text-muted">Current Discord nickname: <strong className="text-ink">{desiredNickname}</strong></p>}
<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>
</aside>
</div>
@@ -3,6 +3,7 @@ import { events, ipObservations, minecraftAccounts, users } from "@minecraft-acc
import { and, desc, eq, isNull, or } from "drizzle-orm";
import Link from "next/link";
import { notFound } from "next/navigation";
import { groupAccessAddresses } from "@/lib/access-address-groups";
import { db } from "@/lib/database";
import { eventIpSummary } from "@/lib/event-ip-summary";
import {
@@ -60,8 +61,11 @@ export default async function AdminUserPage({
.from(ipObservations)
.where(eq(ipObservations.userId, user.id))
.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 nickname = user.firstName
? formatManagedDiscordNickname(user.firstName, primary?.username ?? null)
@@ -162,9 +166,19 @@ export default async function AdminUserPage({
<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="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">
{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>)}
{!observations.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>}
{addressGroups.map((group) => (
<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>
</section>
</aside>
+5 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { SiteFooter } from "@/components/site-footer";
import "./globals.css";
export const metadata: Metadata = {
@@ -10,7 +11,10 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
return (
<html lang="en">
<body>{children}</body>
<body className="flex min-h-screen flex-col">
<div className="flex-1">{children}</div>
<SiteFooter />
</body>
</html>
);
}
-4
View File
@@ -84,10 +84,6 @@ export default async function HomePage({
</aside>
</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>
</main>
);