Files
api/web/components/ApiKey/ApiKeyManager.vue
Eli Bosley b3216874fa fix(theme): API key white text on white background (#1584)
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>
2025-08-13 14:43:42 -04:00

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>