feat(portal): add SSR operations and exclusive groups
CI / validate (push) Successful in 5m20s
Release / release (push) Successful in 6m56s

This commit is contained in:
dmg
2026-08-01 19:21:23 -04:00
parent b88097c15a
commit b7c0083647
45 changed files with 2245 additions and 363 deletions
+123 -123
View File
@@ -1,61 +1,75 @@
"use server";
import { formatDiscordNickname, lookupJavaProfile, updateGuildNickname } from "@minecraft-account-manager/minecraft";
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
import { formatManagedDiscordNickname, lookupJavaProfile, updateGuildNickname } from "@minecraft-account-manager/minecraft";
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 { db } from "@/lib/database";
import { checkAccountAdditionNetwork, toAuditIpData } from "@/lib/ip-intelligence";
import { logger } from "@/lib/logger";
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
type CurrentUser = Awaited<ReturnType<typeof requireCurrentUser>>;
type NicknameSyncResult = "updated" | "not-configured" | "failed";
async function synchronizeNickname(user: CurrentUser, nickname: string, operation: string): Promise<NicknameSyncResult> {
const guildId = process.env.DISCORD_GUILD_ID?.trim();
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
if (!guildId || !botToken) return "not-configured";
try {
await updateGuildNickname({ guildId, discordUserId: user.discordUserId, nickname, botToken });
} catch (error) {
logger.error(
{ err: error, event: "account.discord_nickname_update_failed", operation },
"Failed to update the Discord guild nickname",
);
return "failed";
}
try {
await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", {
nickname,
operation,
});
} catch (error) {
logger.error(
{ err: error, event: "account.discord_nickname_audit_failed", operation },
"Discord nickname updated but its audit event could not be recorded",
);
}
return "updated";
}
function nicknameResultUrl(nickname: string, synchronization: NicknameSyncResult, additionalQuery?: string) {
const result = synchronization === "updated"
? `nicknameUpdated=${encodeURIComponent(nickname)}`
: `error=${synchronization === "not-configured" ? "nickname-not-configured" : "nickname-update-failed"}&nicknameExpected=${encodeURIComponent(nickname)}`;
return `/account?${additionalQuery ? `${additionalQuery}&` : ""}${result}`;
}
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");
}
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)}`);
}
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);
const nickname = formatManagedDiscordNickname(firstName, primary?.username ?? null);
await db.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id));
const synchronization = await synchronizeNickname(user, nickname, "update-first-name");
await recordUserEvent(user, "games.minecraft.account-manager.user.first-name.updated", { firstName });
await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", {
nickname,
operation: "update-first-name",
});
redirect("/account?nicknameUpdated=1");
redirect(nicknameResultUrl(nickname, synchronization));
}
export async function addMinecraftAccount(formData: FormData) {
@@ -82,138 +96,124 @@ export async function addMinecraftAccount(formData: FormData) {
const profile = await lookupJavaProfile(requestedUsername);
if (!profile && !confirmed) redirect(`/account?unverified=${encodeURIComponent(requestedUsername)}`);
const [existing] = await db.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
).limit(1);
const [existing] = await db
.select({ id: minecraftAccounts.id })
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
.limit(1);
let failed = false;
const username = profile?.username ?? requestedUsername;
try {
await db.insert(minecraftAccounts).values({
userId: user.id,
minecraftUuid: profile?.uuid ?? null,
username: profile?.username ?? requestedUsername,
username,
validationStatus: profile ? "verified" : "user_confirmed",
lastVerifiedAt: profile ? new Date() : null,
isPrimary: !existing,
});
} catch {
failed = true;
redirect("/account?error=already-registered");
}
if (failed) redirect("/account?error=already-registered");
const nickname = !existing && user.firstName
? formatManagedDiscordNickname(user.firstName, username)
: null;
const synchronization = nickname
? await synchronizeNickname(user, nickname, "add-first-account")
: null;
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.added", {
username: profile?.username ?? requestedUsername,
username,
minecraftUuid: profile?.uuid ?? null,
validationStatus: profile ? "verified" : "user_confirmed",
});
redirect(existing ? "/account?added=1" : "/account?confirmNickname=1");
redirect(nickname && synchronization
? nicknameResultUrl(nickname, synchronization, "added=1")
: "/account?added=1");
}
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 [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);
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);
const 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;
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: false, updatedAt: new Date() }).where(
and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
);
redirect(`/account?error=nickname-update-failed&pendingPrimary=${encodeURIComponent(requestedAccount.id)}`);
}
await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id));
return true;
});
if (!changed) redirect("/account?error=unknown-account");
const nickname = formatManagedDiscordNickname(user.firstName, requestedAccount.username);
const synchronization = await synchronizeNickname(user, nickname, "set-primary-account");
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");
redirect(nicknameResultUrl(nickname, synchronization));
}
export async function removeMinecraftAccount(formData: FormData) {
const user = await requireCurrentUser();
const accountId = String(formData.get("accountId") ?? "");
const removed = await db.transaction(async (tx) => {
const [account] = await tx.select({ id: minecraftAccounts.id, isPrimary: minecraftAccounts.isPrimary }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
).limit(1);
if (!account) return false;
const result = await db.transaction(async (tx) => {
const [account] = await tx
.select({ id: minecraftAccounts.id, isPrimary: minecraftAccounts.isPrimary })
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
.limit(1);
if (!account) return null;
await tx.update(minecraftAccounts).set({ deletedAt: new Date(), isPrimary: false, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id));
let replacementUsername: string | null = null;
if (account.isPrimary) {
const [replacement] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.userId, user.id), ne(minecraftAccounts.id, account.id), isNull(minecraftAccounts.deletedAt)),
).limit(1);
const [replacement] = await tx
.select({ id: minecraftAccounts.id, username: minecraftAccounts.username })
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.userId, user.id), ne(minecraftAccounts.id, account.id), isNull(minecraftAccounts.deletedAt)))
.limit(1);
if (replacement) {
replacementUsername = replacement.username;
await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, replacement.id));
}
} else {
const [primary] = await tx
.select({ username: minecraftAccounts.username })
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)))
.limit(1);
replacementUsername = primary?.username ?? null;
}
return true;
return { replacementUsername };
});
if (!removed) redirect("/account?error=unknown-account");
if (!result) redirect("/account?error=unknown-account");
const nickname = user.firstName
? formatManagedDiscordNickname(user.firstName, result.replacementUsername)
: null;
const synchronization = nickname
? await synchronizeNickname(user, nickname, "remove-account")
: null;
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.removed", { accountId });
redirect("/account?removed=1&confirmNickname=1");
}
export async function confirmDashboardNickname() {
const user = await requireCurrentUser();
const [account] = await db.select({ username: minecraftAccounts.username }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)),
).limit(1);
const guildId = process.env.DISCORD_GUILD_ID?.trim();
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
if (!user.firstName || !account || !guildId || !botToken) redirect("/account?error=nickname-not-configured");
try {
await updateGuildNickname({
guildId,
discordUserId: user.discordUserId,
nickname: formatDiscordNickname(user.firstName, account.username),
botToken,
});
} catch {
redirect("/account?error=nickname-update-failed&confirmNickname=1");
}
await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", {
nickname: formatDiscordNickname(user.firstName, account.username),
});
redirect("/account?nicknameUpdated=1");
redirect(nickname && synchronization
? nicknameResultUrl(nickname, synchronization, "removed=1")
: "/account?removed=1");
}
+30 -70
View File
@@ -1,4 +1,5 @@
import { formatDiscordNickname } from "@minecraft-account-manager/minecraft";
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";
@@ -7,9 +8,9 @@ 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,
confirmDashboardNickname,
removeMinecraftAccount,
setPrimaryAccount,
updateFirstName,
@@ -30,6 +31,8 @@ const errorMessages: Record<string, string> = {
"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,
}: {
@@ -39,7 +42,7 @@ export default async function AccountPage({
const query = await searchParams;
const error = queryValue(query.error);
const unverified = queryValue(query.unverified);
const [accounts, observations, discord, accessGroups] = await Promise.all([
const [accounts, observations, discord, availableGroups] = await Promise.all([
db
.select()
.from(minecraftAccounts)
@@ -68,27 +71,15 @@ export default async function AccountPage({
]);
const addressGroups = groupAccessAddresses(observations);
const primary = accounts.find((account) => account.isPrimary);
const desiredNickname = user.firstName && primary
? formatDiscordNickname(user.firstName, primary.username)
const desiredNickname = user.firstName
? formatManagedDiscordNickname(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;
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">
@@ -101,46 +92,17 @@ 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>
{error && (
<p className="mt-8 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent">
{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>
)}
{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>}
{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>
<p className="mt-2 text-sm text-muted">Your community nickname will become</p>
<p className="mt-1 font-display text-2xl font-black">{desiredNickname}</p>
</div>
<form action={confirmDashboardNickname} className="mt-5 sm:mt-0">
<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 update</button>
</form>
</section>
)}
<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">
@@ -162,8 +124,8 @@ 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">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>
{!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>
))}
@@ -180,7 +142,7 @@ export default async function AccountPage({
</form>
) : (
<form action={addMinecraftAccount} className="flex flex-col gap-3 border-t border-line pt-6 sm:flex-row">
<input 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 />
<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>
)}
@@ -224,16 +186,14 @@ export default async function AccountPage({
</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">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>
{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>
<div className="mt-4 space-y-3">
{accessGroups.map((group) => <div className="flex items-center justify-between gap-3 border-t border-line pt-3 first:border-0 first:pt-0" key={group.id}><span className="font-mono text-xs font-bold">{group.name}{group.isDefault ? " · default" : ""}</span><span className={`px-2 py-1 font-mono text-[9px] font-bold uppercase ${group.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{group.accessEnabled ? "Access on" : "Access off"}</span></div>)}
</div>
<p className="mt-4 text-xs leading-5 text-muted">Minecraft access is allowed when any listed group has access on.</p>
{effectiveGroup ? <div className="mt-4 flex items-center justify-between gap-3"><span className="font-mono text-xs font-bold">{effectiveGroup.name}{effectiveGroup.isDefault ? " · default" : ""}</span><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></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 access.</p>
</section>
</aside>
</div>