mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-12 07:42:40 +10:00
feat: add ability to review and revoke clients
This commit is contained in:
@ -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",
|
||||||
|
|||||||
@ -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
119
pages/account/devices.vue
Normal 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>
|
||||||
@ -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;
|
||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -3,47 +3,60 @@ 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(
|
||||||
const body = await readBody(h3);
|
async (h3, { clientId, fetchClient, fetchUser }) => {
|
||||||
const rawCapability = body.capability;
|
const body = await readBody(h3);
|
||||||
const configuration = body.configuration;
|
const rawCapability = body.capability;
|
||||||
|
const configuration = body.configuration;
|
||||||
|
|
||||||
if (!rawCapability || typeof rawCapability !== "string")
|
if (!rawCapability || typeof rawCapability !== "string")
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage: "capability must be a string",
|
statusMessage: "capability must be a string",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!configuration || typeof configuration !== "object")
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "configuration must be an object",
|
||||||
|
});
|
||||||
|
|
||||||
|
const capability = rawCapability as InternalClientCapability;
|
||||||
|
|
||||||
|
if (!validCapabilities.includes(capability))
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Invalid capability.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const isValid = await capabilityManager.validateCapabilityConfiguration(
|
||||||
|
capability,
|
||||||
|
configuration
|
||||||
|
);
|
||||||
|
if (!isValid)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Invalid capability configuration.",
|
||||||
|
});
|
||||||
|
|
||||||
|
await capabilityManager.upsertClientCapability(
|
||||||
|
capability,
|
||||||
|
configuration,
|
||||||
|
clientId
|
||||||
|
);
|
||||||
|
|
||||||
|
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"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!configuration || typeof configuration !== "object")
|
return {};
|
||||||
throw createError({
|
}
|
||||||
statusCode: 400,
|
);
|
||||||
statusMessage: "configuration must be an object",
|
|
||||||
});
|
|
||||||
|
|
||||||
const capability = rawCapability as InternalClientCapability;
|
|
||||||
|
|
||||||
if (!validCapabilities.includes(capability))
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: "Invalid capability.",
|
|
||||||
});
|
|
||||||
|
|
||||||
const isValid = await capabilityManager.validateCapabilityConfiguration(
|
|
||||||
capability,
|
|
||||||
configuration
|
|
||||||
);
|
|
||||||
if (!isValid)
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: "Invalid capability configuration.",
|
|
||||||
});
|
|
||||||
|
|
||||||
await capabilityManager.upsertClientCapability(
|
|
||||||
capability,
|
|
||||||
configuration,
|
|
||||||
clientId
|
|
||||||
);
|
|
||||||
|
|
||||||
return {};
|
|
||||||
});
|
|
||||||
|
|||||||
17
server/api/v1/user/client/[id]/index.delete.ts
Normal file
17
server/api/v1/user/client/[id]/index.delete.ts
Normal 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);
|
||||||
|
});
|
||||||
15
server/api/v1/user/client/index.get.ts
Normal file
15
server/api/v1/user/client/index.get.ts
Normal 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;
|
||||||
|
});
|
||||||
@ -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.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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:";
|
||||||
|
|||||||
@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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() },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user