feat: user page & $dropFetch util

This commit is contained in:
DecDuck
2025-03-14 12:22:08 +11:00
parent 3225f536ce
commit bd1cb67cd0
39 changed files with 416 additions and 166 deletions
+2 -2
View File
@@ -115,7 +115,7 @@ const inCollections = computed(() =>
async function toggleLibrary() { async function toggleLibrary() {
isLibraryLoading.value = true; isLibraryLoading.value = true;
try { try {
await $fetch("/api/v1/collection/default/entry", { await $dropFetch("/api/v1/collection/default/entry", {
method: inLibrary.value ? "DELETE" : "POST", method: inLibrary.value ? "DELETE" : "POST",
body: { body: {
id: props.gameId, id: props.gameId,
@@ -142,7 +142,7 @@ async function toggleCollection(id: string) {
if (!collection) return; if (!collection) return;
const index = collection.entries.findIndex((e) => e.gameId == props.gameId); 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", method: index == -1 ? "POST" : "DELETE",
body: { body: {
id: props.gameId, id: props.gameId,
+2 -2
View File
@@ -72,14 +72,14 @@ async function createCollection() {
createCollectionLoading.value = true; createCollectionLoading.value = true;
// Create the collection // Create the collection
const response = await $fetch("/api/v1/collection", { const response = await $dropFetch("/api/v1/collection", {
method: "POST", method: "POST",
body: { name: collectionName.value }, body: { name: collectionName.value },
}); });
// Add the game if provided // Add the game if provided
if (props.gameId) { if (props.gameId) {
const entry = await $fetch< const entry = await $dropFetch<
CollectionEntry & { game: SerializeObject<Game> } CollectionEntry & { game: SerializeObject<Game> }
>(`/api/v1/collection/${response.id}/entry`, { >(`/api/v1/collection/${response.id}/entry`, {
method: "POST", method: "POST",
+1 -1
View File
@@ -48,7 +48,7 @@ async function deleteCollection() {
if (!collection.value) return; if (!collection.value) return;
deleteLoading.value = true; deleteLoading.value = true;
await $fetch(`/api/v1/collection/${collection.value.id}`, { await $dropFetch(`/api/v1/collection/${collection.value.id}`, {
// @ts-ignore // @ts-ignore
method: "DELETE", method: "DELETE",
}); });
+1 -1
View File
@@ -348,7 +348,7 @@ async function createArticle() {
formData.append("content", newArticle.value.content); formData.append("content", newArticle.value.content);
formData.append("tags", JSON.stringify(newArticle.value.tags)); formData.append("tags", JSON.stringify(newArticle.value.tags));
await $fetch("/api/v1/admin/news", { await $dropFetch("/api/v1/admin/news", {
method: "POST", method: "POST",
body: formData, body: formData,
}); });
+1 -1
View File
@@ -48,7 +48,7 @@ import type { Notification } from "@prisma/client";
const props = defineProps<{ notification: Notification }>(); const props = defineProps<{ notification: Notification }>();
async function deleteMe() { async function deleteMe() {
await $fetch(`/api/v1/notifications/${props.notification.id}`, { await $dropFetch(`/api/v1/notifications/${props.notification.id}`, {
method: "DELETE", method: "DELETE",
}); });
const notifications = useNotifications(); const notifications = useNotifications();
+1 -1
View File
@@ -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; open.value = false;
file.value = undefined; file.value = undefined;
emit("upload", result); emit("upload", result);
+3 -3
View File
@@ -10,7 +10,7 @@ export const useCollections = async () => {
const state = useState<FullCollection[]>("collections", () => undefined); const state = useState<FullCollection[]>("collections", () => undefined);
if (state.value === undefined) { if (state.value === undefined) {
const headers = useRequestHeaders(["cookie"]); const headers = useRequestHeaders(["cookie"]);
state.value = await $fetch<FullCollection[]>("/api/v1/collection", { state.value = await $dropFetch<FullCollection[]>("/api/v1/collection", {
headers, headers,
}); });
} }
@@ -20,7 +20,7 @@ export const useCollections = async () => {
export async function refreshCollection(id: string) { export async function refreshCollection(id: string) {
const state = useState<FullCollection[]>("collections"); 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); const index = state.value.findIndex((e) => e.id == id);
if (index == -1) { if (index == -1) {
state.value.push(collection); state.value.push(collection);
@@ -42,7 +42,7 @@ export const useLibrary = async () => {
export async function refreshLibrary() { export async function refreshLibrary() {
const state = useState<FullCollection>("library"); const state = useState<FullCollection>("library");
const headers = useRequestHeaders(["cookie"]); const headers = useRequestHeaders(["cookie"]);
state.value = await $fetch<FullCollection>("/api/v1/collection/default", { state.value = await $dropFetch<FullCollection>("/api/v1/collection/default", {
headers, headers,
}); });
} }
+1 -1
View File
@@ -22,7 +22,7 @@ export const useNews = () => {
}; };
const remove = async (id: string) => { const remove = async (id: string) => {
return await $fetch(`/api/v1/admin/news/${id}`, { return await $dropFetch(`/api/v1/admin/news/${id}`, {
method: "DELETE", method: "DELETE",
}); });
}; };
+37
View 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;
};
+1 -1
View File
@@ -12,5 +12,5 @@ export const updateUser = async () => {
if (user.value === null) return; if (user.value === null) return;
// SSR calls have to be after uses // 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 });
}; };
+12 -4
View File
@@ -167,6 +167,8 @@ import {
Cog6ToothIcon, Cog6ToothIcon,
FlagIcon, FlagIcon,
BellIcon, BellIcon,
DocumentIcon,
UserGroupIcon,
} from "@heroicons/vue/24/outline"; } from "@heroicons/vue/24/outline";
import type { NavigationItem } from "~/composables/types"; import type { NavigationItem } from "~/composables/types";
import { useCurrentNavigationIndex } from "~/composables/current-page-engine"; import { useCurrentNavigationIndex } from "~/composables/current-page-engine";
@@ -182,10 +184,16 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
icon: ServerStackIcon, icon: ServerStackIcon,
}, },
{ {
label: "Auth", label: "Meta",
route: "/admin/auth", route: "/admin/metadata",
prefix: "/admin/auth", prefix: "/admin/metadata",
icon: LockClosedIcon, icon: DocumentIcon,
},
{
label: "Users",
route: "/admin/users",
prefix: "/admin/users",
icon: UserGroupIcon,
}, },
{ {
label: "Settings", label: "Settings",
+29 -45
View File
@@ -7,15 +7,24 @@
<div <div
class="relative flex h-full flex-col overflow-hidden rounded-[calc(var(--radius-lg)+1px)] lg:rounded-l-[calc(2rem+1px)]" 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 <p
class="mt-2 text-lg font-medium tracking-tight text-zinc-100 max-lg:text-center" class="mt-2 text-lg font-medium tracking-tight text-zinc-100 max-lg:text-center"
> >
Library Library
</p> </p>
<p class="mt-2 max-w-lg text-sm/6 text-zinc-400 max-lg:text-center"> <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 Manage your Drop library, and import new games. Your library is the
lorem cupidatat commodo. 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"> &rarr;</span>
</NuxtLink>
</p> </p>
<div <div
@@ -45,31 +54,6 @@
</div> </div>
</div> </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> </div>
<div <div
@@ -78,30 +62,30 @@
</div> </div>
<div class="relative max-lg:row-start-1"> <div class="relative max-lg:row-start-1">
<div <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 <div
class="relative flex h-full flex-col overflow-hidden rounded-[calc(var(--radius-lg)+1px)] max-lg:rounded-t-[calc(2rem+1px)]" 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 <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>
<p class="mt-2 max-w-lg text-sm/6 text-gray-600 max-lg:text-center"> <p class="mt-2 max-w-lg text-sm/6 text-zinc-400 max-lg:text-center">
Lorem ipsum, dolor sit amet consectetur adipisicing elit maiores Your users are people who can access your Drop instance, download
impedit. 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"> &rarr;</span>
</NuxtLink>
</p> </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> </div>
<div <div
@@ -193,5 +177,5 @@ useHead({
}); });
const headers = useRequestHeaders(["cookie"]); const headers = useRequestHeaders(["cookie"]);
const libraryState = await $fetch("/api/v1/admin/library", { headers }); const libraryState = await $dropFetch("/api/v1/admin/library", { headers });
</script> </script>
+13 -9
View File
@@ -165,7 +165,7 @@
'relative cursor-default select-none py-2 pl-3 pr-9', 'relative cursor-default select-none py-2 pl-3 pr-9',
active active
? 'bg-blue-600 text-white outline-none' ? 'bg-blue-600 text-white outline-none'
: 'text-gray-900', : 'text-zinc-100',
]" ]"
> >
<span <span
@@ -321,7 +321,7 @@
'relative cursor-default select-none py-2 pl-3 pr-9', 'relative cursor-default select-none py-2 pl-3 pr-9',
active active
? 'bg-blue-600 text-white outline-none' ? 'bg-blue-600 text-white outline-none'
: 'text-gray-900', : 'text-zinc-100',
]" ]"
> >
<span <span
@@ -553,7 +553,7 @@ const router = useRouter();
const route = useRoute(); const route = useRoute();
const headers = useRequestHeaders(["cookie"]); const headers = useRequestHeaders(["cookie"]);
const gameId = route.params.id.toString(); const gameId = route.params.id.toString();
const versions = await $fetch( const versions = await $dropFetch(
`/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}`, `/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}`,
{ {
headers, headers,
@@ -561,7 +561,7 @@ const versions = await $fetch(
); );
const currentlySelectedVersion = ref(-1); const currentlySelectedVersion = ref(-1);
const versionSettings = ref<{ const versionSettings = ref<{
platform: string; platform: PlatformClient | undefined;
onlySetup: boolean; onlySetup: boolean;
launch: string; launch: string;
@@ -572,7 +572,7 @@ const versionSettings = ref<{
delta: boolean; delta: boolean;
umuId: string; umuId: string;
}>({ }>({
platform: "", platform: undefined,
launch: "", launch: "",
launchArgs: "", launchArgs: "",
setup: "", setup: "",
@@ -582,7 +582,8 @@ const versionSettings = ref<{
umuId: "", umuId: "",
}); });
const versionGuesses = ref<Array<{ platform: string; filename: string }>>(); const versionGuesses =
ref<Array<{ platform: PlatformClient; filename: string }>>();
const launchProcessQuery = ref(""); const launchProcessQuery = ref("");
const setupProcessQuery = ref(""); const setupProcessQuery = ref("");
@@ -637,17 +638,20 @@ async function updateCurrentlySelectedVersion(value: number) {
if (currentlySelectedVersion.value == value) return; if (currentlySelectedVersion.value == value) return;
currentlySelectedVersion.value = value; currentlySelectedVersion.value = value;
const version = versions[currentlySelectedVersion.value]; const version = versions[currentlySelectedVersion.value];
const results = await $fetch( const results = await $dropFetch(
`/api/v1/admin/import/version/preload?id=${encodeURIComponent( `/api/v1/admin/import/version/preload?id=${encodeURIComponent(
gameId gameId
)}&version=${encodeURIComponent(version)}` )}&version=${encodeURIComponent(version)}`
); );
versionGuesses.value = results; versionGuesses.value = results.map((e) => ({
...e,
platform: e.platform as PlatformClient,
}));
} }
async function startImport() { async function startImport() {
if (!versionSettings.value) return; if (!versionSettings.value) return;
const taskId = await $fetch("/api/v1/admin/import/version", { const taskId = await $dropFetch("/api/v1/admin/import/version", {
method: "POST", method: "POST",
body: { body: {
id: gameId, id: gameId,
+9 -9
View File
@@ -530,7 +530,7 @@ const mobileShowFinalDescription = ref(true);
const route = useRoute(); const route = useRoute();
const gameId = route.params.id.toString(); const gameId = route.params.id.toString();
const headers = useRequestHeaders(["cookie"]); const headers = useRequestHeaders(["cookie"]);
const { game: rawGame, unimportedVersions } = await $fetch( const { game: rawGame, unimportedVersions } = await $dropFetch(
`/api/v1/admin/game?id=${encodeURIComponent(gameId)}`, `/api/v1/admin/game?id=${encodeURIComponent(gameId)}`,
{ {
headers, headers,
@@ -579,7 +579,7 @@ async function coreMetadataUpdate() {
formData.append("name", coreMetadataName.value); formData.append("name", coreMetadataName.value);
formData.append("description", coreMetadataDescription.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", method: "POST",
body: formData, body: formData,
}); });
@@ -630,7 +630,7 @@ watch(descriptionHTML, (v) => {
savingTimeout = setTimeout(async () => { savingTimeout = setTimeout(async () => {
try { try {
descriptionSaving.value = 2; descriptionSaving.value = 2;
await $fetch("/api/v1/admin/game", { await $dropFetch("/api/v1/admin/game", {
method: "PATCH", method: "PATCH",
body: { body: {
id: gameId, id: gameId,
@@ -672,7 +672,7 @@ function insertImageAtCursor(id: string) {
async function updateBannerImage(id: string) { async function updateBannerImage(id: string) {
try { try {
if (game.value.mBannerId == id) return; if (game.value.mBannerId == id) return;
const { mBannerId } = await $fetch("/api/v1/admin/game", { const { mBannerId } = await $dropFetch("/api/v1/admin/game", {
method: "PATCH", method: "PATCH",
body: { body: {
id: gameId, id: gameId,
@@ -698,7 +698,7 @@ async function updateBannerImage(id: string) {
async function updateCoverImage(id: string) { async function updateCoverImage(id: string) {
try { try {
if (game.value.mCoverId == id) return; if (game.value.mCoverId == id) return;
const { mCoverId } = await $fetch("/api/v1/admin/game", { const { mCoverId } = await $dropFetch("/api/v1/admin/game", {
method: "PATCH", method: "PATCH",
body: { body: {
id: gameId, id: gameId,
@@ -723,7 +723,7 @@ async function updateCoverImage(id: string) {
async function deleteImage(id: string) { async function deleteImage(id: string) {
try { try {
const { mBannerId, mImageLibrary } = await $fetch( const { mBannerId, mImageLibrary } = await $dropFetch(
"/api/v1/admin/game/image", "/api/v1/admin/game/image",
{ {
method: "DELETE", method: "DELETE",
@@ -757,7 +757,7 @@ async function uploadAfterImageUpload(result: Game) {
async function deleteVersion(versionName: string) { async function deleteVersion(versionName: string) {
try { try {
await $fetch("/api/v1/admin/game/version", { await $dropFetch("/api/v1/admin/game/version", {
method: "DELETE", method: "DELETE",
body: { body: {
id: gameId, id: gameId,
@@ -785,7 +785,7 @@ async function deleteVersion(versionName: string) {
async function updateVersionOrder() { async function updateVersionOrder() {
try { try {
const newVersions = await $fetch("/api/v1/admin/game/version", { const newVersions = await $dropFetch("/api/v1/admin/game/version", {
method: "PATCH", method: "PATCH",
body: { body: {
id: gameId, id: gameId,
@@ -822,7 +822,7 @@ function removeImageFromCarousel(id: string) {
async function updateImageCarousel() { async function updateImageCarousel() {
try { try {
await $fetch("/api/v1/admin/game", { await $dropFetch("/api/v1/admin/game", {
method: "PATCH", method: "PATCH",
body: { body: {
id: gameId, id: gameId,
+3 -3
View File
@@ -158,7 +158,7 @@ definePageMeta({
}); });
const headers = useRequestHeaders(["cookie"]); 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 currentlySelectedGame = ref(-1);
const gameSearchResultsLoading = ref(false); const gameSearchResultsLoading = ref(false);
@@ -174,7 +174,7 @@ async function updateSelectedGame(value: number) {
metadataResults.value = undefined; metadataResults.value = undefined;
currentlySelectedMetadata.value = -1; currentlySelectedMetadata.value = -1;
const results = await $fetch( const results = await $dropFetch(
`/api/v1/admin/import/game/search?q=${encodeURIComponent(game)}` `/api/v1/admin/import/game/search?q=${encodeURIComponent(game)}`
); );
metadataResults.value = results; metadataResults.value = results;
@@ -199,7 +199,7 @@ const importError = ref<string | undefined>();
async function importGame(metadata: boolean) { async function importGame(metadata: boolean) {
if (!metadataResults.value && metadata) return; 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", method: "POST",
body: { body: {
path: games.unimportedGames[currentlySelectedGame.value], path: games.unimportedGames[currentlySelectedGame.value],
+2 -2
View File
@@ -180,7 +180,7 @@ useHead({
const searchQuery = ref(""); const searchQuery = ref("");
const headers = useRequestHeaders(["cookie"]); 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( const libraryGames = ref(
libraryState.games.map((e) => { libraryState.games.map((e) => {
const noVersions = e.status.noVersions; const noVersions = e.status.noVersions;
@@ -210,7 +210,7 @@ const filteredLibraryGames = computed(() =>
); );
async function deleteGame(id: string) { 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); const index = libraryGames.value.findIndex((e) => e.id === id);
libraryGames.value.splice(index, 1); libraryGames.value.splice(index, 1);
} }
+11
View 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" :key="authMech.name"
class="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900" class="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900"
> >
<div <div class="flex items-center gap-x-4 border-b border-zinc-800 p-6">
class="flex items-center gap-x-4 border-b border-zinc-800 p-6"
>
<component <component
:is="authMech.icon" :is="authMech.icon"
:alt="`${authMech.name} icon`" :alt="`${authMech.name} icon`"
@@ -101,23 +99,9 @@ import { IconsSimpleAuthenticationLogo } from "#components";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
import { EllipsisHorizontalIcon } from "@heroicons/vue/20/solid"; import { EllipsisHorizontalIcon } from "@heroicons/vue/20/solid";
import { CheckIcon, XMarkIcon } from "@heroicons/vue/24/solid"; import { CheckIcon, XMarkIcon } from "@heroicons/vue/24/solid";
import { AuthMec } from "@prisma/client";
import type { Component } from "vue"; 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({ useHead({
title: "Authentication", title: "Authentication",
}); });
@@ -125,4 +109,25 @@ useHead({
definePageMeta({ definePageMeta({
layout: "admin", 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> </script>
@@ -459,7 +459,7 @@ async function invite() {
.add(...expiry[expiryKey.value]) .add(...expiry[expiryKey.value])
.toJSON(); .toJSON();
const newInvitation = await $fetch("/api/v1/admin/auth/invitation", { const newInvitation = await $dropFetch("/api/v1/admin/auth/invitation", {
method: "POST", method: "POST",
body: { body: {
username: username.value, username: username.value,
@@ -495,7 +495,7 @@ function invite_wrapper() {
} }
async function deleteInvitation(id: string) { async function deleteInvitation(id: string) {
await $fetch("/api/v1/admin/auth/invitation", { await $dropFetch("/api/v1/admin/auth/invitation", {
method: "DELETE", method: "DELETE",
body: { body: {
id: id, id: id,
+111
View 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 &rarr;
</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>
+1 -1
View File
@@ -175,7 +175,7 @@ const error = ref();
const authToken = ref<string | undefined>(); const authToken = ref<string | undefined>();
async function authorize() { 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", method: "POST",
body: { id: clientId }, body: { id: clientId },
}); });
+1 -1
View File
@@ -224,7 +224,7 @@ const loading = ref(false);
const error = ref<string | undefined>(undefined); const error = ref<string | undefined>(undefined);
async function register() { async function register() {
await $fetch("/api/v1/auth/signup/simple", { await $dropFetch("/api/v1/auth/signup/simple", {
method: "POST", method: "POST",
body: { body: {
invitation: invitationId, invitation: invitationId,
+2 -2
View File
@@ -149,7 +149,7 @@ function signin_wrapper() {
} }
async function signin() { async function signin() {
await $fetch("/api/v1/auth/signin/simple", { await $dropFetch("/api/v1/auth/signin/simple", {
method: "POST", method: "POST",
body: { body: {
username: username.value, username: username.value,
@@ -158,7 +158,7 @@ async function signin() {
}, },
}); });
const user = useUser(); const user = useUser();
user.value = await $fetch<User | null>("/api/v1/user"); user.value = await $dropFetch<User | null>("/api/v1/user");
} }
definePageMeta({ definePageMeta({
+1 -1
View File
@@ -36,6 +36,6 @@ const user = useUser();
user.value = null; user.value = null;
// Redirect to signin page after signout // Redirect to signin page after signout
await $fetch("/signout"); await $dropFetch("/signout");
router.push("/signin"); router.push("/signin");
</script> </script>
+1 -1
View File
@@ -177,7 +177,7 @@ const gameId = route.params.id.toString();
const user = useUser(); const user = useUser();
const headers = useRequestHeaders(["cookie"]); const headers = useRequestHeaders(["cookie"]);
const game = await $fetch<Game & { versions: GameVersion[] }>( const game = await $dropFetch<Game & { versions: GameVersion[] }>(
`/api/v1/games/${gameId}`, `/api/v1/games/${gameId}`,
{ headers } { headers }
); );
+5 -5
View File
@@ -96,13 +96,13 @@
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
const headers = useRequestHeaders(["cookie"]); const headers = useRequestHeaders(["cookie"]);
const recent = await $fetch("/api/v1/store/recent", { headers }); const recent = await $dropFetch("/api/v1/store/recent", { headers });
const updated = await $fetch("/api/v1/store/updated", { headers }); const updated = await $dropFetch("/api/v1/store/updated", { headers });
const released = await $fetch("/api/v1/store/released", { const released = await $dropFetch("/api/v1/store/released", {
headers, headers,
}); });
const developers = await $fetch("/api/v1/store/developers", { headers }); const developers = await $dropFetch("/api/v1/store/developers", { headers });
const publishers = await $fetch("/api/v1/store/publishers", { headers }); const publishers = await $dropFetch("/api/v1/store/publishers", { headers });
useHead({ useHead({
title: "Store", 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;
+4 -3
View File
@@ -3,8 +3,9 @@ enum AuthMec {
} }
model LinkedAuthMec { model LinkedAuthMec {
userId String userId String
mec AuthMec mec AuthMec
enabled Boolean @default(true)
credentials Json credentials Json
@@ -29,7 +30,7 @@ enum APITokenMode {
model APIToken { model APIToken {
id String @id @default(uuid()) id String @id @default(uuid())
token String @default(uuid()) @unique token String @unique @default(uuid())
mode APITokenMode mode APITokenMode
name String name String
+1
View File
@@ -2,6 +2,7 @@ model User {
id String @id @default(uuid()) id String @id @default(uuid())
username String @unique username String @unique
admin Boolean @default(false) admin Boolean @default(false)
enabled Boolean @default(true)
email String email String
displayName String displayName String
+14
View 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;
});
@@ -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"]); const allowed = await aclManager.allowSystemACL(h3, ["user:read"]);
if (!allowed) throw createError({ statusCode: 403 }); 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; return users;
}); });
+46 -23
View File
@@ -5,34 +5,57 @@ import { checkHash } from "~/server/internal/security/simple";
import sessionHandler from "~/server/internal/session"; import sessionHandler from "~/server/internal/session";
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const body = await readBody(h3); const body = await readBody(h3);
const username = body.username; const username = body.username;
const password = body.password; const password = body.password;
const rememberMe = body.rememberMe ?? false; const rememberMe = body.rememberMe ?? false;
if (username === undefined || password === undefined) if (username === undefined || password === undefined)
throw createError({ statusCode: 403, statusMessage: "Username or password missing from request." }); throw createError({
statusCode: 403,
const authMek = await prisma.linkedAuthMec.findFirst({ statusMessage: "Username or password missing from request.",
where: {
mec: AuthMec.Simple,
credentials: {
array_starts_with: username
}
}
}); });
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; if (!authMek)
const hash = credentials.at(1); 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())) if (!hash || !authMek.user.enabled)
throw createError({ statusCode: 401, statusMessage: "Invalid username or password." }); 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 };
});
+1
View File
@@ -35,6 +35,7 @@ export const userACLDescriptions: ObjectFromList<typeof userACLs> = {
}; };
export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = { 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:read": "Fetch simple auth invitations.",
"auth:simple:invitation:new": "Create new simple auth invitations.", "auth:simple:invitation:new": "Create new simple auth invitations.",
"auth:simple:invitation:delete": "Delete a simple auth invitation.", "auth:simple:invitation:delete": "Delete a simple auth invitation.",
+1
View File
@@ -33,6 +33,7 @@ const userACLPrefix = "user:";
type UserACL = Array<(typeof userACLs)[number]>; type UserACL = Array<(typeof userACLs)[number]>;
export const systemACLs = [ export const systemACLs = [
"auth:read",
"auth:simple:invitation:read", "auth:simple:invitation:read",
"auth:simple:invitation:new", "auth:simple:invitation:new",
"auth:simple:invitation:delete", "auth:simple:invitation:delete",
@@ -3,20 +3,12 @@ import prisma from "../db/database";
class ApplicationConfiguration { class ApplicationConfiguration {
// Reference to the currently selected application configuration // Reference to the currently selected application configuration
private currentApplicationSettings: ApplicationSettings = { private currentApplicationSettings: ApplicationSettings | undefined =
timestamp: new Date(), undefined;
enabledAuthencationMechanisms: [],
metadataProviders: [],
};
private applicationStateProxy: object;
private dirty: boolean = false;
private dirtyPromise: Promise<any> | undefined = undefined;
constructor() {
this.applicationStateProxy = {};
}
private async save() { private async save() {
await this.init();
const deepAppConfigCopy: Omit<ApplicationSettings, "timestamp"> & { const deepAppConfigCopy: Omit<ApplicationSettings, "timestamp"> & {
timestamp?: Date; timestamp?: Date;
} = JSON.parse(JSON.stringify(this.currentApplicationSettings)); } = 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 // Default application configuration
async initialiseConfiguration() { async initialiseConfiguration() {
const initialState = await prisma.applicationSettings.create({ const initialState = await prisma.applicationSettings.create({
@@ -56,6 +61,10 @@ class ApplicationConfiguration {
key: T, key: T,
value: ApplicationSettings[T] value: ApplicationSettings[T]
) { ) {
await this.init();
if (!this.currentApplicationSettings)
throw new Error("Somehow, failed to initialise application settings");
if (this.currentApplicationSettings[key] !== value) { if (this.currentApplicationSettings[key] !== value) {
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]; return this.currentApplicationSettings[key];
} }
} }
+1 -1
View File
@@ -125,7 +125,7 @@ export abstract class ObjectBackend {
// If we need to fetch a remote resource, it doesn't make sense // If we need to fetch a remote resource, it doesn't make sense
// to immediately fetch the object, *then* check permissions. // to immediately fetch the object, *then* check permissions.
// Instead the caller can pass a simple anonymous funciton, like // 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. // And if we actually have permission to write, it fetches it then.
async writeWithPermissions( async writeWithPermissions(
id: ObjectReference, id: ObjectReference,
-8
View File
@@ -2,14 +2,6 @@ import { applicationSettings } from "../internal/config/application-configuratio
import prisma from "../internal/db/database"; import prisma from "../internal/db/database";
export default defineNitroPlugin(async (nitro) => { 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 // Ensure system user exists
// The system user owns any user-based code // The system user owns any user-based code
// that we want to re-use for the app // that we want to re-use for the app
+3 -1
View File
@@ -27,7 +27,9 @@ export default defineNitroPlugin(async (nitro) => {
} }
// Add providers based on their position in the application settings // 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; const max = configuredProviderList.length;
for (const [index, providerId] of configuredProviderList.entries()) { for (const [index, providerId] of configuredProviderList.entries()) {
const priority = max * 2 - index; // Offset by the length --- (max - index) + max const priority = max * 2 - index; // Offset by the length --- (max - index) + max