mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-09 20:12:10 +10:00
feat: user page & $dropFetch util
This commit is contained in:
@ -115,7 +115,7 @@ const inCollections = computed(() =>
|
||||
async function toggleLibrary() {
|
||||
isLibraryLoading.value = true;
|
||||
try {
|
||||
await $fetch("/api/v1/collection/default/entry", {
|
||||
await $dropFetch("/api/v1/collection/default/entry", {
|
||||
method: inLibrary.value ? "DELETE" : "POST",
|
||||
body: {
|
||||
id: props.gameId,
|
||||
@ -142,7 +142,7 @@ async function toggleCollection(id: string) {
|
||||
if (!collection) return;
|
||||
const index = collection.entries.findIndex((e) => e.gameId == props.gameId);
|
||||
|
||||
await $fetch(`/api/v1/collection/${id}/entry`, {
|
||||
await $dropFetch(`/api/v1/collection/${id}/entry`, {
|
||||
method: index == -1 ? "POST" : "DELETE",
|
||||
body: {
|
||||
id: props.gameId,
|
||||
|
||||
@ -72,14 +72,14 @@ async function createCollection() {
|
||||
createCollectionLoading.value = true;
|
||||
|
||||
// Create the collection
|
||||
const response = await $fetch("/api/v1/collection", {
|
||||
const response = await $dropFetch("/api/v1/collection", {
|
||||
method: "POST",
|
||||
body: { name: collectionName.value },
|
||||
});
|
||||
|
||||
// Add the game if provided
|
||||
if (props.gameId) {
|
||||
const entry = await $fetch<
|
||||
const entry = await $dropFetch<
|
||||
CollectionEntry & { game: SerializeObject<Game> }
|
||||
>(`/api/v1/collection/${response.id}/entry`, {
|
||||
method: "POST",
|
||||
|
||||
@ -48,7 +48,7 @@ async function deleteCollection() {
|
||||
if (!collection.value) return;
|
||||
|
||||
deleteLoading.value = true;
|
||||
await $fetch(`/api/v1/collection/${collection.value.id}`, {
|
||||
await $dropFetch(`/api/v1/collection/${collection.value.id}`, {
|
||||
// @ts-ignore
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
@ -348,7 +348,7 @@ async function createArticle() {
|
||||
formData.append("content", newArticle.value.content);
|
||||
formData.append("tags", JSON.stringify(newArticle.value.tags));
|
||||
|
||||
await $fetch("/api/v1/admin/news", {
|
||||
await $dropFetch("/api/v1/admin/news", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
@ -48,7 +48,7 @@ import type { Notification } from "@prisma/client";
|
||||
const props = defineProps<{ notification: Notification }>();
|
||||
|
||||
async function deleteMe() {
|
||||
await $fetch(`/api/v1/notifications/${props.notification.id}`, {
|
||||
await $dropFetch(`/api/v1/notifications/${props.notification.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const notifications = useNotifications();
|
||||
|
||||
@ -146,7 +146,7 @@ async function uploadFile() {
|
||||
}
|
||||
}
|
||||
|
||||
const result = await $fetch(props.endpoint, { method: "POST", body: form });
|
||||
const result = await $dropFetch(props.endpoint, { method: "POST", body: form });
|
||||
open.value = false;
|
||||
file.value = undefined;
|
||||
emit("upload", result);
|
||||
|
||||
@ -10,7 +10,7 @@ export const useCollections = async () => {
|
||||
const state = useState<FullCollection[]>("collections", () => undefined);
|
||||
if (state.value === undefined) {
|
||||
const headers = useRequestHeaders(["cookie"]);
|
||||
state.value = await $fetch<FullCollection[]>("/api/v1/collection", {
|
||||
state.value = await $dropFetch<FullCollection[]>("/api/v1/collection", {
|
||||
headers,
|
||||
});
|
||||
}
|
||||
@ -20,7 +20,7 @@ export const useCollections = async () => {
|
||||
|
||||
export async function refreshCollection(id: string) {
|
||||
const state = useState<FullCollection[]>("collections");
|
||||
const collection = await $fetch<FullCollection>(`/api/v1/collection/${id}`);
|
||||
const collection = await $dropFetch<FullCollection>(`/api/v1/collection/${id}`);
|
||||
const index = state.value.findIndex((e) => e.id == id);
|
||||
if (index == -1) {
|
||||
state.value.push(collection);
|
||||
@ -42,7 +42,7 @@ export const useLibrary = async () => {
|
||||
export async function refreshLibrary() {
|
||||
const state = useState<FullCollection>("library");
|
||||
const headers = useRequestHeaders(["cookie"]);
|
||||
state.value = await $fetch<FullCollection>("/api/v1/collection/default", {
|
||||
state.value = await $dropFetch<FullCollection>("/api/v1/collection/default", {
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ export const useNews = () => {
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
return await $fetch(`/api/v1/admin/news/${id}`, {
|
||||
return await $dropFetch(`/api/v1/admin/news/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
|
||||
37
composables/request.ts
Normal file
37
composables/request.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import type {
|
||||
$Fetch,
|
||||
ExtractedRouteMethod,
|
||||
NitroFetchOptions,
|
||||
NitroFetchRequest,
|
||||
TypedInternalResponse,
|
||||
} from "nitropack/types";
|
||||
|
||||
interface DropFetch<
|
||||
DefaultT = unknown,
|
||||
DefaultR extends NitroFetchRequest = NitroFetchRequest
|
||||
> {
|
||||
<
|
||||
T = DefaultT,
|
||||
R extends NitroFetchRequest = DefaultR,
|
||||
O extends NitroFetchOptions<R> = NitroFetchOptions<R>
|
||||
>(
|
||||
request: R,
|
||||
opts?: O
|
||||
): Promise<
|
||||
// @ts-ignore
|
||||
TypedInternalResponse<
|
||||
R,
|
||||
T,
|
||||
NitroFetchOptions<R> extends O ? "get" : ExtractedRouteMethod<R, O>
|
||||
>
|
||||
>;
|
||||
}
|
||||
|
||||
export const $dropFetch: DropFetch = async (request, opts) => {
|
||||
if (!getCurrentInstance()?.proxy) {
|
||||
return (await $fetch(request, opts)) as any;
|
||||
}
|
||||
const { data, error } = await useFetch(request, opts as any);
|
||||
if (error.value) throw error.value;
|
||||
return data.value as any;
|
||||
};
|
||||
@ -12,5 +12,5 @@ export const updateUser = async () => {
|
||||
if (user.value === null) return;
|
||||
|
||||
// SSR calls have to be after uses
|
||||
user.value = await $fetch<User | null>("/api/v1/user", { headers });
|
||||
user.value = await $dropFetch<User | null>("/api/v1/user", { headers });
|
||||
};
|
||||
|
||||
@ -167,6 +167,8 @@ import {
|
||||
Cog6ToothIcon,
|
||||
FlagIcon,
|
||||
BellIcon,
|
||||
DocumentIcon,
|
||||
UserGroupIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import type { NavigationItem } from "~/composables/types";
|
||||
import { useCurrentNavigationIndex } from "~/composables/current-page-engine";
|
||||
@ -182,10 +184,16 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
|
||||
icon: ServerStackIcon,
|
||||
},
|
||||
{
|
||||
label: "Auth",
|
||||
route: "/admin/auth",
|
||||
prefix: "/admin/auth",
|
||||
icon: LockClosedIcon,
|
||||
label: "Meta",
|
||||
route: "/admin/metadata",
|
||||
prefix: "/admin/metadata",
|
||||
icon: DocumentIcon,
|
||||
},
|
||||
{
|
||||
label: "Users",
|
||||
route: "/admin/users",
|
||||
prefix: "/admin/users",
|
||||
icon: UserGroupIcon,
|
||||
},
|
||||
{
|
||||
label: "Settings",
|
||||
|
||||
@ -7,15 +7,24 @@
|
||||
<div
|
||||
class="relative flex h-full flex-col overflow-hidden rounded-[calc(var(--radius-lg)+1px)] lg:rounded-l-[calc(2rem+1px)]"
|
||||
>
|
||||
<div class="px-8 pt-8 pb-3 sm:px-10 sm:pt-10 sm:pb-0">
|
||||
<div class="px-8 pt-8 pb-3 sm:px-10 sm:py-10">
|
||||
<p
|
||||
class="mt-2 text-lg font-medium tracking-tight text-zinc-100 max-lg:text-center"
|
||||
>
|
||||
Library
|
||||
</p>
|
||||
<p class="mt-2 max-w-lg text-sm/6 text-zinc-400 max-lg:text-center">
|
||||
Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui
|
||||
lorem cupidatat commodo.
|
||||
Manage your Drop library, and import new games. Your library is the
|
||||
list of all games currently configured on this instance.
|
||||
</p>
|
||||
<p class="mt-3 text-sm">
|
||||
<NuxtLink
|
||||
href="/admin/library"
|
||||
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
|
||||
>
|
||||
Check it out
|
||||
<span aria-hidden="true"> →</span>
|
||||
</NuxtLink>
|
||||
</p>
|
||||
|
||||
<div
|
||||
@ -45,31 +54,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl
|
||||
class="mt-4 grid max-w-xl grid-cols-1 gap-8 sm:grid-cols-2 xl:mt-8"
|
||||
>
|
||||
<div class="flex flex-col gap-y-3 border-l border-zinc-100/10 pl-6">
|
||||
<dt class="text-sm/6 text-zinc-400">Games</dt>
|
||||
<dd
|
||||
class="order-first text-3xl font-semibold tracking-tight text-zinc-100"
|
||||
>
|
||||
{{ libraryState.games.length }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-y-3 border-l border-zinc-100/10 pl-6">
|
||||
<dt class="text-sm/6 text-zinc-400">Versions</dt>
|
||||
<dd
|
||||
class="order-first text-3xl font-semibold tracking-tight text-zinc-100"
|
||||
>
|
||||
{{
|
||||
libraryState.games
|
||||
.map((e) => e.game.versions.length)
|
||||
.reduce((a, b) => a + b, 0)
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@ -78,30 +62,30 @@
|
||||
</div>
|
||||
<div class="relative max-lg:row-start-1">
|
||||
<div
|
||||
class="absolute inset-px rounded-lg bg-white max-lg:rounded-t-[2rem]"
|
||||
class="absolute inset-px rounded-lg bg-zinc-950 max-lg:rounded-t-[2rem]"
|
||||
/>
|
||||
<div
|
||||
class="relative flex h-full flex-col overflow-hidden rounded-[calc(var(--radius-lg)+1px)] max-lg:rounded-t-[calc(2rem+1px)]"
|
||||
>
|
||||
<div class="px-8 pt-8 sm:px-10 sm:pt-10">
|
||||
<div class="px-8 py-8 sm:px-10 sm:py-10">
|
||||
<p
|
||||
class="mt-2 text-lg font-medium tracking-tight text-gray-950 max-lg:text-center"
|
||||
class="mt-2 text-lg font-medium tracking-tight text-zinc-100 max-lg:text-center"
|
||||
>
|
||||
Performance
|
||||
Users
|
||||
</p>
|
||||
<p class="mt-2 max-w-lg text-sm/6 text-gray-600 max-lg:text-center">
|
||||
Lorem ipsum, dolor sit amet consectetur adipisicing elit maiores
|
||||
impedit.
|
||||
<p class="mt-2 max-w-lg text-sm/6 text-zinc-400 max-lg:text-center">
|
||||
Your users are people who can access your Drop instance, download
|
||||
games from it, and configure API keys for it.
|
||||
</p>
|
||||
<p class="mt-3 text-sm">
|
||||
<NuxtLink
|
||||
href="/admin/users"
|
||||
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
|
||||
>
|
||||
Check it out
|
||||
<span aria-hidden="true"> →</span>
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 items-center justify-center px-8 max-lg:pt-10 max-lg:pb-12 sm:px-10 lg:pb-2"
|
||||
>
|
||||
<img
|
||||
class="w-full max-lg:max-w-xs"
|
||||
src="https://tailwindcss.com/plus-assets/img/component-images/bento-03-performance.png"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@ -193,5 +177,5 @@ useHead({
|
||||
});
|
||||
|
||||
const headers = useRequestHeaders(["cookie"]);
|
||||
const libraryState = await $fetch("/api/v1/admin/library", { headers });
|
||||
const libraryState = await $dropFetch("/api/v1/admin/library", { headers });
|
||||
</script>
|
||||
|
||||
@ -165,7 +165,7 @@
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-none'
|
||||
: 'text-gray-900',
|
||||
: 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
@ -321,7 +321,7 @@
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-none'
|
||||
: 'text-gray-900',
|
||||
: 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
@ -553,7 +553,7 @@ const router = useRouter();
|
||||
const route = useRoute();
|
||||
const headers = useRequestHeaders(["cookie"]);
|
||||
const gameId = route.params.id.toString();
|
||||
const versions = await $fetch(
|
||||
const versions = await $dropFetch(
|
||||
`/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}`,
|
||||
{
|
||||
headers,
|
||||
@ -561,7 +561,7 @@ const versions = await $fetch(
|
||||
);
|
||||
const currentlySelectedVersion = ref(-1);
|
||||
const versionSettings = ref<{
|
||||
platform: string;
|
||||
platform: PlatformClient | undefined;
|
||||
|
||||
onlySetup: boolean;
|
||||
launch: string;
|
||||
@ -572,7 +572,7 @@ const versionSettings = ref<{
|
||||
delta: boolean;
|
||||
umuId: string;
|
||||
}>({
|
||||
platform: "",
|
||||
platform: undefined,
|
||||
launch: "",
|
||||
launchArgs: "",
|
||||
setup: "",
|
||||
@ -582,7 +582,8 @@ const versionSettings = ref<{
|
||||
umuId: "",
|
||||
});
|
||||
|
||||
const versionGuesses = ref<Array<{ platform: string; filename: string }>>();
|
||||
const versionGuesses =
|
||||
ref<Array<{ platform: PlatformClient; filename: string }>>();
|
||||
const launchProcessQuery = ref("");
|
||||
const setupProcessQuery = ref("");
|
||||
|
||||
@ -637,17 +638,20 @@ async function updateCurrentlySelectedVersion(value: number) {
|
||||
if (currentlySelectedVersion.value == value) return;
|
||||
currentlySelectedVersion.value = value;
|
||||
const version = versions[currentlySelectedVersion.value];
|
||||
const results = await $fetch(
|
||||
const results = await $dropFetch(
|
||||
`/api/v1/admin/import/version/preload?id=${encodeURIComponent(
|
||||
gameId
|
||||
)}&version=${encodeURIComponent(version)}`
|
||||
);
|
||||
versionGuesses.value = results;
|
||||
versionGuesses.value = results.map((e) => ({
|
||||
...e,
|
||||
platform: e.platform as PlatformClient,
|
||||
}));
|
||||
}
|
||||
|
||||
async function startImport() {
|
||||
if (!versionSettings.value) return;
|
||||
const taskId = await $fetch("/api/v1/admin/import/version", {
|
||||
const taskId = await $dropFetch("/api/v1/admin/import/version", {
|
||||
method: "POST",
|
||||
body: {
|
||||
id: gameId,
|
||||
|
||||
@ -530,7 +530,7 @@ const mobileShowFinalDescription = ref(true);
|
||||
const route = useRoute();
|
||||
const gameId = route.params.id.toString();
|
||||
const headers = useRequestHeaders(["cookie"]);
|
||||
const { game: rawGame, unimportedVersions } = await $fetch(
|
||||
const { game: rawGame, unimportedVersions } = await $dropFetch(
|
||||
`/api/v1/admin/game?id=${encodeURIComponent(gameId)}`,
|
||||
{
|
||||
headers,
|
||||
@ -579,7 +579,7 @@ async function coreMetadataUpdate() {
|
||||
formData.append("name", coreMetadataName.value);
|
||||
formData.append("description", coreMetadataDescription.value);
|
||||
|
||||
const result = await $fetch(`/api/v1/admin/game/metadata`, {
|
||||
const result = await $dropFetch(`/api/v1/admin/game/metadata`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
@ -630,7 +630,7 @@ watch(descriptionHTML, (v) => {
|
||||
savingTimeout = setTimeout(async () => {
|
||||
try {
|
||||
descriptionSaving.value = 2;
|
||||
await $fetch("/api/v1/admin/game", {
|
||||
await $dropFetch("/api/v1/admin/game", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
id: gameId,
|
||||
@ -672,7 +672,7 @@ function insertImageAtCursor(id: string) {
|
||||
async function updateBannerImage(id: string) {
|
||||
try {
|
||||
if (game.value.mBannerId == id) return;
|
||||
const { mBannerId } = await $fetch("/api/v1/admin/game", {
|
||||
const { mBannerId } = await $dropFetch("/api/v1/admin/game", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
id: gameId,
|
||||
@ -698,7 +698,7 @@ async function updateBannerImage(id: string) {
|
||||
async function updateCoverImage(id: string) {
|
||||
try {
|
||||
if (game.value.mCoverId == id) return;
|
||||
const { mCoverId } = await $fetch("/api/v1/admin/game", {
|
||||
const { mCoverId } = await $dropFetch("/api/v1/admin/game", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
id: gameId,
|
||||
@ -723,7 +723,7 @@ async function updateCoverImage(id: string) {
|
||||
|
||||
async function deleteImage(id: string) {
|
||||
try {
|
||||
const { mBannerId, mImageLibrary } = await $fetch(
|
||||
const { mBannerId, mImageLibrary } = await $dropFetch(
|
||||
"/api/v1/admin/game/image",
|
||||
{
|
||||
method: "DELETE",
|
||||
@ -757,7 +757,7 @@ async function uploadAfterImageUpload(result: Game) {
|
||||
|
||||
async function deleteVersion(versionName: string) {
|
||||
try {
|
||||
await $fetch("/api/v1/admin/game/version", {
|
||||
await $dropFetch("/api/v1/admin/game/version", {
|
||||
method: "DELETE",
|
||||
body: {
|
||||
id: gameId,
|
||||
@ -785,7 +785,7 @@ async function deleteVersion(versionName: string) {
|
||||
|
||||
async function updateVersionOrder() {
|
||||
try {
|
||||
const newVersions = await $fetch("/api/v1/admin/game/version", {
|
||||
const newVersions = await $dropFetch("/api/v1/admin/game/version", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
id: gameId,
|
||||
@ -822,7 +822,7 @@ function removeImageFromCarousel(id: string) {
|
||||
|
||||
async function updateImageCarousel() {
|
||||
try {
|
||||
await $fetch("/api/v1/admin/game", {
|
||||
await $dropFetch("/api/v1/admin/game", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
id: gameId,
|
||||
|
||||
@ -158,7 +158,7 @@ definePageMeta({
|
||||
});
|
||||
|
||||
const headers = useRequestHeaders(["cookie"]);
|
||||
const games = await $fetch("/api/v1/admin/import/game", { headers });
|
||||
const games = await $dropFetch("/api/v1/admin/import/game", { headers });
|
||||
|
||||
const currentlySelectedGame = ref(-1);
|
||||
const gameSearchResultsLoading = ref(false);
|
||||
@ -174,7 +174,7 @@ async function updateSelectedGame(value: number) {
|
||||
metadataResults.value = undefined;
|
||||
currentlySelectedMetadata.value = -1;
|
||||
|
||||
const results = await $fetch(
|
||||
const results = await $dropFetch(
|
||||
`/api/v1/admin/import/game/search?q=${encodeURIComponent(game)}`
|
||||
);
|
||||
metadataResults.value = results;
|
||||
@ -199,7 +199,7 @@ const importError = ref<string | undefined>();
|
||||
async function importGame(metadata: boolean) {
|
||||
if (!metadataResults.value && metadata) return;
|
||||
|
||||
const game = await $fetch("/api/v1/admin/import/game", {
|
||||
const game = await $dropFetch("/api/v1/admin/import/game", {
|
||||
method: "POST",
|
||||
body: {
|
||||
path: games.unimportedGames[currentlySelectedGame.value],
|
||||
|
||||
@ -180,7 +180,7 @@ useHead({
|
||||
const searchQuery = ref("");
|
||||
|
||||
const headers = useRequestHeaders(["cookie"]);
|
||||
const libraryState = await $fetch("/api/v1/admin/library", { headers });
|
||||
const libraryState = await $dropFetch("/api/v1/admin/library", { headers });
|
||||
const libraryGames = ref(
|
||||
libraryState.games.map((e) => {
|
||||
const noVersions = e.status.noVersions;
|
||||
@ -210,7 +210,7 @@ const filteredLibraryGames = computed(() =>
|
||||
);
|
||||
|
||||
async function deleteGame(id: string) {
|
||||
await $fetch(`/api/v1/admin/game?id=${id}`, { method: "DELETE" });
|
||||
await $dropFetch(`/api/v1/admin/game?id=${id}`, { method: "DELETE" });
|
||||
const index = libraryGames.value.findIndex((e) => e.id === id);
|
||||
libraryGames.value.splice(index, 1);
|
||||
}
|
||||
|
||||
11
pages/admin/metadata/index.vue
Normal file
11
pages/admin/metadata/index.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<template></template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: "Metadata",
|
||||
});
|
||||
</script>
|
||||
@ -23,9 +23,7 @@
|
||||
:key="authMech.name"
|
||||
class="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-x-4 border-b border-zinc-800 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-x-4 border-b border-zinc-800 p-6">
|
||||
<component
|
||||
:is="authMech.icon"
|
||||
:alt="`${authMech.name} icon`"
|
||||
@ -101,23 +99,9 @@ import { IconsSimpleAuthenticationLogo } from "#components";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
|
||||
import { EllipsisHorizontalIcon } from "@heroicons/vue/20/solid";
|
||||
import { CheckIcon, XMarkIcon } from "@heroicons/vue/24/solid";
|
||||
import { AuthMec } from "@prisma/client";
|
||||
import type { Component } from "vue";
|
||||
|
||||
const authenticationMechanisms: Array<{
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
icon: Component;
|
||||
route: string;
|
||||
settings?: { [key: string]: string };
|
||||
}> = [
|
||||
{
|
||||
name: "Simple (username/password)",
|
||||
enabled: true,
|
||||
icon: IconsSimpleAuthenticationLogo,
|
||||
route: "/admin/auth/simple",
|
||||
},
|
||||
];
|
||||
|
||||
useHead({
|
||||
title: "Authentication",
|
||||
});
|
||||
@ -125,4 +109,25 @@ useHead({
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
const headers = useRequestHeaders(["cookie"]);
|
||||
const enabledMechanisms = await $dropFetch("/api/v1/admin/auth", {
|
||||
headers,
|
||||
});
|
||||
|
||||
const authenticationMechanisms: Array<{
|
||||
name: string;
|
||||
mec: AuthMec;
|
||||
icon: Component;
|
||||
route: string;
|
||||
enabled: boolean;
|
||||
settings?: { [key: string]: string };
|
||||
}> = [
|
||||
{
|
||||
name: "Simple (username/password)",
|
||||
mec: AuthMec.Simple,
|
||||
icon: IconsSimpleAuthenticationLogo,
|
||||
route: "/admin/users/auth/simple",
|
||||
},
|
||||
].map((e) => ({ ...e, enabled: enabledMechanisms.includes(e.mec) }));
|
||||
</script>
|
||||
@ -459,7 +459,7 @@ async function invite() {
|
||||
.add(...expiry[expiryKey.value])
|
||||
.toJSON();
|
||||
|
||||
const newInvitation = await $fetch("/api/v1/admin/auth/invitation", {
|
||||
const newInvitation = await $dropFetch("/api/v1/admin/auth/invitation", {
|
||||
method: "POST",
|
||||
body: {
|
||||
username: username.value,
|
||||
@ -495,7 +495,7 @@ function invite_wrapper() {
|
||||
}
|
||||
|
||||
async function deleteInvitation(id: string) {
|
||||
await $fetch("/api/v1/admin/auth/invitation", {
|
||||
await $dropFetch("/api/v1/admin/auth/invitation", {
|
||||
method: "DELETE",
|
||||
body: {
|
||||
id: id,
|
||||
111
pages/admin/users/index.vue
Normal file
111
pages/admin/users/index.vue
Normal file
@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-base font-semibold text-zinc-100">Users</h1>
|
||||
<p class="mt-2 text-sm text-zinc-400">
|
||||
Manage the users on your Drop instance, and configure your
|
||||
authentication methods.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
<NuxtLink
|
||||
to="/admin/users/auth"
|
||||
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm 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"
|
||||
>
|
||||
Authentication →
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 flow-root">
|
||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
<table class="min-w-full divide-y divide-zinc-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-3"
|
||||
>
|
||||
Display Name
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
Username
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
Email
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
Admin?
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
Auth Options
|
||||
</th>
|
||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-3">
|
||||
<span class="sr-only">Edit</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id" class="even:bg-zinc-800">
|
||||
<td
|
||||
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
||||
>
|
||||
{{ user.displayName }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
{{ user.username }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
{{ user.email }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
{{ user.admin ? "Admin User" : "Normal user" }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
{{ user.authMecs.map((e) => e.mec).join(", ") }}
|
||||
</td>
|
||||
<td
|
||||
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-3"
|
||||
>
|
||||
<!--
|
||||
<a href="#" class="text-blue-600 hover:text-blue-500"
|
||||
>Edit<span class="sr-only"
|
||||
>, {{ user.displayName }}</span
|
||||
></a
|
||||
>
|
||||
-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: "Users",
|
||||
});
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
const headers = useRequestHeaders(["cookie"]);
|
||||
const { data: users } = await useFetch("/api/v1/admin/users", { headers });
|
||||
</script>
|
||||
@ -175,7 +175,7 @@ const error = ref();
|
||||
const authToken = ref<string | undefined>();
|
||||
|
||||
async function authorize() {
|
||||
const { redirect, token } = await $fetch("/api/v1/client/auth/callback", {
|
||||
const { redirect, token } = await $dropFetch("/api/v1/client/auth/callback", {
|
||||
method: "POST",
|
||||
body: { id: clientId },
|
||||
});
|
||||
|
||||
@ -224,7 +224,7 @@ const loading = ref(false);
|
||||
const error = ref<string | undefined>(undefined);
|
||||
|
||||
async function register() {
|
||||
await $fetch("/api/v1/auth/signup/simple", {
|
||||
await $dropFetch("/api/v1/auth/signup/simple", {
|
||||
method: "POST",
|
||||
body: {
|
||||
invitation: invitationId,
|
||||
|
||||
@ -149,7 +149,7 @@ function signin_wrapper() {
|
||||
}
|
||||
|
||||
async function signin() {
|
||||
await $fetch("/api/v1/auth/signin/simple", {
|
||||
await $dropFetch("/api/v1/auth/signin/simple", {
|
||||
method: "POST",
|
||||
body: {
|
||||
username: username.value,
|
||||
@ -158,7 +158,7 @@ async function signin() {
|
||||
},
|
||||
});
|
||||
const user = useUser();
|
||||
user.value = await $fetch<User | null>("/api/v1/user");
|
||||
user.value = await $dropFetch<User | null>("/api/v1/user");
|
||||
}
|
||||
|
||||
definePageMeta({
|
||||
|
||||
@ -36,6 +36,6 @@ const user = useUser();
|
||||
user.value = null;
|
||||
|
||||
// Redirect to signin page after signout
|
||||
await $fetch("/signout");
|
||||
await $dropFetch("/signout");
|
||||
router.push("/signin");
|
||||
</script>
|
||||
|
||||
@ -177,7 +177,7 @@ const gameId = route.params.id.toString();
|
||||
const user = useUser();
|
||||
|
||||
const headers = useRequestHeaders(["cookie"]);
|
||||
const game = await $fetch<Game & { versions: GameVersion[] }>(
|
||||
const game = await $dropFetch<Game & { versions: GameVersion[] }>(
|
||||
`/api/v1/games/${gameId}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
@ -96,13 +96,13 @@
|
||||
import { ref, onMounted } from "vue";
|
||||
|
||||
const headers = useRequestHeaders(["cookie"]);
|
||||
const recent = await $fetch("/api/v1/store/recent", { headers });
|
||||
const updated = await $fetch("/api/v1/store/updated", { headers });
|
||||
const released = await $fetch("/api/v1/store/released", {
|
||||
const recent = await $dropFetch("/api/v1/store/recent", { headers });
|
||||
const updated = await $dropFetch("/api/v1/store/updated", { headers });
|
||||
const released = await $dropFetch("/api/v1/store/released", {
|
||||
headers,
|
||||
});
|
||||
const developers = await $fetch("/api/v1/store/developers", { headers });
|
||||
const publishers = await $fetch("/api/v1/store/publishers", { headers });
|
||||
const developers = await $dropFetch("/api/v1/store/developers", { headers });
|
||||
const publishers = await $dropFetch("/api/v1/store/publishers", { headers });
|
||||
|
||||
useHead({
|
||||
title: "Store",
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "LinkedAuthMec" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
@ -3,8 +3,9 @@ enum AuthMec {
|
||||
}
|
||||
|
||||
model LinkedAuthMec {
|
||||
userId String
|
||||
mec AuthMec
|
||||
userId String
|
||||
mec AuthMec
|
||||
enabled Boolean @default(true)
|
||||
|
||||
credentials Json
|
||||
|
||||
@ -29,7 +30,7 @@ enum APITokenMode {
|
||||
|
||||
model APIToken {
|
||||
id String @id @default(uuid())
|
||||
token String @default(uuid()) @unique
|
||||
token String @unique @default(uuid())
|
||||
mode APITokenMode
|
||||
name String
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ model User {
|
||||
id String @id @default(uuid())
|
||||
username String @unique
|
||||
admin Boolean @default(false)
|
||||
enabled Boolean @default(true)
|
||||
|
||||
email String
|
||||
displayName String
|
||||
|
||||
14
server/api/v1/admin/auth/index.get.ts
Normal file
14
server/api/v1/admin/auth/index.get.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { AuthMec } from "@prisma/client";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import { applicationSettings } from "~/server/internal/config/application-configuration";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["auth:read"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const enabledMechanisms: AuthMec[] = await applicationSettings.get(
|
||||
"enabledAuthencationMechanisms"
|
||||
);
|
||||
|
||||
return enabledMechanisms;
|
||||
});
|
||||
26
server/api/v1/admin/users/[id]/index.get.ts
Normal file
26
server/api/v1/admin/users/[id]/index.get.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["user:read"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const userId = getRouterParam(h3, "id");
|
||||
if (!userId)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "No userId in route.",
|
||||
});
|
||||
|
||||
if (userId == "system")
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Cannot delete system user.",
|
||||
});
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user)
|
||||
throw createError({ statusCode: 404, statusMessage: "User not found." });
|
||||
|
||||
return user;
|
||||
});
|
||||
@ -5,7 +5,18 @@ export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["user:read"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const users = await prisma.user.findMany({});
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
id: { not: "system" },
|
||||
},
|
||||
include: {
|
||||
authMecs: {
|
||||
select: {
|
||||
mec: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return users;
|
||||
});
|
||||
@ -5,34 +5,57 @@ import { checkHash } from "~/server/internal/security/simple";
|
||||
import sessionHandler from "~/server/internal/session";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const body = await readBody(h3);
|
||||
const body = await readBody(h3);
|
||||
|
||||
const username = body.username;
|
||||
const password = body.password;
|
||||
const rememberMe = body.rememberMe ?? false;
|
||||
if (username === undefined || password === undefined)
|
||||
throw createError({ statusCode: 403, statusMessage: "Username or password missing from request." });
|
||||
|
||||
const authMek = await prisma.linkedAuthMec.findFirst({
|
||||
where: {
|
||||
mec: AuthMec.Simple,
|
||||
credentials: {
|
||||
array_starts_with: username
|
||||
}
|
||||
}
|
||||
const username = body.username;
|
||||
const password = body.password;
|
||||
const rememberMe = body.rememberMe ?? false;
|
||||
if (username === undefined || password === undefined)
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Username or password missing from request.",
|
||||
});
|
||||
|
||||
if (!authMek) throw createError({ statusCode: 401, statusMessage: "Invalid username or password." });
|
||||
const authMek = await prisma.linkedAuthMec.findFirst({
|
||||
where: {
|
||||
mec: AuthMec.Simple,
|
||||
credentials: {
|
||||
array_starts_with: username,
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const credentials = authMek.credentials as JsonArray;
|
||||
const hash = credentials.at(1);
|
||||
if (!authMek)
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Invalid username or password.",
|
||||
});
|
||||
|
||||
if (!hash) throw createError({ statusCode: 403, statusMessage: "Invalid or disabled account. Please contact the server administrator." });
|
||||
const credentials = authMek.credentials as JsonArray;
|
||||
const hash = credentials.at(1);
|
||||
|
||||
if (!await checkHash(password, hash.toString()))
|
||||
throw createError({ statusCode: 401, statusMessage: "Invalid username or password." });
|
||||
if (!hash || !authMek.user.enabled)
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage:
|
||||
"Invalid or disabled account. Please contact the server administrator.",
|
||||
});
|
||||
|
||||
await sessionHandler.setUserId(h3, authMek.userId, rememberMe);
|
||||
if (!(await checkHash(password, hash.toString())))
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Invalid username or password.",
|
||||
});
|
||||
|
||||
return { result: true, userId: authMek.userId }
|
||||
});
|
||||
await sessionHandler.setUserId(h3, authMek.userId, rememberMe);
|
||||
|
||||
return { result: true, userId: authMek.userId };
|
||||
});
|
||||
|
||||
@ -35,6 +35,7 @@ export const userACLDescriptions: ObjectFromList<typeof userACLs> = {
|
||||
};
|
||||
|
||||
export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
|
||||
"auth:read": "Fetch the list of enabled authentication mechanisms configured.",
|
||||
"auth:simple:invitation:read": "Fetch simple auth invitations.",
|
||||
"auth:simple:invitation:new": "Create new simple auth invitations.",
|
||||
"auth:simple:invitation:delete": "Delete a simple auth invitation.",
|
||||
|
||||
@ -33,6 +33,7 @@ const userACLPrefix = "user:";
|
||||
type UserACL = Array<(typeof userACLs)[number]>;
|
||||
|
||||
export const systemACLs = [
|
||||
"auth:read",
|
||||
"auth:simple:invitation:read",
|
||||
"auth:simple:invitation:new",
|
||||
"auth:simple:invitation:delete",
|
||||
|
||||
@ -3,20 +3,12 @@ import prisma from "../db/database";
|
||||
|
||||
class ApplicationConfiguration {
|
||||
// Reference to the currently selected application configuration
|
||||
private currentApplicationSettings: ApplicationSettings = {
|
||||
timestamp: new Date(),
|
||||
enabledAuthencationMechanisms: [],
|
||||
metadataProviders: [],
|
||||
};
|
||||
private applicationStateProxy: object;
|
||||
private dirty: boolean = false;
|
||||
private dirtyPromise: Promise<any> | undefined = undefined;
|
||||
|
||||
constructor() {
|
||||
this.applicationStateProxy = {};
|
||||
}
|
||||
private currentApplicationSettings: ApplicationSettings | undefined =
|
||||
undefined;
|
||||
|
||||
private async save() {
|
||||
await this.init();
|
||||
|
||||
const deepAppConfigCopy: Omit<ApplicationSettings, "timestamp"> & {
|
||||
timestamp?: Date;
|
||||
} = JSON.parse(JSON.stringify(this.currentApplicationSettings));
|
||||
@ -28,6 +20,19 @@ class ApplicationConfiguration {
|
||||
});
|
||||
}
|
||||
|
||||
private async init() {
|
||||
if (this.currentApplicationSettings === undefined) {
|
||||
const applicationSettingsCount = await prisma.applicationSettings.count(
|
||||
{}
|
||||
);
|
||||
if (applicationSettingsCount > 0) {
|
||||
await applicationSettings.pullConfiguration();
|
||||
} else {
|
||||
await applicationSettings.initialiseConfiguration();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default application configuration
|
||||
async initialiseConfiguration() {
|
||||
const initialState = await prisma.applicationSettings.create({
|
||||
@ -56,6 +61,10 @@ class ApplicationConfiguration {
|
||||
key: T,
|
||||
value: ApplicationSettings[T]
|
||||
) {
|
||||
await this.init();
|
||||
if (!this.currentApplicationSettings)
|
||||
throw new Error("Somehow, failed to initialise application settings");
|
||||
|
||||
if (this.currentApplicationSettings[key] !== value) {
|
||||
this.currentApplicationSettings[key] = value;
|
||||
|
||||
@ -63,7 +72,11 @@ class ApplicationConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
get<T extends keyof ApplicationSettings>(key: T): ApplicationSettings[T] {
|
||||
async get<T extends keyof ApplicationSettings>(key: T): Promise<ApplicationSettings[T]> {
|
||||
await this.init();
|
||||
if (!this.currentApplicationSettings)
|
||||
throw new Error("Somehow, failed to initialise application settings");
|
||||
|
||||
return this.currentApplicationSettings[key];
|
||||
}
|
||||
}
|
||||
|
||||
@ -125,7 +125,7 @@ export abstract class ObjectBackend {
|
||||
// If we need to fetch a remote resource, it doesn't make sense
|
||||
// to immediately fetch the object, *then* check permissions.
|
||||
// Instead the caller can pass a simple anonymous funciton, like
|
||||
// () => $fetch('/my-image');
|
||||
// () => $dropFetch('/my-image');
|
||||
// And if we actually have permission to write, it fetches it then.
|
||||
async writeWithPermissions(
|
||||
id: ObjectReference,
|
||||
|
||||
@ -2,14 +2,6 @@ import { applicationSettings } from "../internal/config/application-configuratio
|
||||
import prisma from "../internal/db/database";
|
||||
|
||||
export default defineNitroPlugin(async (nitro) => {
|
||||
const applicationSettingsCount = await prisma.applicationSettings.count({});
|
||||
if (applicationSettingsCount > 0) {
|
||||
await applicationSettings.pullConfiguration();
|
||||
} else {
|
||||
await applicationSettings.initialiseConfiguration();
|
||||
}
|
||||
console.log("initalised application config");
|
||||
|
||||
// Ensure system user exists
|
||||
// The system user owns any user-based code
|
||||
// that we want to re-use for the app
|
||||
|
||||
@ -27,7 +27,9 @@ export default defineNitroPlugin(async (nitro) => {
|
||||
}
|
||||
|
||||
// Add providers based on their position in the application settings
|
||||
const configuredProviderList = applicationSettings.get("metadataProviders");
|
||||
const configuredProviderList = await applicationSettings.get(
|
||||
"metadataProviders"
|
||||
);
|
||||
const max = configuredProviderList.length;
|
||||
for (const [index, providerId] of configuredProviderList.entries()) {
|
||||
const priority = max * 2 - index; // Offset by the length --- (max - index) + max
|
||||
|
||||
Reference in New Issue
Block a user