Files
api/web/components/ApiKey/ApiKeyManager.vue
Eli Bosley 674323fd87 feat: generated UI API key management + OAuth-like API Key Flows (#1609)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* API Key Authorization flow with consent screen, callback support, and
a Tools page.
* Schema-driven API Key creation UI with permission presets, templates,
and Developer Authorization Link.
* Effective Permissions preview and a new multi-select permission
control.

* **UI Improvements**
* Mask/toggle API keys, copy-to-clipboard with toasts, improved select
labels, new label styles, tab wrapping, and accordionized color
controls.

* **Documentation**
  * Public guide for the API Key authorization flow and scopes added.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-27 12:37:39 -04:00

338 lines
12 KiB
Vue

<script setup lang="ts">
import { ref, watchEffect } from 'vue';
import { storeToRefs } from 'pinia';
import { useMutation, useQuery } from '@vue/apollo-composable';
import { useClipboardWithToast } from '~/composables/useClipboardWithToast';
import type { AuthAction, ApiKeyFragment, Role } from '~/composables/gql/graphql';
import { ClipboardDocumentIcon, EyeIcon, EyeSlashIcon, ChevronDownIcon, LinkIcon } from '@heroicons/vue/24/solid';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Badge,
Button,
CardWrapper,
DropdownMenuRoot,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Input,
PageContainer,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@unraid/ui';
import { extractGraphQLErrorMessage } from '~/helpers/functions';
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 EffectivePermissions from '~/components/ApiKey/EffectivePermissions.vue';
import { generateScopes } from '~/utils/authorizationLink';
const { result, refetch } = useQuery(GET_API_KEYS);
const apiKeyStore = useApiKeyStore();
const { createdKey } = storeToRefs(apiKeyStore);
const apiKeys = ref<ApiKeyFragment[]>([]);
watchEffect(() => {
const baseKeys: ApiKeyFragment[] =
result.value?.apiKeys.map((key) => useFragment(API_KEY_FRAGMENT, key)) || [];
if (createdKey.value) {
const existingKeyIndex = baseKeys.findIndex((key) => key.id === createdKey.value?.id);
if (existingKeyIndex >= 0) {
baseKeys[existingKeyIndex] = createdKey.value;
} else {
baseKeys.unshift(createdKey.value);
}
// Don't automatically show keys - keep them hidden by default
}
apiKeys.value = baseKeys;
});
const metaQuery = useQuery(GET_API_KEY_META);
const possibleRoles = ref<string[]>([]);
const possiblePermissions = ref<{ resource: string; actions: AuthAction[] }[]>([]);
watchEffect(() => {
possibleRoles.value = metaQuery.result.value?.apiKeyPossibleRoles || [];
// Cast actions to AuthAction[] since GraphQL returns string[] but we know they're AuthAction values
possiblePermissions.value = (metaQuery.result.value?.apiKeyPossiblePermissions || []).map(p => ({
resource: p.resource,
actions: p.actions as AuthAction[]
}));
});
const showKey = ref<Record<string, boolean>>({});
const { copyWithNotification, copied } = useClipboardWithToast();
// Template input state
const showTemplateInput = ref(false);
const templateUrl = ref('');
const templateError = ref('');
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 | ApiKeyFragment | null = null) {
apiKeyStore.clearCreatedKey();
apiKeyStore.showModal(key as ApiKeyFragment | null);
}
function openCreateFromTemplate() {
showTemplateInput.value = true;
templateUrl.value = '';
templateError.value = '';
}
function cancelTemplateInput() {
showTemplateInput.value = false;
templateUrl.value = '';
templateError.value = '';
}
function applyTemplate() {
templateError.value = '';
try {
// Parse the template URL or query string
let url: URL;
if (templateUrl.value.startsWith('http://') || templateUrl.value.startsWith('https://')) {
// Full URL provided
url = new URL(templateUrl.value);
} else if (templateUrl.value.startsWith('?')) {
// Query string only
url = new URL(window.location.origin + templateUrl.value);
} else {
// Try to parse as query string without ?
url = new URL(window.location.origin + '?' + templateUrl.value);
}
// Extract query parameters
const params = url.searchParams;
// Navigate to the authorization page with these params using window.location
const authUrl = new URL('/Tools/ApiKeyAuthorize', window.location.origin);
params.forEach((value, key) => {
authUrl.searchParams.append(key, value);
});
window.location.href = authUrl.toString();
cancelTemplateInput();
} catch (_err) {
templateError.value = 'Invalid template URL or query string. Please check the format and try again.';
}
}
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);
}
}
async function copyKeyValue(keyValue: string) {
await copyWithNotification(keyValue, 'API key copied to clipboard');
}
async function copyKeyTemplate(key: ApiKeyFragment) {
try {
// Generate scopes using the same logic as DeveloperAuthorizationLink
const scopes = generateScopes(
key.roles as Role[] || [],
key.permissions?.map(p => ({
resource: p.resource,
actions: p.actions as AuthAction[]
})) || []
);
// Build URL parameters for the template
const urlParams = new URLSearchParams({
name: key.name,
scopes: scopes.join(','),
});
if (key.description) {
urlParams.set('description', key.description);
}
// Don't include redirect_uri for templates
const templateQueryString = '?' + urlParams.toString();
await copyWithNotification(templateQueryString, 'Template copied to clipboard');
} catch (error) {
console.error('Failed to copy template:', error);
}
}
</script>
<template>
<PageContainer>
<div>
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold tracking-tight">API Keys</h2>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<Button variant="primary">
Create API Key
<ChevronDownIcon class="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="openCreateModal(null)">
Create New
</DropdownMenuItem>
<DropdownMenuItem @click="openCreateFromTemplate">
Create from Template
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuRoot>
</div>
<div
v-if="deleteError"
class="mb-4 p-3 rounded border border-destructive bg-destructive/10 text-destructive"
>
{{ deleteError }}
</div>
<div v-if="apiKeys.length" class="flex flex-col gap-4 mb-6">
<div v-for="key in apiKeys" :key="key.id" class="w-full">
<CardWrapper :padding="false">
<div class="p-4 overflow-hidden">
<div class="flex flex-col gap-2">
<div class="text-sm truncate max-w-[250px] md:max-w-md"><b>ID:</b> {{ key.id.split(':')[1] }}</div>
<div class="text-sm"><b>Name:</b> {{ key.name }}</div>
<div v-if="key.description" class="text-sm"
><b>Description:</b> {{ key.description }}</div>
<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 class="flex items-center gap-2">
<span class="text-sm text-green-700 font-medium"><b>API Key:</b></span>
<div class="relative flex-1 max-w-[300px]">
<Input
:model-value="showKey[key.id] ? key.key : ''"
class="w-full font-mono text-xs 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" class="h-8 w-8" @click="copyKeyValue(key.key)">
<ClipboardDocumentIcon class="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ copied ? 'Copied!' : 'Copy to clipboard...' }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div v-if="key.permissions?.length || key.roles?.length" class="mt-4 pt-4 border-t">
<Accordion
type="single"
collapsible
class="w-full"
>
<AccordionItem :value="'permissions-' + key.id">
<AccordionTrigger>
<span class="text-sm font-semibold">Effective Permissions</span>
</AccordionTrigger>
<AccordionContent>
<div class="py-2 overflow-auto">
<EffectivePermissions
:roles="key.roles"
:raw-permissions="key.permissions?.map(p => ({
resource: p.resource,
actions: p.actions
})) || []"
:show-header="false"
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
<div class="mt-4 pt-4 border-t flex flex-wrap gap-2">
<Button variant="secondary" size="sm" @click="openCreateModal(key)">Edit</Button>
<TooltipProvider>
<Tooltip :delay-duration="0">
<TooltipTrigger>
<Button variant="outline" size="sm" @click="copyKeyTemplate(key)">
<LinkIcon class="w-4 h-4 mr-1" />
Copy Template
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy a shareable template with these permissions</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button variant="destructive" size="sm" @click="_deleteKey(key.id)">Delete</Button>
</div>
</div>
</CardWrapper>
</div>
</div>
<div v-else class="flex flex-col gap-4 mb-6">
<p class="text-sm">No API keys found</p>
</div>
<!-- Template Input Dialog -->
<div v-if="showTemplateInput" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-background rounded-lg p-6 max-w-lg w-full mx-4">
<h3 class="text-lg font-semibold mb-4">Create from Template</h3>
<p class="text-sm text-muted-foreground mb-4">
Paste a template URL or query string to pre-fill the API key creation form with permissions.
</p>
<Input
v-model="templateUrl"
placeholder="Paste template URL or query string (e.g., ?name=MyApp&scopes=role:admin)"
class="mb-4"
@keydown.enter="applyTemplate"
/>
<div v-if="templateError" class="mb-4 p-3 rounded border border-destructive bg-destructive/10 text-destructive text-sm">
{{ templateError }}
</div>
<div class="flex gap-3 justify-end">
<Button variant="outline" @click="cancelTemplateInput">Cancel</Button>
<Button variant="primary" @click="applyTemplate">Apply Template</Button>
</div>
</div>
</div>
</div>
</PageContainer>
</template>