mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-13 08:12:40 +10:00
Compare commits
11 Commits
1903bb1a5f
...
fix-types
| Author | SHA1 | Date | |
|---|---|---|---|
| d0475d3ebd | |||
| 7c234067a5 | |||
| 824b4e708b | |||
| 94e795787e | |||
| a0b4381f0b | |||
| 80f7757558 | |||
| 90b02b7f8e | |||
| 29fdfcbdd4 | |||
| e3feaeb970 | |||
| ef7a62bf0b | |||
| 7af29ef0eb |
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@ -21,20 +21,17 @@ jobs:
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "pnpm"
|
||||
cache: "yarn"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
run: yarn install --immutable --network-timeout 1000000
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm run typecheck
|
||||
run: yarn typecheck
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
@ -45,17 +42,14 @@ jobs:
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "pnpm"
|
||||
cache: "yarn"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
run: yarn install --immutable --network-timeout 1000000
|
||||
|
||||
- name: Lint
|
||||
run: pnpm run lint
|
||||
run: yarn lint
|
||||
|
||||
@ -1,3 +1 @@
|
||||
drop-base/
|
||||
# file is fully managed by pnpm, no reason to break it
|
||||
pnpm-lock.yaml
|
||||
drop-base/
|
||||
41
Dockerfile
41
Dockerfile
@ -1,45 +1,40 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:lts-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
# so corepack knows pnpm's version
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
# prevent prompt to download
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
# setup for offline
|
||||
RUN corepack pack
|
||||
# don't call out to network anymore
|
||||
ENV COREPACK_ENABLE_NETWORK=0
|
||||
|
||||
### Unified deps builder
|
||||
FROM base AS deps
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
# FROM node:lts-alpine AS deps
|
||||
# WORKDIR /app
|
||||
# COPY package.json yarn.lock ./
|
||||
# RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --network-timeout 1000000 --ignore-scripts
|
||||
|
||||
### Build for app
|
||||
FROM base AS build-system
|
||||
FROM node:lts-alpine AS build-system
|
||||
# setup workdir - has to be the same filepath as app because fuckin' Prisma
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NUXT_TELEMETRY_DISABLED=1
|
||||
# ENV YARN_CACHE_FOLDER=/root/.yarn
|
||||
|
||||
# add git so drop can determine its git ref at build
|
||||
RUN apk add --no-cache git
|
||||
# pnpm for build
|
||||
RUN apk add --no-cache git pnpm
|
||||
|
||||
# copy deps and rest of project files
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
# COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ARG BUILD_DROP_VERSION
|
||||
ARG BUILD_GIT_REF
|
||||
|
||||
# build
|
||||
RUN pnpm run postinstall && pnpm run build
|
||||
RUN pnpm import
|
||||
RUN pnpm install --shamefully-hoist
|
||||
RUN pnpm run build
|
||||
# RUN --mount=type=cache,target=/root/.yarn yarn postinstall && yarn build
|
||||
|
||||
### create run environment for Drop
|
||||
FROM base AS run-system
|
||||
FROM node:lts-alpine AS run-system
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NUXT_TELEMETRY_DISABLED=1
|
||||
@ -47,8 +42,6 @@ ENV NUXT_TELEMETRY_DISABLED=1
|
||||
# RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn add --network-timeout 1000000 --no-lockfile --ignore-scripts prisma@6.11.1
|
||||
RUN apk add --no-cache pnpm
|
||||
RUN pnpm install prisma@6.11.1
|
||||
# init prisma to download all required files
|
||||
RUN pnpm prisma init
|
||||
|
||||
COPY --from=build-system /app/package.json ./
|
||||
COPY --from=build-system /app/.output ./app
|
||||
|
||||
@ -45,7 +45,6 @@ import {
|
||||
LockClosedIcon,
|
||||
DevicePhoneMobileIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
CodeBracketIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import { UserIcon } from "@heroicons/vue/24/solid";
|
||||
import type { Component } from "vue";
|
||||
@ -74,12 +73,6 @@ const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
|
||||
icon: BellIcon,
|
||||
count: notifications.value.length,
|
||||
},
|
||||
{
|
||||
label: t("account.token.title"),
|
||||
route: "/account/tokens",
|
||||
prefix: "/account/tokens",
|
||||
icon: CodeBracketIcon,
|
||||
},
|
||||
{
|
||||
label: t("account.settings"),
|
||||
route: "/account/settings",
|
||||
|
||||
@ -10,16 +10,6 @@
|
||||
d="M4 13.5C4 11.0008 5.38798 8.76189 7.00766 7C8.43926 5.44272 10.0519 4.25811 11.0471 3.5959C11.6287 3.20893 12.3713 3.20893 12.9529 3.5959C13.9481 4.25811 15.5607 5.44272 16.9923 7C18.612 8.76189 20 11.0008 20 13.5C20 17.9183 16.4183 21.5 12 21.5C7.58172 21.5 4 17.9183 4 13.5Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="100"
|
||||
:stroke-dashoffset="dashArray"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ progress?: number }>();
|
||||
|
||||
const dashArray = computed(() =>
|
||||
props.progress === undefined ? 0 : ((100 - props.progress) / 100) * 50 + 50,
|
||||
);
|
||||
</script>
|
||||
|
||||
@ -22,17 +22,21 @@
|
||||
<!-- import games button -->
|
||||
|
||||
<NuxtLink
|
||||
:href="canImport ? `/admin/library/${game.id}/import` : ''"
|
||||
:href="
|
||||
unimportedVersions.length > 0
|
||||
? `/admin/library/${game.id}/import`
|
||||
: ''
|
||||
"
|
||||
type="button"
|
||||
:class="[
|
||||
canImport
|
||||
unimportedVersions.length > 0
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
: 'bg-blue-800/50',
|
||||
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
|
||||
]"
|
||||
>
|
||||
{{
|
||||
canImport
|
||||
unimportedVersions.length > 0
|
||||
? $t("library.admin.import.version.import")
|
||||
: $t("library.admin.import.version.noVersions")
|
||||
}}
|
||||
@ -120,16 +124,10 @@ import { ExclamationCircleIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
// TODO implement UI for this page
|
||||
|
||||
const props = defineProps<{ unimportedVersions: string[] }>();
|
||||
defineProps<{ unimportedVersions: string[] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const hasDeleted = ref(false);
|
||||
|
||||
const canImport = computed(
|
||||
() => hasDeleted.value || props.unimportedVersions.length > 0,
|
||||
);
|
||||
|
||||
type GameAndVersions = GameModel & { versions: GameVersionModel[] };
|
||||
const game = defineModel<SerializeObject<GameAndVersions>>() as Ref<
|
||||
SerializeObject<GameAndVersions>
|
||||
@ -178,7 +176,6 @@ async function deleteVersion(versionName: string) {
|
||||
game.value.versions.findIndex((e) => e.versionName === versionName),
|
||||
1,
|
||||
);
|
||||
hasDeleted.value = true;
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
|
||||
@ -18,12 +18,8 @@
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
|
||||
<DevOnly>
|
||||
<h1 class="mt-3 text-sm text-gray-500">{{ $t("welcome") }}</h1>
|
||||
<DevOnly
|
||||
><h1 class="mt-3 text-sm text-gray-500">{{ $t("welcome") }}</h1>
|
||||
</DevOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
|
||||
</script>
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
<template>
|
||||
<span class="text-xs font-mono text-zinc-400 inline-flex items-top gap-x-2"
|
||||
><span v-if="!short" class="text-zinc-500">{{ log.timestamp }}</span>
|
||||
<span
|
||||
:class="[
|
||||
colours[log.level] || 'text-green-400',
|
||||
'uppercase font-display font-semibold',
|
||||
]"
|
||||
>{{ log.level }}</span
|
||||
>
|
||||
<pre :class="[short ? 'line-clamp-1' : '', 'mt-[1px]']">{{
|
||||
log.message
|
||||
}}</pre>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskLog } from "~/server/internal/tasks";
|
||||
|
||||
defineProps<{ log: typeof TaskLog.infer; short?: boolean }>();
|
||||
|
||||
const colours: { [key: string]: string } = {
|
||||
info: "text-blue-400",
|
||||
warn: "text-yellow-400",
|
||||
error: "text-red-400",
|
||||
};
|
||||
</script>
|
||||
@ -1,267 +0,0 @@
|
||||
<template>
|
||||
<ModalTemplate v-model="model" size-class="max-w-3xl">
|
||||
<template #default>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<label
|
||||
for="name"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>{{ $t("account.token.name") }}</label
|
||||
>
|
||||
<p class="text-zinc-400 block text-xs font-medium leading-6">
|
||||
{{ $t("account.token.nameDesc") }}
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
id="name"
|
||||
v-model="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autocomplete="name"
|
||||
:placeholder="
|
||||
props.suggestedName ?? $t('account.token.namePlaceholder')
|
||||
"
|
||||
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Listbox v-model="expiryKey" as="div">
|
||||
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100">{{
|
||||
$t("users.admin.simple.inviteExpiryLabel")
|
||||
}}</ListboxLabel>
|
||||
<div class="relative mt-2">
|
||||
<ListboxButton
|
||||
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
|
||||
>
|
||||
<span class="block truncate">{{ expiryKey }}</span>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
>
|
||||
<ChevronUpDownIcon
|
||||
class="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
|
||||
<transition
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="[label] in Object.entries(expiry)"
|
||||
:key="label"
|
||||
v-slot="{ active, selected }"
|
||||
as="template"
|
||||
:value="label"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active ? 'bg-blue-600 text-white' : 'text-zinc-300',
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
selected
|
||||
? 'font-semibold text-zinc-100'
|
||||
: 'font-normal',
|
||||
'block truncate',
|
||||
]"
|
||||
>{{ label }}</span
|
||||
>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="name"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>{{ $t("account.token.acls") }}</label
|
||||
>
|
||||
<p class="text-zinc-400 block text-xs font-medium leading-6">
|
||||
{{ $t("account.token.aclsDesc") }}
|
||||
</p>
|
||||
<fieldset class="divide-y divide-zinc-700">
|
||||
<div
|
||||
v-for="[sectionName, sectionAcls] in Object.entries(
|
||||
aclsBySection,
|
||||
)"
|
||||
:key="sectionName"
|
||||
class="grid lg:grid-cols-3 gap-1 py-3"
|
||||
>
|
||||
<div
|
||||
v-for="[acl, description] in Object.entries(sectionAcls)"
|
||||
:key="acl"
|
||||
class="flex gap-3"
|
||||
>
|
||||
<div class="flex h-6 shrink-0 items-center">
|
||||
<div class="group grid size-4 grid-cols-1">
|
||||
<input
|
||||
id="acl"
|
||||
v-model="currentACLs[acl]"
|
||||
aria-describedby="acl-description"
|
||||
name="acl"
|
||||
type="checkbox"
|
||||
class="col-start-1 row-start-1 appearance-none rounded-sm border checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 border-white/10 bg-white/5 dark:checked:border-blue-500 dark:checked:bg-blue-500 dark:indeterminate:border-blue-500 dark:indeterminate:bg-blue-500 dark:focus-visible:outline-blue-500 dark:disabled:border-white/5 dark:disabled:bg-white/10 dark:disabled:checked:bg-white/10 forced-colors:appearance-auto"
|
||||
/>
|
||||
<svg
|
||||
class="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-disabled:stroke-white/25"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
class="opacity-0 group-has-checked:opacity-100"
|
||||
d="M3 8L6 11L11 3.5"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
class="opacity-0 group-has-indeterminate:opacity-100"
|
||||
d="M3 7H11"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm/6">
|
||||
<label
|
||||
for="acl"
|
||||
class="font-display font-medium text-white"
|
||||
>{{ acl }}</label
|
||||
>
|
||||
{{ " " }}
|
||||
<span id="acl-description" class="text-xs text-zinc-400"
|
||||
><span class="sr-only">{{ acl }} </span
|
||||
>{{ description }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #buttons>
|
||||
<LoadingButton :loading="props.loading" @click="() => createToken()">
|
||||
{{ $t("common.create") }}
|
||||
</LoadingButton>
|
||||
<button
|
||||
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
||||
@click="() => cancel()"
|
||||
>
|
||||
{{ $t("cancel") }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/24/outline";
|
||||
import type { DurationLike } from "luxon";
|
||||
|
||||
// Reuse for both admin and user tokens
|
||||
|
||||
const model = defineModel<boolean>({ required: true });
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
acls: { [key: string]: string };
|
||||
loading?: boolean;
|
||||
suggestedAcls?: string[];
|
||||
suggestedName?: string;
|
||||
}>();
|
||||
|
||||
// Label to parameters to moment.js .add()
|
||||
const expiry: Record<string, DurationLike | undefined> = {
|
||||
[t("account.token.expiryMonth")]: {
|
||||
month: 1,
|
||||
},
|
||||
[t("account.token.expiry3Month")]: {
|
||||
month: 3,
|
||||
},
|
||||
[t("account.token.expiry6Month")]: {
|
||||
month: 6,
|
||||
},
|
||||
[t("account.token.expiryYear")]: {
|
||||
year: 1,
|
||||
},
|
||||
[t("account.token.expiry5Year")]: {
|
||||
year: 5,
|
||||
},
|
||||
[t("account.token.noExpiry")]: undefined,
|
||||
};
|
||||
const expiryKey = ref<keyof typeof expiry>(Object.keys(expiry)[0]); // Cast to any because we just know it's okay
|
||||
const name = ref(props.suggestedName ?? "");
|
||||
const currentACLs = ref<{ [key: string]: boolean }>(
|
||||
Object.fromEntries((props.suggestedAcls ?? []).map((v) => [v, true])),
|
||||
);
|
||||
|
||||
const aclsBySection = computed(() => {
|
||||
const sections: { [key: string]: { [key: string]: string } } = {};
|
||||
for (const [acl, description] of Object.entries(props.acls)) {
|
||||
const section = acl.split(":")[0];
|
||||
sections[section] ??= {};
|
||||
sections[section][acl] = description;
|
||||
}
|
||||
return sections;
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [name: string, acls: string[], expiry: DurationLike | undefined];
|
||||
}>();
|
||||
|
||||
function createToken() {
|
||||
emit(
|
||||
"create",
|
||||
name.value,
|
||||
Object.entries(currentACLs.value)
|
||||
.filter(([_acl, enabled]) => enabled)
|
||||
.map(([acl, _enabled]) => acl),
|
||||
expiry[expiryKey.value],
|
||||
);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
model.value = false;
|
||||
}
|
||||
|
||||
watch(model, (c) => {
|
||||
if (!c) {
|
||||
name.value = "";
|
||||
currentACLs.value = {};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@ -1,55 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="task"
|
||||
class="flex w-full items-center justify-between space-x-6 p-6"
|
||||
>
|
||||
<div class="flex-1 truncate">
|
||||
<div class="flex items-center space-x-1">
|
||||
<div>
|
||||
<CheckCircleIcon v-if="task.success" class="size-5 text-green-600" />
|
||||
<XMarkIcon v-else-if="task.error" class="size-5 text-red-600" />
|
||||
<div
|
||||
v-else
|
||||
class="size-2 bg-blue-600 rounded-full animate-pulse m-1"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="truncate text-sm font-medium text-zinc-100">
|
||||
{{ task.name }}
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
v-if="active"
|
||||
class="mt-2 w-full rounded-full overflow-hidden bg-zinc-900"
|
||||
>
|
||||
<div
|
||||
:style="{ width: `${task.progress}%` }"
|
||||
class="bg-blue-600 h-[3px] transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 bg-zinc-950 px-2 pb-1 rounded-sm">
|
||||
<LogLine :short="true" :log="parseTaskLog(task.log.at(-1))" />
|
||||
</div>
|
||||
<NuxtLink
|
||||
type="button"
|
||||
:href="`/admin/task/${task.id}`"
|
||||
class="mt-3 ml-1 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
|
||||
>
|
||||
<i18n-t keypath="tasks.admin.viewTask" tag="span" scope="global">
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- renders server side when we don't want to access the current tasks -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, XMarkIcon } from "@heroicons/vue/24/solid";
|
||||
import type { TaskMessage } from "~/server/internal/tasks";
|
||||
|
||||
defineProps<{ task: TaskMessage | undefined; active?: boolean }>();
|
||||
</script>
|
||||
@ -46,28 +46,10 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => {
|
||||
});
|
||||
const request = requestParts.join("/");
|
||||
|
||||
// If not in setup
|
||||
if (!getCurrentInstance()?.proxy) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Excessive stack depth comparing types
|
||||
return await $fetch(request, opts);
|
||||
} catch (e) {
|
||||
if (import.meta.client && opts?.failTitle) {
|
||||
console.warn(e);
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: opts.failTitle,
|
||||
description:
|
||||
(e as FetchError)?.statusMessage ?? (e as string).toString(),
|
||||
//buttonText: $t("common.close"),
|
||||
},
|
||||
(_, c) => c(),
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Excessive stack depth comparing types
|
||||
return await $fetch(request, opts);
|
||||
}
|
||||
|
||||
const id = request.toString();
|
||||
@ -82,10 +64,26 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => {
|
||||
}
|
||||
|
||||
const headers = useRequestHeaders(["cookie", "authorization"]);
|
||||
const data = await $fetch(request, {
|
||||
...opts,
|
||||
headers: { ...headers, ...opts?.headers },
|
||||
});
|
||||
if (import.meta.server) state.value = data;
|
||||
return data;
|
||||
try {
|
||||
const data = await $fetch(request, {
|
||||
...opts,
|
||||
headers: { ...headers, ...opts?.headers },
|
||||
});
|
||||
if (import.meta.server) state.value = data;
|
||||
return data;
|
||||
} catch (e) {
|
||||
if (import.meta.client && opts?.failTitle) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: opts.failTitle,
|
||||
description:
|
||||
(e as FetchError)?.statusMessage ?? (e as string).toString(),
|
||||
buttonText: $t("common.close"),
|
||||
},
|
||||
(_, c) => c(),
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
Submodule drop-base updated: 06bea06363...4c42edf5ad
@ -1,18 +1,17 @@
|
||||
{
|
||||
"account": {
|
||||
"devices": {
|
||||
"capabilities": "Funktionen",
|
||||
"capabilities": "Möglichkeiten",
|
||||
"lastConnected": "Zuletzt verbunden",
|
||||
"noDevices": "Keine Geräte sind mit deinem Konto verbunden.",
|
||||
"platform": "Plattform",
|
||||
"revoke": "Wiederrufen",
|
||||
"subheader": "Geräte verwalten, die auf Ihr Drop Konto zugreifen dürfen.",
|
||||
"title": "Geräte"
|
||||
},
|
||||
"notifications": {
|
||||
"all": "Alles anzeigen {arrow}",
|
||||
"desc": "Anzeigen und Verwalten deiner Benachrichtigung.",
|
||||
"markAllAsRead": "Markiere alle als gelesen",
|
||||
"all": "Alle anzeigen {arrow}",
|
||||
"desc": "Benachrichtigungen anzeigen und verwalten.",
|
||||
"markAllAsRead": "Alles als gelesen markieren",
|
||||
"markAsRead": "Als gelesen Markieren",
|
||||
"none": "Keine Benachrichtigungen",
|
||||
"notifications": "Benachrichtigungen",
|
||||
@ -20,37 +19,17 @@
|
||||
"unread": "Ungelesene Benachrichtigungen"
|
||||
},
|
||||
"settings": "Einstellungen",
|
||||
"title": "Kontoeinstellungen",
|
||||
"token": {
|
||||
"acls": "Berechtigungen (ACLs/Scopes)",
|
||||
"aclsDesc": "Definiert, wozu dieses Schlüssel berechtigt ist. Du solltest vermeiden, alle ACLs auszuwählen, wenn dies nicht notwendig ist.",
|
||||
"expiry": "Ablaufdatum",
|
||||
"expiry3Month": "3 Monate",
|
||||
"expiry5Year": "5 Jahre",
|
||||
"expiry6Month": "6 Monate",
|
||||
"expiryMonth": "Ein Monat",
|
||||
"expiryYear": "Ein Jahr",
|
||||
"name": "API-Schlüssel Name",
|
||||
"nameDesc": "Der Name des Schlüssels, als Referenz.",
|
||||
"namePlaceholder": "Mein neuer Schlüssel",
|
||||
"noExpiry": "Unbegrenzt gültig",
|
||||
"noTokens": "Keine Schlüssel mit deinem Konto verbunden.",
|
||||
"revoke": "Wiederrufen",
|
||||
"subheader": "Verwalte deine API-Schlüssel und deren Zugriffsrechte.",
|
||||
"success": "Schlüssel erfolgreich erstellt.",
|
||||
"successNote": "Bitte jetzt kopieren, da es nicht noch einmal angezeigt wird.",
|
||||
"title": "API-Schlüssel"
|
||||
}
|
||||
"title": "Kontoeinstellungen"
|
||||
},
|
||||
"actions": "Aktionen",
|
||||
"add": "Hinzufügen",
|
||||
"adminTitle": "Admin Dashboard - Drop",
|
||||
"adminTitleTemplate": "{0} - Admin - Drop",
|
||||
"adminTitle": "Administrator Dashbord - Drop",
|
||||
"adminTitleTemplate": "{0} - Administrator - Drop",
|
||||
"auth": {
|
||||
"callback": {
|
||||
"authClient": "Client autorisieren?",
|
||||
"authorize": "Autorisieren",
|
||||
"authorizedClient": "Drop hat den Client erfolgreich autorisiert. Du kannst dieses Fenster nun schließen.",
|
||||
"authorizedClient": "Drop hat den Client erfolgreich autorisiert. Du darfst dieses Fenster nun schließen.",
|
||||
"issues": "Probleme?",
|
||||
"learn": "Mehr erfahren {arrow}",
|
||||
"paste": "Füge diesen Code in den Client ein, um fortzufahren:",
|
||||
@ -59,10 +38,9 @@
|
||||
"success": "Erfolgreich!"
|
||||
},
|
||||
"code": {
|
||||
"description": "Verwende einen Code, um dein Drop Client zu verbinden, wenn dein Gerät kein Webbrowser öffnen kann.",
|
||||
"title": "Verbinde deinen Drop Client"
|
||||
"description": "Verwende ein Code, um dein Drop Client zu verbinden, wenn dein Gerät kein Webbrowser öffnen kann.",
|
||||
"title": "Verbinde dein Drop Client"
|
||||
},
|
||||
"confirmPassword": "Bestätige @:auth.password",
|
||||
"displayName": "Anzeigename",
|
||||
"email": "E-Mail",
|
||||
"password": "Passwort",
|
||||
@ -77,10 +55,10 @@
|
||||
"signin": {
|
||||
"externalProvider": "Bei externem Anbieter anmelden {arrow}",
|
||||
"forgot": "Passwort vergessen?",
|
||||
"noAccount": "Noch kein Konto? Bitte den Admin, eines für dich zu erstellen.",
|
||||
"noAccount": "Noch kein Konto? Bitten den Administrator, eines für dich zu erstellt.",
|
||||
"or": "ODER",
|
||||
"pageTitle": "Bei Drop anmelden",
|
||||
"rememberMe": "Erinnere mich",
|
||||
"rememberMe": "An mich erinnern",
|
||||
"signin": "Anmelden",
|
||||
"title": "Melde dich bei deinem Konto an"
|
||||
},
|
||||
@ -146,9 +124,9 @@
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"disabled": "Ungültiges oder deaktiviertes Konto. Bitte kontaktiere einen Server Admin.",
|
||||
"disabled": "Ungültiges oder deaktiviertes Konto. Bitte kontaktiere einen Server Administrator.",
|
||||
"invalidInvite": "Ungültige oder abgelaufene Einladung",
|
||||
"invalidPassState": "Ungültiger Passwortzustand. Bitte kontaktiere einen Server Admin.",
|
||||
"invalidPassState": "Ungültiger Passwortzustand. Bitte kontaktiere einen Server Administrator.",
|
||||
"invalidUserOrPass": "Ungültiger Nutzername oder Passwort.",
|
||||
"inviteIdRequired": "id erforderlich beim Abrufen der Einladung",
|
||||
"method": {
|
||||
@ -157,10 +135,6 @@
|
||||
"usernameTaken": "Nutzername bereits vergeben."
|
||||
},
|
||||
"backHome": "{arrow} Zurück zur Startseite",
|
||||
"externalUrl": {
|
||||
"subtitle": "Diese Nachricht ist nur sichtbar für Admins.",
|
||||
"title": "Zugriff über eine andere EXTERNAL_URL. Bitte die Dokumentation prüfen."
|
||||
},
|
||||
"game": {
|
||||
"banner": {
|
||||
"description": "Das Aktualisieren des Banners ist fehlgeschlagen: {0}",
|
||||
@ -218,8 +192,6 @@
|
||||
"occurred": "Bei der Bearbeitung deiner Anfrage ist ein Fehler aufgetreten. Wenn du glaubst, dass es sich um einen Bug handelt, melde diesen bitte. Versuche dich anzumelden, um zu sehen, ob dadurch das Problem behoben wird.",
|
||||
"ohNo": "Oh nein!",
|
||||
"pageTitle": "{0} | Drop",
|
||||
"revokeClient": "Client konnte nicht widerrufen werden",
|
||||
"revokeClientFull": "Client konnte nicht widerrufen werden {0}",
|
||||
"signIn": "Anmelden {arrow}",
|
||||
"support": "Support Discord",
|
||||
"unknown": "Ein unbekannter Fehler ist aufgetreten",
|
||||
@ -259,13 +231,8 @@
|
||||
},
|
||||
"header": {
|
||||
"admin": {
|
||||
"admin": "Admin",
|
||||
"metadata": "Metadaten",
|
||||
"settings": {
|
||||
"store": "Store",
|
||||
"title": "Einstellungen",
|
||||
"tokens": "API-Schlüssel"
|
||||
},
|
||||
"admin": "Administrator",
|
||||
"settings": "Einstellungen",
|
||||
"tasks": "Aufgaben",
|
||||
"users": "Benutzer"
|
||||
},
|
||||
@ -273,7 +240,6 @@
|
||||
"openSidebar": "Öffne Seitenleiste"
|
||||
},
|
||||
"helpUsTranslate": "Hilf uns Drop zu übersetzen {arrow}",
|
||||
"highest": "Höchste",
|
||||
"home": "Startseite",
|
||||
"library": {
|
||||
"addGames": "Alle Spiele",
|
||||
@ -283,117 +249,50 @@
|
||||
"detectedVersion": "Drop hat erkannt, dass du eine neue Version dieses Spiels importieren kannst.",
|
||||
"game": {
|
||||
"addCarouselNoImages": "Keine Bilder zum hinzufügen.",
|
||||
"addDescriptionNoImages": "Keine Bilder zum hinzufügen.",
|
||||
"addImageCarousel": "Aus der Bilderbibliothek hinzufügen",
|
||||
"currentBanner": "Banner",
|
||||
"currentCover": "Cover",
|
||||
"deleteImage": "Bild löschen",
|
||||
"editGameDescription": "Spielbeschreibung",
|
||||
"editGameName": "Spielname",
|
||||
"imageCarousel": "Bilderkarussell",
|
||||
"imageCarouselDescription": "Anpassen, welche Bilder und in welcher Reihenfolge sie auf der Shop-Seite angezeigt werden.",
|
||||
"imageCarouselEmpty": "Es wurden noch keine Bilder zum Karussell hinzugefügt.",
|
||||
"imageLibrary": "Bilderbibliothek",
|
||||
"imageLibraryDescription": "Bitte beachten: Alle hochgeladenen Bilder sind für alle Nutzer über die Browser-Entwicklertools zugänglich.",
|
||||
"removeImageCarousel": "Bild entfernen",
|
||||
"setBanner": "Als Banner festlegen",
|
||||
"setCover": "Als Cover festlegen"
|
||||
"addDescriptionNoImages": "Keine Bilder zum hinzufügen."
|
||||
},
|
||||
"gameLibrary": "Spielebibliothek",
|
||||
"import": {
|
||||
"bulkImportDescription": "Auf dieser Seite wirst du nicht zur Importaufgabe weitergeleitet, sodass du mehrere Spiele nacheinander importieren kannst.",
|
||||
"bulkImportTitle": "Massenimport Modus",
|
||||
"import": "Import",
|
||||
"link": "Import {arrow}",
|
||||
"loading": "Spieldaten werden geladen…",
|
||||
"search": "Suche",
|
||||
"searchPlaceholder": "Fallout 4",
|
||||
"selectDir": "Bitte wähle ein Verzeichnis aus…",
|
||||
"selectGame": "Spiel zum Import auswählen",
|
||||
"selectGamePlaceholder": "Bitte wähle ein Spiel aus…",
|
||||
"selectGameSearch": "Spiel auswählen",
|
||||
"selectPlatform": "Bitte wähle eine Plattform aus…",
|
||||
"version": {
|
||||
"advancedOptions": "Erweiterte Optionen",
|
||||
"import": "Version Importieren",
|
||||
"installDir": "(Installationsverzeichnis)/",
|
||||
"launchCmd": "Programm/Befehl starten",
|
||||
"launchDesc": "Ausführbare Datei zum starten des Spiels",
|
||||
"launchPlaceholder": "spiel.exe",
|
||||
"loadingVersion": "Lade Versionsmetadaten…",
|
||||
"noAdv": "Keine erweiterten Optionen für diese Konfiguration.",
|
||||
"noVersions": "Keine Version zum importieren",
|
||||
"platform": "Plattformversion",
|
||||
"setupCmd": "Installationsprogramm oder Befehl ausführen",
|
||||
"setupDesc": "Wird einmal ausgeführt, wenn das Spiel installiert wird",
|
||||
"setupMode": "Einrichtungsmodus",
|
||||
"setupModeDesc": "Wenn aktiviert, hat diese Version keinen Startbefehl und führt einfach die ausführbare Datei auf dem Computer des Nutzers aus. Nützlich für Spiele, die nur einen Installer bereitstellen und keine portablen Dateien.",
|
||||
"setupPlaceholder": "setup.exe",
|
||||
"umuLauncherId": "UMU Launcher ID",
|
||||
"umuOverride": "Überschreibe UMU Launcher Spiel ID",
|
||||
"umuOverrideDesc": "Standardmäßig verwendet Drop beim Start über den UMU Launcher eine Nicht-ID. Um die richtigen Patches für manche Spiele zu erhalten, musst du dieses Feld eventuell manuell setzen.",
|
||||
"updateMode": "Aktualisierungsmodus",
|
||||
"updateModeDesc": "Wenn aktiviert, werden diese Dateien über die vorherige Version installiert (überschrieben). Werden mehrere ‚Update-Modi‘ hintereinander verwendet, werden sie in der angegebenen Reihenfolge angewendet.",
|
||||
"version": "Wähle die Version für den Import aus"
|
||||
},
|
||||
"withoutMetadata": "Ohne Metadaten importieren"
|
||||
},
|
||||
"libraryHint": "Keine Bibliotheken konfiguriert.",
|
||||
"libraryHintDocsLink": "Was bedeutet das? {arrow}",
|
||||
"metadata": {
|
||||
"companies": {
|
||||
"action": "Verwalte {arrow}",
|
||||
"addGame": {
|
||||
"description": "Wähle ein Spiel aus, das dem Unternehmen hinzugefügt werden soll, und lege fest, ob es als Entwickler, Publisher oder beides geführt werden soll.",
|
||||
"developer": "Entwickler?",
|
||||
"noGames": "Keine Spiele zum hinzufügen",
|
||||
"publisher": "Publisher?",
|
||||
"title": "Verbinde das Spiel mit diesem Unternehmen"
|
||||
"publisher": "Publisher?"
|
||||
},
|
||||
"description": "Unternehmen organisieren Spiele danach, wer sie entwickelt oder veröffentlicht hat.",
|
||||
"editor": {
|
||||
"action": "Spiel hinzufügen {plus}",
|
||||
"descriptionPlaceholder": "{'<'}Beschreibung{'>'}",
|
||||
"developed": "Entwickelt",
|
||||
"libraryDescription": "Hinzufügen, bearbeiten oder entfernen, was diese Firma entwickelt und/oder veröffentlicht hat.",
|
||||
"libraryTitle": "Spielebibliothek",
|
||||
"noDescription": "(Keine Beschreibung)",
|
||||
"published": "Veröffentlicht",
|
||||
"uploadBanner": "Banner hochladen",
|
||||
"uploadIcon": "Icon hochladen",
|
||||
"websitePlaceholder": "{'<'}Webseite{'>'}"
|
||||
"uploadIcon": "Icon hochladen"
|
||||
},
|
||||
"modals": {
|
||||
"createDescription": "Erstelle ein Unternehmen, um deine Spiele besser zu organisieren.",
|
||||
"createFieldDescription": "Unternehmensbeschreibung",
|
||||
"createFieldDescriptionPlaceholder": "Ein kleines Indie-Studio, das…",
|
||||
"createFieldName": "Unternehmensname",
|
||||
"createFieldNamePlaceholder": "Mein neues Unternehmen…",
|
||||
"createFieldWebsite": "Unternehmenswebseite",
|
||||
"createFieldWebsitePlaceholder": "https://beispiel.de/",
|
||||
"createTitle": "Unternehmen erstellen",
|
||||
"nameDescription": "Bearbeite den Namen des Unternehmens. Wird verwendet, um neue Spielimporte zuzuordnen.",
|
||||
"nameTitle": "Bearbeite Firmenname",
|
||||
"shortDeckDescription": "Bearbeite die Firmenbeschreibung. Beeinträchtigt nicht die Lange (markdown) Beschreibung.",
|
||||
"shortDeckTitle": "Bearbeite Firmenbeschreibung",
|
||||
"websiteDescription": "„Bearbeite die Webseite des Unternehmens. Hinweis: Dies wird ein Link sein und bietet keinen Redirect-Schutz.",
|
||||
"websiteTitle": "Unternehmenswebseite bearbeiten"
|
||||
},
|
||||
"noCompanies": "Keine Unternehmen",
|
||||
"noGames": "Keine Spiele",
|
||||
"search": "Suche Unternehmen…",
|
||||
"searchGames": "Unternehmensspiele durchsuchen…",
|
||||
"title": "Unternehmen"
|
||||
"shortDeckTitle": "Bearbeite Firmenbeschreibung"
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"action": "Verwalte {arrow}",
|
||||
"create": "Erstellen",
|
||||
"description": "Tags werden automatisch aus importierten Genres erstellt. Du kannst eigene Tags hinzufügen, um deine Spielbibliothek zu kategorisieren.",
|
||||
"modal": {
|
||||
"description": "Erstelle einen Tag, um deine Bibliothek zu organisieren.",
|
||||
"title": "Tag erstellen"
|
||||
},
|
||||
"title": "Tags"
|
||||
"create": "Erstellen"
|
||||
}
|
||||
},
|
||||
"metadataProvider": "Metadatenanbieter",
|
||||
@ -402,31 +301,13 @@
|
||||
"offlineTitle": "Spiel offline",
|
||||
"openEditor": "Im Editor öffnen {arrow}",
|
||||
"openStore": "Im Store öffnen",
|
||||
"shortDesc": "Kurzbeschreibung",
|
||||
"sources": {
|
||||
"create": "Quelle erstellen",
|
||||
"createDesc": "Drop wird diese Quelle verwenden, um auf deine Spielbibliothek zuzugreifen und die Spiele verfügbar zu machen.",
|
||||
"desc": "Konfiguriere deine Bibliotheksquellen, wo Drop nach neuen Spielen und Versionen zum Import suchen wird.",
|
||||
"documentationLink": "Dokumentation {arrow}",
|
||||
"edit": "Quelle bearbeiten",
|
||||
"fsDesc": "Importiert Spiele von einem Pfad auf der Festplatte. Benötigt eine versionsbasierte Ordnerstruktur und unterstützt archivierte Spiele.",
|
||||
"fsFlatDesc": "Importiert Spiele von einem Pfad auf der Festplatte, jedoch ohne separate Unterordner für Versionen. Nützlich beim Migrieren einer bestehenden Bibliothek zu Drop.",
|
||||
"fsFlatTitle": "Kompatibilität",
|
||||
"fsPath": "Pfad",
|
||||
"fsPathDesc": "Absoluter Pfad zur Spielebibliothek.",
|
||||
"fsPathPlaceholder": "/mnt/spiele",
|
||||
"fsTitle": "Drop-Stil",
|
||||
"link": "Quellen {arrow}",
|
||||
"nameDesc": "Der Name deiner Quelle, als Referenz.",
|
||||
"namePlaceholder": "Meine neue Quelle",
|
||||
"sources": "Bibliotheksquellen",
|
||||
"typeDesc": "Der Typ deiner Quelle. Ändert die erforderlichen Optionen.",
|
||||
"working": "Funktioniert es?"
|
||||
"fsPathPlaceholder": "/mnt/spiele"
|
||||
},
|
||||
"subheader": "Wenn du Ordner zu deinen Bibliotheksquellen hinzufügst, erkennt Drop diese und fordert dich auf, sie zu importieren. Jedes Spiel muss importiert werden, bevor du eine Version importieren kannst.",
|
||||
"title": "Bibliotheken",
|
||||
"version": {
|
||||
"delta": "Upgrade Modus",
|
||||
"noVersions": "Du hast keine verfügbare Version dieses Spiels.",
|
||||
"noVersionsAdded": "keine Versionen hinzugefügt"
|
||||
},
|
||||
@ -450,30 +331,19 @@
|
||||
"launcherOpen": "Im Launcher öffnen",
|
||||
"noGames": "Keine Spiele in der Bibliothek",
|
||||
"notFound": "Spiel nicht gefunden",
|
||||
"search": "Durchsuche Bibliothek…",
|
||||
"subheader": "Verwalte deine Spiele in Sammlungen für einen einfacheren Zugriff auf alle deine Spiele."
|
||||
"search": "Durchsuche Bibliothek…"
|
||||
},
|
||||
"lowest": "Niedrigste",
|
||||
"news": {
|
||||
"article": {
|
||||
"add": "Hinzufügen",
|
||||
"content": "Inhalt (Markdown)",
|
||||
"create": "Neuen Artikel erstellen",
|
||||
"editor": "Editor",
|
||||
"editorGuide": "Verwende die obigen Shortcuts oder schreibe direkt in Markdown. Unterstützt **fett**, *kursiv*, [Links](URL) und mehr.",
|
||||
"new": "Neuer Artikel",
|
||||
"preview": "Vorschau",
|
||||
"shortDesc": "Kurzbeschreibung",
|
||||
"submit": "Absenden",
|
||||
"tagPlaceholder": "Tag hinzufügen…",
|
||||
"titles": "Titel",
|
||||
"uploadCover": "Cover hochladen"
|
||||
"titles": "Titel"
|
||||
},
|
||||
"back": "Zurück zu Neuigkeiten",
|
||||
"checkLater": "Schaue später für Updates vorbei.",
|
||||
"delete": "Artikel löschen",
|
||||
"filter": {
|
||||
"all": "Gesamt",
|
||||
"month": "Diesen Monat",
|
||||
"week": "Diese Woche",
|
||||
"year": "Dieses Jahr"
|
||||
@ -481,9 +351,7 @@
|
||||
"none": "Keine Artikel",
|
||||
"notFound": "Artikel nicht gefunden",
|
||||
"search": "Suche Artikel",
|
||||
"searchPlaceholder": "Suche Artikel…",
|
||||
"subheader": "Bleibe auf dem Laufenden über die neuesten Updates und Ankündigungen.",
|
||||
"title": "Neueste Neuigkeiten"
|
||||
"searchPlaceholder": "Suche Artikel…"
|
||||
},
|
||||
"options": "Einstellungen",
|
||||
"security": "Sicherheit",
|
||||
@ -491,42 +359,30 @@
|
||||
"settings": {
|
||||
"admin": {
|
||||
"description": "Konfiguriere Drop Einstellungen",
|
||||
"store": {
|
||||
"dropGameAltPlaceholder": "Beispiel Spielsymbol",
|
||||
"dropGameDescriptionPlaceholder": "Dies ist ein exemplarisches Spiel. Es wird ersetzt wenn du ein Spiel importierst.",
|
||||
"dropGameNamePlaceholder": "Beispielspiel",
|
||||
"showGamePanelTextDecoration": "Zeige Titel und Beschreibung auf den Spielkacheln (Standard: an)",
|
||||
"title": "Store"
|
||||
},
|
||||
"title": "Einstellungen"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"auth": {
|
||||
"description": "Die Authentifizierung in Drop erfolgt über mehrere konfigurierte ‚Provider‘. Jeder Provider ermöglicht es Nutzern, sich über seine Methode anzumelden. Um zu starten, sollte mindestens ein Authentifizierungs-Provider aktiviert sein und ein Konto über diesen erstellt werden.",
|
||||
"docs": "Dokumentation {arrow}",
|
||||
"enabled": "Aktiviert?",
|
||||
"openid": {
|
||||
"description": "OpenID Connect (OIDC) ist eine oft unterstützte OAuth2 Erweiterung. Drop erfordert die Konfiguration von OIDC über Umgebungsvariablen.",
|
||||
"skip": "Ich habe ein OIDC Nutzer",
|
||||
"title": "OpenID Connect"
|
||||
},
|
||||
"simple": {
|
||||
"description": "Die einfache Authentifizierung verwendet Nutzername und Password zur Authentifizierung von Benutzern. Sie ist standartmäßig aktiviert, wenn kein anderer Authentifizierungsanbieter aktiviert ist.",
|
||||
"register": "Als Admin registrieren {arrow}",
|
||||
"title": "Einfache Authentifizierung"
|
||||
},
|
||||
"title": "Authentifizierung"
|
||||
},
|
||||
"finish": "Los geht's {arrow}",
|
||||
"noPage": "keine Seite",
|
||||
"stages": {
|
||||
"account": {
|
||||
"description": "Du benötigst mindestens ein Konto, um Drop zu benutzen.",
|
||||
"name": "Richte dein Administratorkonto ein."
|
||||
},
|
||||
"library": {
|
||||
"description": "Füge mindestens eine Bibliotheksquelle hinzu, um Drop zu nutzen.",
|
||||
"name": "Erstelle eine Bibliothek."
|
||||
}
|
||||
},
|
||||
@ -535,127 +391,79 @@
|
||||
},
|
||||
"store": {
|
||||
"about": "Über",
|
||||
"commingSoon": "Demnächst verfügbar",
|
||||
"developers": "Entwickler | Entwickler | Entwickler",
|
||||
"exploreMore": "Mehr entdecken {arrow}",
|
||||
"featured": "Empfohlen",
|
||||
"images": "Spielbilder",
|
||||
"lookAt": "Schau es dir an",
|
||||
"noDevelopers": "Keine Entwickler",
|
||||
"noFeatured": "KEINE HERVORGEHOBENEN SPIELE",
|
||||
"noGame": "KEIN SPIEL",
|
||||
"noGame": "Kein Spiel",
|
||||
"noImages": "Keine Bilder",
|
||||
"noPublishers": "Kein Publisher.",
|
||||
"noTags": "Keine Tags",
|
||||
"openAdminDashboard": "Im Admin Dashboard öffnen",
|
||||
"openFeatured": "Spiele in der Admin-Bibliothek markieren {arrow}",
|
||||
"platform": "Plattform | Plattform | Plattform",
|
||||
"publishers": "Publisher | Publisher | Publisher",
|
||||
"rating": "Bewertung",
|
||||
"readLess": "Weniger anzeigen",
|
||||
"readMore": "Mehr anzeigen",
|
||||
"recentlyAdded": "Kürzlich hinzugefügt",
|
||||
"recentlyReleased": "Kürzlich veröffentlicht",
|
||||
"recentlyUpdated": "Kürzlich aktualisiert",
|
||||
"released": "Veröffentlicht",
|
||||
"reviews": "({0} Bewertungen)",
|
||||
"tags": "Tags",
|
||||
"title": "Store",
|
||||
"view": {
|
||||
"sort": "Sortieren",
|
||||
"srFilters": "Filter",
|
||||
"srGames": "Spiele",
|
||||
"srViewGrid": "Raster anzeigen"
|
||||
"srGames": "Spiele"
|
||||
},
|
||||
"viewInStore": "Im Store ansehen",
|
||||
"website": "Webseite"
|
||||
},
|
||||
"tasks": {
|
||||
"admin": {
|
||||
"back": "{arrow} Zurück zu den Aufgaben",
|
||||
"completedTasksTitle": "Abgeschlossene Aufgaben",
|
||||
"dailyScheduledTitle": "Tägliche Aufgaben",
|
||||
"execute": "{arrow} Ausführen",
|
||||
"noTasksRunning": "Keine laufenden Aufgaben",
|
||||
"progress": "{0}%",
|
||||
"runningTasksTitle": "Laufende Aufgaben",
|
||||
"scheduled": {
|
||||
"checkUpdateDescription": "Drop auf Updates überprüfen.",
|
||||
"checkUpdateName": "Auf Updates prüfen.",
|
||||
"cleanupInvitationsDescription": "Bereinigt abgelaufene Einladungen aus der Datenbank, um Speicherplatz zu sparen.",
|
||||
"cleanupInvitationsName": "Einladungen bereinigen",
|
||||
"cleanupObjectsDescription": "Erkennt und löscht nicht referenzierte und ungenutzte Objekte, um Speicherplatz zu sparen.",
|
||||
"cleanupObjectsName": "Objekte bereinigen",
|
||||
"cleanupSessionsDescription": "Bereinigt abgelaufene Sitzungen, um Speicherplatz zu sparen und die Sicherheit zu gewährleisten.",
|
||||
"cleanupSessionsName": "Sitzungen bereinigen."
|
||||
},
|
||||
"viewTask": "Ansehen {arrow}",
|
||||
"weeklyScheduledTitle": "Wöchentliche Aufgaben"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Drop",
|
||||
"titleTemplate": "{0} - Drop",
|
||||
"todo": "Todo",
|
||||
"type": "Typ",
|
||||
"upload": "Hochladen",
|
||||
"uploadFile": "Datei hochladen",
|
||||
"userHeader": {
|
||||
"closeSidebar": "Seitenleiste schließen",
|
||||
"links": {
|
||||
"community": "Community",
|
||||
"library": "Bibliothek",
|
||||
"news": "Neuigkeiten"
|
||||
"library": "Bibliothek"
|
||||
},
|
||||
"profile": {
|
||||
"admin": "Admin Dashboard",
|
||||
"settings": "Kontoeinstellungen"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"admin": {
|
||||
"adminHeader": "Administrator?",
|
||||
"adminUserLabel": "Admin",
|
||||
"authLink": "Authentifizierung {arrow}",
|
||||
"authentication": {
|
||||
"configure": "Konfigurieren",
|
||||
"description": "Drop unterstützt eine Vielzahl von Authentifizierungsmechanismen. Wenn du sie aktivierst oder deaktivierst, werden sie auf dem Anmeldebildschirm angezeigt, damit Benutzer sie auswählen können. Klicke auf die Drei-Punkte, um den Authentifizierungsmechanismus zu konfigurieren.",
|
||||
"disabled": "Deaktiviert",
|
||||
"enabled": "Aktiviert",
|
||||
"enabledKey": "Aktiviert?",
|
||||
"oidc": "OpenID Connect",
|
||||
"simple": "Einfach (Nutzername/Passwort)",
|
||||
"srOpenOptions": "Einstellungen öffnen",
|
||||
"title": "Authentifizierung"
|
||||
},
|
||||
"authoptionsHeader": "Authentifizierungseinstellungen",
|
||||
"delete": "Löschen",
|
||||
"deleteUser": "Benutzer löschen {0}",
|
||||
"description": "Verwalte Benutzer auf deiner Drop-Instanz und konfiguriere deine Authentifizierungsmethode.",
|
||||
"displayNameHeader": "Anzeigename",
|
||||
"emailHeader": "E-Mail",
|
||||
"normalUserLabel": "Normaler Benutzer",
|
||||
"simple": {
|
||||
"adminInvitation": "Admin Einladung",
|
||||
"createInvitation": "Einladung erstellen",
|
||||
"description": "Die einfache Authentifizierung verwendet ein Einladungssystem zur Erstellung von Benutzern. Du kannst eine Einladung erstellen und optional einen Benutzernamen oder eine E-Mail-Adresse für den Benutzer angeben. Daraufhin wird eine magische URL generiert, mit der ein Konto erstellt werden kann.",
|
||||
"expires": "Läuft ab: {expiry}",
|
||||
"invitationTitle": "Einladungen",
|
||||
"invite3Days": "3 Tage",
|
||||
"invite6Months": "6 Monate",
|
||||
"inviteAdminSwitchDescription": "Erstelle diesen Benutzer als Administrator",
|
||||
"inviteAdminSwitchLabel": "Admin Einladung",
|
||||
"inviteButton": "Einladung",
|
||||
"inviteDescription": "Drop erstellt eine URL, die du an die Person senden kannst, die du einladen möchtest. Du kannst optional einen Benutzernamen oder eine E-Mail-Adresse angeben, die sie verwenden soll.",
|
||||
"inviteEmailDescription": "Muss im Format nutzer{'@'}beispiel.de sein",
|
||||
"inviteEmailLabel": "E-Mail-Adresse (optional)",
|
||||
"inviteEmailPlaceholder": "ich{'@'}beispiel.de",
|
||||
"inviteExpiryLabel": "Läuft ab",
|
||||
"inviteMonth": "1 Monat",
|
||||
"inviteNever": "Niemals",
|
||||
"inviteTitle": "Ein Benutzer zu Drop einladen",
|
||||
"inviteUsernameFormat": "Muss mindestens 5 Zeichen lang sein",
|
||||
"inviteUsernameLabel": "Nutzername (optional)",
|
||||
"inviteUsernamePlaceholder": "meinNutzername",
|
||||
"inviteWeek": "1 Woche",
|
||||
"inviteYear": "1 Jahr",
|
||||
"neverExpires": "Läuft niemals ab.",
|
||||
@ -668,6 +476,5 @@
|
||||
"srEditLabel": "Bearbeiten",
|
||||
"usernameHeader": "Nutzername"
|
||||
}
|
||||
},
|
||||
"welcome": "Deutsche, willkommen!"
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
"title": "Messages from the Crows' Nest",
|
||||
"unread": "Unread Messages"
|
||||
},
|
||||
"settings": "Settings",
|
||||
"settings": "Settings, savvy?",
|
||||
"title": "Yer Own Coffer"
|
||||
},
|
||||
"actions": "Deeds",
|
||||
|
||||
@ -19,28 +19,6 @@
|
||||
"title": "Notifications",
|
||||
"unread": "Unread Notifications"
|
||||
},
|
||||
"token": {
|
||||
"title": "API Tokens",
|
||||
"subheader": "Manage your API tokens, and what they can access.",
|
||||
"name": "API token name",
|
||||
"nameDesc": "The name of the token, for reference.",
|
||||
"namePlaceholder": "My New Token",
|
||||
"acls": "ACLs/scopes",
|
||||
"aclsDesc": "Defines what this token has the authority to do. You should avoid selecting all ACLs, if they are not necessary.",
|
||||
"expiry": "Expiry",
|
||||
"noExpiry": "No expiry",
|
||||
"revoke": "Revoke",
|
||||
"noTokens": "No tokens connected to your account.",
|
||||
|
||||
"expiryMonth": "A month",
|
||||
"expiry3Month": "3 months",
|
||||
"expiry6Month": "6 months",
|
||||
"expiryYear": "A year",
|
||||
"expiry5Year": "5 years",
|
||||
|
||||
"success": "Successfully created token.",
|
||||
"successNote": "Make sure to copy it now, as it won't be shown again."
|
||||
},
|
||||
"settings": "Settings",
|
||||
"title": "Account Settings"
|
||||
},
|
||||
@ -159,10 +137,6 @@
|
||||
"usernameTaken": "Username already taken."
|
||||
},
|
||||
"backHome": "{arrow} Back to home",
|
||||
"externalUrl": {
|
||||
"subtitle": "This message is only visible to admins.",
|
||||
"title": "Accessing over different EXTERNAL_URL. Please check the docs."
|
||||
},
|
||||
"game": {
|
||||
"banner": {
|
||||
"description": "Drop failed to update the banner image: {0}",
|
||||
@ -238,6 +212,10 @@
|
||||
"desc": "Drop encountered an error while updating the version: {error}",
|
||||
"title": "There an error while updating the version order"
|
||||
}
|
||||
},
|
||||
"externalUrl": {
|
||||
"title": "Accessing over different EXTERNAL_URL. Please check the docs.",
|
||||
"subtitle": "This message is only visible to admins."
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
@ -263,11 +241,7 @@
|
||||
"admin": {
|
||||
"admin": "Admin",
|
||||
"metadata": "Meta",
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"store": "Store",
|
||||
"tokens": "API tokens"
|
||||
},
|
||||
"settings": "Settings",
|
||||
"tasks": "Tasks",
|
||||
"users": "Users"
|
||||
},
|
||||
@ -353,7 +327,6 @@
|
||||
"description": "Companies organize games by who they were developed or published by.",
|
||||
"editor": {
|
||||
"action": "Add Game {plus}",
|
||||
"descriptionPlaceholder": "{'<'}description{'>'}",
|
||||
"developed": "Developed",
|
||||
"libraryDescription": "Add, remove, or customise what this company has developed and/or published.",
|
||||
"libraryTitle": "Game Library",
|
||||
@ -361,23 +334,25 @@
|
||||
"published": "Published",
|
||||
"uploadBanner": "Upload banner",
|
||||
"uploadIcon": "Upload icon",
|
||||
"descriptionPlaceholder": "{'<'}description{'>'}",
|
||||
"websitePlaceholder": "{'<'}website{'>'}"
|
||||
},
|
||||
"modals": {
|
||||
"createDescription": "Create a company to further organize your games.",
|
||||
"createFieldDescription": "Company Description",
|
||||
"createFieldDescriptionPlaceholder": "A small indie studio that...",
|
||||
"createFieldName": "Company Name",
|
||||
"createFieldNamePlaceholder": "My New Company...",
|
||||
"createFieldWebsite": "Company Website",
|
||||
"createFieldWebsitePlaceholder": "https://example.com/",
|
||||
"createTitle": "Create a company",
|
||||
"nameDescription": "Edit the company's name. Used to match to new game imports.",
|
||||
"nameTitle": "Edit company name",
|
||||
"shortDeckDescription": "Edit the company's description. Doesn't affect long (markdown) description.",
|
||||
"shortDeckTitle": "Edit company description",
|
||||
"websiteDescription": "Edit the company's website. Note: this will be a link, and won't have redirect protection.",
|
||||
"websiteTitle": "Edit company website"
|
||||
"websiteTitle": "Edit company website",
|
||||
|
||||
"createTitle": "Create a company",
|
||||
"createDescription": "Create a company to further organize your games.",
|
||||
"createFieldName": "Company Name",
|
||||
"createFieldNamePlaceholder": "My New Company...",
|
||||
"createFieldDescription": "Company Description",
|
||||
"createFieldDescriptionPlaceholder": "A small indie studio that...",
|
||||
"createFieldWebsite": "Company Website",
|
||||
"createFieldWebsitePlaceholder": "https://example.com/"
|
||||
},
|
||||
"noCompanies": "No companies",
|
||||
"noGames": "No games",
|
||||
@ -398,8 +373,6 @@
|
||||
},
|
||||
"metadataProvider": "Metadata provider",
|
||||
"noGames": "No games imported",
|
||||
"libraryHint": "No libraries configured.",
|
||||
"libraryHintDocsLink": "What does this mean? {arrow}",
|
||||
"offline": "Drop couldn't access this game.",
|
||||
"offlineTitle": "Game offline",
|
||||
"openEditor": "Open in Editor {arrow}",
|
||||
@ -409,15 +382,12 @@
|
||||
"create": "Create source",
|
||||
"createDesc": "Drop will use this source to access your game library, and make them available.",
|
||||
"desc": "Configure your library sources, where Drop will look for new games and versions to import.",
|
||||
"documentationLink": "Documentation {arrow}",
|
||||
"edit": "Edit source",
|
||||
"fsDesc": "Imports games from a path on disk. Requires version-based folder structure, and supports archived games.",
|
||||
"fsFlatDesc": "Imports games from a path on disk, but without a separate version subfolder. Useful when migrating an existing library to Drop.",
|
||||
"fsFlatTitle": "Compatibility",
|
||||
"fsPath": "Path",
|
||||
"fsPathDesc": "An absolute path to your game library.",
|
||||
"fsPathPlaceholder": "/mnt/games",
|
||||
"fsTitle": "Drop-style",
|
||||
"link": "Sources {arrow}",
|
||||
"nameDesc": "The name of your source, for reference.",
|
||||
"namePlaceholder": "My New Source",
|
||||
@ -544,13 +514,13 @@
|
||||
"images": "Game Images",
|
||||
"lookAt": "Check it out",
|
||||
"noDevelopers": "No developers",
|
||||
"noFeatured": "NO FEATURED GAMES",
|
||||
"noGame": "NO GAME",
|
||||
"noFeatured": "NO FEATURED GAMES",
|
||||
"openFeatured": "Star games in Admin Library {arrow}",
|
||||
"noImages": "No images",
|
||||
"noPublishers": "No publishers.",
|
||||
"noTags": "No tags",
|
||||
"openAdminDashboard": "Open in Admin Dashboard",
|
||||
"openFeatured": "Star games in Admin Library {arrow}",
|
||||
"platform": "Platform | Platform | Platforms",
|
||||
"publishers": "Publishers | Publisher | Publishers",
|
||||
"rating": "Rating",
|
||||
@ -590,9 +560,7 @@
|
||||
"cleanupSessionsName": "Clean up sessions."
|
||||
},
|
||||
"viewTask": "View {arrow}",
|
||||
"weeklyScheduledTitle": "Weekly scheduled tasks",
|
||||
"progress": "{0}%",
|
||||
"execute": "{arrow} Execute"
|
||||
"weeklyScheduledTitle": "Weekly scheduled tasks"
|
||||
}
|
||||
},
|
||||
"title": "Drop",
|
||||
@ -617,6 +585,7 @@
|
||||
"admin": {
|
||||
"adminHeader": "Admin?",
|
||||
"adminUserLabel": "Admin user",
|
||||
"authLink": "Authentication {arrow}",
|
||||
"authentication": {
|
||||
"configure": "Configure",
|
||||
"description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.",
|
||||
@ -628,7 +597,6 @@
|
||||
"srOpenOptions": "Open options",
|
||||
"title": "Authentication"
|
||||
},
|
||||
"authLink": "Authentication {arrow}",
|
||||
"authoptionsHeader": "Auth Options",
|
||||
"delete": "Delete",
|
||||
"deleteUser": "Delete user {0}",
|
||||
@ -641,7 +609,7 @@
|
||||
"createInvitation": "Create invitation",
|
||||
"description": "Simple authentication uses a system of 'invitations' to create users. You can create an invitation, and optionally specify a username or email for the user, and then it will generate a magic URL that can be used to create an account.",
|
||||
"expires": "Expires: {expiry}",
|
||||
"invitationTitle": "Invitations",
|
||||
"invitationTitle": "invitations",
|
||||
"invite3Days": "3 days",
|
||||
"invite6Months": "6 months",
|
||||
"inviteAdminSwitchDescription": "Create this user as an administrator",
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
{
|
||||
"account": {
|
||||
"devices": {
|
||||
"capabilities": "Capacités",
|
||||
"lastConnected": "Dernière Connexion",
|
||||
"noDevices": "Aucun appareil n'est connecté à vôtre compte.",
|
||||
"platform": "Plateforme",
|
||||
"revoke": "Révoquer",
|
||||
@ -14,33 +12,13 @@
|
||||
"desc": "Voir et gérer vos notifications.",
|
||||
"markAllAsRead": "Tout marqué comme lu",
|
||||
"markAsRead": "Marquer comme lu",
|
||||
"none": "Pas de notification",
|
||||
"none": "Pas de notifications",
|
||||
"notifications": "Notifications",
|
||||
"title": "Notifications",
|
||||
"unread": "Notifications Non Lues"
|
||||
"unread": "Notifications non lues"
|
||||
},
|
||||
"settings": "Paramètres",
|
||||
"title": "Paramètres du Compte",
|
||||
"token": {
|
||||
"acls": "ACLs/scopes",
|
||||
"aclsDesc": "Définir les permissions du Token. Il n'est pas recommandé de sélectionner toutes les ACLs, à moins que ce soit nécessaire.",
|
||||
"expiry": "Expiration",
|
||||
"expiry3Month": "3 mois",
|
||||
"expiry5Year": "5 Années",
|
||||
"expiry6Month": "6 mois",
|
||||
"expiryMonth": "Un mois",
|
||||
"expiryYear": "Une année",
|
||||
"name": "Nom du Token API",
|
||||
"nameDesc": "Le nom du Token, comme référence.",
|
||||
"namePlaceholder": "Mon nouveau Token",
|
||||
"noExpiry": "Pas d'expiration",
|
||||
"noTokens": "Aucun Token connecté à votre compte.",
|
||||
"revoke": "Révoquer",
|
||||
"subheader": "Gérer vos Tokens et leurs permissions associées.",
|
||||
"success": "Token créé avec succès.",
|
||||
"successNote": "Assurez vous de le sauvegarder maintenant, il ne sera plus disponible après.",
|
||||
"title": "API Tokens"
|
||||
}
|
||||
"title": "Paramètres du compte"
|
||||
},
|
||||
"actions": "Actions",
|
||||
"add": "Ajouter",
|
||||
@ -53,7 +31,7 @@
|
||||
"authorizedClient": "Drop a réussi a autoriser le client. Vous pouvez fermer cette fenêtre.",
|
||||
"issues": "Vous avez des problèmes ?",
|
||||
"learn": "En savoir plus {arrow}",
|
||||
"paste": "Collez ce code dans le client pour continuer :",
|
||||
"paste": "Coller ce code dans le client pour continuer :",
|
||||
"permWarning": "Accepter cette requête autorisera \"{name}\" sur \"{plateform} à :",
|
||||
"requestedAccess": "\"{name} a demandé accès à votre compte Drop.",
|
||||
"success": "Réussi !"
|
||||
@ -76,7 +54,7 @@
|
||||
"signin": {
|
||||
"externalProvider": "Connectez vous avec un fournisseur externe {arrow}",
|
||||
"forgot": "Mot de passe oublié ?",
|
||||
"noAccount": "Pas de compte ? Demandez à un administrateur d'en créer un pour vous.",
|
||||
"noAccount": "Pas de compte ? Demande à un administrateur d'en créer un pour toi.",
|
||||
"or": "OU",
|
||||
"pageTitle": "Se connecter à Drop",
|
||||
"rememberMe": "Se souvenir de moi",
|
||||
@ -131,7 +109,6 @@
|
||||
"italic": "Italique",
|
||||
"italicPlaceholder": "texte italique",
|
||||
"link": "Lien",
|
||||
"linkPlaceholder": "texte du lien",
|
||||
"listItem": "Élement de liste",
|
||||
"listItemPlaceholder": "élément de liste"
|
||||
},
|
||||
@ -593,7 +570,6 @@
|
||||
"srOpenOptions": "Ouvrir les options",
|
||||
"title": "Authentification"
|
||||
},
|
||||
"authoptionsHeader": "Options Auth",
|
||||
"delete": "Supprimer",
|
||||
"deleteUser": "Supprimer l'utilisateur {0}",
|
||||
"description": "Gérer les utilisateurs sur votre instance Drop, et configurer vos méthodes d'authentification.",
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
"lastConnected": "Последнее подключение",
|
||||
"noDevices": "К вашей учетной записи не подключено ни одного устройства.",
|
||||
"platform": "Платформа",
|
||||
"revoke": "Аннулировать",
|
||||
"subheader": "Управляйте устройствами, имеющими доступ к вашей учетной записи Drop.",
|
||||
"title": "Устройства"
|
||||
},
|
||||
@ -20,27 +19,7 @@
|
||||
"unread": "Непрочитанные уведомления"
|
||||
},
|
||||
"settings": "Настройки",
|
||||
"title": "Настройки учетной записи",
|
||||
"token": {
|
||||
"acls": "Доступ и права",
|
||||
"aclsDesc": "Определяет, какие действия разрешены для этого токена. Не выбирайте все ACL, если это не требуется.",
|
||||
"expiry": "Истечение срока",
|
||||
"expiry3Month": "3 месяца",
|
||||
"expiry5Year": "5 лет",
|
||||
"expiry6Month": "6 месяцев",
|
||||
"expiryMonth": "Месяц",
|
||||
"expiryYear": "Год",
|
||||
"name": "Название API-токена",
|
||||
"nameDesc": "Название токена для справки.",
|
||||
"namePlaceholder": "Мои новые токены",
|
||||
"noExpiry": "Без срока действия",
|
||||
"noTokens": "На вашем аккаунте нет подключённых токенов.",
|
||||
"revoke": "Аннулировать",
|
||||
"subheader": "Управляйте своими API-токенами и их доступом.",
|
||||
"success": "Токен успешно создан.",
|
||||
"successNote": "Скопируйте токен сейчас, позже его не будет видно.",
|
||||
"title": "API-Токены"
|
||||
}
|
||||
"title": "Настройки учетной записи"
|
||||
},
|
||||
"actions": "Действия",
|
||||
"add": "Добавить",
|
||||
@ -55,50 +34,7 @@
|
||||
"learn": "Узнать больше {arrow}",
|
||||
"paste": "Вставьте этот код в клиент, чтобы продолжить:",
|
||||
"permWarning": "Принятие этого запроса позволит \"{name}\" на \"{platform}\" выполнять следующие действия:",
|
||||
"requestedAccess": "\"{name}\" запросил доступ к вашей учетной записи Drop.",
|
||||
"success": "Успешно!"
|
||||
},
|
||||
"email": "Элетронная почка",
|
||||
"password": "Пароль",
|
||||
"register": {
|
||||
"confirmPasswordFormat": "Должно совпадать с выше."
|
||||
},
|
||||
"signin": {
|
||||
"forgot": "Забыли пароль?",
|
||||
"noAccount": "Нет аккаунта? Попросите администратора создать его для вас.",
|
||||
"or": "Или",
|
||||
"signin": "Войти",
|
||||
"title": "Войдите в свой аккаунт"
|
||||
},
|
||||
"signout": "Выход",
|
||||
"username": "Имя пользователя"
|
||||
},
|
||||
"cancel": "Отмена",
|
||||
"common": {
|
||||
"add": "Добавить",
|
||||
"close": "Закрыть",
|
||||
"create": "Создать",
|
||||
"date": "Дата",
|
||||
"deleteConfirm": "Вы точно хотите удалить \"{0}\"?",
|
||||
"edit": "Редактировать",
|
||||
"friends": "Друзья",
|
||||
"groups": "Группы",
|
||||
"name": "Имя",
|
||||
"noResults": "Нет результатов",
|
||||
"noSelected": "Не выбранные предметы.",
|
||||
"remove": "Удалить",
|
||||
"save": "Сохранить",
|
||||
"saved": "Сохранено",
|
||||
"servers": "Сервера",
|
||||
"srLoading": "Загрузка…",
|
||||
"tags": "Теги",
|
||||
"today": "Сегодня"
|
||||
},
|
||||
"delete": "Удалить",
|
||||
"drop": {
|
||||
"drop": "Уронить"
|
||||
},
|
||||
"editor": {
|
||||
"link": "Ссылка"
|
||||
"requestedAccess": "\"{name}\" запросил доступ к вашей учетной записи Drop."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -200,7 +200,7 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
|
||||
icon: RectangleStackIcon,
|
||||
},
|
||||
{
|
||||
label: $t("header.admin.settings.title"),
|
||||
label: $t("header.admin.settings"),
|
||||
route: "/admin/settings",
|
||||
prefix: "/admin/settings",
|
||||
icon: Cog6ToothIcon,
|
||||
|
||||
@ -74,8 +74,7 @@ export default defineNuxtConfig({
|
||||
|
||||
vite: {
|
||||
plugins: [
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
tailwindcss() as any,
|
||||
tailwindcss(),
|
||||
// only used in dev server, not build because nitro sucks
|
||||
// see build hook below
|
||||
viteStaticCopy({
|
||||
@ -85,8 +84,7 @@ export default defineNuxtConfig({
|
||||
dest: "twemoji",
|
||||
},
|
||||
],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any,
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
11
package.json
11
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "drop",
|
||||
"version": "0.3.3",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@ -14,14 +14,14 @@
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare && prisma generate",
|
||||
"typecheck": "nuxt typecheck",
|
||||
"lint": "pnpm run lint:eslint && pnpm run lint:prettier",
|
||||
"lint": "yarn lint:eslint && yarn lint:prettier",
|
||||
"lint:eslint": "eslint .",
|
||||
"lint:prettier": "prettier . --check",
|
||||
"lint:fix": "eslint . --fix && prettier --write --list-different ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordapp/twemoji": "^16.0.1",
|
||||
"@drop-oss/droplet": "3.0.1",
|
||||
"@drop-oss/droplet": "1.6.0",
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@heroicons/vue": "^2.1.5",
|
||||
"@lobomfz/prismark": "0.0.3",
|
||||
@ -53,7 +53,7 @@
|
||||
"stream-mime-type": "^2.0.0",
|
||||
"turndown": "^7.2.0",
|
||||
"unstorage": "^1.15.0",
|
||||
"vite-plugin-static-copy": "^3.1.2",
|
||||
"vite-plugin-static-copy": "^3.0.0",
|
||||
"vue": "latest",
|
||||
"vue-router": "latest",
|
||||
"vue3-carousel": "^0.16.0",
|
||||
@ -89,6 +89,5 @@
|
||||
},
|
||||
"prisma": {
|
||||
"schema": "./prisma"
|
||||
},
|
||||
"packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,229 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div>
|
||||
<h2
|
||||
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
|
||||
>
|
||||
{{ $t("account.token.title") }}
|
||||
</h2>
|
||||
<p
|
||||
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
|
||||
>
|
||||
{{ $t("account.token.subheader") }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<LoadingButton :loading="false" @click="() => (createOpen = true)">
|
||||
{{ $t("common.create") }}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="newToken"
|
||||
class="mt-4 rounded-md p-4 bg-green-500/10 outline outline-green-500/20"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="shrink-0">
|
||||
<CheckCircleIcon class="size-5 text-green-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-green-300">
|
||||
{{ $t("account.token.success") }}
|
||||
</p>
|
||||
<p class="text-xs text-green-300/70">
|
||||
{{ $t("account.token.successNote") }}
|
||||
</p>
|
||||
<p
|
||||
class="mt-2 bg-zinc-950 px-3 py-2 font-mono text-zinc-400 rounded-xl"
|
||||
>
|
||||
{{ newToken }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-auto pl-3">
|
||||
<div class="-mx-1.5 -my-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex rounded-md bg-green-50 p-1.5 text-green-500 hover:bg-green-100 focus-visible:ring-2 focus-visible:ring-green-600 focus-visible:ring-offset-2 focus-visible:ring-offset-green-50 focus-visible:outline-hidden dark:bg-transparent dark:text-green-400 dark:hover:bg-green-500/10 dark:focus-visible:ring-green-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-green-900"
|
||||
@click="() => (newToken = undefined)"
|
||||
>
|
||||
<span class="sr-only">{{ $t("common.close") }}</span>
|
||||
<XMarkIcon class="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-8 overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-zinc-800">
|
||||
<thead>
|
||||
<tr class="bg-zinc-800/50">
|
||||
<th
|
||||
scope="col"
|
||||
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
|
||||
>
|
||||
{{ $t("common.name") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
{{ $t("account.token.acls") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
{{ $t("account.token.expiry") }}
|
||||
</th>
|
||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||
<span class="sr-only">{{ $t("actions") }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-800">
|
||||
<tr
|
||||
v-for="(token, tokenIdx) in tokens"
|
||||
:key="token.id"
|
||||
class="transition-colors duration-150 hover:bg-zinc-800/50"
|
||||
>
|
||||
<td
|
||||
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
|
||||
>
|
||||
{{ token.name }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="acl in token.acls"
|
||||
:key="acl"
|
||||
class="inline-flex items-center gap-x-1 rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20"
|
||||
>
|
||||
{{ acl }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
<RelativeTime v-if="token.expiresAt" :date="token.expiresAt" />
|
||||
<span v-else>{{ $t("account.token.noExpiry") }}</span>
|
||||
</td>
|
||||
<td
|
||||
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
|
||||
>
|
||||
<button
|
||||
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
|
||||
@click="() => revokeToken(tokenIdx)"
|
||||
>
|
||||
{{ $t("account.token.revoke") }}
|
||||
<span class="sr-only">
|
||||
{{ $t("chars.srComma", [token.name]) }}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="tokens.length === 0">
|
||||
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
|
||||
{{ $t("account.token.noTokens") }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalCreateToken
|
||||
v-model="createOpen"
|
||||
:acls="acls"
|
||||
:loading="createLoading"
|
||||
:suggested-name="suggestedName"
|
||||
:suggested-acls="suggestedAcls"
|
||||
@create="(name, acls, expiry) => createToken(name, acls, expiry)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArkErrors, type } from "arktype";
|
||||
import { DateTime, type DurationLike } from "luxon";
|
||||
import { CheckCircleIcon, XMarkIcon } from "@heroicons/vue/20/solid";
|
||||
|
||||
const tokens = ref(await $dropFetch("/api/v1/user/token"));
|
||||
const acls = await $dropFetch("/api/v1/user/token/acls");
|
||||
|
||||
const createOpen = ref(false);
|
||||
const createLoading = ref(false);
|
||||
|
||||
const newToken = ref<string | undefined>();
|
||||
|
||||
const suggestedName = ref();
|
||||
const suggestedAcls = ref<string[]>([]);
|
||||
|
||||
const payloadParser = type({
|
||||
name: "string?",
|
||||
acls: "string[]?",
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
if (route.query.payload) {
|
||||
try {
|
||||
const rawPayload = JSON.parse(atob(route.query.payload.toString()));
|
||||
const payload = payloadParser(rawPayload);
|
||||
if (payload instanceof ArkErrors) throw payload;
|
||||
suggestedName.value = payload.name;
|
||||
suggestedAcls.value = payload.acls ?? [];
|
||||
createOpen.value = true;
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Failed to parse the token create payload.",
|
||||
fatal: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function createToken(
|
||||
name: string,
|
||||
acls: string[],
|
||||
expiry: DurationLike | undefined,
|
||||
) {
|
||||
createLoading.value = true;
|
||||
try {
|
||||
const result = await $dropFetch("/api/v1/user/token", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name,
|
||||
acls,
|
||||
expiry: expiry ? DateTime.now().plus(expiry) : undefined,
|
||||
},
|
||||
failTitle: "Failed to create API token.",
|
||||
});
|
||||
console.log(result);
|
||||
newToken.value = result.token;
|
||||
tokens.value.push(result);
|
||||
} finally {
|
||||
/* empty */
|
||||
}
|
||||
createOpen.value = false;
|
||||
createLoading.value = false;
|
||||
}
|
||||
|
||||
async function revokeToken(index: number) {
|
||||
const token = tokens.value[index];
|
||||
if (!token) return;
|
||||
|
||||
await $dropFetch("/api/v1/user/token/:id", {
|
||||
method: "DELETE",
|
||||
params: {
|
||||
id: token.id,
|
||||
},
|
||||
failTitle: "Failed to revoke token.",
|
||||
});
|
||||
|
||||
tokens.value.splice(index, 1);
|
||||
}
|
||||
</script>
|
||||
@ -242,40 +242,11 @@
|
||||
{{ $t("common.noResults") }}
|
||||
</p>
|
||||
<p
|
||||
v-if="
|
||||
filteredLibraryGames.length == 0 &&
|
||||
libraryGames.length == 0 &&
|
||||
libraryState.hasLibraries
|
||||
"
|
||||
v-if="filteredLibraryGames.length == 0 && libraryGames.length == 0"
|
||||
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
|
||||
>
|
||||
{{ $t("library.admin.noGames") }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="!libraryState.hasLibraries"
|
||||
class="flex flex-col gap-2 text-zinc-600 text-center col-span-4"
|
||||
>
|
||||
<span class="text-sm font-display font-bold uppercase">{{
|
||||
$t("library.admin.libraryHint")
|
||||
}}</span>
|
||||
|
||||
<NuxtLink
|
||||
class="transition text-xs text-zinc-600 hover:underline hover:text-zinc-400"
|
||||
href="https://docs.droposs.org/docs/library"
|
||||
target="_blank"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="library.admin.libraryHintDocsLink"
|
||||
tag="span"
|
||||
scope="global"
|
||||
class="inline-flex items-center gap-x-1"
|
||||
>
|
||||
<template #arrow>
|
||||
<ArrowTopRightOnSquareIcon class="size-4" />
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@ -285,11 +256,7 @@ import {
|
||||
ExclamationTriangleIcon,
|
||||
ExclamationCircleIcon,
|
||||
} from "@heroicons/vue/16/solid";
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
InformationCircleIcon,
|
||||
StarIcon,
|
||||
} from "@heroicons/vue/20/solid";
|
||||
import { InformationCircleIcon, StarIcon } from "@heroicons/vue/20/solid";
|
||||
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@ -64,14 +64,8 @@
|
||||
>
|
||||
{{ source.name }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400 inline-flex gap-x-1 items-center"
|
||||
>
|
||||
<component
|
||||
:is="optionsMetadata[source.backend].icon"
|
||||
class="size-5 text-zinc-400"
|
||||
/>
|
||||
{{ optionsMetadata[source.backend].title }}
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
{{ source.backend }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
<CheckIcon
|
||||
@ -195,34 +189,11 @@
|
||||
<RadioGroupLabel
|
||||
as="span"
|
||||
class="font-semibold text-zinc-100"
|
||||
>{{ metadata.title }}
|
||||
<span class="ml-2 font-mono text-zinc-500 text-xs">{{
|
||||
source
|
||||
}}</span></RadioGroupLabel
|
||||
>
|
||||
<RadioGroupDescription
|
||||
as="span"
|
||||
class="text-zinc-400 text-xs"
|
||||
>{{ source }}</RadioGroupLabel
|
||||
>
|
||||
<RadioGroupDescription as="span" class="text-zinc-400">
|
||||
{{ metadata.description }}
|
||||
</RadioGroupDescription>
|
||||
<NuxtLink
|
||||
:href="metadata.docsLink"
|
||||
:external="true"
|
||||
target="_blank"
|
||||
class="mt-2 block w-fit rounded-md bg-blue-600 px-2 py-1 text-center text-xs font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="library.admin.sources.documentationLink"
|
||||
tag="span"
|
||||
scope="global"
|
||||
class="inline-flex items-center gap-x-1"
|
||||
>
|
||||
<template #arrow>
|
||||
<ArrowTopRightOnSquareIcon class="size-4" />
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
@ -298,7 +269,6 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
DropLogo,
|
||||
SourceOptionsFilesystem,
|
||||
SourceOptionsFlatFilesystem,
|
||||
} from "#components";
|
||||
@ -309,11 +279,8 @@ import {
|
||||
RadioGroupLabel,
|
||||
RadioGroupOption,
|
||||
} from "@headlessui/vue";
|
||||
import {
|
||||
XCircleIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
} from "@heroicons/vue/20/solid";
|
||||
import { BackwardIcon, CheckIcon, XMarkIcon } from "@heroicons/vue/24/outline";
|
||||
import { XCircleIcon } from "@heroicons/vue/20/solid";
|
||||
import { CheckIcon, DocumentIcon, XMarkIcon } from "@heroicons/vue/24/outline";
|
||||
import { FetchError } from "ofetch";
|
||||
import type { Component } from "vue";
|
||||
import type { LibraryBackend } from "~/prisma/client/enums";
|
||||
@ -357,23 +324,17 @@ const optionUIs: { [key in LibraryBackend]: Component } = {
|
||||
};
|
||||
const optionsMetadata: {
|
||||
[key in LibraryBackend]: {
|
||||
title: string;
|
||||
description: string;
|
||||
docsLink: string;
|
||||
icon: Component;
|
||||
};
|
||||
} = {
|
||||
Filesystem: {
|
||||
title: t("library.admin.sources.fsTitle"),
|
||||
description: t("library.admin.sources.fsDesc"),
|
||||
docsLink: "https://docs.droposs.org/docs/library#drop-style",
|
||||
icon: DropLogo,
|
||||
icon: DocumentIcon,
|
||||
},
|
||||
FlatFilesystem: {
|
||||
title: t("library.admin.sources.fsFlatTitle"),
|
||||
description: t("library.admin.sources.fsFlatDesc"),
|
||||
docsLink: "https://docs.droposs.org/docs/library#flat-style-or-compat",
|
||||
icon: BackwardIcon,
|
||||
icon: DocumentIcon,
|
||||
},
|
||||
};
|
||||
const optionsMetadataIter = Object.entries(optionsMetadata);
|
||||
|
||||
@ -1,68 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<!-- tabs-->
|
||||
<div>
|
||||
<div class="border-b border-gray-200 dark:border-white/10">
|
||||
<nav class="-mb-px flex gap-x-2" aria-label="Tabs">
|
||||
<NuxtLink
|
||||
v-for="(tab, tabIdx) in navigation"
|
||||
:key="tab.route"
|
||||
:href="tab.route"
|
||||
:class="[
|
||||
currentNavigationIndex == tabIdx
|
||||
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-300',
|
||||
'group inline-flex items-center border-b-2 px-1 py-4 text-sm font-medium',
|
||||
]"
|
||||
:aria-current="tab ? 'page' : undefined"
|
||||
>
|
||||
<component
|
||||
:is="tab.icon"
|
||||
:class="[
|
||||
currentNavigationIndex == tabIdx
|
||||
? 'text-blue-500 dark:text-blue-400'
|
||||
: 'text-gray-400 group-hover:text-gray-500 dark:text-gray-500 dark:group-hover:text-gray-400',
|
||||
'mr-2 -ml-0.5 size-5',
|
||||
]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{ tab.label }}</span>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<!-- content -->
|
||||
<div class="mt-4 grow flex">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BuildingStorefrontIcon,
|
||||
CodeBracketIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
|
||||
const navigation: Array<NavigationItem & { icon: Component }> = [
|
||||
{
|
||||
label: $t("header.admin.settings.store"),
|
||||
route: "/admin/settings",
|
||||
prefix: "/admin/settings",
|
||||
icon: BuildingStorefrontIcon,
|
||||
},
|
||||
{
|
||||
label: $t("header.admin.settings.tokens"),
|
||||
route: "/admin/settings/tokens",
|
||||
prefix: "/admin/settings/tokens",
|
||||
icon: CodeBracketIcon,
|
||||
},
|
||||
];
|
||||
|
||||
// const notifications = useNotifications();
|
||||
// const unreadNotifications = computed(() =>
|
||||
// notifications.value.filter((e) => !e.read)
|
||||
// );
|
||||
|
||||
const currentNavigationIndex = useCurrentNavigationIndex(navigation);
|
||||
</script>
|
||||
@ -1,55 +1,68 @@
|
||||
<template>
|
||||
<form class="space-y-4" @submit.prevent="() => saveSettings()">
|
||||
<div class="pb-4 border-b border-zinc-700">
|
||||
<h2 class="text-xl font-semibold text-zinc-100">
|
||||
{{ $t("settings.admin.store.title") }}
|
||||
</h2>
|
||||
|
||||
<h3 class="text-base font-medium text-zinc-400 mb-3 m-x-0">
|
||||
{{ $t("settings.admin.store.showGamePanelTextDecoration") }}
|
||||
</h3>
|
||||
<ul class="flex gap-3">
|
||||
<li class="inline-block">
|
||||
<OptionWrapper
|
||||
:active="showGamePanelTextDecoration"
|
||||
@click="setShowTitleDescription(true)"
|
||||
>
|
||||
<div class="flex">
|
||||
<GamePanel
|
||||
:animate="false"
|
||||
:game="game"
|
||||
:default-placeholder="true"
|
||||
/>
|
||||
</div>
|
||||
</OptionWrapper>
|
||||
</li>
|
||||
<li class="inline-block">
|
||||
<OptionWrapper
|
||||
:active="!showGamePanelTextDecoration"
|
||||
@click="setShowTitleDescription(false)"
|
||||
>
|
||||
<div class="flex">
|
||||
<GamePanel
|
||||
:game="game"
|
||||
:show-title-description="false"
|
||||
:animate="false"
|
||||
:default-placeholder="true"
|
||||
/>
|
||||
</div>
|
||||
</OptionWrapper>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="space-y-4">
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-2xl font-semibold text-zinc-100">
|
||||
{{ $t("settings.admin.title") }}
|
||||
</h1>
|
||||
<p class="mt-2 text-base text-zinc-400">
|
||||
{{ $t("settings.admin.description") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
class="inline-flex w-full shadow-sm sm:w-auto"
|
||||
:loading="saving"
|
||||
:disabled="!allowSave"
|
||||
>
|
||||
{{ allowSave ? $t("common.save") : $t("common.saved") }}
|
||||
</LoadingButton>
|
||||
</form>
|
||||
<form class="space-y-4" @submit.prevent="() => saveSettings()">
|
||||
<div class="py-6 border-y border-zinc-700">
|
||||
<h2 class="text-xl font-semibold text-zinc-100">
|
||||
{{ $t("settings.admin.store.title") }}
|
||||
</h2>
|
||||
|
||||
<h3 class="text-base font-medium text-zinc-400 mb-3 m-x-0">
|
||||
{{ $t("settings.admin.store.showGamePanelTextDecoration") }}
|
||||
</h3>
|
||||
<ul class="flex gap-3">
|
||||
<li class="inline-block">
|
||||
<OptionWrapper
|
||||
:active="showGamePanelTextDecoration"
|
||||
@click="setShowTitleDescription(true)"
|
||||
>
|
||||
<div class="flex">
|
||||
<GamePanel
|
||||
:animate="false"
|
||||
:game="game"
|
||||
:default-placeholder="true"
|
||||
/>
|
||||
</div>
|
||||
</OptionWrapper>
|
||||
</li>
|
||||
<li class="inline-block">
|
||||
<OptionWrapper
|
||||
:active="!showGamePanelTextDecoration"
|
||||
@click="setShowTitleDescription(false)"
|
||||
>
|
||||
<div class="flex">
|
||||
<GamePanel
|
||||
:game="game"
|
||||
:show-title-description="false"
|
||||
:animate="false"
|
||||
:default-placeholder="true"
|
||||
/>
|
||||
</div>
|
||||
</OptionWrapper>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
class="inline-flex w-full shadow-sm sm:w-auto"
|
||||
:loading="saving"
|
||||
:disabled="!allowSave"
|
||||
>
|
||||
{{ allowSave ? $t("common.save") : $t("common.saved") }}
|
||||
</LoadingButton>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@ -1,233 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div>
|
||||
<h2
|
||||
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
|
||||
>
|
||||
{{ $t("account.token.title") }}
|
||||
</h2>
|
||||
<p
|
||||
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
|
||||
>
|
||||
{{ $t("account.token.subheader") }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<LoadingButton :loading="false" @click="() => (createOpen = true)">
|
||||
{{ $t("common.create") }}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="newToken"
|
||||
class="mt-4 rounded-md p-4 bg-green-500/10 outline outline-green-500/20"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="shrink-0">
|
||||
<CheckCircleIcon class="size-5 text-green-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-green-300">
|
||||
{{ $t("account.token.success") }}
|
||||
</p>
|
||||
<p class="text-xs text-green-300/70">
|
||||
{{ $t("account.token.successNote") }}
|
||||
</p>
|
||||
<p
|
||||
class="mt-2 bg-zinc-950 px-3 py-2 font-mono text-zinc-400 rounded-xl"
|
||||
>
|
||||
{{ newToken }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-auto pl-3">
|
||||
<div class="-mx-1.5 -my-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex rounded-md bg-green-50 p-1.5 text-green-500 hover:bg-green-100 focus-visible:ring-2 focus-visible:ring-green-600 focus-visible:ring-offset-2 focus-visible:ring-offset-green-50 focus-visible:outline-hidden dark:bg-transparent dark:text-green-400 dark:hover:bg-green-500/10 dark:focus-visible:ring-green-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-green-900"
|
||||
@click="() => (newToken = undefined)"
|
||||
>
|
||||
<span class="sr-only">{{ $t("common.close") }}</span>
|
||||
<XMarkIcon class="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-8 overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-zinc-800">
|
||||
<thead>
|
||||
<tr class="bg-zinc-800/50">
|
||||
<th
|
||||
scope="col"
|
||||
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
|
||||
>
|
||||
{{ $t("common.name") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
{{ $t("account.token.acls") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
{{ $t("account.token.expiry") }}
|
||||
</th>
|
||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||
<span class="sr-only">{{ $t("actions") }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-800">
|
||||
<tr
|
||||
v-for="(token, tokenIdx) in tokens"
|
||||
:key="token.id"
|
||||
class="transition-colors duration-150 hover:bg-zinc-800/50"
|
||||
>
|
||||
<td
|
||||
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
|
||||
>
|
||||
{{ token.name }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="acl in token.acls"
|
||||
:key="acl"
|
||||
class="inline-flex items-center gap-x-1 rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20"
|
||||
>
|
||||
{{ acl }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
<RelativeTime v-if="token.expiresAt" :date="token.expiresAt" />
|
||||
<span v-else>{{ $t("account.token.noExpiry") }}</span>
|
||||
</td>
|
||||
<td
|
||||
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
|
||||
>
|
||||
<button
|
||||
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
|
||||
@click="() => revokeToken(tokenIdx)"
|
||||
>
|
||||
{{ $t("account.token.revoke") }}
|
||||
<span class="sr-only">
|
||||
{{ $t("chars.srComma", [token.name]) }}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="tokens.length === 0">
|
||||
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
|
||||
{{ $t("account.token.noTokens") }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalCreateToken
|
||||
v-model="createOpen"
|
||||
:acls="acls"
|
||||
:loading="createLoading"
|
||||
:suggested-name="suggestedName"
|
||||
:suggested-acls="suggestedAcls"
|
||||
@create="(name, acls, expiry) => createToken(name, acls, expiry)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArkErrors, type } from "arktype";
|
||||
import { DateTime, type DurationLike } from "luxon";
|
||||
import { CheckCircleIcon, XMarkIcon } from "@heroicons/vue/20/solid";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
const tokens = ref(await $dropFetch("/api/v1/admin/token"));
|
||||
const acls = await $dropFetch("/api/v1/admin/token/acls");
|
||||
|
||||
const createOpen = ref(false);
|
||||
const createLoading = ref(false);
|
||||
|
||||
const newToken = ref<string | undefined>();
|
||||
|
||||
const suggestedName = ref();
|
||||
const suggestedAcls = ref<string[]>([]);
|
||||
|
||||
const payloadParser = type({
|
||||
name: "string?",
|
||||
acls: "string[]?",
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
if (route.query.payload) {
|
||||
try {
|
||||
const rawPayload = JSON.parse(atob(route.query.payload.toString()));
|
||||
const payload = payloadParser(rawPayload);
|
||||
if (payload instanceof ArkErrors) throw payload;
|
||||
suggestedName.value = payload.name;
|
||||
suggestedAcls.value = payload.acls ?? [];
|
||||
createOpen.value = true;
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Failed to parse the token create payload.",
|
||||
fatal: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function createToken(
|
||||
name: string,
|
||||
acls: string[],
|
||||
expiry: DurationLike | undefined,
|
||||
) {
|
||||
createLoading.value = true;
|
||||
try {
|
||||
const result = await $dropFetch("/api/v1/admin/token", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name,
|
||||
acls,
|
||||
expiry: expiry ? DateTime.now().plus(expiry) : undefined,
|
||||
},
|
||||
failTitle: "Failed to create API token.",
|
||||
});
|
||||
console.log(result);
|
||||
newToken.value = result.token;
|
||||
tokens.value.push(result);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
createOpen.value = false;
|
||||
createLoading.value = false;
|
||||
}
|
||||
|
||||
async function revokeToken(index: number) {
|
||||
const token = tokens.value[index];
|
||||
if (!token) return;
|
||||
|
||||
await $dropFetch("/api/v1/admin/token/:id", {
|
||||
method: "DELETE",
|
||||
params: {
|
||||
id: token.id,
|
||||
},
|
||||
failTitle: "Failed to revoke token.",
|
||||
});
|
||||
|
||||
tokens.value.splice(index, 1);
|
||||
}
|
||||
</script>
|
||||
@ -44,26 +44,19 @@
|
||||
</div>
|
||||
{{ task.name }}
|
||||
</h1>
|
||||
<div
|
||||
class="bg-zinc-950 p-2 rounded-md h-[80vh] flex flex-col flex-col-reverse overflow-y-scroll gap-y-1"
|
||||
>
|
||||
<LogLine
|
||||
v-for="(_, idx) in task.log"
|
||||
:key="idx"
|
||||
:log="parseTaskLog(task.log.at(-(idx + 1)))"
|
||||
/>
|
||||
</div>
|
||||
<div class="relative h-5 rounded-xl bg-zinc-950 overflow-hidden">
|
||||
<div class="h-2 rounded-full bg-zinc-950 overflow-hidden">
|
||||
<div
|
||||
:style="{ width: `${task.progress}%` }"
|
||||
class="transition-all bg-blue-600 h-full"
|
||||
/>
|
||||
<span
|
||||
class="absolute inset-0 flex items-center justify-center text-blue-200 text-sm font-bold font-display"
|
||||
>{{
|
||||
$t("tasks.admin.progress", [Math.round(task.progress * 10) / 10])
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative bg-zinc-950/50 rounded-md p-2 text-zinc-100 h-[80vh] overflow-y-scroll"
|
||||
>
|
||||
<pre v-for="(line, idx) in task.log" :key="idx">{{
|
||||
formatLine(line)
|
||||
}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else role="status" class="w-full flex items-center justify-center">
|
||||
@ -97,6 +90,11 @@ const taskId = route.params.id.toString();
|
||||
|
||||
const task = useTask(taskId);
|
||||
|
||||
function formatLine(line: string): string {
|
||||
const res = parseTaskLog(line);
|
||||
return `[${res.timestamp}] ${res.message}`;
|
||||
}
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
@ -13,7 +13,62 @@
|
||||
:key="task.value?.id"
|
||||
class="col-span-1 divide-y divide-gray-200 rounded-lg bg-zinc-800 border border-zinc-700 shadow-sm"
|
||||
>
|
||||
<TaskWidget :task="task.value" :active="true" />
|
||||
<div
|
||||
v-if="task.value"
|
||||
class="flex w-full items-center justify-between space-x-6 p-6"
|
||||
>
|
||||
<div class="flex-1 truncate">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div>
|
||||
<CheckIcon
|
||||
v-if="task.value.success"
|
||||
class="size-4 text-green-600"
|
||||
/>
|
||||
<XMarkIcon
|
||||
v-else-if="task.value.error"
|
||||
class="size-4 text-red-600"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="size-2 bg-blue-600 rounded-full animate-pulse"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="truncate text-sm font-medium text-zinc-100">
|
||||
{{ task.value.name }}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-xs text-zinc-600 mt-0.5 font-mono">
|
||||
{{ task.value.id }}
|
||||
</p>
|
||||
<div class="mt-1 w-full rounded-full overflow-hidden bg-zinc-900">
|
||||
<div
|
||||
:style="{ width: `${task.value.progress}%` }"
|
||||
class="bg-blue-600 h-1.5 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 truncate text-sm text-zinc-400">
|
||||
{{ parseTaskLog(task.value.log.at(-1) ?? "").message }}
|
||||
</p>
|
||||
<NuxtLink
|
||||
type="button"
|
||||
:href="`/admin/task/${task.value.id}`"
|
||||
class="mt-3 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="tasks.admin.viewTask"
|
||||
tag="span"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- renders server side when we don't want to access the current tasks -->
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
@ -34,7 +89,51 @@
|
||||
:key="task.id"
|
||||
class="col-span-1 divide-y divide-gray-200 rounded-lg bg-zinc-800 border border-zinc-700 shadow-sm"
|
||||
>
|
||||
<TaskWidget :task="task" />
|
||||
<div class="flex w-full items-center justify-between space-x-6 p-6">
|
||||
<div class="flex-1 truncate">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div>
|
||||
<CheckIcon
|
||||
v-if="task.success"
|
||||
class="size-4 text-green-600"
|
||||
/>
|
||||
<XMarkIcon
|
||||
v-else-if="task.error"
|
||||
class="size-4 text-red-600"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="size-2 bg-blue-600 rounded-full animate-pulse"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="truncate text-sm font-medium text-zinc-100">
|
||||
{{ task.name }}
|
||||
</h3>
|
||||
<RelativeTime class="text-zinc-500" :date="task.ended" />
|
||||
</div>
|
||||
<p class="text-xs text-zinc-600 mt-0.5 font-mono">
|
||||
{{ task.id }}
|
||||
</p>
|
||||
<p class="mt-1 truncate text-sm text-zinc-400">
|
||||
{{ parseTaskLog(task.log.at(-1) ?? "").message }}
|
||||
</p>
|
||||
<NuxtLink
|
||||
type="button"
|
||||
:href="`/admin/task/${task.id}`"
|
||||
class="mt-3 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="tasks.admin.viewTask"
|
||||
tag="span"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -58,21 +157,6 @@
|
||||
<p class="mt-1 text-sm text-zinc-400">
|
||||
{{ scheduledTasks[task].description }}
|
||||
</p>
|
||||
<button
|
||||
class="mt-3 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
|
||||
@click="() => startTask(task)"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="tasks.admin.execute"
|
||||
tag="span"
|
||||
scope="global"
|
||||
class="inline-flex items-center gap-x-1"
|
||||
>
|
||||
<template #arrow>
|
||||
<PlayIcon class="size-4" aria-hidden="true" />
|
||||
</template>
|
||||
</i18n-t>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@ -96,21 +180,6 @@
|
||||
<p class="mt-1 text-sm text-zinc-400">
|
||||
{{ scheduledTasks[task].description }}
|
||||
</p>
|
||||
<button
|
||||
class="mt-3 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
|
||||
@click="() => startTask(task)"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="tasks.admin.execute"
|
||||
tag="span"
|
||||
scope="global"
|
||||
class="inline-flex items-center gap-x-1"
|
||||
>
|
||||
<template #arrow>
|
||||
<PlayIcon class="size-4" aria-hidden="true" />
|
||||
</template>
|
||||
</i18n-t>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@ -120,7 +189,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { PlayIcon } from "@heroicons/vue/24/outline";
|
||||
import { CheckIcon, XMarkIcon } from "@heroicons/vue/24/outline";
|
||||
import type { TaskGroup } from "~/server/internal/tasks/group";
|
||||
|
||||
useHead({
|
||||
@ -136,9 +205,7 @@ const { t } = useI18n();
|
||||
const { runningTasks, historicalTasks, dailyTasks, weeklyTasks } =
|
||||
await $dropFetch("/api/v1/admin/task");
|
||||
|
||||
const liveRunningTasks = ref(
|
||||
await Promise.all(runningTasks.map((e) => useTask(e))),
|
||||
);
|
||||
const liveRunningTasks = await Promise.all(runningTasks.map((e) => useTask(e)));
|
||||
|
||||
const scheduledTasks: {
|
||||
[key in TaskGroup]: { name: string; description: string };
|
||||
@ -163,19 +230,5 @@ const scheduledTasks: {
|
||||
name: "",
|
||||
description: "",
|
||||
},
|
||||
debug: {
|
||||
name: "Debug Task",
|
||||
description: "Does debugging things.",
|
||||
},
|
||||
};
|
||||
|
||||
async function startTask(taskGroup: string) {
|
||||
const task = await $dropFetch("/api/v1/admin/task", {
|
||||
method: "POST",
|
||||
body: { taskGroup },
|
||||
failTitle: "Failed to start task",
|
||||
});
|
||||
const taskRef = await useTask(task.id);
|
||||
liveRunningTasks.value.push(taskRef);
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -2,14 +2,10 @@
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<div class="grow grid grid-cols-1 lg:grid-cols-2">
|
||||
<div class="border-b lg:border-b-0 lg:border-r border-zinc-700">
|
||||
<header
|
||||
class="mx-auto w-full max-w-7xl px-6 pt-6 sm:pt-10 lg:col-span-2 lg:col-start-1 lg:row-start-1 lg:px-8"
|
||||
>
|
||||
<header class="mx-auto w-full max-w-7xl px-6 pt-6 sm:pt-10 lg:col-span-2 lg:col-start-1 lg:row-start-1 lg:px-8">
|
||||
<DropWordmark />
|
||||
</header>
|
||||
<main
|
||||
class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8"
|
||||
>
|
||||
<main class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8">
|
||||
<div>
|
||||
<h1 class="text-4xl font-display font-bold text-zinc-100">
|
||||
{{ $t("setup.welcome") }}
|
||||
@ -20,32 +16,19 @@
|
||||
</p>
|
||||
</div>
|
||||
<ul role="list" class="mt-10 divide-y divide-zinc-700/5">
|
||||
<li
|
||||
v-for="(action, actionIdx) in actions"
|
||||
:key="action.name"
|
||||
class="relative flex gap-x-6 py-6"
|
||||
>
|
||||
<li v-for="(action, actionIdx) in actions" :key="action.name" class="relative flex gap-x-6 py-6">
|
||||
<div
|
||||
class="flex size-10 flex-none items-center justify-center rounded-lg shadow-xs outline-1 outline-zinc-100/10"
|
||||
>
|
||||
<component
|
||||
:is="action.icon"
|
||||
v-if="!actionsComplete[actionIdx]"
|
||||
class="size-6 text-blue-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
class="flex size-10 flex-none items-center justify-center rounded-lg shadow-xs outline-1 outline-zinc-100/10">
|
||||
<component :is="action.icon" v-if="!actionsComplete[actionIdx]" class="size-6 text-blue-500"
|
||||
aria-hidden="true" />
|
||||
<CheckIcon v-else class="size-6 text-blue-500" />
|
||||
</div>
|
||||
<div class="flex-auto">
|
||||
<h3 class="text-sm/6 font-semibold text-zinc-100">
|
||||
<button
|
||||
:class="
|
||||
actionsComplete[actionIdx]
|
||||
? 'line-through text-zinc-300'
|
||||
: ''
|
||||
"
|
||||
@click="() => (currentAction = actionIdx)"
|
||||
>
|
||||
<button :class="actionsComplete[actionIdx]
|
||||
? 'line-through text-zinc-300'
|
||||
: ''
|
||||
" @click="() => (currentAction = actionIdx)">
|
||||
<span class="absolute inset-0" aria-hidden="true" />
|
||||
{{ action.name }}
|
||||
</button>
|
||||
@ -55,18 +38,11 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-none self-center">
|
||||
<ChevronRightIcon
|
||||
class="size-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronRightIcon class="size-5 text-gray-400" aria-hidden="true" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<LoadingButton
|
||||
:disabled="!finished"
|
||||
:loading="finishLoading"
|
||||
@click="() => finish()"
|
||||
>
|
||||
<LoadingButton :disabled="!finished" :loading="finishLoading" @click="() => finish()">
|
||||
<i18n-t keypath="setup.finish" tag="span" scope="global">
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
@ -75,25 +51,13 @@
|
||||
</LoadingButton>
|
||||
</main>
|
||||
</div>
|
||||
<component
|
||||
:is="actions[currentAction].page"
|
||||
v-if="actions[currentAction] && !useModal"
|
||||
v-model="actionsComplete[currentAction]"
|
||||
:token="bearerToken"
|
||||
/>
|
||||
<div
|
||||
v-else-if="!useModal"
|
||||
class="bg-zinc-950/30 flex items-center justify-center"
|
||||
>
|
||||
<component :is="actions[currentAction].page" v-if="actions[currentAction] && !useModal"
|
||||
v-model="actionsComplete[currentAction]" :token="bearerToken" />
|
||||
<div v-else-if="!useModal" class="bg-zinc-950/30 flex items-center justify-center">
|
||||
<!-- <p class="uppercase text-sm font-display text-zinc-700 font-bold">
|
||||
{{ $t("setup.noPage") }}
|
||||
</p> -->
|
||||
<img
|
||||
src="/wallpapers/signin.jpg"
|
||||
class="inset-0 h-full w-full object-cover"
|
||||
alt=""
|
||||
preload
|
||||
/>
|
||||
<img src="/wallpapers/signin.jpg" class="inset-0 h-full w-full object-cover" alt="" preload />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@ -102,25 +66,17 @@
|
||||
<div class="fixed inset-0 bg-zinc-900/75 transition-opacity" />
|
||||
|
||||
<div class="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||
<div
|
||||
class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"
|
||||
>
|
||||
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<div
|
||||
class="relative transform overflow-hidden rounded-lg bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm"
|
||||
>
|
||||
class="relative transform overflow-hidden rounded-lg bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm">
|
||||
<div>
|
||||
<component
|
||||
:is="actions[currentAction].page"
|
||||
v-model="actionsComplete[currentAction]"
|
||||
:token="bearerToken"
|
||||
/>
|
||||
<component :is="actions[currentAction].page" v-model="actionsComplete[currentAction]"
|
||||
:token="bearerToken" />
|
||||
</div>
|
||||
<div class="mt-5 sm:mt-6 p-4">
|
||||
<button
|
||||
type="button"
|
||||
<button type="button"
|
||||
class="inline-flex w-full justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-blue-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
@click="currentAction = -1"
|
||||
>
|
||||
@click="currentAction = -1">
|
||||
{{ $t("common.close") }}
|
||||
</button>
|
||||
</div>
|
||||
@ -164,7 +120,7 @@ if (!token)
|
||||
});
|
||||
const bearerToken = `Bearer ${token}`;
|
||||
|
||||
const allowed = await $dropFetch("/api/v1/admin", {
|
||||
const allowed = await $dropFetch("/api/v1/admin/setup", {
|
||||
headers: { Authorization: bearerToken },
|
||||
});
|
||||
if (!allowed)
|
||||
|
||||
12324
pnpm-lock.yaml
generated
12324
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1 +0,0 @@
|
||||
shamefullyHoist: true
|
||||
@ -1,15 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `Task` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
|
||||
*/
|
||||
-- DropIndex
|
||||
DROP INDEX "GameTag_name_idx";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Task" DROP CONSTRAINT "Task_pkey",
|
||||
ADD CONSTRAINT "Task_pkey" PRIMARY KEY ("id", "started");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));
|
||||
@ -1,8 +0,0 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "GameTag_name_idx";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "APIToken" ADD COLUMN "expiresAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));
|
||||
@ -45,8 +45,6 @@ model APIToken {
|
||||
|
||||
acls String[]
|
||||
|
||||
expiresAt DateTime?
|
||||
|
||||
@@index([token])
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
model Task {
|
||||
id String
|
||||
id String @id
|
||||
taskGroup String
|
||||
name String
|
||||
|
||||
@ -12,6 +12,4 @@ model Task {
|
||||
log String[]
|
||||
|
||||
acls String[]
|
||||
|
||||
@@id([id, started])
|
||||
}
|
||||
|
||||
@ -2,6 +2,9 @@ import { AuthMec } from "~/prisma/client/enums";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import authManager from "~/server/internal/auth";
|
||||
|
||||
/**
|
||||
* Fetches all the enabled authentication mechanisms on this instance, and their configuration, if enabled.
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["auth:read", "setup"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
@ -7,6 +7,10 @@ const DeleteInvite = type({
|
||||
id: "string",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
/**
|
||||
* Deletes a "Simple" invitation
|
||||
* @returns nothing
|
||||
*/
|
||||
export default defineEventHandler<{
|
||||
body: typeof DeleteInvite.infer;
|
||||
}>(async (h3) => {
|
||||
|
||||
@ -3,6 +3,9 @@ import { systemConfig } from "~/server/internal/config/sys-conf";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
import taskHandler from "~/server/internal/tasks";
|
||||
|
||||
/**
|
||||
* Fetches a "Simple" invitation
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, [
|
||||
"auth:simple:invitation:read",
|
||||
|
||||
@ -11,6 +11,9 @@ const CreateInvite = SharedRegisterValidator.partial()
|
||||
})
|
||||
.configure(throwingArktype);
|
||||
|
||||
/**
|
||||
* Creates a "Simple" invitation
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, [
|
||||
"auth:simple:invitation:new",
|
||||
|
||||
@ -3,6 +3,11 @@ import prisma from "~/server/internal/db/database";
|
||||
import objectHandler from "~/server/internal/objects";
|
||||
import { handleFileUpload } from "~/server/internal/utils/handlefileupload";
|
||||
|
||||
/**
|
||||
* Multi-part form upload for the banner.
|
||||
* @request `multipart/form-data` data. Only one file, can be named anything.
|
||||
* @param id Company ID
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["company:update"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
@ -7,31 +7,37 @@ const GameDelete = type({
|
||||
id: "string",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["company:update"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
/**
|
||||
* Delete a game's association with a company
|
||||
* @param id Company ID
|
||||
*/
|
||||
export default defineEventHandler<{ body: typeof GameDelete.infer }>(
|
||||
async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["company:update"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const companyId = getRouterParam(h3, "id")!;
|
||||
const companyId = getRouterParam(h3, "id")!;
|
||||
|
||||
const body = await readDropValidatedBody(h3, GameDelete);
|
||||
const body = await readDropValidatedBody(h3, GameDelete);
|
||||
|
||||
await prisma.game.update({
|
||||
where: {
|
||||
id: body.id,
|
||||
},
|
||||
data: {
|
||||
publishers: {
|
||||
disconnect: {
|
||||
id: companyId,
|
||||
await prisma.game.update({
|
||||
where: {
|
||||
id: body.id,
|
||||
},
|
||||
data: {
|
||||
publishers: {
|
||||
disconnect: {
|
||||
id: companyId,
|
||||
},
|
||||
},
|
||||
developers: {
|
||||
disconnect: {
|
||||
id: companyId,
|
||||
},
|
||||
},
|
||||
},
|
||||
developers: {
|
||||
disconnect: {
|
||||
id: companyId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
});
|
||||
return;
|
||||
},
|
||||
);
|
||||
|
||||
@ -9,29 +9,35 @@ const GamePatch = type({
|
||||
id: "string",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["company:update"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
/**
|
||||
* Update a company's association with a game.
|
||||
* @param id Company ID
|
||||
*/
|
||||
export default defineEventHandler<{ body: typeof GamePatch.infer }>(
|
||||
async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["company:update"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const companyId = getRouterParam(h3, "id")!;
|
||||
const companyId = getRouterParam(h3, "id")!;
|
||||
|
||||
const body = await readDropValidatedBody(h3, GamePatch);
|
||||
const body = await readDropValidatedBody(h3, GamePatch);
|
||||
|
||||
const action = body.action === "developed" ? "developers" : "publishers";
|
||||
const actionType = body.enabled ? "connect" : "disconnect";
|
||||
const action = body.action === "developed" ? "developers" : "publishers";
|
||||
const actionType = body.enabled ? "connect" : "disconnect";
|
||||
|
||||
await prisma.game.update({
|
||||
where: {
|
||||
id: body.id,
|
||||
},
|
||||
data: {
|
||||
[action]: {
|
||||
[actionType]: {
|
||||
id: companyId,
|
||||
await prisma.game.update({
|
||||
where: {
|
||||
id: body.id,
|
||||
},
|
||||
data: {
|
||||
[action]: {
|
||||
[actionType]: {
|
||||
id: companyId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
});
|
||||
return;
|
||||
},
|
||||
);
|
||||
|
||||
@ -9,61 +9,67 @@ const GamePost = type({
|
||||
id: "string",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["company:update"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
/**
|
||||
* Add a new game association to this company
|
||||
* @param id Company ID
|
||||
*/
|
||||
export default defineEventHandler<{ body: typeof GamePost.infer }>(
|
||||
async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["company:update"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const companyId = getRouterParam(h3, "id")!;
|
||||
const companyId = getRouterParam(h3, "id")!;
|
||||
|
||||
const body = await readDropValidatedBody(h3, GamePost);
|
||||
const body = await readDropValidatedBody(h3, GamePost);
|
||||
|
||||
if (!body.published && !body.developed)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Must be related (either developed or published).",
|
||||
if (!body.published && !body.developed)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Must be related (either developed or published).",
|
||||
});
|
||||
|
||||
const publisherConnect = body.published
|
||||
? {
|
||||
publishers: {
|
||||
connect: {
|
||||
id: companyId,
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const developerConnect = body.developed
|
||||
? {
|
||||
developers: {
|
||||
connect: {
|
||||
id: companyId,
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const game = await prisma.game.update({
|
||||
where: {
|
||||
id: body.id,
|
||||
},
|
||||
data: {
|
||||
...publisherConnect,
|
||||
...developerConnect,
|
||||
},
|
||||
include: {
|
||||
publishers: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
developers: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const publisherConnect = body.published
|
||||
? {
|
||||
publishers: {
|
||||
connect: {
|
||||
id: companyId,
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const developerConnect = body.developed
|
||||
? {
|
||||
developers: {
|
||||
connect: {
|
||||
id: companyId,
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const game = await prisma.game.update({
|
||||
where: {
|
||||
id: body.id,
|
||||
},
|
||||
data: {
|
||||
...publisherConnect,
|
||||
...developerConnect,
|
||||
},
|
||||
include: {
|
||||
publishers: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
developers: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return game;
|
||||
});
|
||||
return game;
|
||||
},
|
||||
);
|
||||
|
||||
@ -3,6 +3,11 @@ import prisma from "~/server/internal/db/database";
|
||||
import objectHandler from "~/server/internal/objects";
|
||||
import { handleFileUpload } from "~/server/internal/utils/handlefileupload";
|
||||
|
||||
/**
|
||||
* Multi-part form upload for the icon of this company
|
||||
* @request `multipart/form-data` data. Only one file, can be named anything.
|
||||
* @param id Company ID
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["company:update"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
/**
|
||||
* Delete this company
|
||||
* @param id Company ID
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["company:delete"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
/**
|
||||
* Fetch a company and its associations
|
||||
* @param id Company ID
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["company:read"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
/**
|
||||
* Update a company. Pass any fields into the body to be updated on the model
|
||||
* @request Partial of the data returned by GET, minus the `developed` and `published` fields.
|
||||
* @param id Company ID
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["company:update"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
/**
|
||||
* Fetch all companies on this instance
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["company:read"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
@ -12,36 +12,41 @@ const CompanyCreate = type({
|
||||
website: "string",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["company:create"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
/**
|
||||
* Create a new company on this instance
|
||||
*/
|
||||
export default defineEventHandler<{ body: typeof CompanyCreate.infer }>(
|
||||
async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["company:create"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const body = await readDropValidatedBody(h3, CompanyCreate);
|
||||
const obj = new ObjectTransactionalHandler();
|
||||
const [register, pull, _] = obj.new({}, ["internal:read"]);
|
||||
const body = await readDropValidatedBody(h3, CompanyCreate);
|
||||
const obj = new ObjectTransactionalHandler();
|
||||
const [register, pull, _] = obj.new({}, ["internal:read"]);
|
||||
|
||||
const icon = jdenticon.toPng(body.name, 512);
|
||||
const logoId = register(icon);
|
||||
const icon = jdenticon.toPng(body.name, 512);
|
||||
const logoId = register(icon);
|
||||
|
||||
const banner = jdenticon.toPng(body.description, 1024);
|
||||
const bannerId = register(banner);
|
||||
const banner = jdenticon.toPng(body.description, 1024);
|
||||
const bannerId = register(banner);
|
||||
|
||||
const company = await prisma.company.create({
|
||||
data: {
|
||||
metadataSource: MetadataSource.Manual,
|
||||
metadataId: crypto.randomUUID(),
|
||||
metadataOriginalQuery: "",
|
||||
const company = await prisma.company.create({
|
||||
data: {
|
||||
metadataSource: MetadataSource.Manual,
|
||||
metadataId: crypto.randomUUID(),
|
||||
metadataOriginalQuery: "",
|
||||
|
||||
mName: body.name,
|
||||
mShortDescription: body.description,
|
||||
mDescription: "",
|
||||
mLogoObjectId: logoId,
|
||||
mBannerObjectId: bannerId,
|
||||
mWebsite: body.website,
|
||||
},
|
||||
});
|
||||
mName: body.name,
|
||||
mShortDescription: body.description,
|
||||
mDescription: "",
|
||||
mLogoObjectId: logoId,
|
||||
mBannerObjectId: bannerId,
|
||||
mWebsite: body.website,
|
||||
},
|
||||
});
|
||||
|
||||
await pull();
|
||||
await pull();
|
||||
|
||||
return company;
|
||||
});
|
||||
return company;
|
||||
},
|
||||
);
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
/**
|
||||
* Delete a game from this instance
|
||||
* @param id Game ID
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["game:delete"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
@ -2,6 +2,10 @@ import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
import libraryManager from "~/server/internal/library";
|
||||
|
||||
/**
|
||||
* Fetch a game by ID
|
||||
* @param id Game ID
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["game:read"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
@ -17,8 +21,11 @@ export default defineEventHandler(async (h3) => {
|
||||
orderBy: {
|
||||
versionIndex: "asc",
|
||||
},
|
||||
omit: {
|
||||
dropletManifest: true,
|
||||
select: {
|
||||
versionIndex: true,
|
||||
versionName: true,
|
||||
platform: true,
|
||||
delta: true,
|
||||
},
|
||||
},
|
||||
tags: true,
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
/**
|
||||
* Update a game's metadata
|
||||
* @request Partial of data returned by GET
|
||||
* @param id Game ID
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["game:update"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
@ -3,6 +3,11 @@ import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
import { handleFileUpload } from "~/server/internal/utils/handlefileupload";
|
||||
|
||||
/**
|
||||
* Update icon, name, and/or description
|
||||
* @request `multipart/form-data`, any file will become the icon, and `name` and `description` will become their respective fields.
|
||||
* @param id Game ID
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["game:update"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
@ -7,23 +7,29 @@ const PatchTags = type({
|
||||
tags: "string[]",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["game:update"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
/**
|
||||
* Update the tags associated with this game.
|
||||
* @param id Game ID
|
||||
*/
|
||||
export default defineEventHandler<{ body: typeof PatchTags.infer }>(
|
||||
async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["game:update"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const body = await readDropValidatedBody(h3, PatchTags);
|
||||
const id = getRouterParam(h3, "id")!;
|
||||
const body = await readDropValidatedBody(h3, PatchTags);
|
||||
const id = getRouterParam(h3, "id")!;
|
||||
|
||||
await prisma.game.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
tags: {
|
||||
connect: body.tags.map((e) => ({ id: e })),
|
||||
await prisma.game.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
},
|
||||
});
|
||||
data: {
|
||||
tags: {
|
||||
connect: body.tags.map((e) => ({ id: e })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
});
|
||||
return;
|
||||
},
|
||||
);
|
||||
|
||||
@ -9,6 +9,9 @@ const DeleteGameImage = type({
|
||||
imageId: "string",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
/**
|
||||
* Delete a game's image
|
||||
*/
|
||||
export default defineEventHandler<{
|
||||
body: typeof DeleteGameImage.infer;
|
||||
}>(async (h3) => {
|
||||
|
||||
@ -2,6 +2,10 @@ import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
import { handleFileUpload } from "~/server/internal/utils/handlefileupload";
|
||||
|
||||
/**
|
||||
* Upload a game to a game's image library
|
||||
* @request `multipart/form-data`. All files will be uploaded as images. Set the game ID in the `id` field.
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["game:image:new"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
/**
|
||||
* Fetch brief metadata on all games connected to this instance
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["game:read"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
@ -8,7 +8,10 @@ const DeleteVersion = type({
|
||||
versionName: "string",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
export default defineEventHandler<{ body: typeof DeleteVersion }>(
|
||||
/**
|
||||
* Delete a game's version
|
||||
*/
|
||||
export default defineEventHandler<{ body: typeof DeleteVersion.infer }>(
|
||||
async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, [
|
||||
"game:version:delete",
|
||||
|
||||
@ -8,7 +8,10 @@ const UpdateVersionOrder = type({
|
||||
versions: "string[]",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
export default defineEventHandler<{ body: typeof UpdateVersionOrder }>(
|
||||
/**
|
||||
* Update the version order of a game.
|
||||
*/
|
||||
export default defineEventHandler<{ body: typeof UpdateVersionOrder.infer }>(
|
||||
async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, [
|
||||
"game:version:update",
|
||||
@ -18,55 +21,30 @@ export default defineEventHandler<{ body: typeof UpdateVersionOrder }>(
|
||||
const body = await readDropValidatedBody(h3, UpdateVersionOrder);
|
||||
const gameId = body.id;
|
||||
// We expect an array of the version names for this game
|
||||
const unsortedVersions = await prisma.gameVersion.findMany({
|
||||
where: {
|
||||
versionName: { in: body.versions },
|
||||
},
|
||||
select: {
|
||||
versionName: true,
|
||||
versionIndex: true,
|
||||
delta: true,
|
||||
platform: true,
|
||||
},
|
||||
});
|
||||
const versions = body.versions;
|
||||
|
||||
const versions = body.versions
|
||||
.map((e) => unsortedVersions.find((v) => v.versionName === e))
|
||||
.filter((e) => e !== undefined);
|
||||
|
||||
if (versions.length !== unsortedVersions.length)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Sorting versions yielded less results, somehow.",
|
||||
});
|
||||
|
||||
// Validate the new order
|
||||
const has: { [key: string]: boolean } = {};
|
||||
for (const version of versions) {
|
||||
if (version.delta && !has[version.platform])
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: `"${version.versionName}" requires a base version to apply the delta to.`,
|
||||
});
|
||||
has[version.platform] = true;
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
versions.map((version, versionIndex) =>
|
||||
const newVersions = await prisma.$transaction(
|
||||
versions.map((versionName, versionIndex) =>
|
||||
prisma.gameVersion.update({
|
||||
where: {
|
||||
gameId_versionName: {
|
||||
gameId: gameId,
|
||||
versionName: version.versionName,
|
||||
versionName: versionName,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
versionIndex: versionIndex,
|
||||
},
|
||||
select: {
|
||||
versionIndex: true,
|
||||
versionName: true,
|
||||
platform: true,
|
||||
delta: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return versions;
|
||||
return newVersions;
|
||||
},
|
||||
);
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import libraryManager from "~/server/internal/library";
|
||||
|
||||
/**
|
||||
* Fetch all games that are available for import.
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["import:game:read"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
@ -14,6 +14,10 @@ const ImportGameBody = type({
|
||||
},
|
||||
}).configure(throwingArktype);
|
||||
|
||||
/**
|
||||
* Import a game as a background task.
|
||||
* @response Task IDs can be used with the websocket endpoint /api/v1/task
|
||||
*/
|
||||
export default defineEventHandler<{ body: typeof ImportGameBody.infer }>(
|
||||
async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["import:game:new"]);
|
||||
|
||||
@ -1,16 +1,30 @@
|
||||
import { ArkErrors, type } from "arktype";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import metadataHandler from "~/server/internal/metadata";
|
||||
|
||||
const SearchGame = type({
|
||||
q: "string",
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
type APIQuery = typeof SearchGame.infer;
|
||||
|
||||
/**
|
||||
* Search metadata providers for a query. Results can be used to import a game with metadata.
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["import:game:read"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const query = getQuery(h3);
|
||||
const search = query.q?.toString();
|
||||
if (!search)
|
||||
throw createError({ statusCode: 400, statusMessage: "Invalid search" });
|
||||
const search = SearchGame(query);
|
||||
if (search instanceof ArkErrors)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid search: " + search.summary,
|
||||
});
|
||||
|
||||
const results = await metadataHandler.search(search);
|
||||
const results = await metadataHandler.search(search.q);
|
||||
|
||||
if (results.length == 0)
|
||||
throw createError({
|
||||
|
||||
@ -1,19 +1,31 @@
|
||||
import { ArkErrors, type } from "arktype";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
import libraryManager from "~/server/internal/library";
|
||||
|
||||
const Query = type({
|
||||
id: "string",
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
type APIQuery = typeof Query.inferIn;
|
||||
|
||||
/**
|
||||
* Fetch all versions available for import for a game (`id` in query params).
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["import:version:read"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const query = await getQuery(h3);
|
||||
const gameId = query.id?.toString();
|
||||
if (!gameId)
|
||||
const query = Query(await getQuery(h3));
|
||||
if (query instanceof ArkErrors)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Missing id in request params",
|
||||
statusMessage: "Invalid query params: " + query.summary,
|
||||
});
|
||||
|
||||
const gameId = query.id;
|
||||
|
||||
const game = await prisma.game.findUnique({
|
||||
where: { id: gameId },
|
||||
select: { libraryId: true, libraryPath: true },
|
||||
|
||||
@ -19,71 +19,80 @@ const ImportVersion = type({
|
||||
umuId: "string = ''",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["import:version:new"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
/**
|
||||
* Import a version for a game.
|
||||
*/
|
||||
export default defineEventHandler<{ body: typeof ImportVersion.infer }>(
|
||||
async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["import:version:new"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const {
|
||||
id,
|
||||
version,
|
||||
platform,
|
||||
launch,
|
||||
launchArgs,
|
||||
setup,
|
||||
setupArgs,
|
||||
onlySetup,
|
||||
delta,
|
||||
umuId,
|
||||
} = await readDropValidatedBody(h3, ImportVersion);
|
||||
const {
|
||||
id,
|
||||
version,
|
||||
platform,
|
||||
launch,
|
||||
launchArgs,
|
||||
setup,
|
||||
setupArgs,
|
||||
onlySetup,
|
||||
delta,
|
||||
umuId,
|
||||
} = await readDropValidatedBody(h3, ImportVersion);
|
||||
|
||||
const platformParsed = parsePlatform(platform);
|
||||
if (!platformParsed)
|
||||
throw createError({ statusCode: 400, statusMessage: "Invalid platform." });
|
||||
const platformParsed = parsePlatform(platform);
|
||||
if (!platformParsed)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid platform.",
|
||||
});
|
||||
|
||||
if (delta) {
|
||||
const validOverlayVersions = await prisma.gameVersion.count({
|
||||
where: { gameId: id, platform: platformParsed, delta: false },
|
||||
if (delta) {
|
||||
const validOverlayVersions = await prisma.gameVersion.count({
|
||||
where: { gameId: id, platform: platformParsed, delta: false },
|
||||
});
|
||||
if (validOverlayVersions == 0)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage:
|
||||
"Update mode requires a pre-existing version for this platform.",
|
||||
});
|
||||
}
|
||||
|
||||
if (onlySetup) {
|
||||
if (!setup)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Setup required in "setup mode".',
|
||||
});
|
||||
} else {
|
||||
if (!delta && !launch)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage:
|
||||
"Launch executable is required for non-update versions",
|
||||
});
|
||||
}
|
||||
|
||||
// startup & delta require more complex checking logic
|
||||
const taskId = await libraryManager.importVersion(id, version, {
|
||||
platform,
|
||||
onlySetup,
|
||||
|
||||
launch,
|
||||
launchArgs,
|
||||
setup,
|
||||
setupArgs,
|
||||
|
||||
umuId,
|
||||
delta,
|
||||
});
|
||||
if (validOverlayVersions == 0)
|
||||
if (!taskId)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage:
|
||||
"Update mode requires a pre-existing version for this platform.",
|
||||
statusMessage: "Invalid options for import",
|
||||
});
|
||||
}
|
||||
|
||||
if (onlySetup) {
|
||||
if (!setup)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Setup required in "setup mode".',
|
||||
});
|
||||
} else {
|
||||
if (!delta && !launch)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Launch executable is required for non-update versions",
|
||||
});
|
||||
}
|
||||
|
||||
// startup & delta require more complex checking logic
|
||||
const taskId = await libraryManager.importVersion(id, version, {
|
||||
platform,
|
||||
onlySetup,
|
||||
|
||||
launch,
|
||||
launchArgs,
|
||||
setup,
|
||||
setupArgs,
|
||||
|
||||
umuId,
|
||||
delta,
|
||||
});
|
||||
if (!taskId)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid options for import",
|
||||
});
|
||||
|
||||
return { taskId: taskId };
|
||||
});
|
||||
return { taskId: taskId };
|
||||
},
|
||||
);
|
||||
|
||||
@ -1,18 +1,30 @@
|
||||
import { ArkErrors, type } from "arktype";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import libraryManager from "~/server/internal/library";
|
||||
|
||||
const Query = type({
|
||||
id: "string",
|
||||
version: "string",
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
type APIQuery = typeof Query.inferIn;
|
||||
|
||||
/**
|
||||
* Fetch recommendations for version import.
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["import:version:read"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const query = await getQuery(h3);
|
||||
const gameId = query.id?.toString();
|
||||
const versionName = query.version?.toString();
|
||||
if (!gameId || !versionName)
|
||||
const query = Query(await getQuery(h3));
|
||||
if (query instanceof ArkErrors)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Missing id or version in request params",
|
||||
statusMessage: "Invalid query: " + query.summary,
|
||||
});
|
||||
const gameId = query.id;
|
||||
const versionName = query.version;
|
||||
|
||||
const preload = await libraryManager.fetchUnimportedVersionInformation(
|
||||
gameId,
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import libraryManager from "~/server/internal/library";
|
||||
|
||||
/**
|
||||
* Fetch library data for admin UI
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["library:read"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const unimportedGames = await libraryManager.fetchUnimportedGames();
|
||||
const games = await libraryManager.fetchGamesWithStatus();
|
||||
const libraries = await libraryManager.fetchLibraries();
|
||||
|
||||
// Fetch other library data here
|
||||
|
||||
return { unimportedGames, games, hasLibraries: libraries.length > 0 };
|
||||
return { unimportedGames, games };
|
||||
});
|
||||
|
||||
@ -8,6 +8,9 @@ const DeleteLibrarySource = type({
|
||||
id: "string",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
/**
|
||||
* Delete a given library source
|
||||
*/
|
||||
export default defineEventHandler<{ body: typeof DeleteLibrarySource.infer }>(
|
||||
async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, [
|
||||
|
||||
@ -4,6 +4,9 @@ import libraryManager from "~/server/internal/library";
|
||||
|
||||
export type WorkingLibrarySource = LibraryModel & { working: boolean };
|
||||
|
||||
/**
|
||||
* Fetch all library sources on this instance
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, [
|
||||
"library:sources:read",
|
||||
|
||||
@ -12,6 +12,9 @@ const UpdateLibrarySource = type({
|
||||
options: "object",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
/**
|
||||
* Update a library source's options. Validates options and live-updates the source.
|
||||
*/
|
||||
export default defineEventHandler<{ body: typeof UpdateLibrarySource.infer }>(
|
||||
async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, [
|
||||
|
||||
@ -14,6 +14,9 @@ const CreateLibrarySource = type({
|
||||
options: "object",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
/**
|
||||
* Create a new library source with options
|
||||
*/
|
||||
export default defineEventHandler<{ body: typeof CreateLibrarySource.infer }>(
|
||||
async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, [
|
||||
|
||||
@ -2,6 +2,10 @@ import { defineEventHandler, createError } from "h3";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import newsManager from "~/server/internal/news";
|
||||
|
||||
/**
|
||||
* Delete a news article
|
||||
* @param id Article ID
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["news:delete"]);
|
||||
if (!allowed)
|
||||
|
||||
@ -2,6 +2,10 @@ import { defineEventHandler, createError } from "h3";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import newsManager from "~/server/internal/news";
|
||||
|
||||
/**
|
||||
* Fetch a single news article
|
||||
* @param id Article ID
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["news:read"]);
|
||||
if (!allowed)
|
||||
|
||||
@ -2,6 +2,9 @@ import { defineEventHandler, getQuery } from "h3";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import newsManager from "~/server/internal/news";
|
||||
|
||||
/**
|
||||
* Fetch all news articles.
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["news:read"]);
|
||||
if (!allowed)
|
||||
|
||||
@ -11,51 +11,56 @@ const CreateNews = type({
|
||||
tags: "string = '[]'",
|
||||
});
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["news:create"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
/**
|
||||
* Create a new news article
|
||||
*/
|
||||
export default defineEventHandler<{ body: typeof CreateNews.infer }>(
|
||||
async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["news:create"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const form = await readMultipartFormData(h3);
|
||||
if (!form)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "This endpoint requires multipart form data.",
|
||||
const form = await readMultipartFormData(h3);
|
||||
if (!form)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "This endpoint requires multipart form data.",
|
||||
});
|
||||
|
||||
const uploadResult = await handleFileUpload(h3, {}, ["internal:read"], 1);
|
||||
if (!uploadResult)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Failed to upload file",
|
||||
});
|
||||
|
||||
const [imageIds, options, pull, _dump] = uploadResult;
|
||||
|
||||
const body = await CreateNews(options);
|
||||
if (body instanceof ArkErrors)
|
||||
throw createError({ statusCode: 400, statusMessage: body.summary });
|
||||
|
||||
const parsedTags = JSON.parse(body.tags);
|
||||
if (typeof parsedTags !== "object" || !Array.isArray(parsedTags))
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Tags must be an array",
|
||||
});
|
||||
|
||||
const imageId = imageIds.at(0);
|
||||
|
||||
const article = await newsManager.create({
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
content: body.content,
|
||||
|
||||
tags: parsedTags,
|
||||
|
||||
...(imageId && { imageObjectId: imageId }),
|
||||
authorId: "system",
|
||||
});
|
||||
|
||||
const uploadResult = await handleFileUpload(h3, {}, ["internal:read"], 1);
|
||||
if (!uploadResult)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Failed to upload file",
|
||||
});
|
||||
await pull();
|
||||
|
||||
const [imageIds, options, pull, _dump] = uploadResult;
|
||||
|
||||
const body = await CreateNews(options);
|
||||
if (body instanceof ArkErrors)
|
||||
throw createError({ statusCode: 400, statusMessage: body.summary });
|
||||
|
||||
const parsedTags = JSON.parse(body.tags);
|
||||
if (typeof parsedTags !== "object" || !Array.isArray(parsedTags))
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Tags must be an array",
|
||||
});
|
||||
|
||||
const imageId = imageIds.at(0);
|
||||
|
||||
const article = await newsManager.create({
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
content: body.content,
|
||||
|
||||
tags: parsedTags,
|
||||
|
||||
...(imageId && { imageObjectId: imageId }),
|
||||
authorId: "system",
|
||||
});
|
||||
|
||||
await pull();
|
||||
|
||||
return article;
|
||||
});
|
||||
return article;
|
||||
},
|
||||
);
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
/**
|
||||
* Fetch dummy data for rendering the settings page.
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.getUserACL(h3, ["settings:read"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
@ -8,6 +8,9 @@ const UpdateSettings = type({
|
||||
showGamePanelTextDecoration: "boolean",
|
||||
});
|
||||
|
||||
/**
|
||||
* Update global Drop settings.
|
||||
*/
|
||||
export default defineEventHandler<{ body: typeof UpdateSettings.infer }>(
|
||||
async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["settings:update"]);
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
|
||||
/**
|
||||
* Check if we are a setup token
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, []);
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["setup"]);
|
||||
if (!allowed) return false;
|
||||
return true;
|
||||
});
|
||||
@ -1,6 +1,10 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
/**
|
||||
* Delete game tags.
|
||||
* @param id Tag ID
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["tags:delete"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
/**
|
||||
* Fetch all game tags
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["tags:read"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
@ -7,16 +7,21 @@ const CreateTag = type({
|
||||
name: "string",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["tags:read"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
/**
|
||||
* Create a game tag
|
||||
*/
|
||||
export default defineEventHandler<{ body: typeof CreateTag.infer }>(
|
||||
async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["tags:read"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const body = await readDropValidatedBody(h3, CreateTag);
|
||||
const body = await readDropValidatedBody(h3, CreateTag);
|
||||
|
||||
const tag = await prisma.gameTag.create({
|
||||
data: {
|
||||
...body,
|
||||
},
|
||||
});
|
||||
return tag;
|
||||
});
|
||||
const tag = await prisma.gameTag.create({
|
||||
data: {
|
||||
...body,
|
||||
},
|
||||
});
|
||||
return tag;
|
||||
},
|
||||
);
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
import type { TaskMessage } from "~/server/internal/tasks";
|
||||
import taskHandler from "~/server/internal/tasks";
|
||||
|
||||
/**
|
||||
* Fetches all tasks that the current token has access to (ACL-based)
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["task:read"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
@ -14,7 +16,7 @@ export default defineEventHandler(async (h3) => {
|
||||
});
|
||||
|
||||
const runningTasks = (await taskHandler.runningTasks()).map((e) => e.id);
|
||||
const historicalTasks = (await prisma.task.findMany({
|
||||
const historicalTasks = await prisma.task.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
@ -29,7 +31,7 @@ export default defineEventHandler(async (h3) => {
|
||||
ended: "desc",
|
||||
},
|
||||
take: 10,
|
||||
})) as Array<TaskMessage>;
|
||||
});
|
||||
const dailyTasks = await taskHandler.dailyTasks();
|
||||
const weeklyTasks = await taskHandler.weeklyTasks();
|
||||
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
import { type } from "arktype";
|
||||
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import taskHandler from "~/server/internal/tasks";
|
||||
import type { TaskGroup } from "~/server/internal/tasks/group";
|
||||
import { taskGroups } from "~/server/internal/tasks/group";
|
||||
|
||||
const StartTask = type({
|
||||
taskGroup: type("string"),
|
||||
}).configure(throwingArktype);
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["task:start"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const body = await readDropValidatedBody(h3, StartTask);
|
||||
const taskGroup = body.taskGroup as TaskGroup;
|
||||
if (!taskGroups[taskGroup])
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid task group.",
|
||||
});
|
||||
|
||||
const task = await taskHandler.runTaskGroupByName(taskGroup);
|
||||
if (!task)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Could not start task.",
|
||||
});
|
||||
return { id: task };
|
||||
});
|
||||
@ -1,23 +0,0 @@
|
||||
import { APITokenMode } from "~/prisma/client/enums";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, []); // No ACLs only allows session authentication
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const id = h3.context.params?.id;
|
||||
if (!id)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "No id in router params",
|
||||
});
|
||||
|
||||
const deleted = await prisma.aPIToken.delete({
|
||||
where: { id: id, mode: APITokenMode.System },
|
||||
})!;
|
||||
if (!deleted)
|
||||
throw createError({ statusCode: 404, statusMessage: "Token not found" });
|
||||
|
||||
return;
|
||||
});
|
||||
@ -1,9 +0,0 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import { systemACLDescriptions } from "~/server/internal/acls/descriptions";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, []); // No ACLs only allows session authentication
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
return systemACLDescriptions;
|
||||
});
|
||||
@ -1,15 +0,0 @@
|
||||
import { APITokenMode } from "~/prisma/client/enums";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, []); // No ACLs only allows session authentication
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const tokens = await prisma.aPIToken.findMany({
|
||||
where: { mode: APITokenMode.System },
|
||||
omit: { token: true },
|
||||
});
|
||||
|
||||
return tokens;
|
||||
});
|
||||
@ -1,38 +0,0 @@
|
||||
import { type } from "arktype";
|
||||
import { APITokenMode } from "~/prisma/client/enums";
|
||||
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
|
||||
import aclManager, { systemACLs } from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
const CreateToken = type({
|
||||
name: "string",
|
||||
acls: "string[] > 0",
|
||||
expiry: "string.date.iso.parse?",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, []); // No ACLs only allows session authentication
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const body = await readDropValidatedBody(h3, CreateToken);
|
||||
|
||||
const invalidACLs = body.acls.filter(
|
||||
(e) => systemACLs.findIndex((v) => e == v) == -1,
|
||||
);
|
||||
if (invalidACLs.length > 0)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: `Invalid ACLs: ${invalidACLs.join(", ")}`,
|
||||
});
|
||||
|
||||
const token = await prisma.aPIToken.create({
|
||||
data: {
|
||||
mode: APITokenMode.System,
|
||||
name: body.name,
|
||||
acls: body.acls,
|
||||
expiresAt: body.expiry ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return token;
|
||||
});
|
||||
@ -2,6 +2,10 @@ import { defineEventHandler, createError } from "h3";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
/**
|
||||
* Delete a user
|
||||
* @param id User ID
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["user:delete"]);
|
||||
if (!allowed)
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
/**
|
||||
* Fetch a user by ID
|
||||
* @param id User ID
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["user:read"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
/**
|
||||
* Fetch all users and their enabled authentication mechanisms
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["user:read"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import authManager from "~/server/internal/auth";
|
||||
|
||||
/**
|
||||
* Fetch public authentication provider mechanisms
|
||||
*/
|
||||
export default defineEventHandler(() => {
|
||||
return authManager.getEnabledAuthProviders();
|
||||
});
|
||||
|
||||
@ -15,6 +15,9 @@ const signinValidator = type({
|
||||
"rememberMe?": "boolean | undefined",
|
||||
});
|
||||
|
||||
/**
|
||||
* Sign in as a session using the "Simple" authentication mechanism. Not recommended for third-party applications.
|
||||
*/
|
||||
export default defineEventHandler<{
|
||||
body: typeof signinValidator.infer;
|
||||
}>(async (h3) => {
|
||||
|
||||
@ -1,7 +1,18 @@
|
||||
import prisma from "~/server/internal/db/database";
|
||||
import taskHandler from "~/server/internal/tasks";
|
||||
import authManager from "~/server/internal/auth";
|
||||
import { ArkErrors, type } from "arktype";
|
||||
|
||||
const Query = type({
|
||||
id: "string",
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
type APIQuery = typeof Query.inferIn;
|
||||
|
||||
/**
|
||||
* Fetch invitation details for pre-filling
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const t = await useTranslation(h3);
|
||||
|
||||
@ -11,13 +22,13 @@ export default defineEventHandler(async (h3) => {
|
||||
statusMessage: t("errors.auth.method.signinDisabled"),
|
||||
});
|
||||
|
||||
const query = getQuery(h3);
|
||||
const id = query.id?.toString();
|
||||
if (!id)
|
||||
const query = Query(getQuery(h3));
|
||||
if (query instanceof ArkErrors)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: t("errors.auth.inviteIdRequired"),
|
||||
statusMessage: "Invalid query: " + query.summary,
|
||||
});
|
||||
const id = query.id;
|
||||
taskHandler.runTaskGroupByName("cleanup:invitations");
|
||||
|
||||
const invitation = await prisma.invitation.findUnique({ where: { id: id } });
|
||||
|
||||
@ -18,6 +18,9 @@ const CreateUserValidator = SharedRegisterValidator.and({
|
||||
"displayName?": "string | undefined",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
/**
|
||||
* Create user from invitation
|
||||
*/
|
||||
export default defineEventHandler<{
|
||||
body: typeof CreateUserValidator.infer;
|
||||
}>(async (h3) => {
|
||||
|
||||
@ -1,30 +1,41 @@
|
||||
import { type } from "arktype";
|
||||
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import clientHandler from "~/server/internal/clients/handler";
|
||||
import sessionHandler from "~/server/internal/session";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const user = await sessionHandler.getSession(h3);
|
||||
if (!user) throw createError({ statusCode: 403 });
|
||||
const AuthorizeBody = type({
|
||||
id: "string",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
const body = await readBody(h3);
|
||||
const clientId = await body.id;
|
||||
/**
|
||||
* Finalize the authorization for a client
|
||||
*/
|
||||
export default defineEventHandler<{ body: typeof AuthorizeBody.infer }>(
|
||||
async (h3) => {
|
||||
const userId = await aclManager.getUserIdACL(h3, []);
|
||||
if (!userId) throw createError({ statusCode: 403 });
|
||||
|
||||
const client = await clientHandler.fetchClient(clientId);
|
||||
if (!client)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid or expired client ID.",
|
||||
});
|
||||
const body = await readDropValidatedBody(h3, AuthorizeBody);
|
||||
const clientId = body.id;
|
||||
|
||||
if (client.userId != user.userId)
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Not allowed to authorize this client.",
|
||||
});
|
||||
const client = await clientHandler.fetchClient(clientId);
|
||||
if (!client)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid or expired client ID.",
|
||||
});
|
||||
|
||||
const token = await clientHandler.generateAuthToken(clientId);
|
||||
if (client.userId != userId)
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Not allowed to authorize this client.",
|
||||
});
|
||||
|
||||
return {
|
||||
redirect: `drop://handshake/${clientId}/${token}`,
|
||||
token: `${clientId}/${token}`,
|
||||
};
|
||||
});
|
||||
const token = await clientHandler.generateAuthToken(clientId);
|
||||
|
||||
return {
|
||||
redirect: `drop://handshake/${clientId}/${token}`,
|
||||
token: `${clientId}/${token}`,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@ -1,17 +1,25 @@
|
||||
import { ArkErrors, type } from "arktype";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import clientHandler from "~/server/internal/clients/handler";
|
||||
import sessionHandler from "~/server/internal/session";
|
||||
|
||||
const Query = type({
|
||||
code: "string.upper",
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
type APIQuery = typeof Query.inferIn;
|
||||
|
||||
/**
|
||||
* Fetch client ID by authorize code
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const user = await sessionHandler.getSession(h3);
|
||||
if (!user) throw createError({ statusCode: 403 });
|
||||
const userId = await aclManager.getUserIdACL(h3, []);
|
||||
if (!userId) throw createError({ statusCode: 403 });
|
||||
|
||||
const query = getQuery(h3);
|
||||
const code = query.code?.toString()?.toUpperCase();
|
||||
if (!code)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Code required in query params.",
|
||||
});
|
||||
const query = Query(getQuery(h3));
|
||||
if (query instanceof ArkErrors)
|
||||
throw createError({ statusCode: 400, statusMessage: query.summary });
|
||||
const code = query.code;
|
||||
|
||||
const clientId = await clientHandler.fetchClientIdByCode(code);
|
||||
if (!clientId)
|
||||
|
||||
@ -1,35 +1,46 @@
|
||||
import { type } from "arktype";
|
||||
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import clientHandler from "~/server/internal/clients/handler";
|
||||
import sessionHandler from "~/server/internal/session";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const user = await sessionHandler.getSession(h3);
|
||||
if (!user) throw createError({ statusCode: 403 });
|
||||
const CodeAuthorize = type({
|
||||
id: "string",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
const body = await readBody(h3);
|
||||
const clientId = await body.id;
|
||||
/**
|
||||
* Authorize code by client ID, and send token via WS to client
|
||||
*/
|
||||
export default defineEventHandler<{ body: typeof CodeAuthorize.infer }>(
|
||||
async (h3) => {
|
||||
const userId = await aclManager.getUserIdACL(h3, []);
|
||||
if (!userId) throw createError({ statusCode: 403 });
|
||||
|
||||
const client = await clientHandler.fetchClient(clientId);
|
||||
if (!client)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid or expired client ID.",
|
||||
});
|
||||
const body = await readDropValidatedBody(h3, CodeAuthorize);
|
||||
const clientId = body.id;
|
||||
|
||||
if (client.userId != user.userId)
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Not allowed to authorize this client.",
|
||||
});
|
||||
const client = await clientHandler.fetchClient(clientId);
|
||||
if (!client)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid or expired client ID.",
|
||||
});
|
||||
|
||||
if (!client.peer)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "No client listening for authorization.",
|
||||
});
|
||||
if (client.userId != userId)
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Not allowed to authorize this client.",
|
||||
});
|
||||
|
||||
const token = await clientHandler.generateAuthToken(clientId);
|
||||
if (!client.peer)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "No client listening for authorization.",
|
||||
});
|
||||
|
||||
await clientHandler.sendAuthToken(clientId, token);
|
||||
const token = await clientHandler.generateAuthToken(clientId);
|
||||
|
||||
return;
|
||||
});
|
||||
await clientHandler.sendAuthToken(clientId, token);
|
||||
|
||||
return;
|
||||
},
|
||||
);
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import type { FetchError } from "ofetch";
|
||||
import clientHandler from "~/server/internal/clients/handler";
|
||||
|
||||
/**
|
||||
* Client route to listen for code authorization.
|
||||
* @request Pass the code in the `Authorization` header
|
||||
*/
|
||||
export default defineWebSocketHandler({
|
||||
async open(peer) {
|
||||
try {
|
||||
|
||||
@ -1,45 +1,52 @@
|
||||
import { type } from "arktype";
|
||||
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
|
||||
import clientHandler from "~/server/internal/clients/handler";
|
||||
import { useCertificateAuthority } from "~/server/plugins/ca";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const body = await readBody(h3);
|
||||
const clientId = body.clientId;
|
||||
const token = body.token;
|
||||
if (!clientId || !token)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Missing token or client ID from body",
|
||||
});
|
||||
const HandshakeBody = type({
|
||||
clientId: "string",
|
||||
token: "string",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
const metadata = await clientHandler.fetchClient(clientId);
|
||||
if (!metadata)
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Invalid client ID",
|
||||
});
|
||||
if (!metadata.authToken || !metadata.userId)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Un-authorized client ID",
|
||||
});
|
||||
if (metadata.authToken !== token)
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Invalid token",
|
||||
});
|
||||
/**
|
||||
* Client route to complete handshake, after the user has authorize it.
|
||||
*/
|
||||
export default defineEventHandler<{ body: typeof HandshakeBody.infer }>(
|
||||
async (h3) => {
|
||||
const body = await readDropValidatedBody(h3, HandshakeBody);
|
||||
const clientId = body.clientId;
|
||||
const token = body.token;
|
||||
|
||||
const certificateAuthority = useCertificateAuthority();
|
||||
const bundle = await certificateAuthority.generateClientCertificate(
|
||||
clientId,
|
||||
metadata.data.name,
|
||||
);
|
||||
const metadata = await clientHandler.fetchClient(clientId);
|
||||
if (!metadata)
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Invalid client ID",
|
||||
});
|
||||
if (!metadata.authToken || !metadata.userId)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Un-authorized client ID",
|
||||
});
|
||||
if (metadata.authToken !== token)
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Invalid token",
|
||||
});
|
||||
|
||||
const client = await clientHandler.finialiseClient(clientId);
|
||||
await certificateAuthority.storeClientCertificate(clientId, bundle);
|
||||
const certificateAuthority = useCertificateAuthority();
|
||||
const bundle = await certificateAuthority.generateClientCertificate(
|
||||
clientId,
|
||||
metadata.data.name,
|
||||
);
|
||||
|
||||
return {
|
||||
private: bundle.priv,
|
||||
certificate: bundle.cert,
|
||||
id: client.id,
|
||||
};
|
||||
});
|
||||
const client = await clientHandler.finialiseClient(clientId);
|
||||
await certificateAuthority.storeClientCertificate(clientId, bundle);
|
||||
|
||||
return {
|
||||
private: bundle.priv,
|
||||
certificate: bundle.cert,
|
||||
id: client.id,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@ -1,17 +1,25 @@
|
||||
import { ArkErrors, type } from "arktype";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import clientHandler from "~/server/internal/clients/handler";
|
||||
import sessionHandler from "~/server/internal/session";
|
||||
|
||||
const Query = type({
|
||||
id: "string",
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
type APIQuery = typeof Query.inferIn;
|
||||
|
||||
/**
|
||||
* Fetch details about an authorization request, and claim it for the current user
|
||||
*/
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const user = await sessionHandler.getSession(h3);
|
||||
if (!user) throw createError({ statusCode: 403 });
|
||||
const userId = await aclManager.getUserIdACL(h3, []);
|
||||
if (!userId) throw createError({ statusCode: 403 });
|
||||
|
||||
const query = getQuery(h3);
|
||||
const providedClientId = query.id?.toString();
|
||||
if (!providedClientId)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Provide client ID in request params as 'id'",
|
||||
});
|
||||
const query = Query(getQuery(h3));
|
||||
if (query instanceof ArkErrors)
|
||||
throw createError({ statusCode: 400, statusMessage: query.summary });
|
||||
const providedClientId = query.id;
|
||||
|
||||
const client = await clientHandler.fetchClient(providedClientId);
|
||||
if (!client)
|
||||
@ -20,13 +28,13 @@ export default defineEventHandler(async (h3) => {
|
||||
statusMessage: "Request not found.",
|
||||
});
|
||||
|
||||
if (client.userId && user.userId !== client.userId)
|
||||
if (client.userId && userId !== client.userId)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Client already claimed.",
|
||||
});
|
||||
|
||||
await clientHandler.attachUserId(providedClientId, user.userId);
|
||||
await clientHandler.attachUserId(providedClientId, userId);
|
||||
|
||||
return client.data;
|
||||
});
|
||||
|
||||
@ -17,55 +17,61 @@ const ClientAuthInitiate = type({
|
||||
mode: type.valueOf(AuthMode).default(AuthMode.Callback),
|
||||
}).configure(throwingArktype);
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const body = await readDropValidatedBody(h3, ClientAuthInitiate);
|
||||
/**
|
||||
* Client route to initiate authorization flow.
|
||||
* @response The requested callback or code.
|
||||
*/
|
||||
export default defineEventHandler<{ body: typeof ClientAuthInitiate.infer }>(
|
||||
async (h3) => {
|
||||
const body = await readDropValidatedBody(h3, ClientAuthInitiate);
|
||||
|
||||
const platformRaw = body.platform;
|
||||
const capabilities: Partial<CapabilityConfiguration> =
|
||||
body.capabilities ?? {};
|
||||
const platformRaw = body.platform;
|
||||
const capabilities: Partial<CapabilityConfiguration> =
|
||||
body.capabilities ?? {};
|
||||
|
||||
const platform = parsePlatform(platformRaw);
|
||||
if (!platform)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid or unsupported platform",
|
||||
const platform = parsePlatform(platformRaw);
|
||||
if (!platform)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid or unsupported platform",
|
||||
});
|
||||
|
||||
const capabilityIterable = Object.entries(capabilities) as Array<
|
||||
[InternalClientCapability, object]
|
||||
>;
|
||||
if (
|
||||
capabilityIterable.length > 0 &&
|
||||
capabilityIterable
|
||||
.map(([capability]) => validCapabilities.find((v) => capability == v))
|
||||
.filter((e) => e).length == 0
|
||||
)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid capabilities.",
|
||||
});
|
||||
|
||||
if (
|
||||
capabilityIterable.length > 0 &&
|
||||
capabilityIterable.filter(
|
||||
([capability, configuration]) =>
|
||||
!capabilityManager.validateCapabilityConfiguration(
|
||||
capability,
|
||||
configuration,
|
||||
),
|
||||
).length > 0
|
||||
)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid capability configuration.",
|
||||
});
|
||||
|
||||
const result = await clientHandler.initiate({
|
||||
name: body.name,
|
||||
platform,
|
||||
capabilities,
|
||||
mode: body.mode,
|
||||
});
|
||||
|
||||
const capabilityIterable = Object.entries(capabilities) as Array<
|
||||
[InternalClientCapability, object]
|
||||
>;
|
||||
if (
|
||||
capabilityIterable.length > 0 &&
|
||||
capabilityIterable
|
||||
.map(([capability]) => validCapabilities.find((v) => capability == v))
|
||||
.filter((e) => e).length == 0
|
||||
)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid capabilities.",
|
||||
});
|
||||
|
||||
if (
|
||||
capabilityIterable.length > 0 &&
|
||||
capabilityIterable.filter(
|
||||
([capability, configuration]) =>
|
||||
!capabilityManager.validateCapabilityConfiguration(
|
||||
capability,
|
||||
configuration,
|
||||
),
|
||||
).length > 0
|
||||
)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid capability configuration.",
|
||||
});
|
||||
|
||||
const result = await clientHandler.initiate({
|
||||
name: body.name,
|
||||
platform,
|
||||
capabilities,
|
||||
mode: body.mode,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user