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.
229 lines
7.5 KiB
Vue
229 lines
7.5 KiB
Vue
<!--
|
|
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">
|
|
· {{ $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">· {{ 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>
|
|
<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>
|
|
|
|
<!-- 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.loading") : $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") }}
|
|
</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;
|
|
}
|
|
}
|
|
</script>
|