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 -->
This commit is contained in:
Eli Bosley
2025-08-27 12:37:39 -04:00
committed by GitHub
parent 6947b5d4af
commit 674323fd87
119 changed files with 7996 additions and 1459 deletions

View File

@@ -1,50 +1,99 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useMutation, useQuery } from '@vue/apollo-composable';
import { useClipboardWithToast } from '~/composables/useClipboardWithToast';
import {
import { ClipboardDocumentIcon } from '@heroicons/vue/24/solid';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
Dialog,
Input,
Label,
Select,
Button,
Dialog,
jsonFormsAjv,
jsonFormsRenderers
} from '@unraid/ui';
import { JsonForms } from '@jsonforms/vue';
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 {
ApiKeyFormSettings,
AuthAction,
CreateApiKeyInput,
Resource,
Role,
} from '~/composables/gql/graphql';
import type { ComposerTranslation } from 'vue-i18n';
import { useFragment } from '~/composables/gql/fragment-masking';
import { useApiKeyPermissionPresets } from '~/composables/useApiKeyPermissionPresets';
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';
import { GET_API_KEY_CREATION_FORM_SCHEMA } from './api-key-form.query';
import { API_KEY_FRAGMENT, CREATE_API_KEY, UPDATE_API_KEY } from './apikey.query';
import DeveloperAuthorizationLink from './DeveloperAuthorizationLink.vue';
import EffectivePermissions from './EffectivePermissions.vue';
defineProps<{ t: ComposerTranslation }>();
interface Props {
t?: ComposerTranslation;
}
const props = defineProps<Props>();
const { t } = props;
const apiKeyStore = useApiKeyStore();
const { modalVisible, editingKey } = storeToRefs(apiKeyStore);
const { modalVisible, editingKey, isAuthorizationMode, authorizationData, createdKey } =
storeToRefs(apiKeyStore);
const { result: apiKeyMetaResult } = useQuery(GET_API_KEY_META);
const possibleRoles = computed(() => apiKeyMetaResult.value?.apiKeyPossibleRoles || []);
const possiblePermissions = computed(() => apiKeyMetaResult.value?.apiKeyPossiblePermissions || []);
// Form data that matches what the backend expects
// This will be transformed into CreateApiKeyInput or UpdateApiKeyInput
interface FormData extends Partial<CreateApiKeyInput> {
keyName?: string; // Used in authorization mode
authorizationType?: 'roles' | 'groups' | 'custom';
permissionGroups?: string[];
permissionPresets?: string; // For the preset dropdown
customPermissions?: Array<{
resources: Resource[];
actions: AuthAction[];
}>;
requestedPermissions?: {
roles?: Role[];
permissionGroups?: string[];
customPermissions?: Array<{
resources: Resource[];
actions: AuthAction[];
}>;
};
consent?: boolean;
}
const formSchema = ref<ApiKeyFormSettings | null>(null);
const formData = ref<FormData>({
customPermissions: [],
roles: [],
authorizationType: 'roles',
} as FormData);
const formValid = ref(false);
// Use clipboard for copying
const { copyWithNotification, copied } = useClipboardWithToast();
// Computed property to transform formData permissions for the EffectivePermissions component
const formDataPermissions = computed(() => {
if (!formData.value.customPermissions) return [];
// Flatten the resources array into individual permission entries
return formData.value.customPermissions.flatMap((perm) =>
perm.resources.map((resource) => ({
resource,
actions: perm.actions, // Already string[] which can be AuthAction values
}))
);
});
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);
@@ -52,154 +101,302 @@ const postCreateLoading = ref(false);
const loading = computed<boolean>(() => createLoading.value || updateLoading.value);
const error = computed<ApolloError | null>(() => createError.value || updateError.value);
// Computed property for button disabled state
const isButtonDisabled = computed<boolean>(() => {
// In authorization mode, only check loading states if we have a name
if (isAuthorizationMode.value && (formData.value.name || authorizationData.value?.formData?.name)) {
return loading.value || postCreateLoading.value;
}
// Regular validation for non-authorization mode
return loading.value || postCreateLoading.value || !formValid.value;
});
// Load form schema - always use creation form
const loadFormSchema = () => {
// Always load creation form schema
const { onResult, onError } = useQuery(GET_API_KEY_CREATION_FORM_SCHEMA);
onResult(async (result) => {
if (result.data?.getApiKeyCreationFormSchema) {
formSchema.value = result.data.getApiKeyCreationFormSchema;
if (isAuthorizationMode.value && authorizationData.value?.formData) {
// In authorization mode, use the form data from the authorization store
formData.value = { ...authorizationData.value.formData };
// Ensure the name field is set for validation
if (!formData.value.name && authorizationData.value.name) {
formData.value.name = authorizationData.value.name;
}
// In auth mode, if we have all required fields, consider it valid initially
// JsonForms will override this if there are actual errors
if (formData.value.name) {
formValid.value = true;
}
} else if (editingKey.value) {
// If editing, populate form data from existing key
populateFormFromExistingKey();
} else {
// For new keys, initialize with empty data
formData.value = {
customPermissions: [],
};
// Set formValid to true initially for new keys
// JsonForms will update this if there are validation errors
formValid.value = true;
}
}
});
onError((error) => {
console.error('Error loading creation form schema:', error);
});
};
// Initialize form on mount
onMounted(() => {
loadFormSchema();
});
// Watch for editing key changes
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 = [];
() => {
if (!isAuthorizationMode.value) {
populateFormFromExistingKey();
}
},
{ 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);
// Watch for authorization mode changes
watch(
() => isAuthorizationMode.value,
async (newValue) => {
if (newValue && authorizationData.value?.formData) {
formData.value = { ...authorizationData.value.formData };
// Ensure the name field is set for validation
if (!formData.value.name && authorizationData.value.name) {
formData.value.name = authorizationData.value.name;
}
// Set initial valid state if we have required fields
if (formData.value.name) {
formValid.value = true;
}
}
}
}
);
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));
});
}
// Watch for authorization form data changes
watch(
() => authorizationData.value?.formData,
(newFormData) => {
if (isAuthorizationMode.value && newFormData) {
formData.value = { ...newFormData };
// Ensure the name field is set for validation
if (!formData.value.name && authorizationData.value?.name) {
formData.value.name = authorizationData.value.name;
}
}
},
{ deep: true }
);
function selectAllPermissions() {
newKeyPermissions.value = possiblePermissions.value.map((perm) => ({
resource: perm.resource as Resource,
actions: [...perm.actions],
}));
}
// Use the permission presets composable
const { applyPreset } = useApiKeyPermissionPresets();
function clearAllPermissions() {
newKeyPermissions.value = [];
}
// Watch for permission preset selection and expand into custom permissions
watch(
() => formData.value.permissionPresets,
(presetId) => {
if (!presetId || presetId === 'none') return;
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));
}
// Apply the preset to custom permissions
formData.value.customPermissions = applyPreset(presetId, formData.value.customPermissions);
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] });
// Reset the dropdown back to 'none'
formData.value.permissionPresets = 'none';
}
}
);
function clearAllActions(resource: string) {
newKeyPermissions.value = newKeyPermissions.value.filter((p) => p.resource !== resource);
}
// Populate form data from existing key
const populateFormFromExistingKey = async () => {
if (!editingKey.value || !formSchema.value) return;
const fragmentKey = useFragment(
API_KEY_FRAGMENT,
editingKey.value as FragmentType<typeof API_KEY_FRAGMENT>
);
if (fragmentKey) {
// Group permissions by actions for better UI
const permissionGroups = new Map<string, Resource[]>();
if (fragmentKey.permissions) {
for (const perm of fragmentKey.permissions) {
// Create a copy of the actions array to avoid modifying read-only data
const actionKey = [...perm.actions].sort().join(',');
if (!permissionGroups.has(actionKey)) {
permissionGroups.set(actionKey, []);
}
permissionGroups.get(actionKey)!.push(perm.resource);
}
}
const customPermissions = Array.from(permissionGroups.entries()).map(([actionKey, resources]) => ({
resources,
actions: actionKey.split(',') as AuthAction[], // Actions are now already in correct format
}));
formData.value = {
name: fragmentKey.name,
description: fragmentKey.description || '',
authorizationType: fragmentKey.roles.length > 0 ? 'roles' : 'custom',
roles: [...fragmentKey.roles],
customPermissions,
};
}
};
// Transform form data to API format
const transformFormDataForApi = (): CreateApiKeyInput => {
const apiData: CreateApiKeyInput = {
name: formData.value.name || formData.value.keyName || '',
description: formData.value.description,
roles: [],
permissions: undefined,
};
// Both authorization and regular mode now use the same form structure
if (formData.value.roles && formData.value.roles.length > 0) {
apiData.roles = formData.value.roles;
}
// Note: permissionGroups would need to be handled by backend
// The CreateApiKeyInput doesn't have permissionGroups field yet
// For now, we could expand them client-side by querying the permissions
// or add backend support to handle permission groups
// Always include permissions array, even if empty (for updates to clear permissions)
if (formData.value.customPermissions) {
// Expand resources array into individual AddPermissionInput entries
apiData.permissions = formData.value.customPermissions.flatMap((perm) =>
perm.resources.map((resource) => ({
resource,
actions: perm.actions,
}))
);
} else {
// If customPermissions is undefined or null, and we're editing,
// we should still send an empty array to clear permissions
if (editingKey.value) {
apiData.permissions = [];
}
}
// Note: expiresAt field would need to be added to CreateApiKeyInput type
// if (formData.value.expiresAt) {
// apiData.expiresAt = formData.value.expiresAt;
// }
return apiData;
};
const close = () => {
apiKeyStore.hideModal();
formData.value = {} as FormData; // Reset to empty object
};
// Handle form submission
async function upsertKey() {
// In authorization mode, skip validation if we have a name
if (!isAuthorizationMode.value && !formValid.value) {
return;
}
if (isAuthorizationMode.value && !formData.value.name) {
console.error('Cannot authorize without a name');
return;
}
// In authorization mode, validation is enough - no separate consent field
postCreateLoading.value = true;
try {
const apiData = transformFormDataForApi();
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,
...apiData,
},
});
} else {
res = await createApiKey({
input: {
name: newKeyName.value,
description: newKeyDescription.value,
roles: newKeyRoles.value,
permissions: newKeyPermissions.value.length ? newKeyPermissions.value : undefined,
},
input: apiData,
});
}
const apiKeyResult = res?.data?.apiKey;
if (isEdit && apiKeyResult && 'update' in apiKeyResult) {
const fragmentData = useFragment(API_KEY_FRAGMENT_WITH_KEY, apiKeyResult.update);
const fragmentData = useFragment(API_KEY_FRAGMENT, apiKeyResult.update);
apiKeyStore.setCreatedKey(fragmentData);
} else if (!isEdit && apiKeyResult && 'create' in apiKeyResult) {
const fragmentData = useFragment(API_KEY_FRAGMENT_WITH_KEY, apiKeyResult.create);
const fragmentData = useFragment(API_KEY_FRAGMENT, apiKeyResult.create);
apiKeyStore.setCreatedKey(fragmentData);
// If in authorization mode, call the callback with the API key
if (isAuthorizationMode.value && authorizationData.value?.onAuthorize && 'key' in fragmentData) {
authorizationData.value.onAuthorize(fragmentData.key);
// Don't close the modal or reset form - let the callback handle it
return;
}
}
modalVisible.value = false;
editingKey.value = null;
newKeyName.value = '';
newKeyDescription.value = '';
newKeyRoles.value = [];
newKeyPermissions.value = [];
apiKeyStore.hideModal();
formData.value = {} as FormData; // Reset to empty object
} catch (error) {
console.error('Error in upsertKey:', error);
} finally {
postCreateLoading.value = false;
}
}
// Copy API key after creation
const copyApiKey = async () => {
if (createdKey.value && 'key' in createdKey.value) {
await copyWithNotification(createdKey.value.key, 'API key copied to clipboard');
}
};
</script>
<template>
<!-- Modal mode (handles both regular creation and authorization) -->
<Dialog
v-if="modalVisible"
v-model="modalVisible"
size="lg"
:title="editingKey ? t('Edit API Key') : t('Create API Key')"
size="xl"
:title="
isAuthorizationMode
? 'Authorize API Key Access'
: editingKey
? t
? t('Edit API Key')
: 'Edit API Key'
: t
? t('Create API Key')
: 'Create API Key'
"
:scrollable="true"
close-button-text="Cancel"
:primary-button-text="editingKey ? 'Save' : 'Create'"
:primary-button-text="isAuthorizationMode ? 'Authorize' : editingKey ? 'Save' : 'Create'"
:primary-button-loading="loading || postCreateLoading"
:primary-button-loading-text="editingKey ? 'Saving...' : 'Creating...'"
:primary-button-disabled="loading || postCreateLoading"
:primary-button-loading-text="
isAuthorizationMode ? 'Authorizing...' : editingKey ? 'Saving...' : 'Creating...'
"
:primary-button-disabled="isButtonDisabled"
@update:model-value="
(v) => {
if (!v) close();
@@ -207,103 +404,117 @@ async function upsertKey() {
"
@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 class="w-full">
<!-- Show authorization description if in authorization mode -->
<div
v-if="isAuthorizationMode && formSchema?.dataSchema?.description"
class="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg"
>
<p class="text-sm">{{ formSchema.dataSchema.description }}</p>
</div>
<!-- Dynamic Form based on schema -->
<div
v-if="formSchema"
class="[&_.vertical-layout]:space-y-4"
@click.stop
@mousedown.stop
@focus.stop
>
<JsonForms
:schema="formSchema.dataSchema"
:uischema="formSchema.uiSchema"
:renderers="jsonFormsRenderers"
:data="formData"
:ajv="jsonFormsAjv"
@change="
({ data, errors }) => {
formData = data;
formValid = errors ? errors.length === 0 : true;
}
"
/>
</div>
<!-- Loading state -->
<div v-else class="flex items-center justify-center py-8">
<div class="text-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" />
<p class="text-sm text-muted-foreground">Loading form...</p>
</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">
</div>
<!-- Error display -->
<div v-if="error" class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg">
<p class="text-sm text-red-600 dark:text-red-400">
{{ extractGraphQLErrorMessage(error) }}
</p>
</div>
<!-- Permissions Preview -->
<div class="mt-6 p-4 bg-muted/50 rounded-lg border border-muted">
<EffectivePermissions
:roles="formData.roles || []"
:raw-permissions="formDataPermissions"
:show-header="true"
/>
<!-- Show selected roles for context -->
<div
v-if="formData.roles && formData.roles.length > 0"
class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"
>
<div class="text-xs text-gray-600 dark:text-gray-400 mb-1">Selected Roles:</div>
<div class="flex flex-wrap gap-1">
<span
v-for="role in formData.roles"
:key="role"
class="px-2 py-1 bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-300 rounded text-xs"
>
{{ role }}
</span>
</div>
</div>
</form>
</div>
<!-- Developer Tools Accordion (hide in authorization flow) -->
<div v-if="!isAuthorizationMode" class="mt-4">
<Accordion type="single" collapsible class="w-full">
<AccordionItem value="developer-tools">
<AccordionTrigger>
<span class="text-sm font-semibold">Developer Tools</span>
</AccordionTrigger>
<AccordionContent>
<div class="py-2">
<DeveloperAuthorizationLink
:roles="formData.roles || []"
:raw-permissions="formDataPermissions"
:app-name="formData.name || 'My Application'"
:app-description="formData.description || 'API key for my application'"
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
<!-- Success state for authorization mode -->
<div
v-if="isAuthorizationMode && createdKey && 'key' in createdKey"
class="mt-4 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg"
>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium">API Key created successfully!</span>
<Button type="button" variant="ghost" size="sm" @click="copyApiKey">
<ClipboardDocumentIcon class="w-4 h-4 mr-2" />
{{ copied ? 'Copied!' : 'Copy Key' }}
</Button>
</div>
<code class="block mt-2 p-2 bg-white dark:bg-gray-800 rounded text-xs break-all border">
{{ createdKey.key }}
</code>
<p class="text-xs text-muted-foreground mt-2">Save this key securely for your application.</p>
</div>
</div>
</Dialog>
</template>

View File

@@ -2,9 +2,10 @@
import { ref, watchEffect } from 'vue';
import { storeToRefs } from 'pinia';
import { useMutation, useQuery } from '@vue/apollo-composable';
import { useClipboard } from '@vueuse/core';
import { useClipboardWithToast } from '~/composables/useClipboardWithToast';
import type { AuthAction, ApiKeyFragment, Role } from '~/composables/gql/graphql';
import { ClipboardDocumentIcon, EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/solid';
import { ClipboardDocumentIcon, EyeIcon, EyeSlashIcon, ChevronDownIcon, LinkIcon } from '@heroicons/vue/24/solid';
import {
Accordion,
AccordionContent,
@@ -13,6 +14,10 @@ import {
Badge,
Button,
CardWrapper,
DropdownMenuRoot,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Input,
PageContainer,
Tooltip,
@@ -22,30 +27,33 @@ import {
} 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';
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 | ApiKeyWithKeyFragment)[]>([]);
const apiKeys = ref<ApiKeyFragment[]>([]);
watchEffect(() => {
const baseKeys: (ApiKeyFragment | ApiKeyWithKeyFragment)[] =
const baseKeys: ApiKeyFragment[] =
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;
baseKeys[existingKeyIndex] = createdKey.value;
} else {
baseKeys.unshift(createdKey.value as ApiKeyFragment | ApiKeyWithKeyFragment);
baseKeys.unshift(createdKey.value);
}
// Don't automatically show keys - keep them hidden by default
}
apiKeys.value = baseKeys;
@@ -53,14 +61,23 @@ watchEffect(() => {
const metaQuery = useQuery(GET_API_KEY_META);
const possibleRoles = ref<string[]>([]);
const possiblePermissions = ref<{ resource: string; actions: string[] }[]>([]);
const possiblePermissions = ref<{ resource: string; actions: AuthAction[] }[]>([]);
watchEffect(() => {
possibleRoles.value = metaQuery.result.value?.apiKeyPossibleRoles || [];
possiblePermissions.value = metaQuery.result.value?.apiKeyPossiblePermissions || [];
// 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 { copy, copied } = useClipboard();
const { copyWithNotification, copied } = useClipboardWithToast();
// Template input state
const showTemplateInput = ref(false);
const templateUrl = ref('');
const templateError = ref('');
const { mutate: deleteKey } = useMutation(DELETE_API_KEY);
@@ -70,11 +87,57 @@ function toggleShowKey(keyId: string) {
showKey.value[keyId] = !showKey.value[keyId];
}
function openCreateModal(key: ApiKeyFragment | ApiKeyWithKeyFragment | null = null) {
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;
@@ -87,13 +150,40 @@ async function _deleteKey(_id: string) {
}
}
function hasKey(key: ApiKeyFragment | ApiKeyWithKeyFragment): key is ApiKeyWithKeyFragment {
return 'key' in key && !!key.key;
async function copyKeyValue(keyValue: string) {
await copyWithNotification(keyValue, 'API key copied to clipboard');
}
async function copyKeyValue(keyValue: string) {
await copy(keyValue);
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>
@@ -101,7 +191,22 @@ async function copyKeyValue(keyValue: string) {
<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>
<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"
@@ -109,98 +214,124 @@ async function copyKeyValue(keyValue: string) {
>
{{ 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 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 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">
</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>
<PermissionCounter
:permissions="key.permissions"
:possible-permissions="possiblePermissions"
/>
<span class="text-sm font-semibold">Effective Permissions</span>
</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 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 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>
<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="ghost" size="icon" @click="copyKeyValue(key.key)">
<ClipboardDocumentIcon class="w-5 h-5" />
<Button variant="outline" size="sm" @click="copyKeyTemplate(key)">
<LinkIcon class="w-4 h-4 mr-1" />
Copy Template
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ copied ? 'Copied!' : 'Copy to clipboard...' }}</p>
<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>
</li>
</CardWrapper>
</ul>
<ul v-else class="flex flex-col gap-4 mb-6">
<li class="text-sm">No API keys found</li>
</ul>
</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>

View File

@@ -0,0 +1,219 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Button, Input, Switch } from '@unraid/ui';
import { ClipboardDocumentIcon, LinkIcon } from '@heroicons/vue/24/outline';
import { generateAuthorizationUrl } from '~/utils/authorizationLink';
import { useClipboardWithToast } from '~/composables/useClipboardWithToast';
import type { Role, AuthAction } from '~/composables/gql/graphql';
interface RawPermission {
resource: string;
actions: AuthAction[];
}
interface Props {
roles?: Role[];
rawPermissions?: RawPermission[];
appName?: string;
appDescription?: string;
redirectUrl?: string;
show?: boolean;
isAuthorizationMode?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
roles: () => [],
rawPermissions: () => [],
appName: 'CliInternal',
appDescription: '',
redirectUrl: '',
show: true,
isAuthorizationMode: false,
});
// State for UI interactions
const copySuccess = ref(false);
const copyTemplateSuccess = ref(false);
const showUrl = ref(false);
const showTemplate = ref(false);
const useCustomCallback = ref(false);
const customCallbackUrl = ref('');
// Use clipboard composable
const { copyWithNotification } = useClipboardWithToast();
// Reset custom callback URL when checkbox is unchecked
watch(useCustomCallback, (newValue) => {
if (!newValue) {
customCallbackUrl.value = '';
}
});
// Computed property for the effective redirect URL
const effectiveRedirectUrl = computed(() => {
if (useCustomCallback.value && customCallbackUrl.value) {
return customCallbackUrl.value;
}
return props.redirectUrl;
});
// Computed property for authorization URL
const authorizationUrl = computed(() => {
if (!props.show) {
return '';
}
return generateAuthorizationUrl({
appName: props.appName,
appDescription: props.appDescription,
roles: props.roles,
rawPermissions: props.rawPermissions,
redirectUrl: effectiveRedirectUrl.value,
});
});
// Computed property for template query string (without redirect_uri)
const templateQueryString = computed(() => {
if (!props.show) {
return '';
}
// Generate URL without redirect_uri for template sharing
const url = generateAuthorizationUrl({
appName: props.appName,
appDescription: props.appDescription,
roles: props.roles,
rawPermissions: props.rawPermissions,
redirectUrl: '', // Empty redirect URL for templates
});
// Extract just the query string part
const urlObj = new URL(url, window.location.origin);
const params = new URLSearchParams(urlObj.search);
params.delete('redirect_uri'); // Remove redirect_uri from template
return '?' + params.toString();
});
// Check if there are any permissions to show
const hasPermissions = computed(() => {
return props.roles.length > 0 || props.rawPermissions.length > 0;
});
// Function to copy authorization URL
const handleCopy = async () => {
const success = await copyWithNotification(
authorizationUrl.value,
'Authorization URL copied to clipboard'
);
if (success) {
copySuccess.value = true;
setTimeout(() => {
copySuccess.value = false;
}, 2000);
}
};
// Function to toggle URL visibility
const toggleShowUrl = () => {
showUrl.value = !showUrl.value;
showTemplate.value = false; // Hide template when showing URL
};
// Function to toggle template visibility
const toggleShowTemplate = () => {
showTemplate.value = !showTemplate.value;
showUrl.value = false; // Hide URL when showing template
};
// Function to copy template query string
const copyTemplate = async () => {
const success = await copyWithNotification(
templateQueryString.value,
'Template copied to clipboard'
);
if (success) {
copyTemplateSuccess.value = true;
setTimeout(() => {
copyTemplateSuccess.value = false;
}, 2000);
}
};
</script>
<template>
<div v-if="show" class="space-y-3">
<div>
<h4 class="text-sm font-medium mb-2">Developer Authorization Link</h4>
<div v-if="!hasPermissions" class="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg mb-3">
<p class="text-sm text-amber-800 dark:text-amber-200">
No permissions selected. Add roles or permissions above to generate an authorization link.
</p>
</div>
<div v-else class="flex flex-wrap gap-2">
<Button variant="outline" size="sm" @click="toggleShowUrl">
<LinkIcon class="w-4 h-4 mr-1" />
{{ showUrl ? 'Hide' : 'Show' }} URL
</Button>
<Button variant="outline" size="sm" @click="handleCopy">
<ClipboardDocumentIcon class="w-4 h-4 mr-1" />
{{ copySuccess ? 'Copied!' : 'Copy URL' }}
</Button>
<Button variant="outline" size="sm" @click="toggleShowTemplate">
<LinkIcon class="w-4 h-4 mr-1" />
{{ showTemplate ? 'Hide' : 'Show' }} Template
</Button>
<Button variant="outline" size="sm" @click="copyTemplate">
<ClipboardDocumentIcon class="w-4 h-4 mr-1" />
{{ copyTemplateSuccess ? 'Copied!' : 'Copy Template' }}
</Button>
</div>
</div>
<p v-if="hasPermissions" class="text-sm text-muted-foreground">
Use this link to create an API key authorization for <strong>{{ appName }}</strong> with the selected permissions.
Perfect for testing your app's OAuth-style API key flow.
</p>
<div v-if="!isAuthorizationMode" class="flex items-center gap-2 mt-3">
<Switch
id="custom-callback"
v-model="useCustomCallback"
/>
<label for="custom-callback" class="text-sm font-medium cursor-pointer">
Use custom callback URL
</label>
</div>
<div v-if="!isAuthorizationMode && useCustomCallback" class="mt-2">
<Input
v-model="customCallbackUrl"
type="url"
placeholder="https://example.com/callback"
class="w-full"
/>
<p class="text-xs text-muted-foreground mt-1">
Enter the URL where users will be redirected after authorization
</p>
</div>
<div v-if="showUrl" class="p-3 bg-secondary rounded border border-muted mt-3">
<p class="text-xs text-muted-foreground mb-2">Full authorization URL with callback:</p>
<code class="text-xs break-all text-foreground">
{{ authorizationUrl }}
</code>
</div>
<div v-if="showTemplate" class="p-3 bg-secondary rounded border border-muted mt-3">
<p class="text-xs text-muted-foreground mb-2">Template query string (for sharing without callback):</p>
<code class="text-xs break-all text-foreground">
{{ templateQueryString }}
</code>
<p class="text-xs text-muted-foreground mt-2">
This template can be used with "Create from Template" to pre-fill permissions without a callback URL.
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,131 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { useLazyQuery } from '@vue/apollo-composable';
import { Badge } from '@unraid/ui';
import { PREVIEW_EFFECTIVE_PERMISSIONS } from './permissions-preview.query';
import type { AuthAction, Role, PreviewEffectivePermissionsQuery } from '~/composables/gql/graphql';
interface RawPermission {
resource: string;
actions: AuthAction[];
}
interface Props {
roles?: Role[];
rawPermissions?: RawPermission[];
showHeader?: boolean;
headerText?: string;
}
const props = withDefaults(defineProps<Props>(), {
roles: () => [],
rawPermissions: () => [],
showHeader: true,
headerText: 'Effective Permissions',
});
// Query for effective permissions
const { load: loadEffectivePermissions, loading, result } = useLazyQuery<PreviewEffectivePermissionsQuery>(PREVIEW_EFFECTIVE_PERMISSIONS);
// Computed property for effective permissions from the result
const effectivePermissions = computed(() => {
return result.value?.previewEffectivePermissions || [];
});
// Format action for display - show the actual enum value or formatted string
const formatAction = (action: string): string => {
if (action === '*') return 'ALL ACTIONS';
// If it's already an enum value like CREATE_ANY, READ_ANY, show as-is
if (action.includes('_')) {
return action; // Keep the original enum format
}
// If it's in scope format like 'create:any' or just 'create', format for display
if (action.includes(':')) {
return action.split(':')[0].toUpperCase() + ':' + action.split(':')[1].toUpperCase();
}
// For simple verbs, uppercase them
return action.toUpperCase();
};
// Watch for changes to roles and permissions and reload
watch(
() => ({
roles: props.roles,
rawPermissions: props.rawPermissions,
}),
async ({ roles, rawPermissions }) => {
// Skip if no roles or permissions
if ((!roles || roles.length === 0) && (!rawPermissions || rawPermissions.length === 0)) {
return;
}
try {
// Transform permissions to the format expected by the query
const permissions = rawPermissions?.map(perm => ({
resource: perm.resource,
actions: perm.actions
})) || [];
// Call load with the parameters
await loadEffectivePermissions(null, {
roles: roles || [],
permissions: permissions.length > 0 ? permissions : undefined,
});
} catch (error) {
console.error('Failed to load effective permissions:', error);
}
},
{ immediate: true, deep: true }
);
</script>
<template>
<div class="w-full">
<h3 v-if="showHeader" class="text-sm font-semibold mb-3 flex items-center gap-2">
{{ headerText }}
<span v-if="loading" class="text-xs text-muted-foreground">(loading...)</span>
</h3>
<!-- Show effective permissions -->
<div v-if="effectivePermissions.length > 0 && !loading" class="space-y-2">
<div class="text-xs text-muted-foreground mb-2">
These are the actual permissions that will be granted based on selected roles and custom permissions:
</div>
<div class="space-y-2 max-h-64 overflow-y-auto">
<div
v-for="perm in effectivePermissions"
:key="perm.resource"
class="text-xs bg-background p-2 rounded border border-muted"
>
<div class="flex items-center gap-2 mb-1">
<span class="font-medium">{{ perm.resource }}</span>
</div>
<div class="flex flex-wrap gap-1">
<Badge
v-for="action in perm.actions"
:key="action"
variant="green"
size="xs"
>
{{ formatAction(action) }}
</Badge>
</div>
</div>
</div>
</div>
<!-- Show loading state -->
<div v-else-if="loading" class="text-xs text-muted-foreground">
Loading permissions...
</div>
<!-- Show message when no permissions selected -->
<div v-else class="text-xs text-muted-foreground italic">
No permissions selected yet
</div>
</div>
</template>

View File

@@ -4,11 +4,12 @@ import { computed } from 'vue';
import { Badge } from '@unraid/ui';
import { actionVariant } from './actionVariant.js';
import type { AuthAction } from '~/composables/gql/graphql';
const props = withDefaults(
defineProps<{
permissions: { resource: string; actions: string[] }[];
possiblePermissions?: { resource: string; actions: string[] }[];
permissions: { resource: string; actions: AuthAction[] }[];
possiblePermissions?: { resource: string; actions: AuthAction[] }[];
hideNumber?: boolean;
label?: string;
}>(),

View File

@@ -0,0 +1,13 @@
import { graphql } from '~/composables/gql';
export const GET_API_KEY_CREATION_FORM_SCHEMA = graphql(`
query GetApiKeyCreationFormSchema {
getApiKeyCreationFormSchema {
id
dataSchema
uiSchema
values
}
}
`);

View File

@@ -2,20 +2,6 @@ import { graphql } from '~/composables/gql/gql';
export const API_KEY_FRAGMENT = graphql(/* GraphQL */ `
fragment ApiKey on ApiKey {
id
name
description
createdAt
roles
permissions {
resource
actions
}
}
`);
export const API_KEY_FRAGMENT_WITH_KEY = graphql(/* GraphQL */ `
fragment ApiKeyWithKey on ApiKeyWithSecret {
id
key
name
@@ -29,6 +15,8 @@ export const API_KEY_FRAGMENT_WITH_KEY = graphql(/* GraphQL */ `
}
`);
export const API_KEY_FRAGMENT_WITH_KEY = API_KEY_FRAGMENT;
export const GET_API_KEYS = graphql(/* GraphQL */ `
query ApiKeys {
apiKeys {
@@ -41,7 +29,7 @@ export const CREATE_API_KEY = graphql(/* GraphQL */ `
mutation CreateApiKey($input: CreateApiKeyInput!) {
apiKey {
create(input: $input) {
...ApiKeyWithKey
...ApiKey
}
}
}
@@ -51,7 +39,7 @@ export const UPDATE_API_KEY = graphql(/* GraphQL */ `
mutation UpdateApiKey($input: UpdateApiKeyInput!) {
apiKey {
update(input: $input) {
...ApiKeyWithKey
...ApiKey
}
}
}
@@ -74,3 +62,12 @@ export const GET_API_KEY_META = graphql(/* GraphQL */ `
}
}
`);
export const PREVIEW_EFFECTIVE_PERMISSIONS = graphql(/* GraphQL */ `
query PreviewEffectivePermissions($roles: [Role!], $permissions: [AddPermissionInput!]) {
previewEffectivePermissions(roles: $roles, permissions: $permissions) {
resource
actions
}
}
`);

View File

@@ -0,0 +1,19 @@
import gql from 'graphql-tag';
export const PREVIEW_EFFECTIVE_PERMISSIONS = gql`
query PreviewEffectivePermissions($roles: [Role!], $permissions: [AddPermissionInput!]) {
previewEffectivePermissions(roles: $roles, permissions: $permissions) {
resource
actions
}
}
`;
export const GET_PERMISSIONS_FOR_ROLES = gql`
query GetPermissionsForRoles($roles: [Role!]!) {
getPermissionsForRoles(roles: $roles) {
resource
actions
}
}
`;

View File

@@ -0,0 +1,299 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Button, Input } from '@unraid/ui';
import { useClipboardWithToast } from '~/composables/useClipboardWithToast.js';
import { ClipboardDocumentIcon, EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia';
import { useAuthorizationLink } from '~/composables/useAuthorizationLink.js';
import { useApiKeyStore } from '~/store/apiKey.js';
// Use the composables for authorization logic
const {
authParams,
hasValidRedirectUri,
buildCallbackUrl,
formData: authorizationFormData,
displayAppName,
hasPermissions,
permissionsSummary,
} = useAuthorizationLink();
// Use the API key store to control the global modal
const apiKeyStore = useApiKeyStore();
const { createdKey, modalVisible } = storeToRefs(apiKeyStore);
// Component state
const showSuccess = ref(false);
const createdApiKey = ref('');
const error = ref('');
const showKey = ref(false);
// Use clipboard for copying
const { copyWithNotification, copied } = useClipboardWithToast();
// Watch for modal close to restore success view
watch(modalVisible, (isVisible) => {
if (!isVisible && createdKey.value && createdApiKey.value) {
// Modal was closed, restore success view after editing
showSuccess.value = true;
}
});
// Toggle key visibility
const toggleShowKey = () => {
showKey.value = !showKey.value;
};
// Copy API key
const copyApiKey = async () => {
if (createdApiKey.value) {
await copyWithNotification(createdApiKey.value, 'API key copied to clipboard');
}
};
// Open the authorization modal
const openAuthorizationModal = () => {
// Set up authorization parameters in the store
apiKeyStore.setAuthorizationMode(
authParams.value.name,
authParams.value.description || `API key for ${displayAppName.value}`,
authParams.value.scopes,
handleAuthorize,
authorizationFormData.value
);
// Show the modal
apiKeyStore.showModal();
};
// Handle authorization success
const handleAuthorize = (apiKey: string) => {
createdApiKey.value = apiKey;
showSuccess.value = true;
apiKeyStore.hideModal();
// No automatic redirect - user must click the button
};
// Open the edit modal for the created key
const modifyApiKey = () => {
if (createdKey.value) {
// Open the modal in edit mode with the created key
apiKeyStore.showModal(createdKey.value);
// Don't clear states - the watchers will handle the flow
}
};
// Handle denial
const deny = () => {
if (hasValidRedirectUri.value) {
try {
const url = buildCallbackUrl(undefined, 'access_denied');
window.location.href = url;
} catch {
window.location.href = '/';
}
} else {
window.location.href = '/';
}
};
// Return to app with API key
const returnToApp = () => {
if (!hasValidRedirectUri.value || !createdApiKey.value) return;
try {
const url = buildCallbackUrl(createdApiKey.value, undefined);
window.location.href = url;
} catch (_err) {
error.value = 'Failed to redirect back to application';
}
};
</script>
<template>
<div class="w-full max-w-4xl mx-auto p-6">
<!-- Success state -->
<div v-if="showSuccess && createdApiKey" class="w-full bg-background rounded-lg shadow-sm border border-muted">
<!-- Header -->
<div class="p-6 pb-4 border-b border-muted">
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center flex-shrink-0">
<svg class="h-5 w-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">API Key Created Successfully</h3>
<p class="text-sm text-muted-foreground">
Your API key for <strong>{{ displayAppName }}</strong> has been created
</p>
</div>
</div>
</div>
<!-- Content -->
<div class="p-6 space-y-4">
<!-- API Key section -->
<div>
<label class="text-sm font-medium text-muted-foreground mb-2 block">Generated API Key</label>
<div class="p-3 bg-secondary rounded-lg">
<div class="flex gap-2 mb-2">
<div class="relative flex-1">
<Input
:model-value="showKey ? createdApiKey : ''"
class="font-mono text-sm pr-10 bg-background"
readonly
/>
<button
type="button"
class="absolute inset-y-0 right-2 flex items-center px-1 text-muted-foreground hover:text-foreground"
@click="toggleShowKey"
>
<component :is="showKey ? EyeSlashIcon : EyeIcon" class="w-4 h-4" />
</button>
</div>
<Button
variant="outline"
size="icon"
@click="copyApiKey"
>
<ClipboardDocumentIcon class="w-4 h-4" />
</Button>
</div>
<p class="text-xs text-muted-foreground">
{{ copied ? '✓ Copied to clipboard' : hasValidRedirectUri ? 'Save this key securely for your application.' : 'Save this key securely. You can now use it in your application.' }}
</p>
</div>
</div>
<!-- Redirect info if available, or template info -->
<div v-if="hasValidRedirectUri">
<label class="text-sm font-medium text-muted-foreground mb-2 block">Next Step</label>
<div class="p-3 bg-secondary rounded-lg">
<p class="text-sm">
Send this API key to complete the authorization
</p>
<p class="text-xs text-muted-foreground mt-1">
Destination: <code class="bg-background px-1.5 py-0.5 rounded">{{ authParams.redirectUri }}</code>
</p>
</div>
</div>
<div v-else>
<label class="text-sm font-medium text-muted-foreground mb-2 block">Template Applied</label>
<div class="p-3 bg-secondary rounded-lg">
<p class="text-sm">
API key created from template with the configured permissions
</p>
<p class="text-xs text-muted-foreground mt-1">
You can manage this key from the API Keys settings page
</p>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="p-6 pt-2 flex gap-3">
<Button
variant="outline"
class="flex-1"
@click="modifyApiKey"
>
Modify API Key
</Button>
<Button
v-if="hasValidRedirectUri"
variant="primary"
class="flex-1"
@click="returnToApp"
>
Send Key to {{ authParams.name }}
</Button>
</div>
</div>
<!-- Authorization form using ApiKeyCreate component -->
<div v-else class="w-full bg-background rounded-lg shadow-sm border border-muted">
<!-- Header -->
<div class="p-6 pb-4 border-b border-muted">
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-full bg-blue-100 dark:bg-blue-900/20 flex items-center justify-center flex-shrink-0">
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">{{ hasValidRedirectUri ? 'API Key Authorization Request' : 'Create API Key from Template' }}</h3>
<p class="text-sm text-muted-foreground">
<span v-if="hasValidRedirectUri">
<strong>{{ displayAppName }}</strong> is requesting API access to your Unraid server
</span>
<span v-else>
Create an API key for <strong>{{ displayAppName }}</strong> with pre-configured permissions
</span>
</p>
</div>
</div>
</div>
<!-- Content -->
<div class="p-6 space-y-4">
<!-- Permissions section -->
<div>
<label class="text-sm font-medium text-muted-foreground mb-2 block">
{{ hasValidRedirectUri ? 'Requested Permissions' : 'Template Permissions' }}
</label>
<div v-if="hasPermissions" class="p-3 bg-secondary rounded-lg">
<p class="text-sm">{{ permissionsSummary }}</p>
</div>
<div v-else class="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<p class="text-sm text-amber-800 dark:text-amber-200">
<span v-if="hasValidRedirectUri">
No specific permissions requested. The application may be requesting basic access.
</span>
<span v-else>
No specific permissions defined in this template.
</span>
</p>
</div>
</div>
<!-- Redirect info if available -->
<div v-if="hasValidRedirectUri">
<label class="text-sm font-medium text-muted-foreground mb-2 block">After Authorization</label>
<div class="p-3 bg-secondary rounded-lg">
<p class="text-sm">
You will need to confirm and send the API key to the application
</p>
<p class="text-xs text-muted-foreground mt-1">
Destination: <code class="bg-background px-1.5 py-0.5 rounded">{{ authParams.redirectUri }}</code>
</p>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="p-6 pt-2 flex gap-3">
<Button
variant="outline"
class="flex-1"
@click="deny"
>
Cancel
</Button>
<Button
variant="primary"
class="flex-1"
@click="openAuthorizationModal"
>
{{ hasValidRedirectUri ? 'Review Permissions & Authorize' : 'Review Permissions' }}
</Button>
</div>
</div>
<!-- Error message -->
<div v-if="error" class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p class="text-red-800 dark:text-red-200">{{ error }}</p>
</div>
</div>
</template>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { reactive, watch } from 'vue';
import { Input, Label, Select, Switch } from '@unraid/ui';
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent, Input, Label, Select, Switch } from '@unraid/ui';
import { defaultColors } from '~/themes/default';
import type { Theme } from '~/themes/types';
@@ -49,28 +49,35 @@ const items = [
</script>
<template>
<div class="flex flex-col gap-2 border-solid border-2 p-2 border-r-2">
<h1 class="text-lg">Color Theme Customization</h1>
<Accordion>
<AccordionItem value="color-theme-customization">
<AccordionTrigger>Color Theme Customization</AccordionTrigger>
<AccordionContent>
<div class="flex flex-col gap-2 border-solid border-2 p-2 border-r-2">
<h1 class="text-lg">Color Theme Customization</h1>
<Label for="theme-select">Theme</Label>
<Select v-model="form.selectedTheme" :items="items" placeholder="Select a theme" />
<Label for="theme-select">Theme</Label>
<Select v-model="form.selectedTheme" :items="items" placeholder="Select a theme" />
<Label for="primary-text-color">Header Primary Text Color</Label>
<Input id="primary-text-color" v-model="form.textPrimary" />
<Label for="primary-text-color">Header Primary Text Color</Label>
<Input id="primary-text-color" v-model="form.textPrimary" />
<Label for="secondary-text-color">Header Secondary Text Color</Label>
<Input id="secondary-text-color" v-model="form.textSecondary" />
<Label for="secondary-text-color">Header Secondary Text Color</Label>
<Input id="secondary-text-color" v-model="form.textSecondary" />
<Label for="background-color">Header Background Color</Label>
<Input id="background-color" v-model="form.bgColor" />
<Label for="background-color">Header Background Color</Label>
<Input id="background-color" v-model="form.bgColor" />
<Label for="gradient">Gradient</Label>
<Switch id="gradient" v-model:checked="form.gradient" />
<Label for="gradient">Gradient</Label>
<Switch id="gradient" v-model:checked="form.gradient" />
<Label for="description">Description</Label>
<Switch id="description" v-model:checked="form.description" />
<Label for="description">Description</Label>
<Switch id="description" v-model:checked="form.description" />
<Label for="banner">Banner</Label>
<Switch id="banner" v-model:checked="form.banner" />
</div>
<Label for="banner">Banner</Label>
<Switch id="banner" v-model:checked="form.banner" />
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</template>

View File

@@ -5,7 +5,6 @@ import { storeToRefs } from 'pinia';
import { useCallbackActionsStore } from '~/store/callbackActions';
import { useTrialStore } from '~/store/trial';
import { useUpdateOsStore } from '~/store/updateOs';
import { useApiKeyStore } from '~/store/apiKey';
import ApiKeyCreate from '~/components/ApiKey/ApiKeyCreate.vue';
import UpcCallbackFeedback from '~/components/UserProfile/CallbackFeedback.vue';
import UpcTrial from '~/components/UserProfile/Trial.vue';
@@ -18,7 +17,6 @@ const { t } = useI18n();
const { callbackStatus } = storeToRefs(useCallbackActionsStore());
const { trialModalVisible } = storeToRefs(useTrialStore());
const { updateOsModalVisible, changelogModalVisible } = storeToRefs(useUpdateOsStore());
const { modalVisible: apiKeyModalVisible } = storeToRefs(useApiKeyStore());
</script>
<template>
@@ -28,6 +26,6 @@ const { modalVisible: apiKeyModalVisible } = storeToRefs(useApiKeyStore());
<UpdateOsCheckUpdateResponseModal :t="t" :open="updateOsModalVisible" />
<UpdateOsChangelogModal :t="t" :open="changelogModalVisible" />
<ActivationModal :t="t" />
<ApiKeyCreate :open="apiKeyModalVisible" :t="t" />
<ApiKeyCreate :t="t" />
</div>
</template>

View File

@@ -1,18 +1,9 @@
<script setup lang="ts">
import { Button } from '@unraid/ui';
import { computed } from 'vue';
interface Provider {
id: string;
name: string;
buttonText?: string | null;
buttonIcon?: string | null;
buttonVariant?: string | null;
buttonStyle?: string | null;
}
import type { PublicOidcProvider } from '~/composables/gql/graphql';
interface Props {
provider: Provider;
provider: PublicOidcProvider;
disabled?: boolean;
onClick: (providerId: string) => void;
}
@@ -22,97 +13,47 @@ const props = defineProps<Props>();
const handleClick = () => {
props.onClick(props.provider.id);
};
// Extract SVG content from data URI for inline rendering
const inlineSvgContent = computed(() => {
if (!props.provider.buttonIcon?.includes('data:image/svg+xml;base64,')) {
return null;
}
try {
const base64Data = props.provider.buttonIcon.replace('data:image/svg+xml;base64,', '');
const svgContent = atob(base64Data);
return svgContent;
} catch (e: unknown) {
if (e instanceof Error) {
console.error('Error parsing SVG content:', e.message);
} else {
console.error('Error parsing SVG content:', e);
}
return null;
}
});
</script>
<template>
<Button
:disabled="disabled"
:variant="(provider.buttonVariant as any) || 'outline'"
class="sso-provider-button"
:style="provider.buttonStyle || ''"
:disabled="props.disabled"
:variant="(props.provider.buttonVariant as any) || 'outline'"
class="sso-provider-button w-full min-h-[2.5rem] h-auto py-2 px-4"
:style="props.provider.buttonStyle || ''"
@click="handleClick"
>
<div
v-if="inlineSvgContent"
class="w-6 h-6 mr-2 sso-button-icon-svg flex-shrink-0"
v-html="inlineSvgContent"
/>
<img
v-else-if="provider.buttonIcon"
:src="provider.buttonIcon"
class="w-6 h-6 mr-2 sso-button-icon"
:alt="provider.name"
>
{{ provider.buttonText || `Sign in with ${provider.name}` }}
<div class="flex items-center justify-center gap-2 w-full">
<img
v-if="props.provider.buttonIcon"
:src="props.provider.buttonIcon"
class="w-6 h-6 sso-button-icon flex-shrink-0"
alt=""
aria-hidden="true"
>
<span class="text-center whitespace-normal">
{{ props.provider.buttonText || `Sign in with ${props.provider.name}` }}
</span>
</div>
</Button>
</template>
<style scoped>
.sso-button-icon {
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
image-rendering: pixelated;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@supports (image-rendering: -webkit-optimize-contrast) {
.sso-button-icon {
image-rendering: -webkit-optimize-contrast;
}
}
@supports (image-rendering: crisp-edges) {
.sso-button-icon {
image-rendering: crisp-edges;
}
}
/* For SVG specifically, prefer smooth rendering */
.sso-button-icon[src*="svg"] {
/* For SVG images, prefer smooth rendering */
.sso-button-icon[src*="svg"],
.sso-button-icon[src*="data:image/svg"] {
image-rendering: auto;
image-rendering: smooth;
}
/* Inline SVG rendering for perfect quality */
.sso-button-icon-svg {
display: flex;
align-items: center;
justify-content: center;
}
.sso-button-icon-svg svg {
width: 100% !important;
height: 100% !important;
/* Enhanced antialiasing for crisp rendering */
shape-rendering: geometricPrecision;
text-rendering: geometricPrecision;
image-rendering: -webkit-optimize-contrast;
image-rendering: optimize-contrast;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* For raster images, use crisp rendering */
.sso-button-icon:not([src*="svg"]):not([src*="data:image/svg"]) {
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
/* Automatic hover effects for buttons with custom background colors */
.sso-provider-button[style*="background-color"]:hover:not(:disabled) {
filter: brightness(0.9) !important;