feat: add ability to review and revoke clients

This commit is contained in:
DecDuck
2025-04-05 17:42:32 +11:00
parent 7263ec53ac
commit 2cbee3d495
14 changed files with 248 additions and 54 deletions

View File

@ -41,14 +41,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
BellIcon, BellIcon,
CalendarIcon,
ChartPieIcon,
DocumentDuplicateIcon,
FolderIcon,
HomeIcon, HomeIcon,
LockClosedIcon, LockClosedIcon,
UsersIcon, DevicePhoneMobileIcon,
WrenchScrewdriverIcon, WrenchScrewdriverIcon
} from "@heroicons/vue/24/outline"; } from "@heroicons/vue/24/outline";
import { UserIcon } from "@heroicons/vue/24/solid"; import { UserIcon } from "@heroicons/vue/24/solid";
import type { Component } from "vue"; import type { Component } from "vue";
@ -63,6 +59,12 @@ const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
prefix: "/account/security", prefix: "/account/security",
icon: LockClosedIcon, icon: LockClosedIcon,
}, },
{
label: "Devices",
route: "/account/devices",
prefix: "/account/devices",
icon: DevicePhoneMobileIcon,
},
{ {
label: "Notifications", label: "Notifications",
route: "/account/notifications", route: "/account/notifications",

View File

@ -83,7 +83,7 @@
</div> </div>
</div> </div>
<div class="px-4 py-10 sm:px-6 lg:px-8 lg:py-6"> <div class="px-4 py-10 sm:px-6 lg:px-8 lg:py-6 w-full">
<NuxtPage /> <NuxtPage />
</div> </div>
</div> </div>

119
pages/account/devices.vue Normal file
View File

@ -0,0 +1,119 @@
<template>
<div>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">Devices</h1>
<p class="mt-2 text-sm text-zinc-400">
All the devices authorized to access your Drop account.
</p>
</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-800">
<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"
>
Name
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Platform
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Can Access
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Last Connected
</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="client in clients"
:key="client.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"
>
{{ client.name }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ client.platform }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<ul class="flex flex-col gap-y-2">
<li
class="inline-flex items-center gap-x-0.5"
v-for="capability in client.capabilities"
>
<CheckIcon class="size-4" /> {{ capability }}
</li>
</ul>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ moment(client.lastConnected).fromNow() }}
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-3"
>
<button
@click="() => revokeClientWrapper(client.id)"
class="text-red-600 hover:text-red-900"
>
Revoke<span class="sr-only">, {{ client.name }}</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CheckIcon } from "@heroicons/vue/24/outline";
import moment from "moment";
const clients = ref(await $dropFetch("/api/v1/user/client"));
async function revokeClient(id: string) {
await $dropFetch(`/api/v1/user/client/${id}`, { method: "DELETE" });
}
function revokeClientWrapper(id: string) {
revokeClient(id)
.then(() => {
const index = clients.value.findIndex((e) => e.id == id);
clients.value.splice(index, 1);
})
.catch((e) => {
createModal(
ModalType.Notification,
{
title: "Failed to revoke client",
description: `Failed to revoke client: ${e}`,
},
(_, c) => c()
);
});
}
</script>

View File

@ -0,0 +1,8 @@
-- DropForeignKey
ALTER TABLE "SaveSlot" DROP CONSTRAINT "SaveSlot_lastUsedClientId_fkey";
-- AlterTable
ALTER TABLE "SaveSlot" ALTER COLUMN "lastUsedClientId" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "SaveSlot" ADD CONSTRAINT "SaveSlot_lastUsedClientId_fkey" FOREIGN KEY ("lastUsedClientId") REFERENCES "Client"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -76,8 +76,8 @@ model SaveSlot {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
playtime Float @default(0) // hours playtime Float @default(0) // hours
lastUsedClientId String lastUsedClientId String?
lastUsedClient Client @relation(fields: [lastUsedClientId], references: [id]) lastUsedClient Client? @relation(fields: [lastUsedClientId], references: [id])
history String[] // list of objects history String[] // list of objects
historyChecksums String[] // list of hashes historyChecksums String[] // list of hashes

View File

@ -27,7 +27,8 @@ export default defineEventHandler(async (h3) => {
take: parseInt(query.limit as string), take: parseInt(query.limit as string),
skip: parseInt(query.skip as string), skip: parseInt(query.skip as string),
orderBy: orderBy, orderBy: orderBy,
...(tags && { tags: tags.map((e) => e.toString()) }), ...(tags && { tags: tags
.map((e) => e.toString()) }),
search: query.search as string, search: query.search as string,
}; };

View File

@ -3,8 +3,10 @@ import capabilityManager, {
validCapabilities, validCapabilities,
} from "~/server/internal/clients/capabilities"; } from "~/server/internal/clients/capabilities";
import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
import notificationSystem from "~/server/internal/notifications";
export default defineClientEventHandler(async (h3, { clientId }) => { export default defineClientEventHandler(
async (h3, { clientId, fetchClient, fetchUser }) => {
const body = await readBody(h3); const body = await readBody(h3);
const rawCapability = body.capability; const rawCapability = body.capability;
const configuration = body.configuration; const configuration = body.configuration;
@ -45,5 +47,16 @@ export default defineClientEventHandler(async (h3, { clientId }) => {
clientId clientId
); );
return {}; const client = await fetchClient();
const user = await fetchUser();
await notificationSystem.push(user.id, {
nonce: `capability-${clientId}-${capability}`,
title: `"${client.name}" can now access ${capability}`,
description: `A device called "${client.name}" now has access to your ${capability}.`,
actions: ["Review|/account/devices"],
}); });
return {};
}
);

View File

@ -0,0 +1,17 @@
import aclManager from "~/server/internal/acls";
import clientHandler from "~/server/internal/clients/handler";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["clients:revoke"]);
if (!userId) throw createError({ statusCode: 403 });
const clientId = getRouterParam(h3, "id");
if (!clientId)
throw createError({
statusCode: 400,
statusMessage: "Client ID missing in route params",
});
await clientHandler.removeClient(clientId);
});

View File

@ -0,0 +1,15 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["clients:read"]);
if (!userId) throw createError({ statusCode: 403 });
const clients = await prisma.client.findMany({
where: {
userId,
},
});
return clients;
});

View File

@ -31,6 +31,9 @@ export const userACLDescriptions: ObjectFromList<typeof userACLs> = {
"library:add": "Add a game to your library.", "library:add": "Add a game to your library.",
"library:remove": "Remove a game from your library.", "library:remove": "Remove a game from your library.",
"clients:read": "Read the clients connected to this account",
"clients:revoke": "",
"news:read": "Read the server's news articles.", "news:read": "Read the server's news articles.",
}; };

View File

@ -26,6 +26,9 @@ export const userACLs = [
"library:add", "library:add",
"library:remove", "library:remove",
"clients:read",
"clients:revoke",
"news:read", "news:read",
] as const; ] as const;
const userACLPrefix = "user:"; const userACLPrefix = "user:";

View File

@ -64,13 +64,13 @@ export class CertificateAuthority {
async fetchClientCertificate(clientId: string) { async fetchClientCertificate(clientId: string) {
const isBlacklist = await this.certificateStore.checkBlacklistCertificate( const isBlacklist = await this.certificateStore.checkBlacklistCertificate(
clientId `client:${clientId}`
); );
if (isBlacklist) return undefined; if (isBlacklist) return undefined;
return await this.certificateStore.fetch(`client:${clientId}`); return await this.certificateStore.fetch(`client:${clientId}`);
} }
async blacklistClient(clientId: string) { async blacklistClient(clientId: string) {
await this.certificateStore.blacklistCertificate(clientId); await this.certificateStore.blacklistCertificate(`client:${clientId}`);
} }
} }

View File

@ -3,6 +3,7 @@ import { EventHandlerRequest, H3Event } from "h3";
import droplet from "@drop-oss/droplet"; import droplet from "@drop-oss/droplet";
import prisma from "../db/database"; import prisma from "../db/database";
import { useCertificateAuthority } from "~/server/plugins/ca"; import { useCertificateAuthority } from "~/server/plugins/ca";
import moment from "moment";
export type EventHandlerFunction<T> = ( export type EventHandlerFunction<T> = (
h3: H3Event<EventHandlerRequest>, h3: H3Event<EventHandlerRequest>,
@ -122,7 +123,7 @@ export function defineClientEventHandler<T>(handler: EventHandlerFunction<T>) {
fetchUser, fetchUser,
}; };
prisma.client.update({ await prisma.client.update({
where: { id: clientId }, where: { id: clientId },
data: { lastConnected: new Date() }, data: { lastConnected: new Date() },
}); });

View File

@ -2,6 +2,7 @@ import { v4 as uuidv4 } from "uuid";
import { CertificateBundle } from "./ca"; import { CertificateBundle } from "./ca";
import prisma from "../db/database"; import prisma from "../db/database";
import { Platform } from "@prisma/client"; import { Platform } from "@prisma/client";
import { useCertificateAuthority } from "~/server/plugins/ca";
export interface ClientMetadata { export interface ClientMetadata {
name: string; name: string;
@ -82,6 +83,17 @@ export class ClientHandler {
}, },
}); });
} }
async removeClient(id: string) {
const ca = useCertificateAuthority();
await ca.blacklistClient(id);
await prisma.client.delete({
where: {
id,
},
});
}
} }
export const clientHandler = new ClientHandler(); export const clientHandler = new ClientHandler();