diff --git a/components/AccountSidebar.vue b/components/AccountSidebar.vue index fa907aa..2c0a3ec 100644 --- a/components/AccountSidebar.vue +++ b/components/AccountSidebar.vue @@ -45,6 +45,7 @@ import { LockClosedIcon, DevicePhoneMobileIcon, WrenchScrewdriverIcon, + CodeBracketIcon, } from "@heroicons/vue/24/outline"; import { UserIcon } from "@heroicons/vue/24/solid"; import type { Component } from "vue"; @@ -73,6 +74,12 @@ const navigation: (NavigationItem & { icon: Component; count?: number })[] = [ icon: BellIcon, count: notifications.value.length, }, + { + label: t("account.token.title"), + route: "/account/tokens", + prefix: "/account/tokens", + icon: CodeBracketIcon, + }, { label: t("account.settings"), route: "/account/settings", diff --git a/components/Modal/CreateToken.vue b/components/Modal/CreateToken.vue new file mode 100644 index 0000000..ed1c3cf --- /dev/null +++ b/components/Modal/CreateToken.vue @@ -0,0 +1,267 @@ + + + diff --git a/composables/request.ts b/composables/request.ts index 8396b8d..2283366 100644 --- a/composables/request.ts +++ b/composables/request.ts @@ -46,10 +46,28 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => { }); const request = requestParts.join("/"); + // If not in setup if (!getCurrentInstance()?.proxy) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore Excessive stack depth comparing types - return await $fetch(request, opts); + try { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Excessive stack depth comparing types + return await $fetch(request, opts); + } catch (e) { + if (import.meta.client && opts?.failTitle) { + console.warn(e); + createModal( + ModalType.Notification, + { + title: opts.failTitle, + description: + (e as FetchError)?.statusMessage ?? (e as string).toString(), + //buttonText: $t("common.close"), + }, + (_, c) => c(), + ); + } + throw e; + } } const id = request.toString(); @@ -64,26 +82,10 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => { } const headers = useRequestHeaders(["cookie", "authorization"]); - try { - const data = await $fetch(request, { - ...opts, - headers: { ...headers, ...opts?.headers }, - }); - if (import.meta.server) state.value = data; - return data; - } catch (e) { - if (import.meta.client && opts?.failTitle) { - createModal( - ModalType.Notification, - { - title: opts.failTitle, - description: - (e as FetchError)?.statusMessage ?? (e as string).toString(), - buttonText: $t("common.close"), - }, - (_, c) => c(), - ); - } - throw e; - } + const data = await $fetch(request, { + ...opts, + headers: { ...headers, ...opts?.headers }, + }); + if (import.meta.server) state.value = data; + return data; }; diff --git a/drop-base b/drop-base index 04125e8..4c42edf 160000 --- a/drop-base +++ b/drop-base @@ -1 +1 @@ -Subproject commit 04125e89bef517411e103cdabcfa64a1bb563423 +Subproject commit 4c42edf5adfa755c33bc8ce7bf1ddec87a0963a8 diff --git a/i18n/locales/en_us.json b/i18n/locales/en_us.json index 2356acb..8fecfd5 100644 --- a/i18n/locales/en_us.json +++ b/i18n/locales/en_us.json @@ -19,6 +19,28 @@ "title": "Notifications", "unread": "Unread Notifications" }, + "token": { + "title": "API Tokens", + "subheader": "Manage your API tokens, and what they can access.", + "name": "API token name", + "nameDesc": "The name of the token, for reference.", + "namePlaceholder": "My New Token", + "acls": "ACLs/scopes", + "aclsDesc": "Defines what this token has the authority to do. You should avoid selecting all ACLs, if they are not necessary.", + "expiry": "Expiry", + "noExpiry": "No expiry", + "revoke": "Revoke", + "noTokens": "No tokens connected to your account.", + + "expiryMonth": "A month", + "expiry3Month": "3 months", + "expiry6Month": "6 months", + "expiryYear": "A year", + "expiry5Year": "5 years", + + "success": "Successfully created token.", + "successNote": "Make sure to copy it now, as it won't be shown again." + }, "settings": "Settings", "title": "Account Settings" }, @@ -241,7 +263,11 @@ "admin": { "admin": "Admin", "metadata": "Meta", - "settings": "Settings", + "settings": { + "title": "Settings", + "store": "Store", + "tokens": "API tokens" + }, "tasks": "Tasks", "users": "Users" }, diff --git a/layouts/admin.vue b/layouts/admin.vue index 24cef9b..2a34d0b 100644 --- a/layouts/admin.vue +++ b/layouts/admin.vue @@ -200,7 +200,7 @@ const navigation: Array = [ icon: RectangleStackIcon, }, { - label: $t("header.admin.settings"), + label: $t("header.admin.settings.title"), route: "/admin/settings", prefix: "/admin/settings", icon: Cog6ToothIcon, diff --git a/pages/account/tokens.vue b/pages/account/tokens.vue new file mode 100644 index 0000000..4f9ee48 --- /dev/null +++ b/pages/account/tokens.vue @@ -0,0 +1,229 @@ + + + diff --git a/pages/admin/settings.vue b/pages/admin/settings.vue new file mode 100644 index 0000000..6d637d1 --- /dev/null +++ b/pages/admin/settings.vue @@ -0,0 +1,68 @@ + + + diff --git a/pages/admin/settings/index.vue b/pages/admin/settings/index.vue index 3a80db9..6402b63 100644 --- a/pages/admin/settings/index.vue +++ b/pages/admin/settings/index.vue @@ -1,68 +1,55 @@ diff --git a/prisma/migrations/20250823005001_add_api_token_expiry/migration.sql b/prisma/migrations/20250823005001_add_api_token_expiry/migration.sql new file mode 100644 index 0000000..c028e99 --- /dev/null +++ b/prisma/migrations/20250823005001_add_api_token_expiry/migration.sql @@ -0,0 +1,8 @@ +-- DropIndex +DROP INDEX "GameTag_name_idx"; + +-- AlterTable +ALTER TABLE "APIToken" ADD COLUMN "expiresAt" TIMESTAMP(3); + +-- CreateIndex +CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32)); diff --git a/prisma/models/auth.prisma b/prisma/models/auth.prisma index 4b03c7c..8749cf6 100644 --- a/prisma/models/auth.prisma +++ b/prisma/models/auth.prisma @@ -45,6 +45,8 @@ model APIToken { acls String[] + expiresAt DateTime? + @@index([token]) } diff --git a/server/api/v1/admin/game/[id]/index.get.ts b/server/api/v1/admin/game/[id]/index.get.ts index 44693dc..faf0f1c 100644 --- a/server/api/v1/admin/game/[id]/index.get.ts +++ b/server/api/v1/admin/game/[id]/index.get.ts @@ -17,11 +17,8 @@ export default defineEventHandler(async (h3) => { orderBy: { versionIndex: "asc", }, - select: { - versionIndex: true, - versionName: true, - platform: true, - delta: true, + omit: { + dropletManifest: true, }, }, tags: true, diff --git a/server/api/v1/admin/game/version/index.patch.ts b/server/api/v1/admin/game/version/index.patch.ts index 3833840..54e0547 100644 --- a/server/api/v1/admin/game/version/index.patch.ts +++ b/server/api/v1/admin/game/version/index.patch.ts @@ -18,30 +18,55 @@ export default defineEventHandler<{ body: typeof UpdateVersionOrder }>( const body = await readDropValidatedBody(h3, UpdateVersionOrder); const gameId = body.id; // We expect an array of the version names for this game - const versions = body.versions; + const unsortedVersions = await prisma.gameVersion.findMany({ + where: { + versionName: { in: body.versions }, + }, + select: { + versionName: true, + versionIndex: true, + delta: true, + platform: true, + }, + }); - const newVersions = await prisma.$transaction( - versions.map((versionName, versionIndex) => + const versions = body.versions + .map((e) => unsortedVersions.find((v) => v.versionName === e)) + .filter((e) => e !== undefined); + + if (versions.length !== unsortedVersions.length) + throw createError({ + statusCode: 500, + statusMessage: "Sorting versions yielded less results, somehow.", + }); + + // Validate the new order + const has: { [key: string]: boolean } = {}; + for (const version of versions) { + if (version.delta && !has[version.platform]) + throw createError({ + statusCode: 400, + statusMessage: `"${version.versionName}" requires a base version to apply the delta to.`, + }); + has[version.platform] = true; + } + + await prisma.$transaction( + versions.map((version, versionIndex) => prisma.gameVersion.update({ where: { gameId_versionName: { gameId: gameId, - versionName: versionName, + versionName: version.versionName, }, }, data: { versionIndex: versionIndex, }, - select: { - versionIndex: true, - versionName: true, - platform: true, - delta: true, - }, }), ), ); - return newVersions; + return versions; }, ); diff --git a/server/api/v1/admin/token/[id]/index.delete.ts b/server/api/v1/admin/token/[id]/index.delete.ts new file mode 100644 index 0000000..1e33730 --- /dev/null +++ b/server/api/v1/admin/token/[id]/index.delete.ts @@ -0,0 +1,23 @@ +import { APITokenMode } from "~/prisma/client/enums"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, []); // No ACLs only allows session authentication + if (!allowed) throw createError({ statusCode: 403 }); + + const id = h3.context.params?.id; + if (!id) + throw createError({ + statusCode: 400, + statusMessage: "No id in router params", + }); + + const deleted = await prisma.aPIToken.delete({ + where: { id: id, mode: APITokenMode.System }, + })!; + if (!deleted) + throw createError({ statusCode: 404, statusMessage: "Token not found" }); + + return; +}); diff --git a/server/api/v1/admin/token/acls.get.ts b/server/api/v1/admin/token/acls.get.ts new file mode 100644 index 0000000..09b4e0f --- /dev/null +++ b/server/api/v1/admin/token/acls.get.ts @@ -0,0 +1,9 @@ +import aclManager from "~/server/internal/acls"; +import { systemACLDescriptions } from "~/server/internal/acls/descriptions"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, []); // No ACLs only allows session authentication + if (!allowed) throw createError({ statusCode: 403 }); + + return systemACLDescriptions; +}); diff --git a/server/api/v1/admin/token/index.get.ts b/server/api/v1/admin/token/index.get.ts new file mode 100644 index 0000000..2438a4b --- /dev/null +++ b/server/api/v1/admin/token/index.get.ts @@ -0,0 +1,15 @@ +import { APITokenMode } from "~/prisma/client/enums"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, []); // No ACLs only allows session authentication + if (!allowed) throw createError({ statusCode: 403 }); + + const tokens = await prisma.aPIToken.findMany({ + where: { mode: APITokenMode.System }, + omit: { token: true }, + }); + + return tokens; +}); diff --git a/server/api/v1/admin/token/index.post.ts b/server/api/v1/admin/token/index.post.ts new file mode 100644 index 0000000..421fe8d --- /dev/null +++ b/server/api/v1/admin/token/index.post.ts @@ -0,0 +1,38 @@ +import { type } from "arktype"; +import { APITokenMode } from "~/prisma/client/enums"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import aclManager, { systemACLs } from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +const CreateToken = type({ + name: "string", + acls: "string[] > 0", + expiry: "string.date.iso.parse?", +}).configure(throwingArktype); + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, []); // No ACLs only allows session authentication + if (!allowed) throw createError({ statusCode: 403 }); + + const body = await readDropValidatedBody(h3, CreateToken); + + const invalidACLs = body.acls.filter( + (e) => systemACLs.findIndex((v) => e == v) == -1, + ); + if (invalidACLs.length > 0) + throw createError({ + statusCode: 400, + statusMessage: `Invalid ACLs: ${invalidACLs.join(", ")}`, + }); + + const token = await prisma.aPIToken.create({ + data: { + mode: APITokenMode.System, + name: body.name, + acls: body.acls, + expiresAt: body.expiry ?? null, + }, + }); + + return token; +}); diff --git a/server/api/v1/token.get.ts b/server/api/v1/token.get.ts new file mode 100644 index 0000000..1ce5320 --- /dev/null +++ b/server/api/v1/token.get.ts @@ -0,0 +1,6 @@ +import aclManager from "~/server/internal/acls"; + +export default defineEventHandler(async (h3) => { + const acls = await aclManager.fetchAllACLs(h3); + return acls; +}); diff --git a/server/api/v1/user/token/index.post.ts b/server/api/v1/user/token/index.post.ts index b5fb661..aafacf3 100644 --- a/server/api/v1/user/token/index.post.ts +++ b/server/api/v1/user/token/index.post.ts @@ -1,30 +1,22 @@ +import { type } from "arktype"; import { APITokenMode } from "~/prisma/client/enums"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; import aclManager, { userACLs } from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; +const CreateToken = type({ + name: "string", + acls: "string[] > 0", + expiry: "string.date.iso.parse?", +}).configure(throwingArktype); + export default defineEventHandler(async (h3) => { const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication if (!userId) throw createError({ statusCode: 403 }); - const body = await readBody(h3); - const name: string = body.name; - const acls: string[] = body.acls; + const body = await readDropValidatedBody(h3, CreateToken); - if (!name || typeof name !== "string") - throw createError({ - statusCode: 400, - statusMessage: "Token name required", - }); - if (!acls || !Array.isArray(acls)) - throw createError({ statusCode: 400, statusMessage: "ACLs required" }); - - if (acls.length == 0) - throw createError({ - statusCode: 400, - statusMessage: "Token requires more than zero ACLs", - }); - - const invalidACLs = acls.filter( + const invalidACLs = body.acls.filter( (e) => userACLs.findIndex((v) => e == v) == -1, ); if (invalidACLs.length > 0) @@ -36,9 +28,10 @@ export default defineEventHandler(async (h3) => { const token = await prisma.aPIToken.create({ data: { mode: APITokenMode.User, - name: name, + name: body.name, userId: userId, - acls: acls, + acls: body.acls, + expiresAt: body.expiry ?? null, }, }); diff --git a/server/internal/acls/descriptions.ts b/server/internal/acls/descriptions.ts index 1f090a1..af1a007 100644 --- a/server/internal/acls/descriptions.ts +++ b/server/internal/acls/descriptions.ts @@ -36,7 +36,7 @@ export const userACLDescriptions: ObjectFromList = { "library:remove": "Remove a game from your library.", "clients:read": "Read the clients connected to this account", - "clients:revoke": "", + "clients:revoke": "Remove clients connected to this account", "news:read": "Read the server's news articles.",