mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-10 04:22:09 +10:00
Merge branch 'develop' into redistributable
This commit is contained in:
@ -42,6 +42,8 @@ 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,6 +45,7 @@ import {
|
||||
LockClosedIcon,
|
||||
DevicePhoneMobileIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
CodeBracketIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import { UserIcon } from "@heroicons/vue/24/solid";
|
||||
import type { Component } from "vue";
|
||||
@ -73,6 +74,12 @@ 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",
|
||||
|
||||
267
components/Modal/CreateToken.vue
Normal file
267
components/Modal/CreateToken.vue
Normal file
@ -0,0 +1,267 @@
|
||||
<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>
|
||||
@ -46,10 +46,28 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => {
|
||||
});
|
||||
const request = requestParts.join("/");
|
||||
|
||||
// If not in setup
|
||||
if (!getCurrentInstance()?.proxy) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Excessive stack depth comparing types
|
||||
return await $fetch(request, opts);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const id = request.toString();
|
||||
@ -64,26 +82,10 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => {
|
||||
}
|
||||
|
||||
const headers = useRequestHeaders(["cookie", "authorization"]);
|
||||
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;
|
||||
}
|
||||
const data = await $fetch(request, {
|
||||
...opts,
|
||||
headers: { ...headers, ...opts?.headers },
|
||||
});
|
||||
if (import.meta.server) state.value = data;
|
||||
return data;
|
||||
};
|
||||
|
||||
Submodule drop-base updated: 04125e89be...4c42edf5ad
@ -19,6 +19,28 @@
|
||||
"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"
|
||||
},
|
||||
@ -241,7 +263,11 @@
|
||||
"admin": {
|
||||
"admin": "Admin",
|
||||
"metadata": "Meta",
|
||||
"settings": "Settings",
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"store": "Store",
|
||||
"tokens": "API tokens"
|
||||
},
|
||||
"tasks": "Tasks",
|
||||
"users": "Users"
|
||||
},
|
||||
|
||||
@ -200,7 +200,7 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
|
||||
icon: RectangleStackIcon,
|
||||
},
|
||||
{
|
||||
label: $t("header.admin.settings"),
|
||||
label: $t("header.admin.settings.title"),
|
||||
route: "/admin/settings",
|
||||
prefix: "/admin/settings",
|
||||
icon: Cog6ToothIcon,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "drop",
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@ -21,7 +21,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordapp/twemoji": "^16.0.1",
|
||||
"@drop-oss/droplet": "2.3.1",
|
||||
"@drop-oss/droplet": "3.0.1",
|
||||
"@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.0.0",
|
||||
"vite-plugin-static-copy": "^3.1.2",
|
||||
"vue": "latest",
|
||||
"vue-router": "latest",
|
||||
"vue3-carousel": "^0.16.0",
|
||||
|
||||
229
pages/account/tokens.vue
Normal file
229
pages/account/tokens.vue
Normal file
@ -0,0 +1,229 @@
|
||||
<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>
|
||||
68
pages/admin/settings.vue
Normal file
68
pages/admin/settings.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<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,68 +1,55 @@
|
||||
<template>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
233
pages/admin/settings/tokens.vue
Normal file
233
pages/admin/settings/tokens.vue
Normal file
@ -0,0 +1,233 @@
|
||||
<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>
|
||||
@ -0,0 +1,8 @@
|
||||
-- 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));
|
||||
@ -0,0 +1,5 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "GameTag_name_idx";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));
|
||||
@ -45,6 +45,8 @@ model APIToken {
|
||||
|
||||
acls String[]
|
||||
|
||||
expiresAt DateTime?
|
||||
|
||||
@@index([token])
|
||||
}
|
||||
|
||||
|
||||
@ -24,16 +24,8 @@ export default defineEventHandler(async (h3) => {
|
||||
versionIndex: "asc",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
versionId: true,
|
||||
versionName: true,
|
||||
platform: true,
|
||||
gameVersion: {
|
||||
select: {
|
||||
versionIndex: true,
|
||||
delta: true,
|
||||
},
|
||||
},
|
||||
omit: {
|
||||
dropletManifest: true,
|
||||
},
|
||||
},
|
||||
tags: true,
|
||||
|
||||
23
server/api/v1/admin/token/[id]/index.delete.ts
Normal file
23
server/api/v1/admin/token/[id]/index.delete.ts
Normal file
@ -0,0 +1,23 @@
|
||||
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;
|
||||
});
|
||||
9
server/api/v1/admin/token/acls.get.ts
Normal file
9
server/api/v1/admin/token/acls.get.ts
Normal file
@ -0,0 +1,9 @@
|
||||
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;
|
||||
});
|
||||
15
server/api/v1/admin/token/index.get.ts
Normal file
15
server/api/v1/admin/token/index.get.ts
Normal file
@ -0,0 +1,15 @@
|
||||
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;
|
||||
});
|
||||
38
server/api/v1/admin/token/index.post.ts
Normal file
38
server/api/v1/admin/token/index.post.ts
Normal file
@ -0,0 +1,38 @@
|
||||
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;
|
||||
});
|
||||
@ -15,7 +15,7 @@ export default defineEventHandler(async (h3) => {
|
||||
if (userId == "system")
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Cannot delete system user.",
|
||||
statusMessage: "Cannot fetch system user.",
|
||||
});
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
|
||||
6
server/api/v1/token.get.ts
Normal file
6
server/api/v1/token.get.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const acls = await aclManager.fetchAllACLs(h3);
|
||||
return acls;
|
||||
});
|
||||
@ -1,30 +1,22 @@
|
||||
import { type } from "arktype";
|
||||
import { APITokenMode } from "~/prisma/client/enums";
|
||||
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
|
||||
import aclManager, { userACLs } 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 userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication
|
||||
if (!userId) throw createError({ statusCode: 403 });
|
||||
|
||||
const body = await readBody(h3);
|
||||
const name: string = body.name;
|
||||
const acls: string[] = body.acls;
|
||||
const body = await readDropValidatedBody(h3, CreateToken);
|
||||
|
||||
if (!name || typeof name !== "string")
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Token name required",
|
||||
});
|
||||
if (!acls || !Array.isArray(acls))
|
||||
throw createError({ statusCode: 400, statusMessage: "ACLs required" });
|
||||
|
||||
if (acls.length == 0)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Token requires more than zero ACLs",
|
||||
});
|
||||
|
||||
const invalidACLs = acls.filter(
|
||||
const invalidACLs = body.acls.filter(
|
||||
(e) => userACLs.findIndex((v) => e == v) == -1,
|
||||
);
|
||||
if (invalidACLs.length > 0)
|
||||
@ -36,9 +28,10 @@ export default defineEventHandler(async (h3) => {
|
||||
const token = await prisma.aPIToken.create({
|
||||
data: {
|
||||
mode: APITokenMode.User,
|
||||
name: name,
|
||||
name: body.name,
|
||||
userId: userId,
|
||||
acls: acls,
|
||||
acls: body.acls,
|
||||
expiresAt: body.expiry ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -36,7 +36,7 @@ export const userACLDescriptions: ObjectFromList<typeof userACLs> = {
|
||||
"library:remove": "Remove a game from your library.",
|
||||
|
||||
"clients:read": "Read the clients connected to this account",
|
||||
"clients:revoke": "",
|
||||
"clients:revoke": "Remove clients connected to this account",
|
||||
|
||||
"news:read": "Read the server's news articles.",
|
||||
|
||||
|
||||
@ -58,7 +58,7 @@ class DownloadContextManager {
|
||||
async cleanup() {
|
||||
for (const key of this.contexts.keys()) {
|
||||
const context = this.contexts.get(key)!;
|
||||
if (context.timeout.getDate() + TIMEOUT < Date.now()) {
|
||||
if (context.timeout.getTime() < Date.now() - TIMEOUT) {
|
||||
this.contexts.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@ -186,6 +186,8 @@ class LibraryManager {
|
||||
".sh",
|
||||
// No extension is common for Linux binaries
|
||||
"",
|
||||
// AppImages
|
||||
".appimage",
|
||||
],
|
||||
Windows: [".exe", ".bat"],
|
||||
macOS: [
|
||||
|
||||
116
yarn.lock
116
yarn.lock
@ -342,71 +342,71 @@
|
||||
jsonfile "^5.0.0"
|
||||
universalify "^0.1.2"
|
||||
|
||||
"@drop-oss/droplet-darwin-arm64@2.3.1":
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-arm64/-/droplet-darwin-arm64-2.3.1.tgz#ee1c32b4f180fd40bde57b4d428bdf51119cf6c8"
|
||||
integrity sha512-s0qORIlGTyjnDL2W6Eljoz8yloBXVTpuyiEqnOCVTTtse3uLgqG+78wF2sVhiCKKCNuNwELv5icomJ2k4w2K5w==
|
||||
"@drop-oss/droplet-darwin-arm64@3.0.1":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-arm64/-/droplet-darwin-arm64-3.0.1.tgz#37acbeaedcf28623c18b545aa2ed9205533a7128"
|
||||
integrity sha512-LXe8vsXUBL96boI78H6oXpSaPVwF4cCwJ5l/QVtsOWMebNo6gk9wICDZ+5IoR/Ol32t1a1lk+DjbD1zeGenPxg==
|
||||
|
||||
"@drop-oss/droplet-darwin-universal@2.3.1":
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-universal/-/droplet-darwin-universal-2.3.1.tgz#c6529184bd959d326c85ed97c7e8d2b07529abbb"
|
||||
integrity sha512-L1fZjj2LQpLAesNtP3s0PzFPsJ77nVbyXbBeQGaoaq5kF0/npO4NYQ/7flkllGhIlJf/OQENWcARvfablM/G8g==
|
||||
"@drop-oss/droplet-darwin-universal@3.0.1":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-universal/-/droplet-darwin-universal-3.0.1.tgz#8e90214758ae03e2e37501a107e5a8acaeec6d32"
|
||||
integrity sha512-Mf2gjC24u6s8djV/3slZvwdr4+h0qBu2OYXBUSDfR4H/VJwV5TstnWVKF+U8d1hjmHE9eLO8elbGNnpQmSoTOQ==
|
||||
|
||||
"@drop-oss/droplet-darwin-x64@2.3.1":
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-x64/-/droplet-darwin-x64-2.3.1.tgz#711855d85821080ac11e80b81ec91df1eaa23405"
|
||||
integrity sha512-4UvveQRCq10iEoVfArdCeBuQg51jbn5tkW68LXRoFWzicQz6MFhbowVmzz5RbjYcT5kYwI4c7jBZgeKBGOvc+g==
|
||||
"@drop-oss/droplet-darwin-x64@3.0.1":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-x64/-/droplet-darwin-x64-3.0.1.tgz#602cf4e7cb1ceda4ef95673f61542025b9215e9a"
|
||||
integrity sha512-4IIDl/E+hzZ2Vt9m4FMPlZEXwj1EwE6qXyUidACK6TTFqpjLpsEHKuhv1FOxGyJ8qkvagtyPCc+cs1TxoZD6FA==
|
||||
|
||||
"@drop-oss/droplet-linux-arm64-gnu@2.3.1":
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-gnu/-/droplet-linux-arm64-gnu-2.3.1.tgz#f3a3a00239a9063bba1fbcb2209303b337cd862d"
|
||||
integrity sha512-URlpIZZDG/u2pwxM+PaWK240+Phr4RETzwcUQyERAPv22vTjl4l/s4sYa4o4fHX/eosgNLncO+jYgO0f9FS3kg==
|
||||
"@drop-oss/droplet-linux-arm64-gnu@3.0.1":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-gnu/-/droplet-linux-arm64-gnu-3.0.1.tgz#a49d1998229fafbd42ac4b8fc5f67754ab1ac49c"
|
||||
integrity sha512-klGvlLf1xSMT3iYsIAaBbmbir1ZJWtcVyOMUlsfc1lkJ8mgyB+PrW4BsnYj7Pp4G34n7WsOChjC8TdJDBBuBWg==
|
||||
|
||||
"@drop-oss/droplet-linux-arm64-musl@2.3.1":
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-musl/-/droplet-linux-arm64-musl-2.3.1.tgz#8743132ad2fb050ef5f3cd5ddbe93b1b35ebda73"
|
||||
integrity sha512-+m5IVmD4J979KggUSpyrE4PD4mjHAnvJ8EY2AOMSxmCu3JmONMIKHNmx1TA/k9PlesGKe6GjAs2WGlxwZRCZ2g==
|
||||
"@drop-oss/droplet-linux-arm64-musl@3.0.1":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-musl/-/droplet-linux-arm64-musl-3.0.1.tgz#3e0ffee4f0aba051c244236aecdb5c1221c1b999"
|
||||
integrity sha512-oOjvGETlrJGC1RlNhUoVS9N89Rn/0DqBauVz3BBFjJTKSd5jU3/gLzwgmfkKDGVEU5lyGPAn2WQroiESEG9wdA==
|
||||
|
||||
"@drop-oss/droplet-linux-riscv64-gnu@2.3.1":
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-riscv64-gnu/-/droplet-linux-riscv64-gnu-2.3.1.tgz#d7f03d78f107a04f952c8958a024c27df6ad9818"
|
||||
integrity sha512-AkSbSjt8nvcuxJYBeEvGGh6HaQSPO+OeLYUjc4pBZDACUOULHLWnZ5LdD/GUN97JcZOfq+BYfP50mz/1BvWoSA==
|
||||
"@drop-oss/droplet-linux-riscv64-gnu@3.0.1":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-riscv64-gnu/-/droplet-linux-riscv64-gnu-3.0.1.tgz#2208f1a038d54ced68d1537c4daa964b115d4e5c"
|
||||
integrity sha512-Zf3gUsWq9Hqb275MOi7PJDhmJz7Qa/Y1XMen880bxPaOeDFqFOoKUxUr2/qv1MYp6tT3zO27NprGsHirYWqsyA==
|
||||
|
||||
"@drop-oss/droplet-linux-x64-gnu@2.3.1":
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-gnu/-/droplet-linux-x64-gnu-2.3.1.tgz#7deff7a3de0697de13685c81ec4e9c8e6186ed0a"
|
||||
integrity sha512-jaswkLtTJ4U5GhyBn+86r7cEf8dnHQmcklxomEA8P/DWTHkab1g23XlgaSGHX/FaiRpKAxXGNLTPyY8lR6wePQ==
|
||||
"@drop-oss/droplet-linux-x64-gnu@3.0.1":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-gnu/-/droplet-linux-x64-gnu-3.0.1.tgz#ffe2e39f978d32858a003f0c28614a8a4d1bdeef"
|
||||
integrity sha512-sskblycJdtNJVnRHjPHhwHkQUfQNaDIWDzXOzEaBPOcDKqYA7od7VMDAseqBkrKDn7l8bBUtRXFAipdsO8hffw==
|
||||
|
||||
"@drop-oss/droplet-linux-x64-musl@2.3.1":
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-musl/-/droplet-linux-x64-musl-2.3.1.tgz#31a2f5107817a59ff5a516cd1912261c85b2e0c7"
|
||||
integrity sha512-xn+W31FOnPVvkoxks104QusnHS1/J0V4Lwzegu3hPjPpZnc0YqFEo74pcxb2VfltgjMTtBOmzyKaApt9Snhd8Q==
|
||||
"@drop-oss/droplet-linux-x64-musl@3.0.1":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-musl/-/droplet-linux-x64-musl-3.0.1.tgz#4bd501eeeddfdaf3c49e6508cc1798419b0c78cc"
|
||||
integrity sha512-lh+1M6UAf5+ET1/ZEFRsB3shFHjkT/9Ql9akr/vyUue91TWPmP71meqVkCugWDhP6lxBt56jg2VVrJfmPAsK6w==
|
||||
|
||||
"@drop-oss/droplet-win32-arm64-msvc@2.3.1":
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-arm64-msvc/-/droplet-win32-arm64-msvc-2.3.1.tgz#4cf431b654b33c58df4cef256e84881b0556a046"
|
||||
integrity sha512-MwDz/4SfFSCEYzGCsptVD38amB18c2MSheJM+mV7Y8qjVDfjzAGrdX9923e8BYIm8eY8ypnOQBTy4/qae8+3uw==
|
||||
"@drop-oss/droplet-win32-arm64-msvc@3.0.1":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-arm64-msvc/-/droplet-win32-arm64-msvc-3.0.1.tgz#9308c75d22773fbb78bba0286c101870b3eaf5f6"
|
||||
integrity sha512-caQDPoDNJyyJXUEijw+hGTy0wmCrW5efTqBwnvMcQ282EOilg1d5WeJ31pfEcuLYF4MK1t9uaLcG6jZ9YLtzEQ==
|
||||
|
||||
"@drop-oss/droplet-win32-x64-msvc@2.3.1":
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-x64-msvc/-/droplet-win32-x64-msvc-2.3.1.tgz#01b33c5241cca9aa026605cbb18027d82f57cc6f"
|
||||
integrity sha512-5FRfblFZBI9jpLNS0z+QSqYzxpDg3YSTP/2LvEqI2Z7ZYC02j4SKnW/+C4zzblNuR3e/igfaP0I9yBv4uIb6ew==
|
||||
"@drop-oss/droplet-win32-x64-msvc@3.0.1":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-x64-msvc/-/droplet-win32-x64-msvc-3.0.1.tgz#3f50f1328bd7aafd8dfe7edd0413f13217cbc9ce"
|
||||
integrity sha512-bp8KwewF/T3JkVeJWkg86U3b0cGQD9i8k92x6HYPtnF5nLPAb2UIUEJgmYYFNPFe36RECBV7PIIG0ujdT1ELQw==
|
||||
|
||||
"@drop-oss/droplet@2.3.1":
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet/-/droplet-2.3.1.tgz#664e42bf0b205ffa06f5d5f48f0080012145d8c2"
|
||||
integrity sha512-oar7wvwMC1Evau4lXYcGerFcK/HqTi2Fn/4mcBldIF7B33NhsXhg4RncSNGS5J0WfwG4rkbI249nbs4aDeIrFg==
|
||||
"@drop-oss/droplet@3.0.1":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@drop-oss/droplet/-/droplet-3.0.1.tgz#e7f6772aa1f94010d41086fc8a1f396a5d392184"
|
||||
integrity sha512-YhtgpwNqEHO8R03yf9Xb5LXuaLWkQvY+2lxOD1PwzpGI5V9PKlDE+x1IJBmdBF5bDPDGk9MxQidGtnYQuAEBEA==
|
||||
optionalDependencies:
|
||||
"@drop-oss/droplet-darwin-arm64" "2.3.1"
|
||||
"@drop-oss/droplet-darwin-universal" "2.3.1"
|
||||
"@drop-oss/droplet-darwin-x64" "2.3.1"
|
||||
"@drop-oss/droplet-linux-arm64-gnu" "2.3.1"
|
||||
"@drop-oss/droplet-linux-arm64-musl" "2.3.1"
|
||||
"@drop-oss/droplet-linux-riscv64-gnu" "2.3.1"
|
||||
"@drop-oss/droplet-linux-x64-gnu" "2.3.1"
|
||||
"@drop-oss/droplet-linux-x64-musl" "2.3.1"
|
||||
"@drop-oss/droplet-win32-arm64-msvc" "2.3.1"
|
||||
"@drop-oss/droplet-win32-x64-msvc" "2.3.1"
|
||||
"@drop-oss/droplet-darwin-arm64" "3.0.1"
|
||||
"@drop-oss/droplet-darwin-universal" "3.0.1"
|
||||
"@drop-oss/droplet-darwin-x64" "3.0.1"
|
||||
"@drop-oss/droplet-linux-arm64-gnu" "3.0.1"
|
||||
"@drop-oss/droplet-linux-arm64-musl" "3.0.1"
|
||||
"@drop-oss/droplet-linux-riscv64-gnu" "3.0.1"
|
||||
"@drop-oss/droplet-linux-x64-gnu" "3.0.1"
|
||||
"@drop-oss/droplet-linux-x64-musl" "3.0.1"
|
||||
"@drop-oss/droplet-win32-arm64-msvc" "3.0.1"
|
||||
"@drop-oss/droplet-win32-x64-msvc" "3.0.1"
|
||||
|
||||
"@emnapi/core@^1.4.3":
|
||||
version "1.4.5"
|
||||
@ -9124,10 +9124,10 @@ vite-plugin-inspect@^11.3.0:
|
||||
unplugin-utils "^0.2.4"
|
||||
vite-dev-rpc "^1.1.0"
|
||||
|
||||
vite-plugin-static-copy@^3.0.0:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.1.tgz#25d6f52c9a760d2d2e84d0803a37e3310aed644a"
|
||||
integrity sha512-oR53SkL5cX4KT1t18E/xU50vJDo0N8oaHza4EMk0Fm+2/u6nQivxavOfrDk3udWj+dizRizB/QnBvJOOQrTTAQ==
|
||||
vite-plugin-static-copy@^3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.2.tgz#5d5e6ce965e5da6a326d47a5feb5033d52db43ca"
|
||||
integrity sha512-aVmYOzptLVOI2b1jL+cmkF7O6uhRv1u5fvOkQgbohWZp2CbR22kn9ZqkCUIt9umKF7UhdbsEpshn1rf4720QFg==
|
||||
dependencies:
|
||||
chokidar "^3.6.0"
|
||||
fs-extra "^11.3.0"
|
||||
|
||||
Reference in New Issue
Block a user