drop/server/components/GameAccountPanel.vue

265 lines
8.6 KiB
Vue
Raw Permalink Normal View History

feat(community): SSO to native game account bridge, phases 2 and 3 Adds the player-facing half of game account management: a panel on /community/servers/[id] that shows your linked account for that server, creates one, and changes its password. None of these emulators speak OIDC and none will - each ships its own login against its own auth database. So this is a bridge, not SSO: one Drop identity tied to one native account per server, enforced by a unique index on (userId, serverSlug). That constraint IS the feature. Drop owns identity, authorization, the link table and the UI. The per-game DB access and password crypto stay in mmo-portal's provider API (SRP6 verifiers, EQEmu hashing) - code that writes into live auth DBs and fails silently when subtly wrong. The password never touches Drop's database; it goes straight through to the provider, which turns it into whatever that emulator wants. Notable details: - GameServer.slug names the game, provider.key names the emulator. They agree for eqemu and daoc but wow != trinitycore, so the mapping is an explicit three-entry constant, not an assumption. - Panel renders NOTHING when a server has no account backend (Impostor is a lobby with no identity), rather than a dead button. - 'backend unreachable' is kept distinct from 'you have no account' - the latter would invite a duplicate during an outage. - Provision creates in the game first, then links. The reverse would leave a link pointing at a nonexistent account, which reads as corruption to every later call. - Authorization is just an authenticated Drop session with the ACL: Drop access and game access already imply each other, so a third gate could only disagree with them. Typecheck clean.
2026-08-12 17:04:33 -04:00
<!--
The SSO identity <-> native game account bridge, player-facing.
Used on /community/servers/[id] and (M6 phase 5) on /account/settings for
the cross-game view, which is why it takes a serverId + slug rather than
reading the route.
RENDERS NOTHING when the server has no account backend (`supported: false`)
-- Impostor is a lobby with no identity at all, and a game can be in the
registry before its DB is wired. A dead panel would be worse than no panel.
The password never reaches Drop's database. It is posted straight through to
the provider service, which turns it into whatever that emulator's auth DB
wants. Drop stores only the link.
-->
<template>
<div v-if="panel?.supported" class="flex flex-col gap-y-2">
<h2 class="text-lg font-bold font-display text-zinc-100">
{{ $t("community.gameAccount.title") }}
</h2>
<!-- Backend down. Deliberately distinct from "you have no account": we
genuinely cannot tell, and saying otherwise would invite a player to
create a duplicate. -->
<div
v-if="!panel.available"
class="rounded-lg bg-zinc-800/50 p-4 text-sm text-zinc-400"
>
{{ $t("community.gameAccount.unavailable") }}
</div>
<!-- Linked -->
<div
v-else-if="panel.linked && panel.account"
class="rounded-lg bg-zinc-800/50 p-4 flex flex-col gap-y-3"
>
<div class="flex items-center justify-between flex-wrap gap-2">
<div>
<p class="text-sm font-semibold text-zinc-100">
{{ panel.account.username }}
</p>
<p class="text-xs text-zinc-500">
<span v-if="panel.account.created_at">
{{ $t("community.gameAccount.created") }}
{{ new Date(panel.account.created_at).toLocaleDateString() }}
</span>
<span v-if="panel.account.last_login">
&middot; {{ $t("community.gameAccount.lastLogin") }}
{{ new Date(panel.account.last_login).toLocaleDateString() }}
</span>
</p>
</div>
<div class="flex items-center gap-x-2">
<span
v-if="panel.account.banned"
class="rounded bg-red-900/60 px-2 py-0.5 text-xs text-red-200"
>{{ $t("community.gameAccount.banned") }}</span
>
<span
v-else-if="panel.account.locked"
class="rounded bg-amber-900/60 px-2 py-0.5 text-xs text-amber-200"
>{{ $t("community.gameAccount.locked") }}</span
>
<button
type="button"
class="rounded-md bg-zinc-700 px-3 py-1.5 text-sm font-semibold text-zinc-100 hover:bg-zinc-600 duration-200"
@click="mode = mode === 'password' ? null : 'password'"
>
{{ $t("community.gameAccount.changePassword") }}
</button>
</div>
</div>
<ul
v-if="panel.characters?.length"
class="flex flex-wrap gap-2 border-t border-zinc-700/50 pt-3"
>
<li
v-for="c in panel.characters"
:key="String(c.guid ?? c.name)"
class="rounded bg-zinc-900/60 px-2 py-1 text-xs text-zinc-300"
>
{{ c.name }}
<span v-if="c.level" class="text-zinc-500">&middot; {{ c.level }}</span>
</li>
</ul>
</div>
<!-- Not linked yet -->
<div v-else class="rounded-lg bg-zinc-800/50 p-4 flex flex-col gap-y-3">
<p class="text-sm text-zinc-300">
{{ $t("community.gameAccount.noAccount") }}
</p>
<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>
feat(community): SSO to native game account bridge, phases 2 and 3 Adds the player-facing half of game account management: a panel on /community/servers/[id] that shows your linked account for that server, creates one, and changes its password. None of these emulators speak OIDC and none will - each ships its own login against its own auth database. So this is a bridge, not SSO: one Drop identity tied to one native account per server, enforced by a unique index on (userId, serverSlug). That constraint IS the feature. Drop owns identity, authorization, the link table and the UI. The per-game DB access and password crypto stay in mmo-portal's provider API (SRP6 verifiers, EQEmu hashing) - code that writes into live auth DBs and fails silently when subtly wrong. The password never touches Drop's database; it goes straight through to the provider, which turns it into whatever that emulator wants. Notable details: - GameServer.slug names the game, provider.key names the emulator. They agree for eqemu and daoc but wow != trinitycore, so the mapping is an explicit three-entry constant, not an assumption. - Panel renders NOTHING when a server has no account backend (Impostor is a lobby with no identity), rather than a dead button. - 'backend unreachable' is kept distinct from 'you have no account' - the latter would invite a duplicate during an outage. - Provision creates in the game first, then links. The reverse would leave a link pointing at a nonexistent account, which reads as corruption to every later call. - Authorization is just an authenticated Drop session with the ACL: Drop access and game access already imply each other, so a third gate could only disagree with them. Typecheck clean.
2026-08-12 17:04:33 -04:00
</div>
<!-- Form: shared by both actions. Username only matters for provisioning;
the password field is identical either way. -->
<form
v-if="mode"
class="rounded-lg bg-zinc-900/60 p-4 flex flex-col gap-y-3"
@submit.prevent="submit"
>
<div v-if="mode === 'provision'" class="flex flex-col gap-y-1">
<label class="text-xs font-semibold text-zinc-400" for="ga-username">{{
$t("community.gameAccount.username")
}}</label>
<input
id="ga-username"
v-model="username"
autocomplete="off"
class="rounded-md bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div class="flex flex-col gap-y-1">
<label class="text-xs font-semibold text-zinc-400" for="ga-password">{{
$t("community.gameAccount.password")
}}</label>
<input
id="ga-password"
v-model="password"
type="password"
autocomplete="new-password"
class="rounded-md bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:ring-1 focus:ring-blue-500"
/>
<!-- Said plainly because it is the single most confusing thing about
this feature: the game password is NOT the SSO password. -->
<p class="text-xs text-zinc-500">
{{ $t("community.gameAccount.passwordHint") }}
</p>
</div>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
<p v-if="notice" class="text-sm text-green-400">{{ notice }}</p>
<div class="flex items-center gap-x-2">
<button
type="submit"
: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.srLoading") : $t("common.save") }}
feat(community): SSO to native game account bridge, phases 2 and 3 Adds the player-facing half of game account management: a panel on /community/servers/[id] that shows your linked account for that server, creates one, and changes its password. None of these emulators speak OIDC and none will - each ships its own login against its own auth database. So this is a bridge, not SSO: one Drop identity tied to one native account per server, enforced by a unique index on (userId, serverSlug). That constraint IS the feature. Drop owns identity, authorization, the link table and the UI. The per-game DB access and password crypto stay in mmo-portal's provider API (SRP6 verifiers, EQEmu hashing) - code that writes into live auth DBs and fails silently when subtly wrong. The password never touches Drop's database; it goes straight through to the provider, which turns it into whatever that emulator wants. Notable details: - GameServer.slug names the game, provider.key names the emulator. They agree for eqemu and daoc but wow != trinitycore, so the mapping is an explicit three-entry constant, not an assumption. - Panel renders NOTHING when a server has no account backend (Impostor is a lobby with no identity), rather than a dead button. - 'backend unreachable' is kept distinct from 'you have no account' - the latter would invite a duplicate during an outage. - Provision creates in the game first, then links. The reverse would leave a link pointing at a nonexistent account, which reads as corruption to every later call. - Authorization is just an authenticated Drop session with the ACL: Drop access and game access already imply each other, so a third gate could only disagree with them. Typecheck clean.
2026-08-12 17:04:33 -04:00
</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("cancel") }}
feat(community): SSO to native game account bridge, phases 2 and 3 Adds the player-facing half of game account management: a panel on /community/servers/[id] that shows your linked account for that server, creates one, and changes its password. None of these emulators speak OIDC and none will - each ships its own login against its own auth database. So this is a bridge, not SSO: one Drop identity tied to one native account per server, enforced by a unique index on (userId, serverSlug). That constraint IS the feature. Drop owns identity, authorization, the link table and the UI. The per-game DB access and password crypto stay in mmo-portal's provider API (SRP6 verifiers, EQEmu hashing) - code that writes into live auth DBs and fails silently when subtly wrong. The password never touches Drop's database; it goes straight through to the provider, which turns it into whatever that emulator wants. Notable details: - GameServer.slug names the game, provider.key names the emulator. They agree for eqemu and daoc but wow != trinitycore, so the mapping is an explicit three-entry constant, not an assumption. - Panel renders NOTHING when a server has no account backend (Impostor is a lobby with no identity), rather than a dead button. - 'backend unreachable' is kept distinct from 'you have no account' - the latter would invite a duplicate during an outage. - Provision creates in the game first, then links. The reverse would leave a link pointing at a nonexistent account, which reads as corruption to every later call. - Authorization is just an authenticated Drop session with the ACL: Drop access and game access already imply each other, so a third gate could only disagree with them. Typecheck clean.
2026-08-12 17:04:33 -04:00
</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{ serverId: string }>();
interface Panel {
supported: boolean;
available: boolean;
linked: boolean;
account: {
account_id: string;
username: string;
created_at?: string | null;
last_login?: string | null;
locked: boolean;
banned: boolean;
} | null;
characters: Record<string, unknown>[] | null;
}
const mode = ref<"provision" | "password" | null>(null);
const username = ref("");
const password = ref("");
const error = ref("");
const notice = ref("");
const busy = ref(false);
const { data: panel, refresh } = await useFetch<Panel>(
() => `/api/v1/community/servers/${props.serverId}/account`,
);
function reset() {
mode.value = null;
username.value = "";
password.value = "";
error.value = "";
notice.value = "";
}
async function submit() {
error.value = "";
notice.value = "";
busy.value = true;
try {
await $fetch(`/api/v1/community/servers/${props.serverId}/account`, {
method: "POST",
body: {
action: mode.value,
username: username.value,
password: password.value,
},
});
notice.value = "";
reset();
await refresh();
} catch (e) {
// statusMessage carries the provider's own wording for a rejected name or
// password (422) -- surfaced verbatim, because the emulator is the
// authority on its own rules.
const err = e as { statusMessage?: string; data?: { statusMessage?: string } };
error.value =
err?.data?.statusMessage ??
err?.statusMessage ??
"Something went wrong.";
} finally {
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;
}
}
feat(community): SSO to native game account bridge, phases 2 and 3 Adds the player-facing half of game account management: a panel on /community/servers/[id] that shows your linked account for that server, creates one, and changes its password. None of these emulators speak OIDC and none will - each ships its own login against its own auth database. So this is a bridge, not SSO: one Drop identity tied to one native account per server, enforced by a unique index on (userId, serverSlug). That constraint IS the feature. Drop owns identity, authorization, the link table and the UI. The per-game DB access and password crypto stay in mmo-portal's provider API (SRP6 verifiers, EQEmu hashing) - code that writes into live auth DBs and fails silently when subtly wrong. The password never touches Drop's database; it goes straight through to the provider, which turns it into whatever that emulator wants. Notable details: - GameServer.slug names the game, provider.key names the emulator. They agree for eqemu and daoc but wow != trinitycore, so the mapping is an explicit three-entry constant, not an assumption. - Panel renders NOTHING when a server has no account backend (Impostor is a lobby with no identity), rather than a dead button. - 'backend unreachable' is kept distinct from 'you have no account' - the latter would invite a duplicate during an outage. - Provision creates in the game first, then links. The reverse would leave a link pointing at a nonexistent account, which reads as corruption to every later call. - Authorization is just an authenticated Drop session with the ACL: Drop access and game access already imply each other, so a third gate could only disagree with them. Typecheck clean.
2026-08-12 17:04:33 -04:00
</script>