Files
api/web/components/ApiKey/ApiKeyManager.vue
Eli Bosley 2c62e0ad09 feat: tailwind v4 (#1522)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Streamlined Tailwind CSS integration using Vite plugin, eliminating
the need for separate Tailwind config files.
* Updated theme and color variables for improved consistency and
maintainability.

* **Style**
* Standardized spacing, sizing, and font classes across all components
using Tailwind’s default scale.
* Reduced excessive gaps, padding, and font sizes for a more compact and
cohesive UI.
* Updated gradient, border, and shadow classes to match Tailwind v4
conventions.
* Replaced custom pixel-based classes with Tailwind’s bracketed
arbitrary value syntax where needed.
* Replaced focus outline styles from `outline-none` to `outline-hidden`
for consistent focus handling.
* Updated flex shrink/grow utility classes to use newer shorthand forms.
* Converted several component templates to use self-closing tags for
cleaner markup.
  * Adjusted icon sizes and spacing for improved visual balance.

* **Chores**
* Removed legacy Tailwind/PostCSS configuration files and related
scripts.
* Updated and cleaned up package dependencies for Tailwind v4 and
related plugins.
  * Removed unused or redundant build scripts and configuration exports.
  * Updated documentation to reflect new Tailwind v4 usage.
  * Removed Prettier Tailwind plugin from formatting configurations.
* Removed Nuxt Tailwind module in favor of direct Vite plugin
integration.
  * Cleaned up ESLint config by removing Prettier integration.

* **Bug Fixes**
  * Corrected invalid or outdated Tailwind class names and syntax.
* Fixed issues with max-width and other utility classes for improved
layout consistency.

* **Tests**
* Updated test assertions to match new class names and styling
conventions.

* **Documentation**
* Revised README and internal notes to clarify Tailwind v4 adoption and
configuration changes.
* Added new development notes emphasizing Tailwind v4 usage and
documentation references.

* **UI Components**
* Enhanced BrandButton stories with detailed variant, size, and padding
showcases for better visual testing.
* Improved theme store to apply dark mode class on both `<html>` and
`<body>` elements for compatibility.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-21 09:58:02 -04:00

207 lines
8.1 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 bg-gray-50 border border-gray-200 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>