mirror of
https://github.com/unraid/api.git
synced 2026-01-06 00:30:22 -06:00
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:
205
web/__test__/authorizationScopes.test.ts
Normal file
205
web/__test__/authorizationScopes.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { encodePermissionsToScopes, decodeScopesToPermissions } from '../utils/authorizationScopes';
|
||||
import { AuthAction, Resource } from '../composables/gql/graphql';
|
||||
|
||||
describe('authorizationScopes', () => {
|
||||
describe('encodePermissionsToScopes', () => {
|
||||
describe('duplicate handling', () => {
|
||||
it('should deduplicate action verbs when multiple permissions have duplicate actions', () => {
|
||||
const permissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY, AuthAction.READ_OWN, AuthAction.UPDATE_ANY]
|
||||
}
|
||||
];
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
// Should produce "docker:read_any+read_own+update_any" with distinct actions preserved
|
||||
expect(scopes).toHaveLength(1);
|
||||
expect(scopes[0]).toBe('docker:read_any+read_own+update_any');
|
||||
});
|
||||
|
||||
it('should deduplicate resource names when grouped', () => {
|
||||
const permissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY]
|
||||
},
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_OWN]
|
||||
}
|
||||
];
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
// Should produce "docker:read_any+read_own" merging both permissions
|
||||
expect(scopes).toHaveLength(1);
|
||||
expect(scopes[0]).toBe('docker:read_any+read_own');
|
||||
});
|
||||
|
||||
it('should handle multiple duplicate resources and actions correctly', () => {
|
||||
const permissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY, AuthAction.READ_OWN, AuthAction.UPDATE_ANY]
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_OWN, AuthAction.UPDATE_ANY]
|
||||
},
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_OWN, AuthAction.UPDATE_OWN]
|
||||
}
|
||||
];
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
// Docker: READ_ANY, READ_OWN, UPDATE_ANY, UPDATE_OWN (merged from both)
|
||||
// VMS: READ_ANY, UPDATE_OWN, UPDATE_ANY
|
||||
// Different action sets, so separate scopes
|
||||
expect(scopes).toHaveLength(2);
|
||||
const scopeStrings = scopes.sort();
|
||||
expect(scopeStrings).toContain('docker:read_any+read_own+update_any+update_own');
|
||||
expect(scopeStrings).toContain('vms:read_any+update_any+update_own');
|
||||
});
|
||||
|
||||
it('should maintain consistent sorting for actions and resources', () => {
|
||||
const permissions = [
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.UPDATE_ANY, AuthAction.READ_ANY]
|
||||
},
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.UPDATE_ANY, AuthAction.READ_ANY]
|
||||
}
|
||||
];
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
// Should sort resources (docker before vms) and actions alphabetically
|
||||
expect(scopes).toHaveLength(1);
|
||||
expect(scopes[0]).toBe('docker+vms:read_any+update_any');
|
||||
});
|
||||
|
||||
it('should handle all CRUD actions without creating wildcard', () => {
|
||||
const permissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [
|
||||
AuthAction.CREATE_ANY, AuthAction.CREATE_OWN,
|
||||
AuthAction.READ_ANY, AuthAction.READ_OWN,
|
||||
AuthAction.UPDATE_ANY, AuthAction.UPDATE_OWN,
|
||||
AuthAction.DELETE_ANY, AuthAction.DELETE_OWN
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
// Should just list all actions, no wildcard conversion
|
||||
expect(scopes).toHaveLength(1);
|
||||
expect(scopes[0]).toBe('docker:create_any+create_own+delete_any+delete_own+read_any+read_own+update_any+update_own');
|
||||
});
|
||||
|
||||
it('should handle edge case with empty actions after deduplication', () => {
|
||||
const permissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: []
|
||||
}
|
||||
];
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
// Should not produce any scope for empty actions
|
||||
expect(scopes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should deduplicate resources across multiple permissions with same action set', () => {
|
||||
const permissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY]
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_OWN]
|
||||
},
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_OWN]
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_ANY]
|
||||
}
|
||||
];
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
// Both DOCKER and VMS have READ_ANY and READ_OWN, should group without duplicates
|
||||
expect(scopes).toHaveLength(1);
|
||||
expect(scopes[0]).toBe('docker+vms:read_any+read_own');
|
||||
});
|
||||
|
||||
it('should produce deterministic output for same input regardless of order', () => {
|
||||
const permissions1 = [
|
||||
{ resource: Resource.VMS, actions: [AuthAction.UPDATE_ANY, AuthAction.READ_ANY] },
|
||||
{ resource: Resource.DOCKER, actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY] }
|
||||
];
|
||||
|
||||
const permissions2 = [
|
||||
{ resource: Resource.DOCKER, actions: [AuthAction.UPDATE_ANY, AuthAction.READ_ANY] },
|
||||
{ resource: Resource.VMS, actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY] }
|
||||
];
|
||||
|
||||
const scopes1 = encodePermissionsToScopes([], permissions1);
|
||||
const scopes2 = encodePermissionsToScopes([], permissions2);
|
||||
|
||||
expect(scopes1).toEqual(scopes2);
|
||||
expect(scopes1[0]).toBe('docker+vms:read_any+update_any');
|
||||
});
|
||||
});
|
||||
|
||||
describe('roundtrip encoding/decoding', () => {
|
||||
it('should maintain permissions through encode/decode cycle with duplicates', () => {
|
||||
const originalPermissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY, AuthAction.READ_OWN, AuthAction.UPDATE_ANY]
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_OWN, AuthAction.UPDATE_OWN, AuthAction.UPDATE_ANY]
|
||||
}
|
||||
];
|
||||
|
||||
const scopes = encodePermissionsToScopes([], originalPermissions);
|
||||
const { permissions: decoded } = decodeScopesToPermissions(scopes);
|
||||
|
||||
// Now possession is preserved in the encoding
|
||||
expect(decoded).toHaveLength(2);
|
||||
|
||||
const dockerPerm = decoded.find(p => p.resource === Resource.DOCKER);
|
||||
const vmsPerm = decoded.find(p => p.resource === Resource.VMS);
|
||||
|
||||
// Docker should have its specific actions preserved
|
||||
expect(dockerPerm?.actions).toContain(AuthAction.READ_ANY);
|
||||
expect(dockerPerm?.actions).toContain(AuthAction.READ_OWN);
|
||||
expect(dockerPerm?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
|
||||
// VMS should have its specific actions preserved
|
||||
expect(vmsPerm?.actions).toContain(AuthAction.READ_OWN);
|
||||
expect(vmsPerm?.actions).toContain(AuthAction.UPDATE_OWN);
|
||||
expect(vmsPerm?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
|
||||
// The scopes should be separate since they have different action sets
|
||||
expect(scopes).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { nextTick } from 'vue';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { Input, Label, Select, SelectTrigger, Switch } from '@unraid/ui';
|
||||
import { Input, Label, Select, Switch } from '@unraid/ui';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -68,6 +68,12 @@ describe('ColorSwitcher', () => {
|
||||
const wrapper = mount(ColorSwitcher, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
stubs: {
|
||||
Accordion: { template: '<div><slot /></div>' },
|
||||
AccordionItem: { template: '<div><slot /></div>' },
|
||||
AccordionTrigger: { template: '<div><slot /></div>' },
|
||||
AccordionContent: { template: '<div><slot /></div>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -80,13 +86,22 @@ describe('ColorSwitcher', () => {
|
||||
const switches = wrapper.findAllComponents(Switch);
|
||||
expect(switches).toHaveLength(3);
|
||||
|
||||
expect(wrapper.findComponent(SelectTrigger).exists()).toBe(true);
|
||||
expect(wrapper.findComponent(Select).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('updates theme store when theme selection changes', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn });
|
||||
setActivePinia(pinia);
|
||||
themeStore = useThemeStore();
|
||||
|
||||
const wrapper = mount(ColorSwitcher, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: {
|
||||
Accordion: { template: '<div><slot /></div>' },
|
||||
AccordionItem: { template: '<div><slot /></div>' },
|
||||
AccordionTrigger: { template: '<div><slot /></div>' },
|
||||
AccordionContent: { template: '<div><slot /></div>' },
|
||||
Select: {
|
||||
template: '<div/>',
|
||||
props: ['modelValue'],
|
||||
@@ -96,9 +111,13 @@ describe('ColorSwitcher', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Clear mocks after initial mount
|
||||
vi.clearAllMocks();
|
||||
|
||||
const selectComponent = wrapper.findComponent(Select);
|
||||
await selectComponent.vm.$emit('update:modelValue', 'black');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(themeStore.setTheme).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -114,8 +133,20 @@ describe('ColorSwitcher', () => {
|
||||
});
|
||||
|
||||
it('updates theme store when color inputs change', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn });
|
||||
setActivePinia(pinia);
|
||||
themeStore = useThemeStore();
|
||||
|
||||
const wrapper = mount(ColorSwitcher, {
|
||||
global: {},
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: {
|
||||
Accordion: { template: '<div><slot /></div>' },
|
||||
AccordionItem: { template: '<div><slot /></div>' },
|
||||
AccordionTrigger: { template: '<div><slot /></div>' },
|
||||
AccordionContent: { template: '<div><slot /></div>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const inputs = wrapper.findAllComponents(Input);
|
||||
@@ -123,19 +154,25 @@ describe('ColorSwitcher', () => {
|
||||
const secondaryTextInput = inputs[1];
|
||||
const bgInput = inputs[2];
|
||||
|
||||
await primaryTextInput.setValue('#ff0000');
|
||||
// Clear mocks after initial mount
|
||||
vi.clearAllMocks();
|
||||
|
||||
await primaryTextInput.vm.$emit('update:modelValue', '#ff0000');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(themeStore.setTheme).toHaveBeenCalledTimes(2);
|
||||
expect(themeStore.setTheme).toHaveBeenCalledWith(expect.objectContaining({ textColor: '#ff0000' }));
|
||||
|
||||
await secondaryTextInput.setValue('#00ff00');
|
||||
await secondaryTextInput.vm.$emit('update:modelValue', '#00ff00');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(themeStore.setTheme).toHaveBeenCalledTimes(3);
|
||||
expect(themeStore.setTheme).toHaveBeenCalledWith(expect.objectContaining({ metaColor: '#00ff00' }));
|
||||
|
||||
await bgInput.setValue('#0000ff');
|
||||
await bgInput.vm.$emit('update:modelValue', '#0000ff');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(themeStore.setTheme).toHaveBeenCalledTimes(4);
|
||||
@@ -154,7 +191,15 @@ describe('ColorSwitcher', () => {
|
||||
|
||||
it('updates theme store when switches change', async () => {
|
||||
const wrapper = mount(ColorSwitcher, {
|
||||
global: {},
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
stubs: {
|
||||
Accordion: { template: '<div><slot /></div>' },
|
||||
AccordionItem: { template: '<div><slot /></div>' },
|
||||
AccordionTrigger: { template: '<div><slot /></div>' },
|
||||
AccordionContent: { template: '<div><slot /></div>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
themeStore = useThemeStore();
|
||||
@@ -189,12 +234,23 @@ describe('ColorSwitcher', () => {
|
||||
});
|
||||
|
||||
it('enables gradient automatically when banner is enabled', async () => {
|
||||
const wrapper = mount(ColorSwitcher, {
|
||||
global: {},
|
||||
});
|
||||
|
||||
// Create a single Pinia instance to be shared between component and store
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn });
|
||||
setActivePinia(pinia);
|
||||
themeStore = useThemeStore();
|
||||
|
||||
const wrapper = mount(ColorSwitcher, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: {
|
||||
Accordion: { template: '<div><slot /></div>' },
|
||||
AccordionItem: { template: '<div><slot /></div>' },
|
||||
AccordionTrigger: { template: '<div><slot /></div>' },
|
||||
AccordionContent: { template: '<div><slot /></div>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const switches = wrapper.findAllComponents(Switch);
|
||||
const gradientSwitch = switches[0];
|
||||
const bannerSwitch = switches[2];
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ref } from 'vue';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { provideApolloClient } from '@vue/apollo-composable';
|
||||
import { ApolloClient, InMemoryCache } from '@apollo/client/core';
|
||||
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -93,6 +95,22 @@ describe('UserProfile.ce.vue', () => {
|
||||
let consoleSpies: Array<ReturnType<typeof vi.spyOn>> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a mock Apollo Client
|
||||
const mockApolloClient = new ApolloClient({
|
||||
cache: new InMemoryCache(),
|
||||
defaultOptions: {
|
||||
query: {
|
||||
fetchPolicy: 'no-cache',
|
||||
},
|
||||
watchQuery: {
|
||||
fetchPolicy: 'no-cache',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Provide the Apollo client globally
|
||||
provideApolloClient(mockApolloClient);
|
||||
|
||||
// Suppress all console outputs
|
||||
consoleSpies = [
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {}),
|
||||
|
||||
175
web/__test__/composables/useApiKeyAuthorization.test.ts
Normal file
175
web/__test__/composables/useApiKeyAuthorization.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useApiKeyAuthorization } from '~/composables/useApiKeyAuthorization';
|
||||
import { AuthAction, Resource, Role } from '~/composables/gql/graphql';
|
||||
|
||||
describe('useApiKeyAuthorization', () => {
|
||||
describe('parameter parsing', () => {
|
||||
it('should parse query parameters correctly', () => {
|
||||
const params = new URLSearchParams('?name=TestApp&scopes=docker:read,vms:*&redirect_uri=https://example.com&state=abc123');
|
||||
const { authParams } = useApiKeyAuthorization(params);
|
||||
|
||||
expect(authParams.value.name).toBe('TestApp');
|
||||
expect(authParams.value.scopes).toEqual(['docker:read', 'vms:*']);
|
||||
expect(authParams.value.redirectUri).toBe('https://example.com');
|
||||
expect(authParams.value.state).toBe('abc123');
|
||||
});
|
||||
|
||||
it('should handle missing parameters with defaults', () => {
|
||||
const params = new URLSearchParams('');
|
||||
const { authParams } = useApiKeyAuthorization(params);
|
||||
|
||||
expect(authParams.value.name).toBe('Unknown Application');
|
||||
expect(authParams.value.scopes).toEqual([]);
|
||||
expect(authParams.value.redirectUri).toBe('');
|
||||
expect(authParams.value.state).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPermissions', () => {
|
||||
it('should format role scopes correctly', () => {
|
||||
const params = new URLSearchParams('?scopes=role:admin,role:viewer');
|
||||
const { formattedPermissions } = useApiKeyAuthorization(params);
|
||||
|
||||
expect(formattedPermissions.value).toEqual([
|
||||
{
|
||||
scope: 'role:admin',
|
||||
name: 'ADMIN',
|
||||
description: 'Grant admin role access',
|
||||
isRole: true,
|
||||
},
|
||||
{
|
||||
scope: 'role:viewer',
|
||||
name: 'VIEWER',
|
||||
description: 'Grant viewer role access',
|
||||
isRole: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should format resource:action scopes correctly', () => {
|
||||
const params = new URLSearchParams('?scopes=docker:read,vms:*');
|
||||
const { formattedPermissions } = useApiKeyAuthorization(params);
|
||||
|
||||
expect(formattedPermissions.value).toEqual([
|
||||
{
|
||||
scope: 'docker:read',
|
||||
name: 'Docker - Read',
|
||||
description: 'Read access to Docker',
|
||||
isRole: false,
|
||||
},
|
||||
{
|
||||
scope: 'vms:*',
|
||||
name: 'Vms - Full',
|
||||
description: 'Full access to Vms',
|
||||
isRole: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertScopesToPermissions', () => {
|
||||
it('should convert role scopes to roles', () => {
|
||||
const params = new URLSearchParams('?scopes=role:admin');
|
||||
const { convertScopesToPermissions } = useApiKeyAuthorization(params);
|
||||
const result = convertScopesToPermissions(['role:admin']);
|
||||
|
||||
expect(result.roles).toContain(Role.ADMIN);
|
||||
expect(result.permissions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should convert resource scopes to permissions', () => {
|
||||
const params = new URLSearchParams('?scopes=docker:read');
|
||||
const { convertScopesToPermissions } = useApiKeyAuthorization(params);
|
||||
const result = convertScopesToPermissions(['docker:read']);
|
||||
|
||||
expect(result.permissions).toEqual([
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
]);
|
||||
expect(result.roles).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle wildcard actions', () => {
|
||||
const params = new URLSearchParams('?scopes=vms:*');
|
||||
const { convertScopesToPermissions } = useApiKeyAuthorization(params);
|
||||
const result = convertScopesToPermissions(['vms:*']);
|
||||
|
||||
expect(result.permissions).toEqual([
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.CREATE_ANY, AuthAction.READ_ANY, AuthAction.UPDATE_ANY, AuthAction.DELETE_ANY],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should merge multiple actions for same resource', () => {
|
||||
const params = new URLSearchParams('');
|
||||
const { convertScopesToPermissions } = useApiKeyAuthorization(params);
|
||||
const result = convertScopesToPermissions(['docker:read', 'docker:update']);
|
||||
|
||||
expect(result.permissions).toEqual([
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redirect URI validation', () => {
|
||||
it('should accept HTTPS URLs', () => {
|
||||
const params = new URLSearchParams('?redirect_uri=https://example.com/callback');
|
||||
const { hasValidRedirectUri } = useApiKeyAuthorization(params);
|
||||
|
||||
expect(hasValidRedirectUri.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept localhost URLs', () => {
|
||||
const params = new URLSearchParams('?redirect_uri=http://localhost:3000/callback');
|
||||
const { hasValidRedirectUri } = useApiKeyAuthorization(params);
|
||||
|
||||
expect(hasValidRedirectUri.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept HTTP URLs (non-localhost)', () => {
|
||||
const params = new URLSearchParams('?redirect_uri=http://example.com/callback');
|
||||
const { hasValidRedirectUri } = useApiKeyAuthorization(params);
|
||||
|
||||
expect(hasValidRedirectUri.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid URLs', () => {
|
||||
const params = new URLSearchParams('?redirect_uri=not-a-url');
|
||||
const { hasValidRedirectUri } = useApiKeyAuthorization(params);
|
||||
|
||||
expect(hasValidRedirectUri.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCallbackUrl', () => {
|
||||
it('should build callback URL with API key', () => {
|
||||
const params = new URLSearchParams('');
|
||||
const { buildCallbackUrl } = useApiKeyAuthorization(params);
|
||||
const url = buildCallbackUrl('https://example.com/callback', 'test-key', undefined, 'state123');
|
||||
|
||||
expect(url).toBe('https://example.com/callback?api_key=test-key&state=state123');
|
||||
});
|
||||
|
||||
it('should build callback URL with error', () => {
|
||||
const params = new URLSearchParams('');
|
||||
const { buildCallbackUrl } = useApiKeyAuthorization(params);
|
||||
const url = buildCallbackUrl('https://example.com/callback', undefined, 'access_denied', 'state123');
|
||||
|
||||
expect(url).toBe('https://example.com/callback?error=access_denied&state=state123');
|
||||
});
|
||||
|
||||
it('should throw for invalid redirect URI', () => {
|
||||
const params = new URLSearchParams('');
|
||||
const { buildCallbackUrl } = useApiKeyAuthorization(params);
|
||||
|
||||
expect(() => buildCallbackUrl('not-a-url', 'key')).toThrow('Invalid redirect URI');
|
||||
});
|
||||
});
|
||||
});
|
||||
342
web/__test__/composables/useAuthorizationLink.test.ts
Normal file
342
web/__test__/composables/useAuthorizationLink.test.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useAuthorizationLink } from '~/composables/useAuthorizationLink.js';
|
||||
import { Role, Resource, AuthAction } from '~/composables/gql/graphql.js';
|
||||
|
||||
// Mock window.location for the tests
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
search: '',
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe('useAuthorizationLink', () => {
|
||||
it('should convert role scopes to form data', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'MyApp',
|
||||
description: 'My test application',
|
||||
scopes: 'role:admin,role:viewer',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
});
|
||||
|
||||
const { formData, displayAppName, hasPermissions, permissionsSummary } = useAuthorizationLink(params);
|
||||
|
||||
expect(formData.value).toEqual({
|
||||
name: 'MyApp',
|
||||
description: 'My test application',
|
||||
roles: [Role.ADMIN, Role.VIEWER],
|
||||
customPermissions: [],
|
||||
});
|
||||
|
||||
expect(displayAppName.value).toBe('MyApp');
|
||||
expect(hasPermissions.value).toBe(true);
|
||||
expect(permissionsSummary.value).toBe('2 role(s)');
|
||||
});
|
||||
|
||||
it('should group resources by their action sets', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Docker Manager',
|
||||
scopes: 'docker:read_any,docker:update_any,vms:read_any',
|
||||
});
|
||||
|
||||
const { formData, hasPermissions, permissionsSummary } = useAuthorizationLink(params);
|
||||
|
||||
// docker has read_any+update_any, vms only has read_any - these should be separate groups
|
||||
expect(formData.value.customPermissions!).toHaveLength(2);
|
||||
|
||||
// Find the group with just READ_ANY
|
||||
const readOnlyGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.length === 1 && p.actions[0] === AuthAction.READ_ANY
|
||||
);
|
||||
expect(readOnlyGroup).toBeDefined();
|
||||
expect(readOnlyGroup?.resources).toEqual([Resource.VMS]);
|
||||
|
||||
// Find the group with READ_ANY and UPDATE_ANY
|
||||
const readUpdateGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.length === 2 &&
|
||||
p.actions.includes(AuthAction.READ_ANY) &&
|
||||
p.actions.includes(AuthAction.UPDATE_ANY)
|
||||
);
|
||||
expect(readUpdateGroup).toBeDefined();
|
||||
expect(readUpdateGroup?.resources).toEqual([Resource.DOCKER]);
|
||||
|
||||
expect(hasPermissions.value).toBe(true);
|
||||
expect(permissionsSummary.value).toBe('3 permission(s)');
|
||||
});
|
||||
|
||||
it('should handle mixed role and permission scopes', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Mixed Access App',
|
||||
scopes: 'role:admin,docker:read_any,vms:*',
|
||||
});
|
||||
|
||||
const { formData, hasPermissions, permissionsSummary } = useAuthorizationLink(params);
|
||||
|
||||
expect(formData.value.roles).toEqual([Role.ADMIN]);
|
||||
expect(formData.value.customPermissions!).toHaveLength(2);
|
||||
|
||||
// Docker should have just read permission
|
||||
const dockerGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.DOCKER)
|
||||
);
|
||||
expect(dockerGroup).toBeDefined();
|
||||
expect(dockerGroup?.actions).toEqual([AuthAction.READ_ANY]);
|
||||
|
||||
// VMs should have all CRUD permissions from wildcard
|
||||
const vmsGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.VMS)
|
||||
);
|
||||
expect(vmsGroup).toBeDefined();
|
||||
expect(vmsGroup?.actions).toContain(AuthAction.CREATE_ANY);
|
||||
expect(vmsGroup?.actions).toContain(AuthAction.READ_ANY);
|
||||
expect(vmsGroup?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(vmsGroup?.actions).toContain(AuthAction.DELETE_ANY);
|
||||
|
||||
expect(hasPermissions.value).toBe(true);
|
||||
expect(permissionsSummary.value).toBe('1 role(s), 2 permission(s)');
|
||||
});
|
||||
|
||||
it('should handle wildcard permissions correctly', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Full Access App',
|
||||
scopes: 'docker:*',
|
||||
});
|
||||
|
||||
const { hasPermissions, permissionsSummary } = useAuthorizationLink(params);
|
||||
|
||||
expect(hasPermissions.value).toBe(true);
|
||||
expect(permissionsSummary.value).toBe('1 permission(s)');
|
||||
});
|
||||
|
||||
it('should handle empty scopes gracefully', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'No Permissions App',
|
||||
scopes: '',
|
||||
});
|
||||
|
||||
const { formData, hasPermissions, permissionsSummary } = useAuthorizationLink(params);
|
||||
|
||||
expect(formData.value).toEqual({
|
||||
name: 'No Permissions App',
|
||||
description: '',
|
||||
roles: [],
|
||||
customPermissions: [],
|
||||
});
|
||||
|
||||
expect(hasPermissions.value).toBe(false);
|
||||
expect(permissionsSummary.value).toBe('');
|
||||
});
|
||||
|
||||
it('should handle app names ending with " API Key"', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'MyApp API Key',
|
||||
scopes: 'role:viewer',
|
||||
});
|
||||
|
||||
const { formData, displayAppName } = useAuthorizationLink(params);
|
||||
|
||||
expect(displayAppName.value).toBe('MyApp');
|
||||
// Name should be used as-is without appending
|
||||
expect(formData.value.name).toBe('MyApp API Key');
|
||||
});
|
||||
|
||||
it('should handle invalid scopes gracefully', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Invalid Scopes App',
|
||||
scopes: 'role:invalid_role,unknown_resource:read,docker:invalid_action',
|
||||
});
|
||||
|
||||
const { hasPermissions, permissionsSummary } = useAuthorizationLink(params);
|
||||
|
||||
expect(hasPermissions.value).toBe(true); // Has scopes, even if invalid
|
||||
expect(permissionsSummary.value).toBe('1 role(s), 2 permission(s)');
|
||||
});
|
||||
|
||||
it('should use default values when parameters are missing', () => {
|
||||
const params = new URLSearchParams(); // Empty params
|
||||
|
||||
const { formData, displayAppName } = useAuthorizationLink(params);
|
||||
|
||||
expect(formData.value.name).toBe('Unknown Application');
|
||||
expect(displayAppName.value).toBe('Unknown Application');
|
||||
});
|
||||
|
||||
describe('permission grouping and preservation', () => {
|
||||
it('should group multiple resources with same actions into single permission group', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Multi-Resource Reader',
|
||||
scopes: 'connect:read_any,disk:read_any,docker:read_any',
|
||||
});
|
||||
|
||||
const { formData } = useAuthorizationLink(params);
|
||||
|
||||
// All have same action (read), so should be in one group
|
||||
expect(formData.value.customPermissions!).toHaveLength(1);
|
||||
expect(formData.value.customPermissions![0]).toEqual({
|
||||
resources: [Resource.CONNECT, Resource.DISK, Resource.DOCKER],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create separate groups for resources with different action sets', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Mixed Actions App',
|
||||
scopes: 'docker:read_any,docker:update_any,vms:create_any,vms:delete_any',
|
||||
});
|
||||
|
||||
const { formData } = useAuthorizationLink(params);
|
||||
|
||||
// Docker has read+update, VMs has create+delete - these should be separate
|
||||
expect(formData.value.customPermissions!).toHaveLength(2);
|
||||
|
||||
const dockerGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.DOCKER)
|
||||
);
|
||||
expect(dockerGroup).toBeDefined();
|
||||
expect(dockerGroup?.actions).toContain(AuthAction.READ_ANY);
|
||||
expect(dockerGroup?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
|
||||
const vmsGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.VMS)
|
||||
);
|
||||
expect(vmsGroup).toBeDefined();
|
||||
expect(vmsGroup?.actions).toContain(AuthAction.CREATE_ANY);
|
||||
expect(vmsGroup?.actions).toContain(AuthAction.DELETE_ANY);
|
||||
});
|
||||
|
||||
it('should handle duplicate scopes correctly', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Duplicate Scopes App',
|
||||
scopes: 'docker:read_any,docker:read_any,vms:update_any,vms:update_any',
|
||||
});
|
||||
|
||||
const { formData } = useAuthorizationLink(params);
|
||||
|
||||
// Docker has read, VMs has update - different actions so separate groups
|
||||
expect(formData.value.customPermissions!).toHaveLength(2);
|
||||
|
||||
const readGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.includes(AuthAction.READ_ANY)
|
||||
);
|
||||
expect(readGroup?.resources).toEqual([Resource.DOCKER]);
|
||||
|
||||
const updateGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.includes(AuthAction.UPDATE_ANY)
|
||||
);
|
||||
expect(updateGroup?.resources).toEqual([Resource.VMS]);
|
||||
});
|
||||
|
||||
it('should preserve wildcard expansion for resources', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Wildcard App',
|
||||
scopes: 'docker:*,vms:read_any',
|
||||
});
|
||||
|
||||
const { formData } = useAuthorizationLink(params);
|
||||
|
||||
// Docker has all CRUD, VMs has just read - different action sets so separate groups
|
||||
expect(formData.value.customPermissions!).toHaveLength(2);
|
||||
|
||||
const dockerGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.DOCKER)
|
||||
);
|
||||
expect(dockerGroup).toBeDefined();
|
||||
// Should have all CRUD actions from wildcard
|
||||
expect(dockerGroup?.actions).toContain(AuthAction.CREATE_ANY);
|
||||
expect(dockerGroup?.actions).toContain(AuthAction.READ_ANY);
|
||||
expect(dockerGroup?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(dockerGroup?.actions).toContain(AuthAction.DELETE_ANY);
|
||||
|
||||
const vmsGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.VMS)
|
||||
);
|
||||
expect(vmsGroup).toBeDefined();
|
||||
expect(vmsGroup?.actions).toEqual([AuthAction.READ_ANY]);
|
||||
});
|
||||
|
||||
it('should handle complex permission combinations', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Complex Permissions App',
|
||||
scopes: 'connect:read_any,disk:read_any,docker:*,vms:update_any,vms:delete_any,dashboard:read_any',
|
||||
});
|
||||
|
||||
const { formData } = useAuthorizationLink(params);
|
||||
|
||||
// Should group by action sets:
|
||||
// - connect, disk, dashboard all have just read (group 1)
|
||||
// - docker has all CRUD from wildcard (group 2)
|
||||
// - vms has update+delete (group 3)
|
||||
expect(formData.value.customPermissions!).toHaveLength(3);
|
||||
|
||||
// Find read-only group (connect, disk, dashboard)
|
||||
const readOnlyGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.length === 1 && p.actions[0] === AuthAction.READ_ANY
|
||||
);
|
||||
expect(readOnlyGroup).toBeDefined();
|
||||
expect(readOnlyGroup?.resources).toContain(Resource.CONNECT);
|
||||
expect(readOnlyGroup?.resources).toContain(Resource.DISK);
|
||||
expect(readOnlyGroup?.resources).toContain(Resource.DASHBOARD);
|
||||
|
||||
// Find full CRUD group (docker with wildcard)
|
||||
const fullCrudGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.length === 4 && p.resources.includes(Resource.DOCKER)
|
||||
);
|
||||
expect(fullCrudGroup).toBeDefined();
|
||||
expect(fullCrudGroup?.actions).toContain(AuthAction.CREATE_ANY);
|
||||
expect(fullCrudGroup?.actions).toContain(AuthAction.READ_ANY);
|
||||
expect(fullCrudGroup?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(fullCrudGroup?.actions).toContain(AuthAction.DELETE_ANY);
|
||||
|
||||
// Find update+delete group (vms)
|
||||
const updateDeleteGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.VMS)
|
||||
);
|
||||
expect(updateDeleteGroup).toBeDefined();
|
||||
expect(updateDeleteGroup?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(updateDeleteGroup?.actions).toContain(AuthAction.DELETE_ANY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('efficient scope encoding', () => {
|
||||
it('should decode grouped scopes correctly', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Grouped App',
|
||||
scopes: 'docker+vms:read_any+update_any',
|
||||
});
|
||||
|
||||
const { formData } = useAuthorizationLink(params);
|
||||
|
||||
// Should decode to a single group with both resources and both actions
|
||||
expect(formData.value.customPermissions!).toHaveLength(1);
|
||||
expect(formData.value.customPermissions![0].resources).toContain(Resource.DOCKER);
|
||||
expect(formData.value.customPermissions![0].resources).toContain(Resource.VMS);
|
||||
expect(formData.value.customPermissions![0].actions).toContain(AuthAction.READ_ANY);
|
||||
expect(formData.value.customPermissions![0].actions).toContain(AuthAction.UPDATE_ANY);
|
||||
});
|
||||
|
||||
it('should handle mixed grouped and individual scopes', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Mixed Grouped App',
|
||||
scopes: 'docker+vms:read_any,dashboard:update_any',
|
||||
});
|
||||
|
||||
const { formData } = useAuthorizationLink(params);
|
||||
|
||||
// Should have two groups: docker+vms with read, dashboard with update
|
||||
expect(formData.value.customPermissions!).toHaveLength(2);
|
||||
|
||||
const readGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.includes(AuthAction.READ_ANY)
|
||||
);
|
||||
expect(readGroup).toBeDefined();
|
||||
expect(readGroup?.resources).toContain(Resource.DOCKER);
|
||||
expect(readGroup?.resources).toContain(Resource.VMS);
|
||||
|
||||
const updateGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.includes(AuthAction.UPDATE_ANY)
|
||||
);
|
||||
expect(updateGroup).toBeDefined();
|
||||
expect(updateGroup?.resources).toEqual([Resource.DASHBOARD]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
219
web/components/ApiKey/DeveloperAuthorizationLink.vue
Normal file
219
web/components/ApiKey/DeveloperAuthorizationLink.vue
Normal 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>
|
||||
131
web/components/ApiKey/EffectivePermissions.vue
Normal file
131
web/components/ApiKey/EffectivePermissions.vue
Normal 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>
|
||||
@@ -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;
|
||||
}>(),
|
||||
|
||||
13
web/components/ApiKey/api-key-form.query.ts
Normal file
13
web/components/ApiKey/api-key-form.query.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
19
web/components/ApiKey/permissions-preview.query.ts
Normal file
19
web/components/ApiKey/permissions-preview.query.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`;
|
||||
299
web/components/ApiKeyAuthorize.ce.vue
Normal file
299
web/components/ApiKeyAuthorize.ce.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,13 +17,15 @@ type Documents = {
|
||||
"\n query PartnerInfo {\n publicPartnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n }\n": typeof types.PartnerInfoDocument,
|
||||
"\n query PublicWelcomeData {\n publicPartnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n isInitialSetup\n }\n": typeof types.PublicWelcomeDataDocument,
|
||||
"\n query ActivationCode {\n vars {\n regState\n }\n customization {\n activationCode {\n code\n partnerName\n serverName\n sysModel\n comment\n header\n headermetacolor\n background\n showBannerGradient\n theme\n }\n partnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n }\n }\n": typeof types.ActivationCodeDocument,
|
||||
"\n fragment ApiKey on ApiKey {\n id\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n": typeof types.ApiKeyFragmentDoc,
|
||||
"\n fragment ApiKeyWithKey on ApiKeyWithSecret {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n": typeof types.ApiKeyWithKeyFragmentDoc,
|
||||
"\n query GetApiKeyCreationFormSchema {\n getApiKeyCreationFormSchema {\n id\n dataSchema\n uiSchema\n values\n }\n }\n": typeof types.GetApiKeyCreationFormSchemaDocument,
|
||||
"\n fragment ApiKey on ApiKey {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n": typeof types.ApiKeyFragmentDoc,
|
||||
"\n query ApiKeys {\n apiKeys {\n ...ApiKey\n }\n }\n": typeof types.ApiKeysDocument,
|
||||
"\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKeyWithKey\n }\n } \n }\n": typeof types.CreateApiKeyDocument,
|
||||
"\n mutation UpdateApiKey($input: UpdateApiKeyInput!) {\n apiKey {\n update(input: $input) {\n ...ApiKeyWithKey\n }\n }\n }\n": typeof types.UpdateApiKeyDocument,
|
||||
"\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKey\n }\n } \n }\n": typeof types.CreateApiKeyDocument,
|
||||
"\n mutation UpdateApiKey($input: UpdateApiKeyInput!) {\n apiKey {\n update(input: $input) {\n ...ApiKey\n }\n }\n }\n": typeof types.UpdateApiKeyDocument,
|
||||
"\n mutation DeleteApiKey($input: DeleteApiKeyInput!) {\n apiKey {\n delete(input: $input)\n }\n }\n": typeof types.DeleteApiKeyDocument,
|
||||
"\n query ApiKeyMeta {\n apiKeyPossibleRoles\n apiKeyPossiblePermissions {\n resource\n actions\n }\n }\n": typeof types.ApiKeyMetaDocument,
|
||||
"\n query PreviewEffectivePermissions($roles: [Role!], $permissions: [AddPermissionInput!]) {\n previewEffectivePermissions(roles: $roles, permissions: $permissions) {\n resource\n actions\n }\n }\n": typeof types.PreviewEffectivePermissionsDocument,
|
||||
"\n query GetPermissionsForRoles($roles: [Role!]!) {\n getPermissionsForRoles(roles: $roles) {\n resource\n actions\n }\n }\n": typeof types.GetPermissionsForRolesDocument,
|
||||
"\n query Unified {\n settings {\n unified {\n id\n dataSchema\n uiSchema\n values\n }\n }\n }\n": typeof types.UnifiedDocument,
|
||||
"\n mutation UpdateConnectSettings($input: JSON!) {\n updateSettings(input: $input) {\n restartRequired\n values\n }\n }\n": typeof types.UpdateConnectSettingsDocument,
|
||||
"\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": typeof types.LogFilesDocument,
|
||||
@@ -60,13 +62,15 @@ const documents: Documents = {
|
||||
"\n query PartnerInfo {\n publicPartnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n }\n": types.PartnerInfoDocument,
|
||||
"\n query PublicWelcomeData {\n publicPartnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n isInitialSetup\n }\n": types.PublicWelcomeDataDocument,
|
||||
"\n query ActivationCode {\n vars {\n regState\n }\n customization {\n activationCode {\n code\n partnerName\n serverName\n sysModel\n comment\n header\n headermetacolor\n background\n showBannerGradient\n theme\n }\n partnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n }\n }\n": types.ActivationCodeDocument,
|
||||
"\n fragment ApiKey on ApiKey {\n id\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n": types.ApiKeyFragmentDoc,
|
||||
"\n fragment ApiKeyWithKey on ApiKeyWithSecret {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n": types.ApiKeyWithKeyFragmentDoc,
|
||||
"\n query GetApiKeyCreationFormSchema {\n getApiKeyCreationFormSchema {\n id\n dataSchema\n uiSchema\n values\n }\n }\n": types.GetApiKeyCreationFormSchemaDocument,
|
||||
"\n fragment ApiKey on ApiKey {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n": types.ApiKeyFragmentDoc,
|
||||
"\n query ApiKeys {\n apiKeys {\n ...ApiKey\n }\n }\n": types.ApiKeysDocument,
|
||||
"\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKeyWithKey\n }\n } \n }\n": types.CreateApiKeyDocument,
|
||||
"\n mutation UpdateApiKey($input: UpdateApiKeyInput!) {\n apiKey {\n update(input: $input) {\n ...ApiKeyWithKey\n }\n }\n }\n": types.UpdateApiKeyDocument,
|
||||
"\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKey\n }\n } \n }\n": types.CreateApiKeyDocument,
|
||||
"\n mutation UpdateApiKey($input: UpdateApiKeyInput!) {\n apiKey {\n update(input: $input) {\n ...ApiKey\n }\n }\n }\n": types.UpdateApiKeyDocument,
|
||||
"\n mutation DeleteApiKey($input: DeleteApiKeyInput!) {\n apiKey {\n delete(input: $input)\n }\n }\n": types.DeleteApiKeyDocument,
|
||||
"\n query ApiKeyMeta {\n apiKeyPossibleRoles\n apiKeyPossiblePermissions {\n resource\n actions\n }\n }\n": types.ApiKeyMetaDocument,
|
||||
"\n query PreviewEffectivePermissions($roles: [Role!], $permissions: [AddPermissionInput!]) {\n previewEffectivePermissions(roles: $roles, permissions: $permissions) {\n resource\n actions\n }\n }\n": types.PreviewEffectivePermissionsDocument,
|
||||
"\n query GetPermissionsForRoles($roles: [Role!]!) {\n getPermissionsForRoles(roles: $roles) {\n resource\n actions\n }\n }\n": types.GetPermissionsForRolesDocument,
|
||||
"\n query Unified {\n settings {\n unified {\n id\n dataSchema\n uiSchema\n values\n }\n }\n }\n": types.UnifiedDocument,
|
||||
"\n mutation UpdateConnectSettings($input: JSON!) {\n updateSettings(input: $input) {\n restartRequired\n values\n }\n }\n": types.UpdateConnectSettingsDocument,
|
||||
"\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": types.LogFilesDocument,
|
||||
@@ -129,11 +133,11 @@ export function graphql(source: "\n query ActivationCode {\n vars {\n r
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment ApiKey on ApiKey {\n id\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n"): (typeof documents)["\n fragment ApiKey on ApiKey {\n id\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n"];
|
||||
export function graphql(source: "\n query GetApiKeyCreationFormSchema {\n getApiKeyCreationFormSchema {\n id\n dataSchema\n uiSchema\n values\n }\n }\n"): (typeof documents)["\n query GetApiKeyCreationFormSchema {\n getApiKeyCreationFormSchema {\n id\n dataSchema\n uiSchema\n values\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment ApiKeyWithKey on ApiKeyWithSecret {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n"): (typeof documents)["\n fragment ApiKeyWithKey on ApiKeyWithSecret {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n"];
|
||||
export function graphql(source: "\n fragment ApiKey on ApiKey {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n"): (typeof documents)["\n fragment ApiKey on ApiKey {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -141,11 +145,11 @@ export function graphql(source: "\n query ApiKeys {\n apiKeys {\n ...Ap
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKeyWithKey\n }\n } \n }\n"): (typeof documents)["\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKeyWithKey\n }\n } \n }\n"];
|
||||
export function graphql(source: "\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKey\n }\n } \n }\n"): (typeof documents)["\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKey\n }\n } \n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation UpdateApiKey($input: UpdateApiKeyInput!) {\n apiKey {\n update(input: $input) {\n ...ApiKeyWithKey\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateApiKey($input: UpdateApiKeyInput!) {\n apiKey {\n update(input: $input) {\n ...ApiKeyWithKey\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n mutation UpdateApiKey($input: UpdateApiKeyInput!) {\n apiKey {\n update(input: $input) {\n ...ApiKey\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateApiKey($input: UpdateApiKeyInput!) {\n apiKey {\n update(input: $input) {\n ...ApiKey\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -154,6 +158,14 @@ export function graphql(source: "\n mutation DeleteApiKey($input: DeleteApiKeyI
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query ApiKeyMeta {\n apiKeyPossibleRoles\n apiKeyPossiblePermissions {\n resource\n actions\n }\n }\n"): (typeof documents)["\n query ApiKeyMeta {\n apiKeyPossibleRoles\n apiKeyPossiblePermissions {\n resource\n actions\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query PreviewEffectivePermissions($roles: [Role!], $permissions: [AddPermissionInput!]) {\n previewEffectivePermissions(roles: $roles, permissions: $permissions) {\n resource\n actions\n }\n }\n"): (typeof documents)["\n query PreviewEffectivePermissions($roles: [Role!], $permissions: [AddPermissionInput!]) {\n previewEffectivePermissions(roles: $roles, permissions: $permissions) {\n resource\n actions\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query GetPermissionsForRoles($roles: [Role!]!) {\n getPermissionsForRoles(roles: $roles) {\n resource\n actions\n }\n }\n"): (typeof documents)["\n query GetPermissionsForRoles($roles: [Role!]!) {\n getPermissionsForRoles(roles: $roles) {\n resource\n actions\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
@@ -120,7 +120,7 @@ export type ActivationCode = {
|
||||
};
|
||||
|
||||
export type AddPermissionInput = {
|
||||
actions: Array<Scalars['String']['input']>;
|
||||
actions: Array<AuthAction>;
|
||||
resource: Resource;
|
||||
};
|
||||
|
||||
@@ -143,24 +143,36 @@ export type ApiKey = Node & {
|
||||
createdAt: Scalars['String']['output'];
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
key: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
permissions: Array<Permission>;
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
export type ApiKeyFormSettings = FormSchema & Node & {
|
||||
__typename?: 'ApiKeyFormSettings';
|
||||
/** The data schema for the API key form */
|
||||
dataSchema: Scalars['JSON']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** The UI schema for the API key form */
|
||||
uiSchema: Scalars['JSON']['output'];
|
||||
/** The current values of the API key form */
|
||||
values: Scalars['JSON']['output'];
|
||||
};
|
||||
|
||||
/** API Key related mutations */
|
||||
export type ApiKeyMutations = {
|
||||
__typename?: 'ApiKeyMutations';
|
||||
/** Add a role to an API key */
|
||||
addRole: Scalars['Boolean']['output'];
|
||||
/** Create an API key */
|
||||
create: ApiKeyWithSecret;
|
||||
create: ApiKey;
|
||||
/** Delete one or more API keys */
|
||||
delete: Scalars['Boolean']['output'];
|
||||
/** Remove a role from an API key */
|
||||
removeRole: Scalars['Boolean']['output'];
|
||||
/** Update an API key */
|
||||
update: ApiKeyWithSecret;
|
||||
update: ApiKey;
|
||||
};
|
||||
|
||||
|
||||
@@ -199,17 +211,6 @@ export type ApiKeyResponse = {
|
||||
valid: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type ApiKeyWithSecret = Node & {
|
||||
__typename?: 'ApiKeyWithSecret';
|
||||
createdAt: Scalars['String']['output'];
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
key: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
permissions: Array<Permission>;
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
export type ArrayCapacity = {
|
||||
__typename?: 'ArrayCapacity';
|
||||
/** Capacity in number of disks */
|
||||
@@ -370,19 +371,24 @@ export enum ArrayStateInputState {
|
||||
STOP = 'STOP'
|
||||
}
|
||||
|
||||
/** Available authentication action verbs */
|
||||
export enum AuthActionVerb {
|
||||
CREATE = 'CREATE',
|
||||
DELETE = 'DELETE',
|
||||
READ = 'READ',
|
||||
UPDATE = 'UPDATE'
|
||||
}
|
||||
|
||||
/** Available authentication possession types */
|
||||
export enum AuthPossession {
|
||||
ANY = 'ANY',
|
||||
OWN = 'OWN',
|
||||
OWN_ANY = 'OWN_ANY'
|
||||
/** Authentication actions with possession (e.g., create:any, read:own) */
|
||||
export enum AuthAction {
|
||||
/** Create any resource */
|
||||
CREATE_ANY = 'CREATE_ANY',
|
||||
/** Create own resource */
|
||||
CREATE_OWN = 'CREATE_OWN',
|
||||
/** Delete any resource */
|
||||
DELETE_ANY = 'DELETE_ANY',
|
||||
/** Delete own resource */
|
||||
DELETE_OWN = 'DELETE_OWN',
|
||||
/** Read any resource */
|
||||
READ_ANY = 'READ_ANY',
|
||||
/** Read own resource */
|
||||
READ_OWN = 'READ_OWN',
|
||||
/** Update any resource */
|
||||
UPDATE_ANY = 'UPDATE_ANY',
|
||||
/** Update own resource */
|
||||
UPDATE_OWN = 'UPDATE_OWN'
|
||||
}
|
||||
|
||||
/** Operators for authorization rule matching */
|
||||
@@ -776,6 +782,15 @@ export type FlashBackupStatus = {
|
||||
status: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type FormSchema = {
|
||||
/** The data schema for the form */
|
||||
dataSchema: Scalars['JSON']['output'];
|
||||
/** The UI schema for the form */
|
||||
uiSchema: Scalars['JSON']['output'];
|
||||
/** The current values of the form */
|
||||
values: Scalars['JSON']['output'];
|
||||
};
|
||||
|
||||
export type Info = Node & {
|
||||
__typename?: 'Info';
|
||||
/** Motherboard information */
|
||||
@@ -1053,7 +1068,7 @@ export type InfoVersions = Node & {
|
||||
core: CoreVersions;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Software package versions */
|
||||
packages: PackageVersions;
|
||||
packages?: Maybe<PackageVersions>;
|
||||
};
|
||||
|
||||
export type InitiateFlashBackupInput = {
|
||||
@@ -1519,7 +1534,7 @@ export type ParityCheck = {
|
||||
/** Speed of the parity check, in MB/s */
|
||||
speed?: Maybe<Scalars['String']['output']>;
|
||||
/** Status of the parity check */
|
||||
status?: Maybe<Scalars['String']['output']>;
|
||||
status: ParityCheckStatus;
|
||||
};
|
||||
|
||||
/** Parity check related mutations, WIP, response types and functionaliy will change */
|
||||
@@ -1541,9 +1556,19 @@ export type ParityCheckMutationsStartArgs = {
|
||||
correct: Scalars['Boolean']['input'];
|
||||
};
|
||||
|
||||
export enum ParityCheckStatus {
|
||||
CANCELLED = 'CANCELLED',
|
||||
COMPLETED = 'COMPLETED',
|
||||
FAILED = 'FAILED',
|
||||
NEVER_RUN = 'NEVER_RUN',
|
||||
PAUSED = 'PAUSED',
|
||||
RUNNING = 'RUNNING'
|
||||
}
|
||||
|
||||
export type Permission = {
|
||||
__typename?: 'Permission';
|
||||
actions: Array<Scalars['String']['output']>;
|
||||
/** Actions allowed on this resource */
|
||||
actions: Array<AuthAction>;
|
||||
resource: Resource;
|
||||
};
|
||||
|
||||
@@ -1613,6 +1638,12 @@ export type Query = {
|
||||
disks: Array<Disk>;
|
||||
docker: Docker;
|
||||
flash: Flash;
|
||||
/** Get JSON Schema for API key creation form */
|
||||
getApiKeyCreationFormSchema: ApiKeyFormSettings;
|
||||
/** Get all available authentication actions with possession */
|
||||
getAvailableAuthActions: Array<AuthAction>;
|
||||
/** Get the actual permissions that would be granted by a set of roles */
|
||||
getPermissionsForRoles: Array<Permission>;
|
||||
info: Info;
|
||||
isInitialSetup: Scalars['Boolean']['output'];
|
||||
isSSOEnabled: Scalars['Boolean']['output'];
|
||||
@@ -1632,6 +1663,8 @@ export type Query = {
|
||||
parityHistory: Array<ParityCheck>;
|
||||
/** List all installed plugins with their metadata */
|
||||
plugins: Array<Plugin>;
|
||||
/** Preview the effective permissions for a combination of roles and explicit permissions */
|
||||
previewEffectivePermissions: Array<Permission>;
|
||||
/** Get public OIDC provider information for login buttons */
|
||||
publicOidcProviders: Array<PublicOidcProvider>;
|
||||
publicPartnerInfo?: Maybe<PublicPartnerInfo>;
|
||||
@@ -1665,6 +1698,11 @@ export type QueryDiskArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryGetPermissionsForRolesArgs = {
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryLogFileArgs = {
|
||||
lines?: InputMaybe<Scalars['Int']['input']>;
|
||||
path: Scalars['String']['input'];
|
||||
@@ -1677,6 +1715,12 @@ export type QueryOidcProviderArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryPreviewEffectivePermissionsArgs = {
|
||||
permissions?: InputMaybe<Array<AddPermissionInput>>;
|
||||
roles?: InputMaybe<Array<Role>>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryUpsDeviceByIdArgs = {
|
||||
id: Scalars['String']['input'];
|
||||
};
|
||||
@@ -1869,10 +1913,14 @@ export enum Resource {
|
||||
|
||||
/** Available roles for API keys and users */
|
||||
export enum Role {
|
||||
/** Full administrative access to all resources */
|
||||
ADMIN = 'ADMIN',
|
||||
/** Internal Role for Unraid Connect */
|
||||
CONNECT = 'CONNECT',
|
||||
/** Basic read access to user profile only */
|
||||
GUEST = 'GUEST',
|
||||
USER = 'USER'
|
||||
/** Read-only access to all resources */
|
||||
VIEWER = 'VIEWER'
|
||||
}
|
||||
|
||||
export type Server = Node & {
|
||||
@@ -2149,7 +2197,7 @@ export enum UrlType {
|
||||
WIREGUARD = 'WIREGUARD'
|
||||
}
|
||||
|
||||
export type UnifiedSettings = Node & {
|
||||
export type UnifiedSettings = FormSchema & Node & {
|
||||
__typename?: 'UnifiedSettings';
|
||||
/** The data schema for the settings */
|
||||
dataSchema: Scalars['JSON']['output'];
|
||||
@@ -2173,6 +2221,8 @@ export type UnraidArray = Node & {
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Parity disks in the current array */
|
||||
parities: Array<ArrayDisk>;
|
||||
/** Current parity check status */
|
||||
parityCheckStatus: ParityCheck;
|
||||
/** Current array state */
|
||||
state: ArrayState;
|
||||
};
|
||||
@@ -2501,9 +2551,12 @@ export type ActivationCodeQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
export type ActivationCodeQuery = { __typename?: 'Query', vars: { __typename?: 'Vars', regState?: RegistrationState | null }, customization?: { __typename?: 'Customization', activationCode?: { __typename?: 'ActivationCode', code?: string | null, partnerName?: string | null, serverName?: string | null, sysModel?: string | null, comment?: string | null, header?: string | null, headermetacolor?: string | null, background?: string | null, showBannerGradient?: boolean | null, theme?: string | null } | null, partnerInfo?: { __typename?: 'PublicPartnerInfo', hasPartnerLogo: boolean, partnerName?: string | null, partnerUrl?: string | null, partnerLogoUrl?: string | null } | null } | null };
|
||||
|
||||
export type ApiKeyFragment = { __typename?: 'ApiKey', id: string, name: string, description?: string | null, createdAt: string, roles: Array<Role>, permissions: Array<{ __typename?: 'Permission', resource: Resource, actions: Array<string> }> } & { ' $fragmentName'?: 'ApiKeyFragment' };
|
||||
export type GetApiKeyCreationFormSchemaQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
export type ApiKeyWithKeyFragment = { __typename?: 'ApiKeyWithSecret', id: string, key: string, name: string, description?: string | null, createdAt: string, roles: Array<Role>, permissions: Array<{ __typename?: 'Permission', resource: Resource, actions: Array<string> }> } & { ' $fragmentName'?: 'ApiKeyWithKeyFragment' };
|
||||
|
||||
export type GetApiKeyCreationFormSchemaQuery = { __typename?: 'Query', getApiKeyCreationFormSchema: { __typename?: 'ApiKeyFormSettings', id: string, dataSchema: any, uiSchema: any, values: any } };
|
||||
|
||||
export type ApiKeyFragment = { __typename?: 'ApiKey', id: string, key: string, name: string, description?: string | null, createdAt: string, roles: Array<Role>, permissions: Array<{ __typename?: 'Permission', resource: Resource, actions: Array<AuthAction> }> } & { ' $fragmentName'?: 'ApiKeyFragment' };
|
||||
|
||||
export type ApiKeysQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
@@ -2519,8 +2572,8 @@ export type CreateApiKeyMutationVariables = Exact<{
|
||||
|
||||
|
||||
export type CreateApiKeyMutation = { __typename?: 'Mutation', apiKey: { __typename?: 'ApiKeyMutations', create: (
|
||||
{ __typename?: 'ApiKeyWithSecret' }
|
||||
& { ' $fragmentRefs'?: { 'ApiKeyWithKeyFragment': ApiKeyWithKeyFragment } }
|
||||
{ __typename?: 'ApiKey' }
|
||||
& { ' $fragmentRefs'?: { 'ApiKeyFragment': ApiKeyFragment } }
|
||||
) } };
|
||||
|
||||
export type UpdateApiKeyMutationVariables = Exact<{
|
||||
@@ -2529,8 +2582,8 @@ export type UpdateApiKeyMutationVariables = Exact<{
|
||||
|
||||
|
||||
export type UpdateApiKeyMutation = { __typename?: 'Mutation', apiKey: { __typename?: 'ApiKeyMutations', update: (
|
||||
{ __typename?: 'ApiKeyWithSecret' }
|
||||
& { ' $fragmentRefs'?: { 'ApiKeyWithKeyFragment': ApiKeyWithKeyFragment } }
|
||||
{ __typename?: 'ApiKey' }
|
||||
& { ' $fragmentRefs'?: { 'ApiKeyFragment': ApiKeyFragment } }
|
||||
) } };
|
||||
|
||||
export type DeleteApiKeyMutationVariables = Exact<{
|
||||
@@ -2543,7 +2596,22 @@ export type DeleteApiKeyMutation = { __typename?: 'Mutation', apiKey: { __typena
|
||||
export type ApiKeyMetaQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type ApiKeyMetaQuery = { __typename?: 'Query', apiKeyPossibleRoles: Array<Role>, apiKeyPossiblePermissions: Array<{ __typename?: 'Permission', resource: Resource, actions: Array<string> }> };
|
||||
export type ApiKeyMetaQuery = { __typename?: 'Query', apiKeyPossibleRoles: Array<Role>, apiKeyPossiblePermissions: Array<{ __typename?: 'Permission', resource: Resource, actions: Array<AuthAction> }> };
|
||||
|
||||
export type PreviewEffectivePermissionsQueryVariables = Exact<{
|
||||
roles?: InputMaybe<Array<Role> | Role>;
|
||||
permissions?: InputMaybe<Array<AddPermissionInput> | AddPermissionInput>;
|
||||
}>;
|
||||
|
||||
|
||||
export type PreviewEffectivePermissionsQuery = { __typename?: 'Query', previewEffectivePermissions: Array<{ __typename?: 'Permission', resource: Resource, actions: Array<AuthAction> }> };
|
||||
|
||||
export type GetPermissionsForRolesQueryVariables = Exact<{
|
||||
roles: Array<Role> | Role;
|
||||
}>;
|
||||
|
||||
|
||||
export type GetPermissionsForRolesQuery = { __typename?: 'Query', getPermissionsForRoles: Array<{ __typename?: 'Permission', resource: Resource, actions: Array<AuthAction> }> };
|
||||
|
||||
export type UnifiedQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
@@ -2738,19 +2806,21 @@ export type GetThemeQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
export type GetThemeQuery = { __typename?: 'Query', publicTheme: { __typename?: 'Theme', name: ThemeName, showBannerImage: boolean, showBannerGradient: boolean, headerBackgroundColor?: string | null, showHeaderDescription: boolean, headerPrimaryTextColor?: string | null, headerSecondaryTextColor?: string | null } };
|
||||
|
||||
export const ApiKeyFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKey"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<ApiKeyFragment, unknown>;
|
||||
export const ApiKeyWithKeyFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKeyWithKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKeyWithSecret"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<ApiKeyWithKeyFragment, unknown>;
|
||||
export const ApiKeyFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKey"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<ApiKeyFragment, unknown>;
|
||||
export const NotificationFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NotificationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Notification"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"importance"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"formattedTimestamp"}}]}}]} as unknown as DocumentNode<NotificationFragmentFragment, unknown>;
|
||||
export const NotificationCountFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NotificationCountFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NotificationCounts"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"total"}},{"kind":"Field","name":{"kind":"Name","value":"info"}},{"kind":"Field","name":{"kind":"Name","value":"warning"}},{"kind":"Field","name":{"kind":"Name","value":"alert"}}]}}]} as unknown as DocumentNode<NotificationCountFragmentFragment, unknown>;
|
||||
export const PartialCloudFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PartialCloud"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Cloud"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cloud"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"minigraphql"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"relay"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]} as unknown as DocumentNode<PartialCloudFragment, unknown>;
|
||||
export const PartnerInfoDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PartnerInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publicPartnerInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasPartnerLogo"}},{"kind":"Field","name":{"kind":"Name","value":"partnerName"}},{"kind":"Field","name":{"kind":"Name","value":"partnerUrl"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoUrl"}}]}}]}}]} as unknown as DocumentNode<PartnerInfoQuery, PartnerInfoQueryVariables>;
|
||||
export const PublicWelcomeDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PublicWelcomeData"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publicPartnerInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasPartnerLogo"}},{"kind":"Field","name":{"kind":"Name","value":"partnerName"}},{"kind":"Field","name":{"kind":"Name","value":"partnerUrl"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isInitialSetup"}}]}}]} as unknown as DocumentNode<PublicWelcomeDataQuery, PublicWelcomeDataQueryVariables>;
|
||||
export const ActivationCodeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ActivationCode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"vars"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"regState"}}]}},{"kind":"Field","name":{"kind":"Name","value":"customization"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activationCode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"partnerName"}},{"kind":"Field","name":{"kind":"Name","value":"serverName"}},{"kind":"Field","name":{"kind":"Name","value":"sysModel"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}},{"kind":"Field","name":{"kind":"Name","value":"header"}},{"kind":"Field","name":{"kind":"Name","value":"headermetacolor"}},{"kind":"Field","name":{"kind":"Name","value":"background"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerGradient"}},{"kind":"Field","name":{"kind":"Name","value":"theme"}}]}},{"kind":"Field","name":{"kind":"Name","value":"partnerInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasPartnerLogo"}},{"kind":"Field","name":{"kind":"Name","value":"partnerName"}},{"kind":"Field","name":{"kind":"Name","value":"partnerUrl"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoUrl"}}]}}]}}]}}]} as unknown as DocumentNode<ActivationCodeQuery, ActivationCodeQueryVariables>;
|
||||
export const ApiKeysDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ApiKeys"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKeys"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApiKey"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKey"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<ApiKeysQuery, ApiKeysQueryVariables>;
|
||||
export const CreateApiKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateApiKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateApiKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApiKeyWithKey"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKeyWithKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKeyWithSecret"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<CreateApiKeyMutation, CreateApiKeyMutationVariables>;
|
||||
export const UpdateApiKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateApiKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateApiKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"update"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApiKeyWithKey"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKeyWithKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKeyWithSecret"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<UpdateApiKeyMutation, UpdateApiKeyMutationVariables>;
|
||||
export const GetApiKeyCreationFormSchemaDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetApiKeyCreationFormSchema"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getApiKeyCreationFormSchema"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSchema"}},{"kind":"Field","name":{"kind":"Name","value":"uiSchema"}},{"kind":"Field","name":{"kind":"Name","value":"values"}}]}}]}}]} as unknown as DocumentNode<GetApiKeyCreationFormSchemaQuery, GetApiKeyCreationFormSchemaQueryVariables>;
|
||||
export const ApiKeysDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ApiKeys"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKeys"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApiKey"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKey"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<ApiKeysQuery, ApiKeysQueryVariables>;
|
||||
export const CreateApiKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateApiKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateApiKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApiKey"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKey"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<CreateApiKeyMutation, CreateApiKeyMutationVariables>;
|
||||
export const UpdateApiKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateApiKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateApiKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"update"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApiKey"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKey"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<UpdateApiKeyMutation, UpdateApiKeyMutationVariables>;
|
||||
export const DeleteApiKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteApiKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteApiKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"delete"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]} as unknown as DocumentNode<DeleteApiKeyMutation, DeleteApiKeyMutationVariables>;
|
||||
export const ApiKeyMetaDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ApiKeyMeta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKeyPossibleRoles"}},{"kind":"Field","name":{"kind":"Name","value":"apiKeyPossiblePermissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<ApiKeyMetaQuery, ApiKeyMetaQueryVariables>;
|
||||
export const PreviewEffectivePermissionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PreviewEffectivePermissions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"roles"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Role"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"permissions"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddPermissionInput"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"previewEffectivePermissions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"roles"},"value":{"kind":"Variable","name":{"kind":"Name","value":"roles"}}},{"kind":"Argument","name":{"kind":"Name","value":"permissions"},"value":{"kind":"Variable","name":{"kind":"Name","value":"permissions"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<PreviewEffectivePermissionsQuery, PreviewEffectivePermissionsQueryVariables>;
|
||||
export const GetPermissionsForRolesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPermissionsForRoles"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"roles"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Role"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getPermissionsForRoles"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"roles"},"value":{"kind":"Variable","name":{"kind":"Name","value":"roles"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<GetPermissionsForRolesQuery, GetPermissionsForRolesQueryVariables>;
|
||||
export const UnifiedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Unified"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"settings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unified"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSchema"}},{"kind":"Field","name":{"kind":"Name","value":"uiSchema"}},{"kind":"Field","name":{"kind":"Name","value":"values"}}]}}]}}]}}]} as unknown as DocumentNode<UnifiedQuery, UnifiedQueryVariables>;
|
||||
export const UpdateConnectSettingsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateConnectSettings"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateSettings"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"restartRequired"}},{"kind":"Field","name":{"kind":"Name","value":"values"}}]}}]}}]} as unknown as DocumentNode<UpdateConnectSettingsMutation, UpdateConnectSettingsMutationVariables>;
|
||||
export const LogFilesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"LogFiles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFiles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"modifiedAt"}}]}}]}}]} as unknown as DocumentNode<LogFilesQuery, LogFilesQueryVariables>;
|
||||
|
||||
211
web/composables/useApiKeyAuthorization.ts
Normal file
211
web/composables/useApiKeyAuthorization.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { AuthAction, Resource, Role } from '~/composables/gql/graphql';
|
||||
|
||||
export interface ScopeConversion {
|
||||
permissions: Array<{ resource: Resource; actions: AuthAction[] }>;
|
||||
roles: Role[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert scope strings to permissions and roles
|
||||
* Scopes can be in format:
|
||||
* - "role:admin" for roles
|
||||
* - "docker:read" for resource permissions
|
||||
* - "docker:*" for all actions on a resource
|
||||
*/
|
||||
function convertScopesToPermissions(scopes: string[]): ScopeConversion {
|
||||
const permissions: Array<{ resource: Resource; actions: AuthAction[] }> = [];
|
||||
const roles: Role[] = [];
|
||||
|
||||
for (const scope of scopes) {
|
||||
if (scope.startsWith('role:')) {
|
||||
// Handle role scope
|
||||
const roleStr = scope.substring(5).toUpperCase();
|
||||
if (Object.values(Role).includes(roleStr as Role)) {
|
||||
roles.push(roleStr as Role);
|
||||
} else {
|
||||
console.warn(`Unknown role in scope: ${scope}`);
|
||||
}
|
||||
} else {
|
||||
// Handle permission scope
|
||||
const [resourceStr, actionStr] = scope.split(':');
|
||||
if (resourceStr && actionStr) {
|
||||
const resourceUpper = resourceStr.toUpperCase();
|
||||
const resource = Object.values(Resource).find(r => r === resourceUpper) as Resource;
|
||||
|
||||
if (!resource) {
|
||||
console.warn(`Unknown resource in scope: ${scope}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle wildcard or specific action
|
||||
let actions: AuthAction[];
|
||||
if (actionStr === '*') {
|
||||
// Wildcard means all CRUD actions
|
||||
actions = [
|
||||
AuthAction.CREATE_ANY,
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.DELETE_ANY
|
||||
];
|
||||
} else {
|
||||
// Convert action string to AuthAction enum
|
||||
// Scopes come in as 'read', 'create', etc. - convert to 'READ_ANY', 'CREATE_ANY'
|
||||
const enumValue = `${actionStr.toUpperCase()}_ANY` as AuthAction;
|
||||
if (Object.values(AuthAction).includes(enumValue)) {
|
||||
actions = [enumValue];
|
||||
} else {
|
||||
console.warn(`Unknown action in scope: ${scope}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge with existing permissions for the same resource
|
||||
const existing = permissions.find(p => p.resource === resource);
|
||||
if (existing) {
|
||||
actions.forEach(a => {
|
||||
if (!existing.actions.includes(a)) {
|
||||
existing.actions.push(a);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
permissions.push({ resource, actions });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { permissions, roles };
|
||||
}
|
||||
|
||||
export interface ApiKeyAuthorizationParams {
|
||||
name: string;
|
||||
description: string;
|
||||
scopes: string[];
|
||||
redirectUri: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface FormattedPermission {
|
||||
scope: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isRole: boolean;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Composable for handling API key authorization flow
|
||||
*/
|
||||
export function useApiKeyAuthorization(urlSearchParams?: URLSearchParams) {
|
||||
// Parse query parameters with SSR safety
|
||||
const params = urlSearchParams || (
|
||||
typeof window !== 'undefined'
|
||||
? new URLSearchParams(window.location.search)
|
||||
: new URLSearchParams()
|
||||
);
|
||||
|
||||
const authParams = ref<ApiKeyAuthorizationParams>({
|
||||
name: params.get('name') || 'Unknown Application',
|
||||
description: params.get('description') || '',
|
||||
scopes: (params.get('scopes') || '').split(',').filter(Boolean),
|
||||
redirectUri: params.get('redirect_uri') || '',
|
||||
state: params.get('state') || '',
|
||||
});
|
||||
|
||||
// Validate redirect URI - allow any valid URL including app URLs and custom schemes
|
||||
const isValidRedirectUri = (uri: string): boolean => {
|
||||
if (!uri) return false;
|
||||
try {
|
||||
// Just check if it's a valid URL format, don't restrict protocols or hosts
|
||||
new URL(uri);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Format scopes for display
|
||||
const formatPermissions = (scopes: string[]): FormattedPermission[] => {
|
||||
return scopes.map(scope => {
|
||||
if (scope.startsWith('role:')) {
|
||||
const role = scope.substring(5);
|
||||
return {
|
||||
scope,
|
||||
name: role.toUpperCase(),
|
||||
description: `Grant ${role} role access`,
|
||||
isRole: true,
|
||||
};
|
||||
} else {
|
||||
const [resource, action] = scope.split(':');
|
||||
if (resource && action) {
|
||||
const resourceName = resource.charAt(0).toUpperCase() + resource.slice(1);
|
||||
const actionDesc = action === '*'
|
||||
? 'Full'
|
||||
: action.charAt(0).toUpperCase() + action.slice(1);
|
||||
return {
|
||||
scope,
|
||||
name: `${resourceName} - ${actionDesc}`,
|
||||
description: `${actionDesc} access to ${resourceName}`,
|
||||
isRole: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
scope,
|
||||
name: scope,
|
||||
description: scope,
|
||||
isRole: false
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Use the shared convertScopesToFrontendFormPermissions function from @unraid/shared
|
||||
// This ensures consistent scope parsing across frontend and backend
|
||||
|
||||
// Build redirect URL with API key or error
|
||||
const buildCallbackUrl = (
|
||||
redirectUri: string,
|
||||
apiKey?: string,
|
||||
error?: string,
|
||||
state?: string
|
||||
): string => {
|
||||
try {
|
||||
const url = new URL(redirectUri);
|
||||
if (apiKey) {
|
||||
url.searchParams.set('api_key', apiKey);
|
||||
}
|
||||
if (error) {
|
||||
url.searchParams.set('error', error);
|
||||
}
|
||||
if (state) {
|
||||
url.searchParams.set('state', state);
|
||||
}
|
||||
return url.toString();
|
||||
} catch {
|
||||
throw new Error('Invalid redirect URI');
|
||||
}
|
||||
};
|
||||
|
||||
// Computed properties
|
||||
const formattedPermissions = computed(() =>
|
||||
formatPermissions(authParams.value.scopes)
|
||||
);
|
||||
|
||||
const hasValidRedirectUri = computed(() =>
|
||||
isValidRedirectUri(authParams.value.redirectUri)
|
||||
);
|
||||
|
||||
const defaultKeyName = computed(() => authParams.value.name);
|
||||
|
||||
return {
|
||||
authParams,
|
||||
formattedPermissions,
|
||||
hasValidRedirectUri,
|
||||
defaultKeyName,
|
||||
isValidRedirectUri,
|
||||
formatPermissions,
|
||||
convertScopesToPermissions,
|
||||
buildCallbackUrl,
|
||||
};
|
||||
}
|
||||
110
web/composables/useApiKeyPermissionPresets.ts
Normal file
110
web/composables/useApiKeyPermissionPresets.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { Resource } from '~/composables/gql/graphql.js';
|
||||
import { AuthAction } from '~/composables/gql/graphql.js';
|
||||
|
||||
export interface PermissionPreset {
|
||||
resources: Resource[];
|
||||
actions: AuthAction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission preset definitions matching the backend form schema
|
||||
*/
|
||||
export const PERMISSION_PRESETS: Record<string, PermissionPreset> = {
|
||||
docker_manager: {
|
||||
resources: ['DOCKER' as Resource],
|
||||
actions: [
|
||||
AuthAction.CREATE_ANY,
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.DELETE_ANY,
|
||||
],
|
||||
},
|
||||
vm_manager: {
|
||||
resources: ['VMS' as Resource],
|
||||
actions: [
|
||||
AuthAction.CREATE_ANY,
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.DELETE_ANY,
|
||||
],
|
||||
},
|
||||
monitoring: {
|
||||
resources: ['INFO', 'DASHBOARD', 'LOGS', 'ARRAY', 'DISK', 'NETWORK'] as Resource[],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
backup_manager: {
|
||||
resources: ['FLASH', 'SHARE'] as Resource[],
|
||||
actions: [
|
||||
AuthAction.CREATE_ANY,
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.DELETE_ANY,
|
||||
],
|
||||
},
|
||||
network_admin: {
|
||||
resources: ['NETWORK', 'SERVICES'] as Resource[],
|
||||
actions: [
|
||||
AuthAction.CREATE_ANY,
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.DELETE_ANY,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Composable for working with API key permission presets
|
||||
*/
|
||||
export function useApiKeyPermissionPresets() {
|
||||
/**
|
||||
* Get a specific preset by ID
|
||||
*/
|
||||
const getPreset = (presetId: string): PermissionPreset | undefined => {
|
||||
return PERMISSION_PRESETS[presetId];
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply a preset to custom permissions array
|
||||
*/
|
||||
const applyPreset = (
|
||||
presetId: string,
|
||||
existingPermissions: Array<{ resources: Resource[]; actions: AuthAction[] }> = []
|
||||
): Array<{ resources: Resource[]; actions: AuthAction[] }> => {
|
||||
const preset = getPreset(presetId);
|
||||
if (!preset) return existingPermissions;
|
||||
|
||||
return [...existingPermissions, {
|
||||
resources: preset.resources,
|
||||
actions: preset.actions,
|
||||
}];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all available preset IDs
|
||||
*/
|
||||
const getPresetIds = (): string[] => {
|
||||
return Object.keys(PERMISSION_PRESETS);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a human-readable label for a preset
|
||||
*/
|
||||
const getPresetLabel = (presetId: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
docker_manager: 'Docker Manager',
|
||||
vm_manager: 'VM Manager',
|
||||
monitoring: 'Monitoring (Read Only)',
|
||||
backup_manager: 'Backup Manager',
|
||||
network_admin: 'Network Administrator',
|
||||
};
|
||||
return labels[presetId] || presetId;
|
||||
};
|
||||
|
||||
return {
|
||||
getPreset,
|
||||
applyPreset,
|
||||
getPresetIds,
|
||||
getPresetLabel,
|
||||
PERMISSION_PRESETS,
|
||||
};
|
||||
}
|
||||
248
web/composables/useApiKeyScopeGroups.ts
Normal file
248
web/composables/useApiKeyScopeGroups.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { Resource, Role, AuthAction } from '~/composables/gql/graphql';
|
||||
|
||||
/**
|
||||
* Create a scope string from a role
|
||||
* @param role - The role to convert
|
||||
* @returns Scope string like "role:admin"
|
||||
*/
|
||||
export function roleToScope(role: Role | string): string {
|
||||
return `role:${role.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a scope string from resource and action
|
||||
* @param resource - The resource
|
||||
* @param action - The action (can be verb, AuthAction, or wildcard)
|
||||
* @returns Scope string like "docker:read" or "docker:*"
|
||||
*/
|
||||
export function permissionToScope(resource: Resource | string, action: string): string {
|
||||
return `${resource.toLowerCase()}:${action.toLowerCase()}`;
|
||||
}
|
||||
|
||||
export interface PermissionGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
permissions: Array<{
|
||||
resource: Resource;
|
||||
actions: AuthAction[];
|
||||
}>;
|
||||
}
|
||||
|
||||
// Permission groups that generate explicit permissions
|
||||
export const PERMISSION_GROUPS: PermissionGroup[] = [
|
||||
{
|
||||
id: 'docker_manager',
|
||||
name: 'Docker Manager',
|
||||
description: 'Full access to Docker containers and images',
|
||||
icon: 'docker',
|
||||
permissions: [
|
||||
{ resource: Resource.DOCKER, actions: [AuthAction.CREATE_ANY, AuthAction.READ_ANY, AuthAction.UPDATE_ANY, AuthAction.DELETE_ANY] },
|
||||
{ resource: Resource.ARRAY, actions: [AuthAction.READ_ANY] },
|
||||
{ resource: Resource.DISK, actions: [AuthAction.READ_ANY] },
|
||||
{ resource: Resource.NETWORK, actions: [AuthAction.READ_ANY] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vm_manager',
|
||||
name: 'VM Manager',
|
||||
description: 'Full access to virtual machines',
|
||||
icon: 'computer',
|
||||
permissions: [
|
||||
{ resource: Resource.VMS, actions: [AuthAction.CREATE_ANY, AuthAction.READ_ANY, AuthAction.UPDATE_ANY, AuthAction.DELETE_ANY] },
|
||||
{ resource: Resource.ARRAY, actions: [AuthAction.READ_ANY] },
|
||||
{ resource: Resource.DISK, actions: [AuthAction.READ_ANY] },
|
||||
{ resource: Resource.NETWORK, actions: [AuthAction.READ_ANY] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'backup_manager',
|
||||
name: 'Backup Manager',
|
||||
description: 'Access to manage backups and flash storage',
|
||||
icon: 'archive',
|
||||
permissions: [
|
||||
{ resource: Resource.FLASH, actions: [AuthAction.CREATE_ANY, AuthAction.READ_ANY, AuthAction.UPDATE_ANY, AuthAction.DELETE_ANY] },
|
||||
{ resource: Resource.ARRAY, actions: [AuthAction.READ_ANY] },
|
||||
{ resource: Resource.DISK, actions: [AuthAction.READ_ANY] },
|
||||
{ resource: Resource.SHARE, actions: [AuthAction.READ_ANY] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'network_admin',
|
||||
name: 'Network Admin',
|
||||
description: 'Full network configuration access',
|
||||
icon: 'network',
|
||||
permissions: [
|
||||
{ resource: Resource.NETWORK, actions: [AuthAction.CREATE_ANY, AuthAction.READ_ANY, AuthAction.UPDATE_ANY, AuthAction.DELETE_ANY] },
|
||||
{ resource: Resource.SERVICES, actions: [AuthAction.CREATE_ANY, AuthAction.READ_ANY, AuthAction.UPDATE_ANY, AuthAction.DELETE_ANY] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'monitoring',
|
||||
name: 'Monitoring',
|
||||
description: 'Read-only access for monitoring and dashboards',
|
||||
icon: 'chart-bar',
|
||||
permissions: [
|
||||
{ resource: Resource.DOCKER, actions: [AuthAction.READ_ANY] },
|
||||
{ resource: Resource.VMS, actions: [AuthAction.READ_ANY] },
|
||||
{ resource: Resource.ARRAY, actions: [AuthAction.READ_ANY] },
|
||||
{ resource: Resource.DISK, actions: [AuthAction.READ_ANY] },
|
||||
{ resource: Resource.NETWORK, actions: [AuthAction.READ_ANY] },
|
||||
{ resource: Resource.INFO, actions: [AuthAction.READ_ANY] },
|
||||
{ resource: Resource.DASHBOARD, actions: [AuthAction.READ_ANY] },
|
||||
{ resource: Resource.LOGS, actions: [AuthAction.READ_ANY] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Core roles with descriptions
|
||||
export interface RoleInfo {
|
||||
role: Role;
|
||||
name: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export const CORE_ROLES: RoleInfo[] = [
|
||||
{
|
||||
role: Role.ADMIN,
|
||||
name: 'Administrator',
|
||||
description: 'Full administrative access to all resources',
|
||||
icon: 'shield',
|
||||
},
|
||||
{
|
||||
role: Role.VIEWER,
|
||||
name: 'Read Only',
|
||||
description: 'Read-only access to all resources',
|
||||
icon: 'eye',
|
||||
},
|
||||
{
|
||||
role: Role.CONNECT,
|
||||
name: 'Connect',
|
||||
description: 'Internal role for Unraid Connect',
|
||||
icon: 'link',
|
||||
},
|
||||
{
|
||||
role: Role.GUEST,
|
||||
name: 'Guest',
|
||||
description: 'Basic profile access only',
|
||||
icon: 'user',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Convert permissions and roles to scope strings
|
||||
*/
|
||||
export function convertPermissionsToScopes(
|
||||
permissions: Array<{ resource: Resource; actions: AuthAction[] }>,
|
||||
roles: Role[]
|
||||
): string[] {
|
||||
const scopes: string[] = [];
|
||||
|
||||
// Convert permissions to scopes
|
||||
for (const perm of permissions) {
|
||||
|
||||
// Check if all CRUD actions are selected (means wildcard)
|
||||
const hasAllActions =
|
||||
perm.actions.includes(AuthAction.CREATE_ANY) &&
|
||||
perm.actions.includes(AuthAction.READ_ANY) &&
|
||||
perm.actions.includes(AuthAction.UPDATE_ANY) &&
|
||||
perm.actions.includes(AuthAction.DELETE_ANY);
|
||||
|
||||
if (hasAllActions) {
|
||||
scopes.push(permissionToScope(perm.resource, '*'));
|
||||
} else {
|
||||
// Add individual action scopes using shared utility
|
||||
for (const action of perm.actions) {
|
||||
scopes.push(permissionToScope(perm.resource, action));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert roles to scopes using shared utility
|
||||
for (const role of roles) {
|
||||
scopes.push(roleToScope(role));
|
||||
}
|
||||
|
||||
return scopes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an authorization URL with the given parameters
|
||||
*/
|
||||
export function buildAuthorizationUrl(
|
||||
baseUrl: string,
|
||||
appName: string,
|
||||
scopes: string[],
|
||||
options?: {
|
||||
appDescription?: string;
|
||||
redirectUri?: string;
|
||||
state?: string;
|
||||
}
|
||||
): string {
|
||||
const url = new URL(`${baseUrl}/ApiKeyAuthorize`);
|
||||
|
||||
url.searchParams.set('name', appName);
|
||||
url.searchParams.set('scopes', scopes.join(','));
|
||||
|
||||
if (options?.appDescription) {
|
||||
url.searchParams.set('description', options.appDescription);
|
||||
}
|
||||
if (options?.redirectUri) {
|
||||
url.searchParams.set('redirect_uri', options.redirectUri);
|
||||
}
|
||||
if (options?.state) {
|
||||
url.searchParams.set('state', options.state);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for API key scope groups functionality
|
||||
*/
|
||||
export function useApiKeyScopeGroups() {
|
||||
const permissionGroups = PERMISSION_GROUPS;
|
||||
const coreRoles = CORE_ROLES;
|
||||
|
||||
/**
|
||||
* Get role info by role
|
||||
*/
|
||||
const getRoleInfo = (role: Role): RoleInfo | undefined => {
|
||||
return coreRoles.find(r => r.role === role);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get permission group by ID
|
||||
*/
|
||||
const getPermissionGroup = (id: string): PermissionGroup | undefined => {
|
||||
return permissionGroups.find(g => g.id === id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert permission group to explicit permissions
|
||||
*/
|
||||
const getPermissionsFromGroup = (groupId: string): Array<{ resource: Resource; actions: AuthAction[] }> => {
|
||||
const group = getPermissionGroup(groupId);
|
||||
return group ? group.permissions : [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all available core roles
|
||||
*/
|
||||
const getAvailableRoles = (): Role[] => {
|
||||
return coreRoles.map(r => r.role);
|
||||
};
|
||||
|
||||
return {
|
||||
permissionGroups,
|
||||
coreRoles,
|
||||
getRoleInfo,
|
||||
getPermissionGroup,
|
||||
getPermissionsFromGroup,
|
||||
getAvailableRoles,
|
||||
convertPermissionsToScopes,
|
||||
buildAuthorizationUrl,
|
||||
};
|
||||
}
|
||||
139
web/composables/useAuthorizationLink.ts
Normal file
139
web/composables/useAuthorizationLink.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import {
|
||||
decodeScopesToPermissions,
|
||||
scopesToFormData,
|
||||
buildCallbackUrl as buildUrl,
|
||||
generateAuthorizationUrl as generateUrl
|
||||
} from '~/utils/authorizationScopes.js';
|
||||
|
||||
export interface ApiKeyAuthorizationParams {
|
||||
name: string;
|
||||
description: string;
|
||||
scopes: string[];
|
||||
redirectUri: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
// Re-export types from utils for convenience
|
||||
export type {
|
||||
AuthorizationFormData,
|
||||
AuthorizationLinkParams,
|
||||
RawPermission
|
||||
} from '~/utils/authorizationScopes.js';
|
||||
|
||||
// Re-export functions for direct use
|
||||
export {
|
||||
encodePermissionsToScopes,
|
||||
decodeScopesToPermissions,
|
||||
extractActionVerb,
|
||||
generateAuthorizationUrl,
|
||||
buildCallbackUrl
|
||||
} from '~/utils/authorizationScopes.js';
|
||||
|
||||
/**
|
||||
* Composable for authorization link handling with reactive state
|
||||
*/
|
||||
export function useAuthorizationLink(urlSearchParams?: URLSearchParams) {
|
||||
// Parse query parameters with SSR safety
|
||||
const params = urlSearchParams || (
|
||||
typeof window !== 'undefined'
|
||||
? new URLSearchParams(window.location.search)
|
||||
: new URLSearchParams()
|
||||
);
|
||||
|
||||
// Parse authorization parameters from URL
|
||||
const authParams = ref<ApiKeyAuthorizationParams>({
|
||||
name: params.get('name') || 'Unknown Application',
|
||||
description: params.get('description') || '',
|
||||
scopes: (params.get('scopes') || '').split(',').filter(Boolean),
|
||||
redirectUri: params.get('redirect_uri') || '',
|
||||
state: params.get('state') || '',
|
||||
});
|
||||
|
||||
// Convert to form data structure with grouped permissions
|
||||
const formData = computed(() => {
|
||||
return scopesToFormData(
|
||||
authParams.value.scopes,
|
||||
authParams.value.name,
|
||||
authParams.value.description
|
||||
);
|
||||
});
|
||||
|
||||
// Decode scopes to permissions and roles
|
||||
const decodedPermissions = computed(() => {
|
||||
return decodeScopesToPermissions(authParams.value.scopes);
|
||||
});
|
||||
|
||||
// Validate redirect URI
|
||||
const hasValidRedirectUri = computed(() => {
|
||||
const uri = authParams.value.redirectUri;
|
||||
if (!uri) return false;
|
||||
try {
|
||||
new URL(uri);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Get display name (remove " API Key" suffix if present)
|
||||
const displayAppName = computed(() => {
|
||||
const name = authParams.value.name;
|
||||
if (name.endsWith(' API Key')) {
|
||||
return name.slice(0, -8);
|
||||
}
|
||||
return name;
|
||||
});
|
||||
|
||||
// Check if there are any permissions
|
||||
const hasPermissions = computed(() => {
|
||||
return authParams.value.scopes && authParams.value.scopes.length > 0;
|
||||
});
|
||||
|
||||
// Get permissions summary for display
|
||||
const permissionsSummary = computed(() => {
|
||||
const scopes = authParams.value.scopes;
|
||||
if (!scopes || scopes.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const roleCount = scopes.filter(scope => scope.startsWith('role:')).length;
|
||||
const permissionCount = scopes.filter(scope => !scope.startsWith('role:')).length;
|
||||
|
||||
const summary: string[] = [];
|
||||
if (roleCount > 0) {
|
||||
summary.push(`${roleCount} role(s)`);
|
||||
}
|
||||
if (permissionCount > 0) {
|
||||
summary.push(`${permissionCount} permission(s)`);
|
||||
}
|
||||
|
||||
return summary.join(', ');
|
||||
});
|
||||
|
||||
// Wrapper functions that use the reactive state
|
||||
const buildCallbackUrl = (apiKey?: string, error?: string) => {
|
||||
return buildUrl(authParams.value.redirectUri, apiKey, error, authParams.value.state);
|
||||
};
|
||||
|
||||
const generateAuthorizationUrl = generateUrl;
|
||||
|
||||
return {
|
||||
// Parsed params
|
||||
authParams,
|
||||
|
||||
// Decoded data
|
||||
decodedPermissions,
|
||||
formData,
|
||||
|
||||
// Display helpers
|
||||
displayAppName,
|
||||
hasPermissions,
|
||||
permissionsSummary,
|
||||
hasValidRedirectUri,
|
||||
|
||||
// URL generation functions
|
||||
generateAuthorizationUrl,
|
||||
buildCallbackUrl,
|
||||
};
|
||||
}
|
||||
42
web/composables/useClipboardWithToast.ts
Normal file
42
web/composables/useClipboardWithToast.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { useToast } from '@unraid/ui';
|
||||
|
||||
/**
|
||||
* Composable for clipboard operations with toast notifications
|
||||
*/
|
||||
export function useClipboardWithToast() {
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
const toast = useToast();
|
||||
|
||||
/**
|
||||
* Copy text and show toast
|
||||
* @param text - The text to copy
|
||||
* @param successMessage - Optional custom success message
|
||||
*/
|
||||
const copyWithNotification = async (
|
||||
text: string,
|
||||
successMessage: string = 'Copied to clipboard'
|
||||
): Promise<boolean> => {
|
||||
if (!isSupported.value) {
|
||||
console.warn('Clipboard API is not supported');
|
||||
toast.error('Clipboard not supported');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await copy(text);
|
||||
toast.success(successMessage);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
toast.error('Failed to copy to clipboard');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
copyWithNotification,
|
||||
copied,
|
||||
isSupported,
|
||||
};
|
||||
}
|
||||
@@ -1,44 +1,15 @@
|
||||
<template>
|
||||
<div class="text-black bg-white dark:text-white dark:bg-black">
|
||||
<ClientOnly>
|
||||
<div class="flex flex-row items-center justify-center gap-6 p-6 bg-white dark:bg-zinc-800">
|
||||
<template v-for="route in routes" :key="route.path">
|
||||
<NuxtLink
|
||||
:to="route.path"
|
||||
class="underline hover:no-underline focus:no-underline"
|
||||
active-class="text-orange"
|
||||
>
|
||||
{{ formatRouteName(route.name) }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<ModalsCe />
|
||||
</div>
|
||||
<slot />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue';
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
import { ClientOnly, NuxtLink } from '#components';
|
||||
import { Badge, Toaster } from '@unraid/ui';
|
||||
|
||||
import ColorSwitcherCe from '~/components/ColorSwitcher.ce.vue';
|
||||
import DummyServerSwitcher from '~/components/DummyServerSwitcher.vue';
|
||||
import ModalsCe from '~/components/Modals.ce.vue';
|
||||
import { useThemeStore } from '~/store/theme';
|
||||
|
||||
const router = useRouter();
|
||||
const themeStore = useThemeStore();
|
||||
const { theme } = storeToRefs(themeStore);
|
||||
|
||||
// Watch for theme changes (satisfies linter by using theme)
|
||||
watch(
|
||||
theme,
|
||||
() => {
|
||||
// Theme is being watched for reactivity
|
||||
console.debug('Theme changed:', theme.value);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const routes = computed(() => {
|
||||
return router
|
||||
@@ -47,10 +18,12 @@ const routes = computed(() => {
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
});
|
||||
|
||||
function formatRouteName(name) {
|
||||
function formatRouteName(name: string | symbol | undefined) {
|
||||
if (!name) return 'Home';
|
||||
// Convert symbols to strings if needed
|
||||
const nameStr = typeof name === 'symbol' ? name.toString() : name;
|
||||
// Convert route names like "web-components" to "Web Components"
|
||||
return name
|
||||
return nameStr
|
||||
.replace(/-/g, ' ')
|
||||
.split(' ')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
@@ -58,7 +31,38 @@ function formatRouteName(name) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style >
|
||||
<template>
|
||||
<div class="text-black bg-white dark:text-white dark:bg-black">
|
||||
<ClientOnly>
|
||||
<div class="bg-white dark:bg-zinc-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 p-3 md:p-4">
|
||||
<nav class="flex flex-wrap items-center gap-2">
|
||||
<template v-for="route in routes" :key="route.path">
|
||||
<NuxtLink :to="route.path">
|
||||
<Badge
|
||||
:variant="router.currentRoute.value.path === route.path ? 'orange' : 'gray'"
|
||||
size="xs"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
{{ formatRouteName(route.name) }}
|
||||
</Badge>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</nav>
|
||||
<ModalsCe />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row items-center justify-center gap-3 p-3 md:p-4 bg-gray-50 dark:bg-zinc-900 border-b border-gray-200 dark:border-gray-700">
|
||||
<DummyServerSwitcher />
|
||||
<ColorSwitcherCe />
|
||||
</div>
|
||||
<slot />
|
||||
<Toaster />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Import theme styles */
|
||||
@import '~/assets/main.css';
|
||||
</style>
|
||||
|
||||
@@ -239,6 +239,7 @@ export default defineNuxtConfig({
|
||||
createWebComponentTag('UnraidThemeSwitcher', '@/components/ThemeSwitcher.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
createWebComponentTag('UnraidApiKeyManager', '@/components/ApiKeyPage.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
createWebComponentTag('UnraidDevModalTest', '@/components/DevModalTest.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
createWebComponentTag('UnraidApiKeyAuthorize', '@/components/ApiKeyAuthorize.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"serve": "NODE_ENV=production PORT=${PORT:-4321} node .output/server/index.mjs",
|
||||
"// Build": "",
|
||||
"prebuild:dev": "pnpm predev",
|
||||
"build:dev": "nuxi build --dotenv .env.staging && pnpm run manifest-ts && pnpm run deploy-to-unraid:dev",
|
||||
"build:dev": "nuxi build --dotenv .env.production && pnpm run manifest-ts && pnpm run deploy-to-unraid:dev",
|
||||
"build:webgui": "pnpm run type-check && nuxi build --dotenv .env.production && pnpm run manifest-ts && pnpm run copy-to-webgui-repo",
|
||||
"build": "NODE_ENV=production nuxi build --dotenv .env.production && pnpm run manifest-ts && pnpm run validate:css",
|
||||
"prebuild:watch": "pnpm predev",
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useUpdateOsStore } from '~/store/updateOs';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import UpdateOsChangelogModal from '~/components/UpdateOs/ChangelogModal.vue';
|
||||
import ColorSwitcherCe from '~/components/ColorSwitcher.ce.vue';
|
||||
|
||||
const updateOsStore = useUpdateOsStore();
|
||||
const { changelogModalVisible } = storeToRefs(updateOsStore);
|
||||
@@ -79,7 +78,6 @@ function showChangelogFromLocalhost() {
|
||||
<h1 class="text-2xl font-bold mb-6">Changelog</h1>
|
||||
<UpdateOsChangelogModal :t="t" :open="changelogModalVisible" />
|
||||
<div class="mb-6 flex flex-col gap-4">
|
||||
<ColorSwitcherCe />
|
||||
<div class="max-w-md flex flex-col gap-4">
|
||||
<button
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
|
||||
@@ -5,17 +5,14 @@ import { storeToRefs } from 'pinia';
|
||||
import { ExclamationTriangleIcon } from '@heroicons/vue/24/solid';
|
||||
import { BrandButton, Toaster } from '@unraid/ui';
|
||||
import { UButton } from '#components';
|
||||
import { useHead } from '#imports';
|
||||
import { useDummyServerStore } from '~/_data/serverState';
|
||||
import AES from 'crypto-js/aes';
|
||||
|
||||
import type { SendPayloads } from '@unraid/shared-callbacks';
|
||||
|
||||
import WelcomeModalCe from '~/components/Activation/WelcomeModal.ce.vue';
|
||||
import ColorSwitcherCe from '~/components/ColorSwitcher.ce.vue';
|
||||
import ConnectSettingsCe from '~/components/ConnectSettings/ConnectSettings.ce.vue';
|
||||
import DowngradeOsCe from '~/components/DowngradeOs.ce.vue';
|
||||
import DummyServerSwitcher from '~/components/DummyServerSwitcher.vue';
|
||||
import HeaderOsVersionCe from '~/components/HeaderOsVersion.ce.vue';
|
||||
import LogViewerCe from '~/components/Logs/LogViewer.ce.vue';
|
||||
import ModalsCe from '~/components/Modals.ce.vue';
|
||||
@@ -27,11 +24,6 @@ import { useThemeStore } from '~/store/theme';
|
||||
|
||||
const serverStore = useDummyServerStore();
|
||||
const { serverState } = storeToRefs(serverStore);
|
||||
const { theme } = storeToRefs(useThemeStore());
|
||||
|
||||
useHead({
|
||||
meta: [{ name: 'viewport', content: 'width=1300' }],
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.cookie = 'unraid_session_cookie=mockusersession';
|
||||
@@ -89,9 +81,9 @@ onMounted(() => {
|
||||
'forUpc'
|
||||
);
|
||||
});
|
||||
|
||||
const bannerImage = ref<string>('none');
|
||||
|
||||
const { theme } = storeToRefs(useThemeStore());
|
||||
watch(
|
||||
theme,
|
||||
(newTheme) => {
|
||||
@@ -103,6 +95,7 @@ watch(
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -110,8 +103,6 @@ watch(
|
||||
<div class="pb-12 mx-auto">
|
||||
<client-only>
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<DummyServerSwitcher />
|
||||
<ColorSwitcherCe />
|
||||
<h2 class="text-xl font-semibold font-mono">Vue Components</h2>
|
||||
<h3 class="text-lg font-semibold font-mono">UserProfileCe</h3>
|
||||
<header
|
||||
|
||||
7
web/pages/tools/apikeyauthorize.vue
Normal file
7
web/pages/tools/apikeyauthorize.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import ApiKeyAuthorize from '~/components/ApiKeyAuthorize.ce.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ApiKeyAuthorize />
|
||||
</template>
|
||||
@@ -1,26 +1,62 @@
|
||||
import { ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
import type { ApiKeyFragment, ApiKeyWithKeyFragment } from '~/composables/gql/graphql';
|
||||
import type { ApiKeyFragment } from '~/composables/gql/graphql.js';
|
||||
import type { AuthorizationFormData } from '~/utils/authorizationScopes';
|
||||
|
||||
import '~/store/globalPinia';
|
||||
import '~/store/globalPinia.js';
|
||||
|
||||
export const useApiKeyStore = defineStore('apiKey', () => {
|
||||
const modalVisible = ref(false);
|
||||
const editingKey = ref<ApiKeyFragment | null>(null);
|
||||
const createdKey = ref<ApiKeyWithKeyFragment | null>(null);
|
||||
const createdKey = ref<ApiKeyFragment | null>(null);
|
||||
|
||||
// Authorization mode state
|
||||
const isAuthorizationMode = ref(false);
|
||||
const authorizationData = ref<{
|
||||
name: string;
|
||||
description: string;
|
||||
scopes: string[];
|
||||
formData?: AuthorizationFormData;
|
||||
onAuthorize?: (apiKey: string) => void;
|
||||
} | null>(null);
|
||||
|
||||
function showModal(key: ApiKeyFragment | null = null) {
|
||||
editingKey.value = key;
|
||||
modalVisible.value = true;
|
||||
// Reset authorization mode if editing
|
||||
if (key) {
|
||||
isAuthorizationMode.value = false;
|
||||
authorizationData.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function hideModal() {
|
||||
modalVisible.value = false;
|
||||
editingKey.value = null;
|
||||
isAuthorizationMode.value = false;
|
||||
authorizationData.value = null;
|
||||
}
|
||||
|
||||
function setAuthorizationMode(
|
||||
name: string,
|
||||
description: string,
|
||||
scopes: string[],
|
||||
onAuthorize?: (apiKey: string) => void,
|
||||
formData?: AuthorizationFormData
|
||||
) {
|
||||
isAuthorizationMode.value = true;
|
||||
authorizationData.value = {
|
||||
name,
|
||||
description,
|
||||
scopes,
|
||||
formData,
|
||||
onAuthorize,
|
||||
};
|
||||
editingKey.value = null;
|
||||
}
|
||||
|
||||
function setCreatedKey(key: ApiKeyWithKeyFragment | null) {
|
||||
function setCreatedKey(key: ApiKeyFragment | null) {
|
||||
createdKey.value = key;
|
||||
}
|
||||
|
||||
@@ -32,8 +68,11 @@ export const useApiKeyStore = defineStore('apiKey', () => {
|
||||
modalVisible,
|
||||
editingKey,
|
||||
createdKey,
|
||||
isAuthorizationMode,
|
||||
authorizationData,
|
||||
showModal,
|
||||
hideModal,
|
||||
setAuthorizationMode,
|
||||
setCreatedKey,
|
||||
clearCreatedKey,
|
||||
};
|
||||
|
||||
2
web/utils/authorizationLink.ts
Normal file
2
web/utils/authorizationLink.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Re-export everything from authorizationScopes
|
||||
export * from './authorizationScopes.js';
|
||||
310
web/utils/authorizationScopes.ts
Normal file
310
web/utils/authorizationScopes.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { AuthAction, Resource, Role } from '~/composables/gql/graphql.js';
|
||||
|
||||
export interface RawPermission {
|
||||
resource: string;
|
||||
actions: AuthAction[];
|
||||
}
|
||||
|
||||
export interface AuthorizationLinkParams {
|
||||
appName: string;
|
||||
appDescription?: string;
|
||||
roles?: Role[];
|
||||
rawPermissions?: RawPermission[];
|
||||
redirectUrl?: string;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
export interface AuthorizationFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
roles?: Role[];
|
||||
customPermissions?: Array<{
|
||||
resources: Resource[];
|
||||
actions: AuthAction[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert AuthAction enum to simple action verb
|
||||
* E.g., CREATE_ANY -> create, READ_OWN -> read
|
||||
* @deprecated Use extractActionWithPossession for possession-aware extraction
|
||||
*/
|
||||
export function extractActionVerb(action: AuthAction | string): string {
|
||||
const actionStr = String(action);
|
||||
|
||||
// Handle enum values like CREATE_ANY, READ_ANY
|
||||
if (actionStr.includes('_')) {
|
||||
return actionStr.split('_')[0].toLowerCase();
|
||||
}
|
||||
|
||||
// Handle scope format like 'read:any'
|
||||
if (actionStr.includes(':')) {
|
||||
return actionStr.split(':')[0].toLowerCase();
|
||||
}
|
||||
|
||||
// Already just a verb
|
||||
return actionStr.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert AuthAction enum to lowercase format with possession
|
||||
* E.g., CREATE_ANY -> create_any, READ_OWN -> read_own
|
||||
*/
|
||||
export function extractActionWithPossession(action: AuthAction | string): string {
|
||||
// Just convert the full enum value to lowercase
|
||||
return String(action).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode permissions into efficient scope strings
|
||||
* Groups resources with identical action sets using a compact format:
|
||||
* - Individual: "docker:read_any", "vms:update_own"
|
||||
* - Grouped: "docker+vms:read_any+update_own" (resources sharing same actions)
|
||||
*/
|
||||
export function encodePermissionsToScopes(roles: Role[] = [], rawPermissions: RawPermission[] = []): string[] {
|
||||
const scopes: string[] = [];
|
||||
|
||||
// Add role scopes
|
||||
for (const role of roles) {
|
||||
scopes.push(`role:${role.toLowerCase()}`);
|
||||
}
|
||||
|
||||
// Skip empty permissions
|
||||
const validPermissions = rawPermissions.filter(perm => perm.actions && perm.actions.length > 0);
|
||||
|
||||
// First, merge all permissions for the same resource
|
||||
const resourceActionsMap = new Map<string, Set<string>>();
|
||||
|
||||
for (const perm of validPermissions) {
|
||||
const resourceName = perm.resource.toLowerCase();
|
||||
if (!resourceActionsMap.has(resourceName)) {
|
||||
resourceActionsMap.set(resourceName, new Set<string>());
|
||||
}
|
||||
|
||||
const actionsSet = resourceActionsMap.get(resourceName)!;
|
||||
for (const action of perm.actions) {
|
||||
actionsSet.add(extractActionWithPossession(action));
|
||||
}
|
||||
}
|
||||
|
||||
// Now group resources by their action sets for efficient encoding
|
||||
const actionGroups = new Map<string, Set<string>>();
|
||||
|
||||
for (const [resourceName, actionsSet] of resourceActionsMap) {
|
||||
const actionsWithPossession = Array.from(actionsSet).sort();
|
||||
|
||||
// Create a key from the sorted actions
|
||||
const actionKey = actionsWithPossession.join('+');
|
||||
|
||||
if (!actionGroups.has(actionKey)) {
|
||||
actionGroups.set(actionKey, new Set<string>());
|
||||
}
|
||||
actionGroups.get(actionKey)!.add(resourceName);
|
||||
}
|
||||
|
||||
// Generate efficient scopes
|
||||
for (const [actions, resourcesSet] of actionGroups.entries()) {
|
||||
// Convert Set to sorted array for consistent output
|
||||
const resources = Array.from(resourcesSet).sort();
|
||||
|
||||
if (resources.length === 1) {
|
||||
// Single resource: "docker:read_any+update_own"
|
||||
scopes.push(`${resources[0]}:${actions}`);
|
||||
} else {
|
||||
// Multiple resources with same actions: "docker+vms:read_any+update_own"
|
||||
scopes.push(`${resources.join('+')}:${actions}`);
|
||||
}
|
||||
}
|
||||
|
||||
return scopes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode scope strings back to permissions and roles
|
||||
* Supports both individual and grouped formats:
|
||||
* - "role:admin" -> role
|
||||
* - "docker:read_any" -> single permission with possession
|
||||
* - "docker+vms:read_any+update_own" -> multiple permissions with same actions
|
||||
* - "docker:*" -> wildcard (all CRUD actions)
|
||||
*/
|
||||
export function decodeScopesToPermissions(scopes: string[]): {
|
||||
permissions: Array<{ resource: Resource; actions: AuthAction[] }>,
|
||||
roles: Role[]
|
||||
} {
|
||||
const roles: Role[] = [];
|
||||
// Use a map to merge permissions for the same resource
|
||||
const resourcePermissions = new Map<Resource, Set<AuthAction>>();
|
||||
|
||||
for (const scope of scopes) {
|
||||
if (!scope) continue;
|
||||
|
||||
if (scope.startsWith('role:')) {
|
||||
// Handle role scope
|
||||
const roleStr = scope.substring(5).toUpperCase();
|
||||
if (Object.values(Role).includes(roleStr as Role)) {
|
||||
roles.push(roleStr as Role);
|
||||
}
|
||||
} else {
|
||||
// Handle permission scope (potentially grouped)
|
||||
const [resourcesPart, actionsPart] = scope.split(':');
|
||||
if (!resourcesPart || !actionsPart) continue;
|
||||
|
||||
// Split grouped resources (docker+vms)
|
||||
const resourceNames = resourcesPart.split('+');
|
||||
|
||||
// Parse actions
|
||||
let actions: AuthAction[];
|
||||
if (actionsPart === '*') {
|
||||
// Wildcard: all CRUD actions
|
||||
actions = [
|
||||
AuthAction.CREATE_ANY,
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.DELETE_ANY
|
||||
];
|
||||
} else {
|
||||
// Split grouped actions (read_any+update_own)
|
||||
const actionParts = actionsPart.split('+');
|
||||
actions = actionParts
|
||||
.map(actionStr => {
|
||||
// Convert to AuthAction enum (e.g., "read_any" -> "READ_ANY")
|
||||
const enumValue = actionStr.toUpperCase() as AuthAction;
|
||||
return Object.values(AuthAction).includes(enumValue) ? enumValue : null;
|
||||
})
|
||||
.filter((action): action is AuthAction => action !== null);
|
||||
}
|
||||
|
||||
// Add actions to each resource
|
||||
for (const resourceName of resourceNames) {
|
||||
const resourceUpper = resourceName.toUpperCase();
|
||||
const resource = Object.values(Resource).find(r => r === resourceUpper) as Resource;
|
||||
|
||||
if (resource && actions.length > 0) {
|
||||
if (!resourcePermissions.has(resource)) {
|
||||
resourcePermissions.set(resource, new Set());
|
||||
}
|
||||
actions.forEach(action => resourcePermissions.get(resource)!.add(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map to array of permissions
|
||||
const permissions = Array.from(resourcePermissions.entries()).map(([resource, actionsSet]) => ({
|
||||
resource,
|
||||
actions: Array.from(actionsSet)
|
||||
}));
|
||||
|
||||
return { permissions, roles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert scopes to form data structure with grouped permissions
|
||||
*/
|
||||
export function scopesToFormData(scopes: string[], name: string, description: string = ''): AuthorizationFormData {
|
||||
const { permissions, roles } = decodeScopesToPermissions(scopes);
|
||||
|
||||
// Group permissions by their action sets for the form
|
||||
const permissionGroups = new Map<string, { resources: Set<Resource>; actions: Set<AuthAction> }>();
|
||||
|
||||
for (const perm of permissions) {
|
||||
// Create a key based on sorted actions to group resources
|
||||
const actionKey = [...perm.actions].sort().join(',');
|
||||
|
||||
if (!permissionGroups.has(actionKey)) {
|
||||
permissionGroups.set(actionKey, {
|
||||
resources: new Set<Resource>(),
|
||||
actions: new Set<AuthAction>(perm.actions),
|
||||
});
|
||||
}
|
||||
|
||||
permissionGroups.get(actionKey)!.resources.add(perm.resource);
|
||||
}
|
||||
|
||||
// Convert to array format expected by form
|
||||
const customPermissions = Array.from(permissionGroups.values()).map(group => ({
|
||||
resources: Array.from(group.resources),
|
||||
actions: Array.from(group.actions),
|
||||
}));
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
roles,
|
||||
customPermissions: customPermissions.length > 0 ? customPermissions : [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate authorization URL from params
|
||||
*/
|
||||
export function generateAuthorizationUrl(params: AuthorizationLinkParams): string {
|
||||
const {
|
||||
appName,
|
||||
appDescription,
|
||||
roles = [],
|
||||
rawPermissions = [],
|
||||
redirectUrl,
|
||||
state
|
||||
} = params;
|
||||
|
||||
// Compute redirectUrl with SSR safety
|
||||
const computedRedirectUrl = redirectUrl || (
|
||||
typeof window !== 'undefined'
|
||||
? window.location.origin + '/api-key-created'
|
||||
: '/api-key-created'
|
||||
);
|
||||
|
||||
const scopes = encodePermissionsToScopes(roles, rawPermissions);
|
||||
|
||||
// Build URL parameters
|
||||
const urlParams = new URLSearchParams({
|
||||
name: appName,
|
||||
redirect_uri: computedRedirectUrl,
|
||||
scopes: scopes.join(','),
|
||||
});
|
||||
|
||||
if (appDescription) {
|
||||
urlParams.set('description', appDescription);
|
||||
}
|
||||
|
||||
if (state) {
|
||||
urlParams.set('state', state);
|
||||
}
|
||||
|
||||
// Use current origin for the authorization URL with SSR safety
|
||||
const baseUrl = typeof window !== 'undefined'
|
||||
? `${window.location.origin}/Tools/ApiKeyAuthorize`
|
||||
: '/Tools/ApiKeyAuthorize';
|
||||
|
||||
return `${baseUrl}?${urlParams.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build callback URL with API key or error
|
||||
*/
|
||||
export function buildCallbackUrl(
|
||||
redirectUri: string,
|
||||
apiKey?: string,
|
||||
error?: string,
|
||||
state?: string
|
||||
): string {
|
||||
try {
|
||||
const url = new URL(redirectUri);
|
||||
if (apiKey) {
|
||||
url.searchParams.set('api_key', apiKey);
|
||||
}
|
||||
if (error) {
|
||||
url.searchParams.set('error', error);
|
||||
}
|
||||
if (state) {
|
||||
url.searchParams.set('state', state);
|
||||
}
|
||||
return url.toString();
|
||||
} catch {
|
||||
throw new Error('Invalid redirect URI');
|
||||
}
|
||||
}
|
||||
|
||||
// Alias for backward compatibility
|
||||
export const generateScopes = encodePermissionsToScopes;
|
||||
Reference in New Issue
Block a user