feat(portal): add SSR operations and exclusive groups
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user