204 lines
14 KiB
TypeScript
204 lines
14 KiB
TypeScript
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 3–16 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 couldn’t 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>
|
||
);
|
||
}
|