Files
api/web/components/ApiKey/ApiKeyCreate.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

310 lines
11 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,
Input,
Label,
Select,
} 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
v-model="modalVisible"
size="lg"
:title="editingKey ? t('Edit API Key') : t('Create API Key')"
:scrollable="true"
close-button-text="Cancel"
:primary-button-text="editingKey ? 'Save' : 'Create'"
:primary-button-loading="loading || postCreateLoading"
:primary-button-loading-text="editingKey ? 'Saving...' : 'Creating...'"
:primary-button-disabled="loading || postCreateLoading"
@update:model-value="
(v) => {
if (!v) close();
}
"
@primary-click="upsertKey"
>
<div class="max-w-[800px]">
<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"
:items="possibleRoles"
:multiple="true"
:placeholder="'Select Roles'"
class="mt-1 w-full"
/>
</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() ? 'Select None' : '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) ? 'Select None' : '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>
</div>
</Dialog>
</template>