feat(game-accounts): add account recovery action, optional password on provision, settings page panel and design doc update

This commit is contained in:
wdunn001 2026-08-20 23:32:26 -04:00
parent a74ed9eded
commit 927f9aae21
8 changed files with 267 additions and 21 deletions

View file

@ -244,8 +244,37 @@ anything, including scripts.
would only create disagreement between two existing ones.
- **One account per user per server.** Unique on `(userId, serverSlug)`.
## Status
| Phase | State |
|---|---|
| 1. provider-svc `/internal/v1/*` | **DONE, deployed.** mmo-portal `dd31f44`. Verified live against the real TrinityCore and EQEmu auth DBs; 401 without a token; router not mounted at all when `INTERNAL_API_TOKEN` is unset. |
| 2. Drop read path | **DONE.** `GameAccountLink`, `account.get.ts`, `GameAccountPanel.vue`. |
| 3. Drop write path | **DONE.** `account.post.ts` — provision + password change. |
| 4. Admin dashboard | not started |
| 5. `/account/settings` cross-game view | not started — the panel component already takes a `serverId`, so it is a loop over links, not new UI |
| 6. Retire mmo-portal account routes | not started, and deliberately last |
### Things learned building 1-3, which the doc had wrong
- **`slug != key` for WoW.** Drop names the game, the provider names the
emulator. Now an explicit three-entry map in `gameAccountProvider.ts`,
asserted by mmo-portal's tests.
- **`daoc` is unconfigured on the live portal.** The stack sets `TC_*` and
`EQEMU_*` but no `DAOC_AUTH_DB_HOST`, so OpenDAoC reports
`configured=false` and its panel correctly renders nothing. Real gap, not
a bug.
- **Prisma here uses the multi-file schema.** `prisma generate --schema
prisma/schema.prisma` silently produces a client with NO models; it must
point at the `prisma/` FOLDER.
- **The fork's build needs real git history.** `postinstall` runs `git
rev-parse --short HEAD`, so a `git archive` tarball fails the build. Clone
(from Forgejo on .88) rather than export.
## Open
- **Does `LinkedAuthMec.credentials` carry the Authentik `sub`?** Determines
whether the existing mmo-portal links migrate by join or by one-time
re-confirmation. Verify before building phase 2; do not assume.
re-confirmation. Not yet answered — phases 2 and 3 were built without
depending on it, so an empty panel is the current, correct behaviour for a
user whose account predates the bridge.

View file

@ -91,13 +91,26 @@
<p class="text-sm text-zinc-300">
{{ $t("community.gameAccount.noAccount") }}
</p>
<button
type="button"
class="w-fit rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 duration-200"
@click="mode = mode === 'provision' ? null : 'provision'"
>
{{ $t("community.gameAccount.create") }}
</button>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="w-fit rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 duration-200"
@click="mode = mode === 'provision' ? null : 'provision'"
>
{{ $t("community.gameAccount.create") }}
</button>
<button
type="button"
:disabled="busy"
class="w-fit rounded-md bg-zinc-700 px-4 py-2 text-sm font-semibold text-zinc-100 hover:bg-zinc-600 disabled:opacity-50 duration-200"
@click="recover"
>
{{ $t("community.gameAccount.recover") }}
</button>
</div>
<p class="text-xs text-zinc-500">
{{ $t("community.gameAccount.recoverHint") }}
</p>
</div>
<!-- Form: shared by both actions. Username only matters for provisioning;
@ -146,14 +159,14 @@
:disabled="busy"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 disabled:opacity-50 duration-200"
>
{{ busy ? $t("common.loading") : $t("common.save") }}
{{ busy ? $t("common.srLoading") : $t("common.save") }}
</button>
<button
type="button"
class="rounded-md bg-zinc-800 px-4 py-2 text-sm font-semibold text-zinc-300 hover:bg-zinc-700 duration-200"
@click="reset"
>
{{ $t("common.cancel") }}
{{ $t("cancel") }}
</button>
</div>
</form>
@ -226,4 +239,26 @@ async function submit() {
busy.value = false;
}
}
async function recover() {
error.value = "";
notice.value = "";
busy.value = true;
try {
await $fetch(`/api/v1/community/servers/${props.serverId}/account`, {
method: "POST",
body: { action: "recover" },
});
notice.value = $t("community.gameAccount.recoverSuccess");
await refresh();
} catch (e) {
const err = e as { statusMessage?: string; data?: { statusMessage?: string } };
error.value =
err?.data?.statusMessage ??
err?.statusMessage ??
"Something went wrong.";
} finally {
busy.value = false;
}
}
</script>

View file

@ -10,7 +10,9 @@
"title": "Devices"
},
"home": {
"title": "Home"
"title": "Home",
"manageGameAccounts": "Manage game accounts",
"browseServers": "Browse servers"
},
"notifications": {
"all": "View all {arrow}",
@ -53,6 +55,9 @@
"title": "Security"
},
"settings": "Settings",
"settingsOpenServer": "Open server",
"settingsDescription": "Create, recover, and update your game-server account links.",
"settingsNoServers": "No visible servers right now.",
"title": "Account Settings",
"token": {
"acls": "ACLs/scopes",
@ -293,6 +298,10 @@
"title": "Game account",
"noAccount": "You do not have an account on this server yet. Create one to log in with the game client.",
"create": "Create game account",
"recover": "Link existing account",
"recoverHint": "Use this if you already have an account on this server with the same username and email as your Drop profile.",
"recoverSuccess": "Linked your existing game account.",
"manageAll": "Manage game accounts from account settings",
"changePassword": "Change password",
"username": "Game account username",
"password": "Game account password",

View file

@ -1 +1,68 @@
<template><div></div></template>
<template>
<div class="mx-auto max-w-4xl px-4 py-10 space-y-6">
<div>
<h1 class="text-2xl font-bold font-display text-zinc-100">
{{ $t("account.settings") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("account.settingsDescription") }}
</p>
</div>
<div v-if="servers.length === 0" class="rounded-lg bg-zinc-800/50 p-4 text-sm text-zinc-400">
{{ $t("account.settingsNoServers") }}
</div>
<div v-else class="space-y-4">
<section
v-for="server in servers"
:key="server.id"
class="rounded-xl border border-zinc-800 bg-zinc-900 p-5 space-y-3"
>
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-zinc-100">{{ server.name }}</h2>
<p class="text-xs text-zinc-500">
{{ server.kind }} &middot; {{ server.host }}:{{ server.port }}
</p>
</div>
<NuxtLink
:to="`/community/servers/${server.id}`"
class="text-sm font-semibold text-blue-400 hover:text-blue-300"
>
{{ $t("account.settingsOpenServer") }}
</NuxtLink>
</div>
<GameAccountPanel :server-id="server.id" />
</section>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: "default",
});
useHead({
title: "Account Settings",
});
interface ServerListItem {
id: string;
name: string;
kind: string;
host: string;
port: number;
}
const servers = ref<ServerListItem[]>([]);
async function fetchServers() {
servers.value = await $dropFetch<ServerListItem[]>("/api/v1/community/servers").catch(
() => [],
);
}
await fetchServers();
</script>

View file

@ -104,6 +104,12 @@
(see GameAccountPanel.vue). Placed after "how to join" on purpose:
the account is the thing you need BEFORE the join instructions are
any use. -->
<NuxtLink
to="/account/settings"
class="w-fit text-sm font-semibold text-blue-400 hover:text-blue-300"
>
{{ $t("community.gameAccount.manageAll") }}
</NuxtLink>
<GameAccountPanel :server-id="server.id" />
<div v-if="history.length > 0" class="flex flex-col gap-y-2">

View file

@ -7,6 +7,7 @@ import {
GameProviderUserError,
changePassword,
provision,
recoverExistingLink,
} from "~/server/internal/community/gameAccountService";
// Provision a game account, or change the password on the existing one.
@ -22,9 +23,9 @@ import {
// wants (an SRP6 verifier, EQEmu's own hash). Drop stores only the link.
const AccountWrite = type({
action: "'provision' | 'password'",
action: "'provision' | 'password' | 'recover'",
"username?": "string",
password: "string",
"password?": "string",
"email?": "string",
});
@ -51,11 +52,18 @@ export default defineEventHandler(async (h3) => {
// a user-facing message (422 -> GameProviderUserError) that we surface
// verbatim rather than duplicating three sets of rules here and getting them
// subtly wrong.
if (body.password.length < 6)
throw createError({
statusCode: 400,
statusMessage: "Password must be at least 6 characters.",
});
if (body.action !== "recover") {
if (!body.password)
throw createError({
statusCode: 400,
statusMessage: "A password is required.",
});
if (body.password.length < 6)
throw createError({
statusCode: 400,
statusMessage: "Password must be at least 6 characters.",
});
}
try {
if (body.action === "provision") {
@ -69,13 +77,18 @@ export default defineEventHandler(async (h3) => {
user.id,
server.slug,
username,
body.password,
body.password!,
body.email ?? "",
);
return { ok: true, account };
}
await changePassword(user.id, server.slug, body.password);
if (body.action === "recover") {
const account = await recoverExistingLink(user.id, server.slug);
return { ok: true, account, recovered: true };
}
await changePassword(user.id, server.slug, body.password!);
return { ok: true };
} catch (err) {
// Conflict: already linked, or a concurrent provision won the race.

View file

@ -197,6 +197,24 @@ export async function accountExists(
);
}
export async function listAccounts(
slug: string,
limit = 500,
): Promise<ProviderAccountT[]> {
const key = providerKeyForSlug(slug);
if (!key) return [];
const capped = Math.max(1, Math.min(limit, 5000));
const body = await call<{ accounts: unknown[] }>(
`/internal/v1/providers/${key}/accounts?limit=${capped}`,
);
const out: ProviderAccountT[] = [];
for (const raw of body.accounts ?? []) {
const parsed = ProviderAccount(raw);
if (!(parsed instanceof type.errors)) out.push(parsed);
}
return out;
}
export async function createAccount(
slug: string,
username: string,

View file

@ -20,6 +20,7 @@ import {
getCharacters,
getProvider,
isConfigured,
listAccounts,
providerKeyForSlug,
setPassword,
type ProviderAccountT,
@ -144,6 +145,10 @@ export async function listPanelsForUser(
export class GameAccountConflict extends Error {}
function normalize(value: string | null | undefined): string {
return (value ?? "").trim().toLowerCase();
}
/** Provision a native game account and link it to this Drop identity.
*
* ONE PER USER PER SERVER is enforced here AND by a unique index. The index
@ -199,6 +204,70 @@ export async function provision(
return account;
}
/** Recover a missing Drop<->game link by matching an existing account that is
* provably the same person (same username and same email). This is for
* migration drift / accidental row loss, not for claiming arbitrary names. */
export async function recoverExistingLink(
userId: string,
serverSlug: string,
): Promise<ProviderAccountT> {
const existing = await prisma.gameAccountLink.findUnique({
where: { userId_serverSlug: { userId, serverSlug } },
});
if (existing)
throw new GameAccountConflict(
"You already have an account linked for this server.",
);
const provider = await getProvider(serverSlug);
if (!provider || !provider.configured)
throw new GameProviderUnavailable("This server has no account system.");
const user = await prisma.user.findUnique({
where: { id: userId },
select: { username: true, email: true },
});
if (!user) throw new GameAccountConflict("User not found.");
const all = await listAccounts(serverSlug, 1000);
const sameUsername = all.filter(
(a) => normalize(a.username) === normalize(user.username),
);
const exactMatches = sameUsername.filter(
(a) => normalize(a.email) !== "" && normalize(a.email) === normalize(user.email),
);
if (exactMatches.length === 0) {
throw new GameAccountConflict(
"No recoverable account match was found. We only auto-link when both " +
"username and email match your Drop profile exactly.",
);
}
if (exactMatches.length > 1) {
throw new GameAccountConflict(
"More than one matching game account was found. Ask an admin to relink manually.",
);
}
const match = exactMatches[0];
try {
await prisma.gameAccountLink.create({
data: {
userId,
serverSlug,
accountId: match.account_id,
accountUsername: match.username,
},
});
} catch {
throw new GameAccountConflict(
"An account was just linked for this server. Refresh and try again.",
);
}
return match;
}
/** Change the password on the player's own linked account. The link lookup IS
* the authorization: a user can only ever reach their own accountId. */
export async function changePassword(