mirror of
https://github.com/unraid/api.git
synced 2026-01-05 08:00:33 -06:00
When generating an API key with the black theme active, the key was displayed with white text on a white background. This was caused by hardcoded light-theme classes (`bg-gray-50` and `border-gray-200`) on the `Input` component that displays the key. This change removes the hardcoded background and border color classes, allowing the themed styles to be applied correctly. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - Refactor - Consolidated common UI and form components behind single import entry points, simplifying usage and making components easier to discover. No functional changes. - Style - Refined API key input appearance for a cleaner, less intrusive look while preserving readability and existing controls (copy, visibility toggle, read-only). - Chores - Streamlined component import paths to improve developer experience and consistency across the app. No user-facing behavior changes. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
207 lines
8.0 KiB
Vue
207 lines
8.0 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watchEffect } from 'vue';
|
|
import { storeToRefs } from 'pinia';
|
|
import { useMutation, useQuery } from '@vue/apollo-composable';
|
|
import { useClipboard } from '@vueuse/core';
|
|
|
|
import { ClipboardDocumentIcon, EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/solid';
|
|
import {
|
|
Accordion,
|
|
AccordionContent,
|
|
AccordionItem,
|
|
AccordionTrigger,
|
|
Badge,
|
|
Button,
|
|
CardWrapper,
|
|
Input,
|
|
PageContainer,
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from '@unraid/ui';
|
|
import { extractGraphQLErrorMessage } from '~/helpers/functions';
|
|
|
|
import type { ApiKeyFragment, ApiKeyWithKeyFragment } from '~/composables/gql/graphql';
|
|
|
|
import { useFragment } from '~/composables/gql/fragment-masking';
|
|
import { useApiKeyStore } from '~/store/apiKey';
|
|
import { API_KEY_FRAGMENT, DELETE_API_KEY, GET_API_KEY_META, GET_API_KEYS } from './apikey.query';
|
|
import PermissionCounter from './PermissionCounter.vue';
|
|
|
|
const { result, refetch } = useQuery(GET_API_KEYS);
|
|
|
|
const apiKeyStore = useApiKeyStore();
|
|
const { createdKey } = storeToRefs(apiKeyStore);
|
|
const apiKeys = ref<(ApiKeyFragment | ApiKeyWithKeyFragment)[]>([]);
|
|
|
|
watchEffect(() => {
|
|
const baseKeys: (ApiKeyFragment | ApiKeyWithKeyFragment)[] =
|
|
result.value?.apiKeys.map((key) => useFragment(API_KEY_FRAGMENT, key)) || [];
|
|
console.log(createdKey.value);
|
|
if (createdKey.value) {
|
|
const existingKeyIndex = baseKeys.findIndex((key) => key.id === createdKey.value?.id);
|
|
if (existingKeyIndex >= 0) {
|
|
baseKeys[existingKeyIndex] = createdKey.value as ApiKeyFragment | ApiKeyWithKeyFragment;
|
|
} else {
|
|
baseKeys.unshift(createdKey.value as ApiKeyFragment | ApiKeyWithKeyFragment);
|
|
}
|
|
}
|
|
|
|
apiKeys.value = baseKeys;
|
|
});
|
|
|
|
const metaQuery = useQuery(GET_API_KEY_META);
|
|
const possibleRoles = ref<string[]>([]);
|
|
const possiblePermissions = ref<{ resource: string; actions: string[] }[]>([]);
|
|
watchEffect(() => {
|
|
possibleRoles.value = metaQuery.result.value?.apiKeyPossibleRoles || [];
|
|
possiblePermissions.value = metaQuery.result.value?.apiKeyPossiblePermissions || [];
|
|
});
|
|
|
|
const showKey = ref<Record<string, boolean>>({});
|
|
const { copy, copied } = useClipboard();
|
|
|
|
const { mutate: deleteKey } = useMutation(DELETE_API_KEY);
|
|
|
|
const deleteError = ref<string | null>(null);
|
|
|
|
function toggleShowKey(keyId: string) {
|
|
showKey.value[keyId] = !showKey.value[keyId];
|
|
}
|
|
|
|
function openCreateModal(key: ApiKeyFragment | ApiKeyWithKeyFragment | null = null) {
|
|
apiKeyStore.clearCreatedKey();
|
|
apiKeyStore.showModal(key as ApiKeyFragment | null);
|
|
}
|
|
|
|
async function _deleteKey(_id: string) {
|
|
if (!window.confirm('Are you sure you want to delete this API key? This action cannot be undone.'))
|
|
return;
|
|
deleteError.value = null;
|
|
try {
|
|
await deleteKey({ input: { ids: [_id] } });
|
|
await refetch();
|
|
} catch (err: unknown) {
|
|
deleteError.value = extractGraphQLErrorMessage(err);
|
|
}
|
|
}
|
|
|
|
function hasKey(key: ApiKeyFragment | ApiKeyWithKeyFragment): key is ApiKeyWithKeyFragment {
|
|
return 'key' in key && !!key.key;
|
|
}
|
|
|
|
async function copyKeyValue(keyValue: string) {
|
|
await copy(keyValue);
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<PageContainer>
|
|
<div>
|
|
<div class="flex items-center justify-between mb-6">
|
|
<h2 class="text-2xl font-bold tracking-tight">API Keys</h2>
|
|
<Button variant="primary" @click="openCreateModal(null)">Create API Key</Button>
|
|
</div>
|
|
<div
|
|
v-if="deleteError"
|
|
class="mb-4 p-3 rounded border border-destructive bg-destructive/10 text-destructive"
|
|
>
|
|
{{ deleteError }}
|
|
</div>
|
|
<ul v-if="apiKeys.length" class="flex flex-col gap-4 mb-6">
|
|
<CardWrapper v-for="key in apiKeys" :key="key.id">
|
|
<li class="flex flex-row items-start justify-between gap-4 p-4 list-none">
|
|
<div class="flex-1 min-w-0">
|
|
<header class="flex gap-2 justify-between items-start">
|
|
<div class="flex flex-col gap-2">
|
|
<span class="text-sm truncate"><b>ID:</b> {{ key.id.split(':')[1] }}</span>
|
|
<span class="text-sm truncate"><b>Name:</b> {{ key.name }}</span>
|
|
<span v-if="key.description" class="text-sm truncate"
|
|
><b>Description:</b> {{ key.description }}</span
|
|
>
|
|
<div v-if="key.roles.length" class="flex flex-wrap gap-2 items-center">
|
|
<span class="text-sm"><b>Roles:</b></span>
|
|
<Badge v-for="role in key.roles" :key="role" variant="blue" size="xs">{{
|
|
role
|
|
}}</Badge>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2 shrink-0">
|
|
<Button variant="secondary" size="sm" @click="openCreateModal(key)">Edit</Button>
|
|
<Button variant="destructive" size="sm" @click="_deleteKey(key.id)">Delete</Button>
|
|
</div>
|
|
</header>
|
|
<div v-if="key.permissions?.length" class="pt-2 w-full">
|
|
<span class="text-sm"><b>Permissions:</b></span>
|
|
<Accordion type="single" collapsible class="w-full">
|
|
<AccordionItem :value="'permissions-' + key.id">
|
|
<AccordionTrigger>
|
|
<PermissionCounter
|
|
:permissions="key.permissions"
|
|
:possible-permissions="possiblePermissions"
|
|
/>
|
|
</AccordionTrigger>
|
|
<AccordionContent>
|
|
<div v-if="key.permissions?.length" class="flex flex-col gap-2 my-2">
|
|
<div
|
|
v-for="perm in key.permissions ?? []"
|
|
:key="perm.resource"
|
|
class="border rounded-sm p-2"
|
|
>
|
|
<div class="flex items-center gap-2 justify-between">
|
|
<span class="font-semibold">{{ perm.resource }}</span>
|
|
<PermissionCounter
|
|
:permissions="[perm]"
|
|
:possible-permissions="possiblePermissions"
|
|
:hide-number="true"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
</Accordion>
|
|
</div>
|
|
|
|
<div v-if="hasKey(key)" class="mt-4 flex items-center gap-2">
|
|
<span class="text-green-700 font-medium">API Key:</span>
|
|
<div class="relative w-64">
|
|
<Input
|
|
:model-value="showKey[key.id] ? key.key : '••••••••••••••••••••••••••••••••'"
|
|
class="w-full font-mono text-base px-2 py-1 rounded pr-10"
|
|
readonly
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="absolute inset-y-0 right-2 flex items-center px-1 text-gray-500 hover:text-gray-700"
|
|
tabindex="-1"
|
|
@click="toggleShowKey(key.id)"
|
|
>
|
|
<component :is="showKey[key.id] ? EyeSlashIcon : EyeIcon" class="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<TooltipProvider>
|
|
<Tooltip :delay-duration="0">
|
|
<TooltipTrigger>
|
|
<Button variant="ghost" size="icon" @click="copyKeyValue(key.key)">
|
|
<ClipboardDocumentIcon class="w-5 h-5" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{{ copied ? 'Copied!' : 'Copy to clipboard...' }}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
</CardWrapper>
|
|
</ul>
|
|
<ul v-else class="flex flex-col gap-4 mb-6">
|
|
<li class="text-sm">No API keys found</li>
|
|
</ul>
|
|
</div>
|
|
</PageContainer>
|
|
</template>
|