Compare commits
3 commits
5516453b7f
...
62708af762
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62708af762 | ||
|
|
5278bce000 | ||
|
|
927f9aae21 |
13 changed files with 444 additions and 106 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@
|
|||
class="relative group/iconupload rounded-xl overflow-hidden w-20 mx-auto"
|
||||
>
|
||||
<img v-if="objectId" :src="useObject(objectId)" :alt="imageAlt" />
|
||||
<div
|
||||
v-else-if="fallbackName"
|
||||
class="w-20 h-20 rounded-xl bg-zinc-800 flex items-center justify-center text-zinc-500 font-bold font-display text-2xl"
|
||||
>
|
||||
{{ fallbackName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<ArrowUpTrayIcon v-else />
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -18,10 +24,11 @@
|
|||
<script setup lang="ts">
|
||||
import { ArrowUpTrayIcon } from "@heroicons/vue/24/solid";
|
||||
|
||||
const { objectId, openModal, hoverText, imageAlt } = defineProps<{
|
||||
const { objectId, openModal, hoverText, imageAlt, fallbackName } = defineProps<{
|
||||
objectId: string | null;
|
||||
openModal: () => void;
|
||||
hoverText: string;
|
||||
imageAlt: string;
|
||||
fallbackName?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -3,9 +3,14 @@
|
|||
<MenuButton>
|
||||
<UserHeaderWidget>
|
||||
<div class="inline-flex items-center text-zinc-300 hover:text-white">
|
||||
<img
|
||||
:src="useObject(user.profilePictureObjectId)"
|
||||
class="w-5 h-5 rounded-sm"
|
||||
<UserAvatar
|
||||
:avatar-url="
|
||||
user.profilePictureObjectId
|
||||
? useObject(user.profilePictureObjectId)
|
||||
: null
|
||||
"
|
||||
:name="user.displayName"
|
||||
:size-px="20"
|
||||
/>
|
||||
<span class="ml-2 text-sm font-bold">{{ user.displayName }}</span>
|
||||
<ChevronDownIcon class="ml-3 h-4" />
|
||||
|
|
@ -30,9 +35,14 @@
|
|||
class="transition inline-flex items-center w-full py-3 px-4 hover:bg-zinc-800"
|
||||
>
|
||||
<div class="inline-flex items-center text-zinc-300">
|
||||
<img
|
||||
:src="useObject(user.profilePictureObjectId)"
|
||||
class="w-5 h-5 rounded-sm"
|
||||
<UserAvatar
|
||||
:avatar-url="
|
||||
user.profilePictureObjectId
|
||||
? useObject(user.profilePictureObjectId)
|
||||
: null
|
||||
"
|
||||
:name="user.displayName"
|
||||
:size-px="20"
|
||||
/>
|
||||
<span class="ml-2 text-sm font-bold">{{ user.displayName }}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,89 +1,77 @@
|
|||
<template>
|
||||
<!-- go away eslint -->
|
||||
<div />
|
||||
<!-- I don't want to localize this -->
|
||||
<!--
|
||||
<div>
|
||||
<div v-if="user" class="mx-auto max-w-2xl lg:mx-0">
|
||||
<h2
|
||||
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
|
||||
>
|
||||
Hello, {{ user.displayName }}!
|
||||
</h2>
|
||||
<p
|
||||
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
|
||||
>
|
||||
Welcome to your Drop account. Here you can view and manage your account
|
||||
information.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mx-auto max-w-3xl px-4 py-10">
|
||||
<h1 class="text-2xl font-bold font-display text-zinc-100">
|
||||
{{ $t("account.title") }}
|
||||
</h1>
|
||||
|
||||
<div v-if="user" class="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div
|
||||
class="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm transition-all duration-200 hover:shadow-lg hover:shadow-zinc-900/50"
|
||||
>
|
||||
<div class="p-6">
|
||||
<h3 class="text-base font-semibold text-zinc-100">
|
||||
Account Information
|
||||
</h3>
|
||||
<dl class="mt-4 space-y-4">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm font-medium text-zinc-400">Username</dt>
|
||||
<dd class="text-sm text-zinc-100">{{ user.username }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm font-medium text-zinc-400">Email</dt>
|
||||
<dd class="text-sm text-zinc-100">{{ user.email }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm font-medium text-zinc-400">Account Type</dt>
|
||||
<dd>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset',
|
||||
user.admin
|
||||
? 'bg-blue-400/10 text-blue-400 ring-blue-400/20'
|
||||
: 'bg-zinc-400/10 text-zinc-400 ring-zinc-400/20',
|
||||
]"
|
||||
>
|
||||
{{ user.admin ? "Administrator" : "Standard User" }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div
|
||||
v-if="user"
|
||||
class="mt-6 rounded-xl border border-zinc-800 bg-zinc-900 p-6 space-y-6"
|
||||
>
|
||||
<div class="flex items-center gap-x-4">
|
||||
<ImageUpload
|
||||
:object-id="avatarObjectId || null"
|
||||
:open-modal="openAvatarModal"
|
||||
:hover-text="$t('upload')"
|
||||
:image-alt="user.displayName"
|
||||
:fallback-name="user.displayName"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<span class="text-zinc-100 font-semibold">{{ user.displayName }}</span>
|
||||
<span class="text-zinc-400 text-sm">@{{ user.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm transition-all duration-200 hover:shadow-lg hover:shadow-zinc-900/50"
|
||||
>
|
||||
<div class="p-6">
|
||||
<h3 class="text-base font-semibold text-zinc-100">Account Actions</h3>
|
||||
<div class="mt-4 space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full inline-flex items-center justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-[1.02] hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600"
|
||||
>
|
||||
Change Password
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full inline-flex items-center justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-[1.02] hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600"
|
||||
>
|
||||
Update Email
|
||||
</button>
|
||||
</div>
|
||||
<dl class="space-y-3">
|
||||
<div class="flex justify-between gap-x-6">
|
||||
<dt class="text-zinc-400 text-sm">{{ $t("auth.displayName") }}</dt>
|
||||
<dd class="text-zinc-100 text-sm text-right">{{ user.displayName }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-x-6">
|
||||
<dt class="text-zinc-400 text-sm">{{ $t("auth.username") }}</dt>
|
||||
<dd class="text-zinc-100 text-sm text-right">{{ user.username }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-x-6">
|
||||
<dt class="text-zinc-400 text-sm">{{ $t("auth.email") }}</dt>
|
||||
<dd class="text-zinc-100 text-sm text-right">{{ user.email }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<NuxtLink
|
||||
:to="`/community/profile/${user.username}`"
|
||||
class="inline-flex text-sm font-semibold text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{{ $t("userHeader.profile.communityProfile") }}
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/account/settings"
|
||||
class="inline-flex text-sm font-semibold text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{{ $t("account.home.manageGameAccounts") }}
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/community/servers"
|
||||
class="inline-flex text-sm font-semibold text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{{ $t("account.home.browseServers") }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center min-h-[200px]">
|
||||
<div class="text-zinc-400">Loading account information...</div>
|
||||
</div>
|
||||
|
||||
<ModalUploadFile
|
||||
v-model="uploadAvatarOpen"
|
||||
endpoint="/api/v1/user/avatar"
|
||||
accept="image/*"
|
||||
@upload="updateAvatar"
|
||||
/>
|
||||
</div>
|
||||
-->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserModel } from "~/prisma/client/models";
|
||||
|
||||
definePageMeta({
|
||||
layout: "default",
|
||||
});
|
||||
|
|
@ -91,4 +79,33 @@ definePageMeta({
|
|||
useHead({
|
||||
title: "Account",
|
||||
});
|
||||
|
||||
const user = useUser();
|
||||
|
||||
const uploadAvatarOpen = ref(false);
|
||||
const avatarObjectId = ref(user.value?.profilePictureObjectId || "");
|
||||
|
||||
watch(
|
||||
() => user.value?.profilePictureObjectId,
|
||||
(value) => {
|
||||
avatarObjectId.value = value || "";
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const openAvatarModal = () => {
|
||||
uploadAvatarOpen.value = true;
|
||||
};
|
||||
|
||||
async function updateAvatar(response: { id: string }) {
|
||||
avatarObjectId.value = response.id;
|
||||
if (user.value) {
|
||||
user.value = {
|
||||
...(user.value as UserModel),
|
||||
profilePictureObjectId: response.id,
|
||||
};
|
||||
}
|
||||
|
||||
await updateUser().catch(() => {});
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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 }} · {{ 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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
<template>
|
||||
<div class="max-w-6xl mx-auto px-4 py-10">
|
||||
<div class="flex items-center gap-x-6">
|
||||
<img
|
||||
v-if="profile?.profilePictureObjectId"
|
||||
:src="useObject(profile.profilePictureObjectId)"
|
||||
class="w-24 h-24 rounded-md object-cover"
|
||||
<UserAvatar
|
||||
:avatar-url="
|
||||
profile?.profilePictureObjectId
|
||||
? useObject(profile.profilePictureObjectId)
|
||||
: null
|
||||
"
|
||||
:name="profile?.displayName ?? profile?.username ?? $t('user.unknown')"
|
||||
:size-px="96"
|
||||
/>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold font-display text-zinc-100">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
54
server/server/api/v1/user/avatar.post.ts
Normal file
54
server/server/api/v1/user/avatar.post.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
import objectHandler from "~/server/internal/objects";
|
||||
import { handleFileUpload } from "~/server/internal/utils/handlefileupload";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const userId = await aclManager.getUserIdACL(h3, ["object:update"]);
|
||||
if (!userId) throw createError({ statusCode: 403 });
|
||||
|
||||
const result = await handleFileUpload(h3, {}, ["anonymous:read"], 1);
|
||||
if (!result) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "File upload required (multipart form)",
|
||||
});
|
||||
}
|
||||
|
||||
const [ids, , pull, dump] = result;
|
||||
const id = ids.at(0);
|
||||
if (!id) {
|
||||
dump();
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Upload at least one file.",
|
||||
});
|
||||
}
|
||||
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { profilePictureObjectId: true },
|
||||
});
|
||||
if (!current) {
|
||||
dump();
|
||||
throw createError({ statusCode: 404, statusMessage: "User not found." });
|
||||
}
|
||||
|
||||
const { count } = await prisma.user.updateMany({
|
||||
where: { id: userId },
|
||||
data: { profilePictureObjectId: id },
|
||||
});
|
||||
if (count === 0) {
|
||||
dump();
|
||||
throw createError({ statusCode: 404, statusMessage: "User not found." });
|
||||
}
|
||||
|
||||
await pull();
|
||||
|
||||
const previous = current.profilePictureObjectId?.trim();
|
||||
if (previous && previous !== id) {
|
||||
await objectHandler.deleteAsSystem(previous).catch(() => {});
|
||||
}
|
||||
|
||||
return { id };
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue