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

View File

@ -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,

View File

@ -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",

View File

@ -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",
});

View File

@ -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,
});

View File

@ -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();

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;
file.value = undefined;
emit("upload", result);

View File

@ -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,
});
}

View File

@ -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
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;
};

View File

@ -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 });
};

View File

@ -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",

View File

@ -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"> &rarr;</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"> &rarr;</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>

View File

@ -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,

View File

@ -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,

View File

@ -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],

View File

@ -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);
}

View File

@ -0,0 +1,11 @@
<template></template>
<script setup lang="ts">
definePageMeta({
layout: "admin",
});
useHead({
title: "Metadata",
});
</script>

View File

@ -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>

View File

@ -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
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>

View File

@ -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 },
});

View File

@ -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,

View File

@ -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({

View File

@ -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>

View File

@ -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 }
);

View File

@ -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",

View File

@ -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;

View File

@ -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

View File

@ -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

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;
});

View 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;
});

View File

@ -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;
});

View File

@ -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 };
});

View File

@ -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.",

View File

@ -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",

View File

@ -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];
}
}

View File

@ -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,

View File

@ -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

View File

@ -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