From 927f9aae2135fb50d92ea20b461ae7b47cd07ddb Mon Sep 17 00:00:00 2001 From: wdunn001 Date: Thu, 20 Aug 2026 23:32:26 -0400 Subject: [PATCH 1/2] feat(game-accounts): add account recovery action, optional password on provision, settings page panel and design doc update --- docs/design/0002-game-account-management.md | 31 ++++++++- server/components/GameAccountPanel.vue | 53 +++++++++++--- server/i18n/locales/en_us.json | 11 ++- server/pages/account/settings.vue | 69 ++++++++++++++++++- server/pages/community/servers/[id]/index.vue | 6 ++ .../v1/community/servers/[id]/account.post.ts | 31 ++++++--- .../internal/community/gameAccountProvider.ts | 18 +++++ .../internal/community/gameAccountService.ts | 69 +++++++++++++++++++ 8 files changed, 267 insertions(+), 21 deletions(-) diff --git a/docs/design/0002-game-account-management.md b/docs/design/0002-game-account-management.md index 76d9487b..04f45df8 100644 --- a/docs/design/0002-game-account-management.md +++ b/docs/design/0002-game-account-management.md @@ -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. diff --git a/server/components/GameAccountPanel.vue b/server/components/GameAccountPanel.vue index 95623f4d..25220995 100644 --- a/server/components/GameAccountPanel.vue +++ b/server/components/GameAccountPanel.vue @@ -91,13 +91,26 @@

{{ $t("community.gameAccount.noAccount") }}

- +
+ + +
+

+ {{ $t("community.gameAccount.recoverHint") }} +

+ + {{ $t("community.gameAccount.manageAll") }} +
diff --git a/server/server/api/v1/community/servers/[id]/account.post.ts b/server/server/api/v1/community/servers/[id]/account.post.ts index 9467fab5..f263c63c 100644 --- a/server/server/api/v1/community/servers/[id]/account.post.ts +++ b/server/server/api/v1/community/servers/[id]/account.post.ts @@ -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. diff --git a/server/server/internal/community/gameAccountProvider.ts b/server/server/internal/community/gameAccountProvider.ts index 7a55223a..7142f1f1 100644 --- a/server/server/internal/community/gameAccountProvider.ts +++ b/server/server/internal/community/gameAccountProvider.ts @@ -197,6 +197,24 @@ export async function accountExists( ); } +export async function listAccounts( + slug: string, + limit = 500, +): Promise { + 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, diff --git a/server/server/internal/community/gameAccountService.ts b/server/server/internal/community/gameAccountService.ts index 71d48898..091cb665 100644 --- a/server/server/internal/community/gameAccountService.ts +++ b/server/server/internal/community/gameAccountService.ts @@ -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 { + 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( From 5278bce000305dd9fcabb4bc6c0962e48b0e7482 Mon Sep 17 00:00:00 2001 From: wdunn001 Date: Thu, 20 Aug 2026 23:32:27 -0400 Subject: [PATCH 2/2] feat(profile): user avatar upload endpoint, UserAvatar in header and profile, account home rework --- server/components/ImageUpload.vue | 9 +- server/components/UserHeader/UserWidget.vue | 22 ++- server/pages/account/index.vue | 165 +++++++++++--------- server/pages/user/[id]/index.vue | 12 +- server/server/api/v1/user/avatar.post.ts | 54 +++++++ 5 files changed, 177 insertions(+), 85 deletions(-) create mode 100644 server/server/api/v1/user/avatar.post.ts diff --git a/server/components/ImageUpload.vue b/server/components/ImageUpload.vue index f1283a0c..a86e3eaf 100644 --- a/server/components/ImageUpload.vue +++ b/server/components/ImageUpload.vue @@ -3,6 +3,12 @@ class="relative group/iconupload rounded-xl overflow-hidden w-20 mx-auto" > +
+ {{ fallbackName.charAt(0).toUpperCase() }} +