Files
minecraft-account-manager/apps/web/src/app/account/page.tsx
T
dmg 71856bb869
CI / validate (push) Successful in 5m45s
Release / release (push) Successful in 7m21s
feat(admission): add group VPN exceptions
2026-08-02 10:16:16 -04:00

204 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { resolveEffectiveGroup } from "@minecraft-account-manager/auth";
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
import { groups, ipIntelligence, ipObservations, minecraftAccounts, userGroupMemberships } from "@minecraft-account-manager/database";
import { and, desc, eq, isNull, or } 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 { discordIdentity } from "@/lib/discord-identity";
import { intelligenceSummary } from "@/lib/event-ip-summary";
import { NicknameNotice } from "@/components/nickname-notice";
import {
addMinecraftAccount,
removeMinecraftAccount,
setPrimaryAccount,
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.",
"already-registered": "That Minecraft account is already registered.",
"unknown-account": "That account is no longer available.",
"nickname-not-configured": "Discord nickname updates are not configured.",
"nickname-update-failed": "Discord rejected the nickname update. An admin may need to adjust bot permissions.",
"vpn-blocked": "Turn off your VPN, proxy, or Tor connection before adding a Minecraft account.",
"ip-check-unavailable": "We could not verify your network, so account addition is temporarily blocked.",
};
export const dynamic = "force-dynamic";
export default async function AccountPage({
searchParams,
}: {
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, discord, availableGroups] = await Promise.all([
db
.select()
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
.orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username),
db
.select({
id: ipObservations.id,
ipAddress: ipObservations.ipAddress,
source: ipObservations.source,
classification: ipObservations.classification,
observedAt: ipObservations.observedAt,
intelligence: ipIntelligence.rawResponse,
})
.from(ipObservations)
.leftJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
.where(eq(ipObservations.userId, user.id))
.orderBy(desc(ipObservations.observedAt))
.limit(100),
discordIdentity(user),
db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed, isDefault: groups.isDefault })
.from(groups)
.leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
.where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id)))
.orderBy(desc(groups.isDefault), groups.name),
]);
const addressGroups = groupAccessAddresses(observations);
const primary = accounts.find((account) => account.isPrimary);
const desiredNickname = user.firstName
? formatManagedDiscordNickname(user.firstName, primary?.username ?? null)
: null;
const explicitGroup = availableGroups.find((group) => !group.isDefault) ?? null;
const defaultGroup = availableGroups.find((group) => group.isDefault) ?? null;
const effectiveGroup = resolveEffectiveGroup(explicitGroup, defaultGroup);
const nicknameUpdated = queryValue(query.nicknameUpdated);
const nicknameExpected = queryValue(query.nicknameExpected);
const nicknameError = error === "nickname-update-failed" || error === "nickname-not-configured" ? error : null;
return (
<main className="min-h-screen bg-canvas px-6 py-10 text-ink sm:py-16">
<section className="mx-auto max-w-6xl">
<header className="flex flex-col gap-6 border-b border-line pb-8 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Account registry</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">{user.firstName ?? user.discordUsername}</h1>
</div>
<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>
{error && !nicknameError && (
<p className="mt-8 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">
{errorMessages[error] ?? "The requested change could not be completed."}
</p>
)}
<NicknameNotice
error={nicknameError
? `${errorMessages[nicknameError]}${nicknameExpected ? ` Your intended nickname is ${nicknameExpected}.` : ""}`
: undefined}
nickname={nicknameUpdated}
/>
<div className="mt-12 grid gap-10 lg:grid-cols-[1.35fr_0.65fr]">
<div className="space-y-10">
<section>
<div className="flex items-end justify-between border-b border-line pb-4">
<div><p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Whitelist identities</p><h2 className="mt-2 font-display text-3xl font-black uppercase">Minecraft accounts</h2></div>
<span className="font-mono text-xs text-muted">{accounts.length} active</span>
</div>
<div className="divide-y divide-line">
{accounts.map((account) => (
<article className="grid gap-4 py-6 sm:grid-cols-[1fr_auto] sm:items-center" key={account.id}>
<div>
<div className="flex flex-wrap items-center gap-3">
<h3 className="font-mono text-lg font-bold">{account.username}</h3>
{account.isPrimary && <span className="bg-accent px-2 py-1 font-mono text-[9px] font-bold uppercase tracking-widest text-canvas">Primary</span>}
<span className="border border-line px-2 py-1 font-mono text-[9px] uppercase tracking-wider text-muted">{account.validationStatus === "verified" ? "UUID verified" : "User confirmed"}</span>
</div>
<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>}
<details className="relative"><summary className="cursor-pointer list-none font-mono text-[10px] font-bold uppercase tracking-wider text-accent underline underline-offset-4">Remove</summary><form action={removeMinecraftAccount} className="absolute right-0 z-10 mt-2 w-60 border border-accent bg-panel p-4 shadow-[5px_5px_0_var(--color-accent)]"><input name="accountId" type="hidden" value={account.id} /><p className="text-xs leading-5">Remove {account.username}? Your Discord nickname will update automatically.</p><button className="mt-3 bg-accent px-3 py-2 font-mono text-[9px] font-bold uppercase text-canvas" type="submit">Confirm removal</button></form></details>
</div>
</article>
))}
{accounts.length === 0 && <p className="py-8 text-muted">No active Minecraft accounts. Add one before joining the server.</p>}
</div>
{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 {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={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>
) : (
<form action={addMinecraftAccount} className="flex flex-col gap-3 border-t border-line pt-6 sm:flex-row">
<input aria-label="Minecraft username" className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-sm outline-none focus:border-accent" maxLength={16} minLength={3} name="username" pattern="[A-Za-z0-9_]{3,16}" placeholder="Minecraft username" required />
<button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Add account</button>
</form>
)}
</section>
<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>
{addressGroups.length ? (
<div className="divide-y divide-line font-mono text-xs">
<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>}
</section>
</div>
<aside>
<form action={updateFirstName} className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Profile</p>
<dl className="mt-5 space-y-3 border-b border-line pb-5 font-mono text-[10px]">
<div><dt className="uppercase tracking-wider text-muted">Discord name</dt><dd className="mt-1 break-all font-bold text-ink">{discord.globalName ?? discord.username}</dd></div>
<div><dt className="uppercase tracking-wider text-muted">Discord username</dt><dd className="mt-1 break-all text-ink">@{discord.username}</dd></div>
<div><dt className="uppercase tracking-wider text-muted">Guild nickname</dt><dd className="mt-1 break-all text-ink">{discord.nickname ?? "No guild nickname"}</dd></div>
<div><dt className="uppercase tracking-wider text-muted">Discord ID</dt><dd className="mt-1 break-all text-ink">{discord.id}</dd></div>
</dl>
<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">Managed Discord nickname: <strong className="text-ink">{desiredNickname}</strong></p>}
<p className="mt-3 text-xs leading-5 text-muted">Saving automatically synchronizes this Discord guild nickname.</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>
</form>
<section className="mt-8 border border-line bg-panel p-6">
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Access groups</p>
{effectiveGroup ? <div className="mt-4 flex flex-wrap items-center justify-between gap-3"><span className="font-mono text-xs font-bold">{effectiveGroup.name}{effectiveGroup.isDefault ? " · default" : ""}</span><div className="flex gap-2"><span className={`px-2 py-1 font-mono text-[9px] font-bold uppercase ${effectiveGroup.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{effectiveGroup.accessEnabled ? "Access on" : "Access off"}</span><span className="border border-line px-2 py-1 font-mono text-[9px] font-bold uppercase">VPN {effectiveGroup.anonymizedNetworksAllowed ? "allowed" : "denied"}</span></div></div> : <p className="mt-4 text-sm text-accent">No default access group is configured.</p>}
<p className="mt-4 text-xs leading-5 text-muted">Your effective group alone determines Minecraft and VPN/proxy/Tor access.</p>
</section>
</aside>
</div>
</section>
</main>
);
}