mirror of
https://github.com/unraid/api.git
synced 2026-01-01 14:10:10 -06:00
335 lines
12 KiB
Vue
335 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue';
|
|
import { storeToRefs } from 'pinia';
|
|
import { useMutation, useQuery } from '@vue/apollo-composable';
|
|
|
|
import {
|
|
Accordion,
|
|
AccordionContent,
|
|
AccordionItem,
|
|
AccordionTrigger,
|
|
Button,
|
|
Dialog,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogScrollContent,
|
|
DialogTitle,
|
|
Input,
|
|
Label,
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@unraid/ui';
|
|
import { extractGraphQLErrorMessage } from '~/helpers/functions';
|
|
|
|
import type { ApolloError } from '@apollo/client/errors';
|
|
import type { FragmentType } from '~/composables/gql/fragment-masking';
|
|
import type { Resource, Role } from '~/composables/gql/graphql';
|
|
import type { ComposerTranslation } from 'vue-i18n';
|
|
|
|
import { useFragment } from '~/composables/gql/fragment-masking';
|
|
import { useApiKeyStore } from '~/store/apiKey';
|
|
import {
|
|
API_KEY_FRAGMENT,
|
|
API_KEY_FRAGMENT_WITH_KEY,
|
|
CREATE_API_KEY,
|
|
GET_API_KEY_META,
|
|
UPDATE_API_KEY,
|
|
} from './apikey.query';
|
|
import PermissionCounter from './PermissionCounter.vue';
|
|
|
|
defineProps<{ t: ComposerTranslation }>();
|
|
|
|
const apiKeyStore = useApiKeyStore();
|
|
const { modalVisible, editingKey } = storeToRefs(apiKeyStore);
|
|
|
|
const { result: apiKeyMetaResult } = useQuery(GET_API_KEY_META);
|
|
const possibleRoles = computed(() => apiKeyMetaResult.value?.apiKeyPossibleRoles || []);
|
|
const possiblePermissions = computed(() => apiKeyMetaResult.value?.apiKeyPossiblePermissions || []);
|
|
|
|
const newKeyName = ref('');
|
|
const newKeyDescription = ref('');
|
|
const newKeyRoles = ref<Role[]>([]);
|
|
const newKeyPermissions = ref<{ resource: Resource; actions: string[] }[]>([]);
|
|
const { mutate: createApiKey, loading: createLoading, error: createError } = useMutation(CREATE_API_KEY);
|
|
const { mutate: updateApiKey, loading: updateLoading, error: updateError } = useMutation(UPDATE_API_KEY);
|
|
const postCreateLoading = ref(false);
|
|
|
|
const loading = computed<boolean>(() => createLoading.value || updateLoading.value);
|
|
const error = computed<ApolloError | null>(() => createError.value || updateError.value);
|
|
|
|
watch(
|
|
() => editingKey.value,
|
|
(key) => {
|
|
const fragmentKey = key
|
|
? useFragment(API_KEY_FRAGMENT, key as FragmentType<typeof API_KEY_FRAGMENT>)
|
|
: null;
|
|
if (fragmentKey) {
|
|
newKeyName.value = fragmentKey.name;
|
|
newKeyDescription.value = fragmentKey.description || '';
|
|
newKeyRoles.value = [...fragmentKey.roles];
|
|
newKeyPermissions.value = fragmentKey.permissions
|
|
? fragmentKey.permissions.map((p) => ({
|
|
resource: p.resource as Resource,
|
|
actions: [...p.actions],
|
|
}))
|
|
: [];
|
|
} else {
|
|
newKeyName.value = '';
|
|
newKeyDescription.value = '';
|
|
newKeyRoles.value = [];
|
|
newKeyPermissions.value = [];
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
function togglePermission(resource: string, action: string, checked: boolean) {
|
|
const res = resource as Resource;
|
|
const perm = newKeyPermissions.value.find((p) => p.resource === res);
|
|
if (checked) {
|
|
if (perm) {
|
|
if (!perm.actions.includes(action)) perm.actions.push(action);
|
|
} else {
|
|
newKeyPermissions.value.push({ resource: res, actions: [action] });
|
|
}
|
|
} else {
|
|
if (perm) {
|
|
perm.actions = perm.actions.filter((a) => a !== action);
|
|
if (perm.actions.length === 0) {
|
|
newKeyPermissions.value = newKeyPermissions.value.filter((p) => p.resource !== res);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function areAllPermissionsSelected() {
|
|
return possiblePermissions.value.every((perm) => {
|
|
const selected = newKeyPermissions.value.find((p) => p.resource === perm.resource)?.actions || [];
|
|
return perm.actions.every((a) => selected.includes(a));
|
|
});
|
|
}
|
|
|
|
function selectAllPermissions() {
|
|
newKeyPermissions.value = possiblePermissions.value.map((perm) => ({
|
|
resource: perm.resource as Resource,
|
|
actions: [...perm.actions],
|
|
}));
|
|
}
|
|
|
|
function clearAllPermissions() {
|
|
newKeyPermissions.value = [];
|
|
}
|
|
|
|
function areAllActionsSelected(resource: string) {
|
|
const perm = possiblePermissions.value.find((p) => p.resource === resource);
|
|
if (!perm) return false;
|
|
const selected = newKeyPermissions.value.find((p) => p.resource === resource)?.actions || [];
|
|
return perm.actions.every((a) => selected.includes(a));
|
|
}
|
|
|
|
function selectAllActions(resource: string) {
|
|
const res = resource as Resource;
|
|
const perm = possiblePermissions.value.find((p) => p.resource === res);
|
|
if (!perm) return;
|
|
const idx = newKeyPermissions.value.findIndex((p) => p.resource === res);
|
|
if (idx !== -1) {
|
|
newKeyPermissions.value[idx].actions = [...perm.actions];
|
|
} else {
|
|
newKeyPermissions.value.push({ resource: res, actions: [...perm.actions] });
|
|
}
|
|
}
|
|
|
|
function clearAllActions(resource: string) {
|
|
newKeyPermissions.value = newKeyPermissions.value.filter((p) => p.resource !== resource);
|
|
}
|
|
|
|
const close = () => {
|
|
apiKeyStore.hideModal();
|
|
};
|
|
|
|
async function upsertKey() {
|
|
postCreateLoading.value = true;
|
|
try {
|
|
const isEdit = !!editingKey.value?.id;
|
|
let res;
|
|
if (isEdit && editingKey.value) {
|
|
res = await updateApiKey({
|
|
input: {
|
|
id: editingKey.value.id,
|
|
name: newKeyName.value,
|
|
description: newKeyDescription.value,
|
|
roles: newKeyRoles.value,
|
|
permissions: newKeyPermissions.value.length ? newKeyPermissions.value : undefined,
|
|
},
|
|
});
|
|
} else {
|
|
res = await createApiKey({
|
|
input: {
|
|
name: newKeyName.value,
|
|
description: newKeyDescription.value,
|
|
roles: newKeyRoles.value,
|
|
permissions: newKeyPermissions.value.length ? newKeyPermissions.value : undefined,
|
|
},
|
|
});
|
|
}
|
|
|
|
const apiKeyResult = res?.data?.apiKey;
|
|
if (isEdit && apiKeyResult && 'update' in apiKeyResult) {
|
|
const fragmentData = useFragment(API_KEY_FRAGMENT_WITH_KEY, apiKeyResult.update);
|
|
apiKeyStore.setCreatedKey(fragmentData);
|
|
} else if (!isEdit && apiKeyResult && 'create' in apiKeyResult) {
|
|
const fragmentData = useFragment(API_KEY_FRAGMENT_WITH_KEY, apiKeyResult.create);
|
|
apiKeyStore.setCreatedKey(fragmentData);
|
|
}
|
|
|
|
modalVisible.value = false;
|
|
editingKey.value = null;
|
|
newKeyName.value = '';
|
|
newKeyDescription.value = '';
|
|
newKeyRoles.value = [];
|
|
newKeyPermissions.value = [];
|
|
} finally {
|
|
postCreateLoading.value = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Dialog
|
|
:open="modalVisible"
|
|
@close="close"
|
|
@update:open="
|
|
(v) => {
|
|
if (!v) close();
|
|
}
|
|
"
|
|
>
|
|
<DialogScrollContent class="max-w-800px">
|
|
<DialogHeader>
|
|
<DialogTitle>{{ editingKey ? t('Edit API Key') : t('Create API Key') }}</DialogTitle>
|
|
</DialogHeader>
|
|
<DialogDescription>
|
|
<form @submit.prevent="upsertKey">
|
|
<div class="mb-2">
|
|
<Label for="api-key-name">Name</Label>
|
|
<Input id="api-key-name" v-model="newKeyName" placeholder="Name" class="mt-1" />
|
|
</div>
|
|
<div class="mb-2">
|
|
<Label for="api-key-desc">Description</Label>
|
|
<Input
|
|
id="api-key-desc"
|
|
v-model="newKeyDescription"
|
|
placeholder="Description"
|
|
class="mt-1"
|
|
/>
|
|
</div>
|
|
<div class="mb-2">
|
|
<Label for="api-key-roles">Roles</Label>
|
|
<Select v-model="newKeyRoles" multiple class="mt-1 w-full">
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select Roles" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="role in possibleRoles" :key="role" :value="role">{{
|
|
role
|
|
}}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div class="mb-2">
|
|
<Label for="api-key-permissions">Permissions</Label>
|
|
<Accordion id="api-key-permissions" type="single" collapsible class="w-full mt-2">
|
|
<AccordionItem value="permissions">
|
|
<AccordionTrigger>
|
|
<PermissionCounter
|
|
:permissions="newKeyPermissions"
|
|
:possible-permissions="possiblePermissions"
|
|
/>
|
|
</AccordionTrigger>
|
|
<AccordionContent>
|
|
<div class="flex flex-row justify-end my-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
type="button"
|
|
@click="
|
|
areAllPermissionsSelected() ? clearAllPermissions() : selectAllPermissions()
|
|
"
|
|
>
|
|
{{ areAllPermissionsSelected() ? 'Clear All' : 'Select All' }}
|
|
</Button>
|
|
</div>
|
|
<div class="flex flex-col gap-2 mt-1">
|
|
<div
|
|
v-for="perm in possiblePermissions"
|
|
:key="perm.resource"
|
|
class="rounded-sm p-2 border"
|
|
>
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="font-semibold">{{ perm.resource }}</span>
|
|
<Button
|
|
size="sm"
|
|
variant="link"
|
|
type="button"
|
|
@click="
|
|
areAllActionsSelected(perm.resource)
|
|
? clearAllActions(perm.resource)
|
|
: selectAllActions(perm.resource)
|
|
"
|
|
>
|
|
{{ areAllActionsSelected(perm.resource) ? 'Clear All' : 'Select All' }}
|
|
</Button>
|
|
</div>
|
|
<div class="flex gap-4 flex-wrap">
|
|
<label
|
|
v-for="action in perm.actions"
|
|
:key="action"
|
|
class="flex items-center gap-1"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
:checked="
|
|
!!newKeyPermissions.find(
|
|
(p) => p.resource === perm.resource && p.actions.includes(action)
|
|
)
|
|
"
|
|
@change="
|
|
(e: Event) =>
|
|
togglePermission(
|
|
perm.resource,
|
|
action,
|
|
(e.target as HTMLInputElement)?.checked
|
|
)
|
|
"
|
|
/>
|
|
<span class="text-sm">{{ action }}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
</Accordion>
|
|
</div>
|
|
<div v-if="error" class="text-red-500 mt-2 text-sm">
|
|
{{ extractGraphQLErrorMessage(error) }}
|
|
</div>
|
|
</form>
|
|
</DialogDescription>
|
|
<DialogFooter>
|
|
<Button variant="secondary" @click="close">Cancel</Button>
|
|
<Button variant="primary" :disabled="loading || postCreateLoading" @click="upsertKey()">
|
|
<span v-if="loading || postCreateLoading">
|
|
{{ editingKey ? 'Saving...' : 'Creating...' }}
|
|
</span>
|
|
<span v-else>{{ editingKey ? 'Save' : 'Create' }}</span>
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogScrollContent>
|
|
</Dialog>
|
|
</template>
|