From d37dc3bce28bad1c893ae7eff96ca5ffd9177648 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 23 May 2025 10:12:26 -0700 Subject: [PATCH] feat: API key management (#1407) ## Summary by CodeRabbit - **New Features** - Added a full-featured API key management UI, including creation, listing, and deletion of API keys with customizable roles and permissions. - Introduced a new page for API key management. - Accordion UI components are now available for enhanced interface interactions. - API now provides queries for possible API key roles and permissions. - **Improvements** - API key-related mutations are now grouped under a single field, improving organization and usability. - Permissions can be assigned directly to API keys, not just roles. - **Bug Fixes** - Validation updated to require at least one role or permission when creating an API key. - **Documentation** - Updated and added rules and configuration documentation for code generation and testing. - **Tests** - Added and updated tests for new API key mutation logic; removed obsolete tests for deprecated mutations. --- .cursor/rules/api-rules.mdc | 1 + .cursor/rules/web-graphql.mdc | 9 + api/dev/states/myservers.cfg | 2 +- api/generated-schema.graphql | 88 +++++---- .../unraid-api/auth/api-key.service.spec.ts | 2 +- api/src/unraid-api/auth/api-key.service.ts | 6 +- .../graph/resolvers/api-key/api-key.model.ts | 8 + .../graph/resolvers/api-key/api-key.module.ts | 7 +- .../api-key/api-key.mutation.spec.ts | 165 ++++++++++++++++ .../resolvers/api-key/api-key.mutation.ts | 80 ++++++++ .../api-key/api-key.resolver.spec.ts | 61 ------ .../resolvers/api-key/api-key.resolver.ts | 74 ++----- .../resolvers/mutation/mutation.model.ts | 8 + .../resolvers/mutation/mutation.resolver.ts | 14 +- .../graph/resolvers/resolvers.module.ts | 6 +- pnpm-lock.yaml | 2 +- unraid-ui/package.json | 2 +- .../components/common/accordion/Accordion.vue | 19 ++ .../common/accordion/AccordionContent.vue | 21 ++ .../common/accordion/AccordionItem.vue | 18 ++ .../common/accordion/AccordionTrigger.vue | 30 +++ .../src/components/common/accordion/index.ts | 4 + unraid-ui/src/index.ts | 10 + unraid-ui/tailwind.config.ts | 23 +++ web/components/ApiKey/ApiKeyCreate.vue | 183 ++++++++++++++++++ web/components/ApiKey/ApiKeyManager.vue | 125 ++++++++++++ web/components/ApiKey/apikey.query.ts | 55 ++++++ web/composables/gql/gql.ts | 24 +++ web/composables/gql/graphql.ts | 92 +++++++-- web/pages/apikey.vue | 10 + 30 files changed, 965 insertions(+), 184 deletions(-) create mode 100644 .cursor/rules/web-graphql.mdc create mode 100644 api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.spec.ts create mode 100644 api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.ts create mode 100644 unraid-ui/src/components/common/accordion/Accordion.vue create mode 100644 unraid-ui/src/components/common/accordion/AccordionContent.vue create mode 100644 unraid-ui/src/components/common/accordion/AccordionItem.vue create mode 100644 unraid-ui/src/components/common/accordion/AccordionTrigger.vue create mode 100644 unraid-ui/src/components/common/accordion/index.ts create mode 100644 web/components/ApiKey/ApiKeyCreate.vue create mode 100644 web/components/ApiKey/ApiKeyManager.vue create mode 100644 web/components/ApiKey/apikey.query.ts create mode 100644 web/pages/apikey.vue diff --git a/.cursor/rules/api-rules.mdc b/.cursor/rules/api-rules.mdc index f9f84ce91..1efb2f8e5 100644 --- a/.cursor/rules/api-rules.mdc +++ b/.cursor/rules/api-rules.mdc @@ -8,5 +8,6 @@ alwaysApply: false * always run scripts from api/package.json unless requested * prefer adding new files to the nest repo located at api/src/unraid-api/ instead of the legacy code * Test suite is VITEST, do not use jest +pnpm --filter ./api test * Prefer to not mock simple dependencies diff --git a/.cursor/rules/web-graphql.mdc b/.cursor/rules/web-graphql.mdc new file mode 100644 index 000000000..7cf9e8988 --- /dev/null +++ b/.cursor/rules/web-graphql.mdc @@ -0,0 +1,9 @@ +--- +description: +globs: web/**/* +alwaysApply: false +--- +* Always run `pnpm codegen` for GraphQL code generation in the web directory +* GraphQL queries must be placed in `.query.ts` files +* GraphQL mutations must be placed in `.mutation.ts` files +* All GraphQL under `web/` and follow this naming convention diff --git a/api/dev/states/myservers.cfg b/api/dev/states/myservers.cfg index 103160cf3..db7fa0954 100644 --- a/api/dev/states/myservers.cfg +++ b/api/dev/states/myservers.cfg @@ -1,5 +1,5 @@ [api] -version="4.7.0" +version="4.8.0" extraOrigins="https://google.com,https://test.com" [local] sandbox="yes" diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index cb565af0e..6938cf1eb 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -883,6 +883,52 @@ type VmMutations { reset(id: PrefixedID!): Boolean! } +"""API Key related mutations""" +type ApiKeyMutations { + """Create an API key""" + create(input: CreateApiKeyInput!): ApiKeyWithSecret! + + """Add a role to an API key""" + addRole(input: AddRoleForApiKeyInput!): Boolean! + + """Remove a role from an API key""" + removeRole(input: RemoveRoleFromApiKeyInput!): Boolean! + + """Delete one or more API keys""" + delete(input: DeleteApiKeyInput!): Boolean! +} + +input CreateApiKeyInput { + name: String! + description: String + roles: [Role!] + permissions: [AddPermissionInput!] + + """ + This will replace the existing key if one already exists with the same name, otherwise returns the existing key + """ + overwrite: Boolean +} + +input AddPermissionInput { + resource: Resource! + actions: [String!]! +} + +input AddRoleForApiKeyInput { + apiKeyId: PrefixedID! + role: Role! +} + +input RemoveRoleFromApiKeyInput { + apiKeyId: PrefixedID! + role: Role! +} + +input DeleteApiKeyInput { + ids: [PrefixedID!]! +} + """ Parity check related mutations, WIP, response types and functionaliy will change """ @@ -1455,8 +1501,6 @@ type UserAccount implements Node { scalar PrefixedID type Query { - apiKeys: [ApiKey!]! - apiKey(id: PrefixedID!): ApiKey cloud: Cloud! config: Config! display: Display! @@ -1482,6 +1526,14 @@ type Query { vms: Vms! parityHistory: [ParityCheck!]! array: UnraidArray! + apiKeys: [ApiKey!]! + apiKey(id: PrefixedID!): ApiKey + + """All possible roles for API keys""" + apiKeyPossibleRoles: [Role!]! + + """All possible permissions for API keys""" + apiKeyPossiblePermissions: [Permission!]! connect: Connect! remoteAccess: RemoteAccess! extraAllowedOrigins: [String!]! @@ -1496,10 +1548,6 @@ type Query { } type Mutation { - createApiKey(input: CreateApiKeyInput!): ApiKeyWithSecret! - addRoleForApiKey(input: AddRoleForApiKeyInput!): Boolean! - removeRoleFromApiKey(input: RemoveRoleFromApiKeyInput!): Boolean! - """Creates a new notification record""" createNotification(input: NotificationData!): Notification! deleteNotification(id: PrefixedID!, type: NotificationType!): NotificationOverview! @@ -1523,6 +1571,7 @@ type Mutation { docker: DockerMutations! vm: VmMutations! parityCheck: ParityCheckMutations! + apiKey: ApiKeyMutations! updateApiSettings(input: ApiSettingsInput!): ConnectSettingsValues! connectSignIn(input: ConnectSignInInput!): Boolean! connectSignOut: Boolean! @@ -1532,33 +1581,6 @@ type Mutation { setDemo: String! } -input CreateApiKeyInput { - name: String! - description: String - roles: [Role!] - permissions: [AddPermissionInput!] - - """ - This will replace the existing key if one already exists with the same name, otherwise returns the existing key - """ - overwrite: Boolean -} - -input AddPermissionInput { - resource: Resource! - actions: [String!]! -} - -input AddRoleForApiKeyInput { - apiKeyId: PrefixedID! - role: Role! -} - -input RemoveRoleFromApiKeyInput { - apiKeyId: PrefixedID! - role: Role! -} - input NotificationData { title: String! subject: String! diff --git a/api/src/unraid-api/auth/api-key.service.spec.ts b/api/src/unraid-api/auth/api-key.service.spec.ts index 750b30da7..e647c3d76 100644 --- a/api/src/unraid-api/auth/api-key.service.spec.ts +++ b/api/src/unraid-api/auth/api-key.service.spec.ts @@ -185,7 +185,7 @@ describe('ApiKeyService', () => { await expect( apiKeyService.create({ name: 'name', description: 'desc', roles: [] }) - ).rejects.toThrow('At least one role must be specified'); + ).rejects.toThrow('At least one role or permission must be specified'); await expect( apiKeyService.create({ diff --git a/api/src/unraid-api/auth/api-key.service.ts b/api/src/unraid-api/auth/api-key.service.ts index 5b8f8a744..208fdda0e 100644 --- a/api/src/unraid-api/auth/api-key.service.ts +++ b/api/src/unraid-api/auth/api-key.service.ts @@ -130,11 +130,11 @@ export class ApiKeyService implements OnModuleInit { throw new GraphQLError('API key name is required'); } - if (!roles?.length) { - throw new GraphQLError('At least one role must be specified'); + if (!roles?.length && !permissions?.length) { + throw new GraphQLError('At least one role or permission must be specified'); } - if (roles.some((role) => !ApiKeyService.validRoles.has(role))) { + if (roles?.some((role) => !ApiKeyService.validRoles.has(role))) { throw new GraphQLError('Invalid role specified'); } diff --git a/api/src/unraid-api/graph/resolvers/api-key/api-key.model.ts b/api/src/unraid-api/graph/resolvers/api-key/api-key.model.ts index 08a030d27..0a73f4a05 100644 --- a/api/src/unraid-api/graph/resolvers/api-key/api-key.model.ts +++ b/api/src/unraid-api/graph/resolvers/api-key/api-key.model.ts @@ -132,3 +132,11 @@ export class RemoveRoleFromApiKeyInput { @IsEnum(Role) role!: Role; } + +@InputType() +export class DeleteApiKeyInput { + @Field(() => [PrefixedID]) + @IsArray() + @IsString({ each: true }) + ids!: string[]; +} diff --git a/api/src/unraid-api/graph/resolvers/api-key/api-key.module.ts b/api/src/unraid-api/graph/resolvers/api-key/api-key.module.ts index 3f26e3100..fa4a6c8a9 100644 --- a/api/src/unraid-api/graph/resolvers/api-key/api-key.module.ts +++ b/api/src/unraid-api/graph/resolvers/api-key/api-key.module.ts @@ -1,11 +1,14 @@ import { Module } from '@nestjs/common'; import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js'; +import { AuthModule } from '@app/unraid-api/auth/auth.module.js'; import { AuthService } from '@app/unraid-api/auth/auth.service.js'; +import { ApiKeyMutationsResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.mutation.js'; import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js'; @Module({ - providers: [ApiKeyResolver, ApiKeyService, AuthService], - exports: [ApiKeyResolver], + imports: [AuthModule], + providers: [ApiKeyResolver, ApiKeyService, AuthService, ApiKeyMutationsResolver], + exports: [ApiKeyResolver, ApiKeyService], }) export class ApiKeyModule {} diff --git a/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.spec.ts b/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.spec.ts new file mode 100644 index 000000000..892cd7c58 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.spec.ts @@ -0,0 +1,165 @@ +import { newEnforcer } from 'casbin'; +import { AuthZService } from 'nest-authz'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js'; +import { AuthService } from '@app/unraid-api/auth/auth.service.js'; +import { CookieService } from '@app/unraid-api/auth/cookie.service.js'; +import { + ApiKey, + ApiKeyWithSecret, + CreateApiKeyInput, + DeleteApiKeyInput, +} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js'; +import { ApiKeyMutationsResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.mutation.js'; +import { Role } from '@app/unraid-api/graph/resolvers/base.model.js'; + +describe('ApiKeyMutationsResolver', () => { + let resolver: ApiKeyMutationsResolver; + let authService: AuthService; + let apiKeyService: ApiKeyService; + let authzService: AuthZService; + let cookieService: CookieService; + + const mockApiKey: ApiKey = { + id: 'test-api-id', + name: 'Test API Key', + description: 'Test API Key Description', + roles: [Role.GUEST], + createdAt: new Date().toISOString(), + permissions: [], + }; + + const mockApiKeyWithSecret: ApiKeyWithSecret = { + id: 'test-api-id', + key: 'test-api-key', + name: 'Test API Key', + description: 'Test API Key Description', + roles: [Role.GUEST], + createdAt: new Date().toISOString(), + permissions: [], + }; + + beforeEach(async () => { + vi.resetAllMocks(); + + const enforcer = await newEnforcer(); + + apiKeyService = new ApiKeyService(); + authzService = new AuthZService(enforcer); + cookieService = new CookieService(); + authService = new AuthService(cookieService, apiKeyService, authzService); + resolver = new ApiKeyMutationsResolver(authService, apiKeyService); + }); + + describe('create', () => { + it('should create new API key and sync roles', async () => { + const input: CreateApiKeyInput = { + name: 'New API Key', + description: 'New API Key Description', + roles: [Role.GUEST], + permissions: [], + }; + + vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret); + vi.spyOn(authService, 'syncApiKeyRoles').mockResolvedValue(); + + const result = await resolver.create(input); + + expect(result).toEqual(mockApiKeyWithSecret); + expect(apiKeyService.create).toHaveBeenCalledWith({ + name: input.name, + description: input.description, + overwrite: false, + roles: input.roles, + permissions: [], + }); + expect(authService.syncApiKeyRoles).toHaveBeenCalledWith(mockApiKey.id, mockApiKey.roles); + }); + + it('should throw if API key creation fails', async () => { + const input: CreateApiKeyInput = { + name: 'Failing API Key', + description: 'Should fail', + roles: [Role.GUEST], + permissions: [], + }; + vi.spyOn(apiKeyService, 'create').mockRejectedValue(new Error('Create failed')); + await expect(resolver.create(input)).rejects.toThrow('Create failed'); + }); + + it('should throw if role synchronization fails', async () => { + const input: CreateApiKeyInput = { + name: 'Sync Fail API Key', + description: 'Should fail sync', + roles: [Role.GUEST], + permissions: [], + }; + vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret); + vi.spyOn(authService, 'syncApiKeyRoles').mockRejectedValue(new Error('Sync failed')); + await expect(resolver.create(input)).rejects.toThrow('Sync failed'); + }); + + it('should throw if input validation fails (empty name)', async () => { + const input: CreateApiKeyInput = { + name: '', + description: 'No name', + roles: [Role.GUEST], + permissions: [], + }; + await expect(resolver.create(input)).rejects.toThrow(); + }); + }); + + describe('delete', () => { + it('should delete API keys', async () => { + const input: DeleteApiKeyInput = { ids: [mockApiKey.id] }; + vi.spyOn(apiKeyService, 'deleteApiKeys').mockResolvedValue(); + + const result = await resolver.delete(input); + + expect(result).toBe(true); + expect(apiKeyService.deleteApiKeys).toHaveBeenCalledWith(input.ids); + }); + }); + + describe('addRole', () => { + it('should add a role to an API key', async () => { + const input = { apiKeyId: mockApiKey.id, role: Role.ADMIN }; + vi.spyOn(authService, 'addRoleToApiKey').mockResolvedValue(true); + + const result = await resolver.addRole(input); + + expect(result).toBe(true); + expect(authService.addRoleToApiKey).toHaveBeenCalledWith(input.apiKeyId, input.role); + }); + + it('should throw if addRoleToApiKey throws', async () => { + const input = { apiKeyId: 'bad-id', role: Role.ADMIN }; + vi.spyOn(authService, 'addRoleToApiKey').mockRejectedValue(new Error('API key not found')); + + await expect(resolver.addRole(input)).rejects.toThrow('API key not found'); + }); + }); + + describe('removeRole', () => { + it('should remove a role from an API key', async () => { + const input = { apiKeyId: mockApiKey.id, role: Role.GUEST }; + vi.spyOn(authService, 'removeRoleFromApiKey').mockResolvedValue(true); + + const result = await resolver.removeRole(input); + + expect(result).toBe(true); + expect(authService.removeRoleFromApiKey).toHaveBeenCalledWith(input.apiKeyId, input.role); + }); + + it('should throw if removeRoleFromApiKey throws', async () => { + const input = { apiKeyId: 'bad-id', role: Role.GUEST }; + vi.spyOn(authService, 'removeRoleFromApiKey').mockRejectedValue( + new Error('API key not found') + ); + + await expect(resolver.removeRole(input)).rejects.toThrow('API key not found'); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.ts b/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.ts new file mode 100644 index 000000000..dbda656a8 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.ts @@ -0,0 +1,80 @@ +import { Args, ResolveField, Resolver } from '@nestjs/graphql'; + +import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js'; +import { AuthService } from '@app/unraid-api/auth/auth.service.js'; +import { + AuthActionVerb, + AuthPossession, + UsePermissions, +} from '@app/unraid-api/graph/directives/use-permissions.directive.js'; +import { + AddRoleForApiKeyInput, + ApiKeyWithSecret, + CreateApiKeyInput, + DeleteApiKeyInput, + RemoveRoleFromApiKeyInput, +} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js'; +import { Resource, Role } from '@app/unraid-api/graph/resolvers/base.model.js'; +import { ApiKeyMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js'; +import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js'; + +@Resolver(() => ApiKeyMutations) +export class ApiKeyMutationsResolver { + constructor( + private authService: AuthService, + private apiKeyService: ApiKeyService + ) {} + + @UsePermissions({ + action: AuthActionVerb.CREATE, + resource: Resource.API_KEY, + possession: AuthPossession.ANY, + }) + @ResolveField(() => ApiKeyWithSecret, { description: 'Create an API key' }) + async create(@Args('input') unvalidatedInput: CreateApiKeyInput): Promise { + const input = await validateObject(CreateApiKeyInput, unvalidatedInput); + const apiKey = await this.apiKeyService.create({ + name: input.name, + description: input.description ?? undefined, + roles: input.roles ?? [], + permissions: input.permissions ?? [], + overwrite: input.overwrite ?? false, + }); + await this.authService.syncApiKeyRoles(apiKey.id, apiKey.roles); + return apiKey; + } + + @UsePermissions({ + action: AuthActionVerb.UPDATE, + resource: Resource.API_KEY, + possession: AuthPossession.ANY, + }) + @ResolveField(() => Boolean, { description: 'Add a role to an API key' }) + async addRole(@Args('input') input: AddRoleForApiKeyInput): Promise { + const validatedInput = await validateObject(AddRoleForApiKeyInput, input); + return this.authService.addRoleToApiKey(validatedInput.apiKeyId, Role[validatedInput.role]); + } + + @UsePermissions({ + action: AuthActionVerb.UPDATE, + resource: Resource.API_KEY, + possession: AuthPossession.ANY, + }) + @ResolveField(() => Boolean, { description: 'Remove a role from an API key' }) + async removeRole(@Args('input') input: RemoveRoleFromApiKeyInput): Promise { + const validatedInput = await validateObject(RemoveRoleFromApiKeyInput, input); + return this.authService.removeRoleFromApiKey(validatedInput.apiKeyId, Role[validatedInput.role]); + } + + @UsePermissions({ + action: AuthActionVerb.DELETE, + resource: Resource.API_KEY, + possession: AuthPossession.ANY, + }) + @ResolveField(() => Boolean, { description: 'Delete one or more API keys' }) + async delete(@Args('input') input: DeleteApiKeyInput): Promise { + const validatedInput = await validateObject(DeleteApiKeyInput, input); + await this.apiKeyService.deleteApiKeys(validatedInput.ids); + return true; + } +} diff --git a/api/src/unraid-api/graph/resolvers/api-key/api-key.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/api-key/api-key.resolver.spec.ts index 86b11715d..8e7fa7c74 100644 --- a/api/src/unraid-api/graph/resolvers/api-key/api-key.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/api-key/api-key.resolver.spec.ts @@ -78,65 +78,4 @@ describe('ApiKeyResolver', () => { expect(apiKeyService.findById).toHaveBeenCalled(); }); }); - - describe('createApiKey', () => { - it('should create new API key and sync roles', async () => { - const input = { - name: 'New API Key', - description: 'New API Key Description', - roles: [Role.GUEST], - permissions: [], - }; - - vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret); - vi.spyOn(authService, 'syncApiKeyRoles').mockResolvedValue(); - - const result = await resolver.createApiKey(input); - - expect(result).toEqual(mockApiKeyWithSecret); - expect(apiKeyService.create).toHaveBeenCalledWith({ - name: input.name, - description: input.description, - overwrite: false, - roles: input.roles, - permissions: [], - }); - expect(authService.syncApiKeyRoles).toHaveBeenCalledWith(mockApiKey.id, mockApiKey.roles); - }); - }); - - describe('addRoleForApiKey', () => { - it('should add role to API key', async () => { - const input = { - apiKeyId: mockApiKey.id, - role: Role.ADMIN, - }; - - vi.spyOn(authService, 'addRoleToApiKey').mockResolvedValue(true); - - const result = await resolver.addRoleForApiKey(input); - - expect(result).toBe(true); - expect(authService.addRoleToApiKey).toHaveBeenCalledWith(input.apiKeyId, Role[input.role]); - }); - }); - - describe('removeRoleFromApiKey', () => { - it('should remove role from API key', async () => { - const input = { - apiKeyId: mockApiKey.id, - role: Role.ADMIN, - }; - - vi.spyOn(authService, 'removeRoleFromApiKey').mockResolvedValue(true); - - const result = await resolver.removeRoleFromApiKey(input); - - expect(result).toBe(true); - expect(authService.removeRoleFromApiKey).toHaveBeenCalledWith( - input.apiKeyId, - Role[input.role] - ); - }); - }); }); diff --git a/api/src/unraid-api/graph/resolvers/api-key/api-key.resolver.ts b/api/src/unraid-api/graph/resolvers/api-key/api-key.resolver.ts index 2fca74c41..09602e4e1 100644 --- a/api/src/unraid-api/graph/resolvers/api-key/api-key.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/api-key/api-key.resolver.ts @@ -1,4 +1,4 @@ -import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { Args, Query, Resolver } from '@nestjs/graphql'; import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js'; import { AuthService } from '@app/unraid-api/auth/auth.service.js'; @@ -7,15 +7,8 @@ import { AuthPossession, UsePermissions, } from '@app/unraid-api/graph/directives/use-permissions.directive.js'; -import { - AddRoleForApiKeyInput, - ApiKey, - ApiKeyWithSecret, - CreateApiKeyInput, - RemoveRoleFromApiKeyInput, -} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js'; +import { ApiKey, Permission } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js'; import { Resource, Role } from '@app/unraid-api/graph/resolvers/base.model.js'; -import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js'; import { PrefixedID } from '@app/unraid-api/graph/scalars/graphql-type-prefixed-id.js'; @Resolver(() => ApiKey) @@ -48,60 +41,29 @@ export class ApiKeyResolver { return this.apiKeyService.findById(id); } - @Mutation(() => ApiKeyWithSecret) + @Query(() => [Role], { description: 'All possible roles for API keys' }) @UsePermissions({ - action: AuthActionVerb.CREATE, - resource: Resource.API_KEY, + action: AuthActionVerb.READ, + resource: Resource.PERMISSION, possession: AuthPossession.ANY, }) - async createApiKey( - @Args('input') - unvalidatedInput: CreateApiKeyInput - ): Promise { - // Validate the input using class-validator - const input = await validateObject(CreateApiKeyInput, unvalidatedInput); - - const apiKey = await this.apiKeyService.create({ - name: input.name, - description: input.description ?? undefined, - roles: input.roles ?? [], - permissions: input.permissions ?? [], - overwrite: input.overwrite ?? false, - }); - - await this.authService.syncApiKeyRoles(apiKey.id, apiKey.roles); - - return apiKey; + async apiKeyPossibleRoles(): Promise { + return Object.values(Role); } - @Mutation(() => Boolean) + @Query(() => [Permission], { description: 'All possible permissions for API keys' }) @UsePermissions({ - action: AuthActionVerb.UPDATE, - resource: Resource.API_KEY, + action: AuthActionVerb.READ, + resource: Resource.PERMISSION, possession: AuthPossession.ANY, }) - async addRoleForApiKey( - @Args('input') - input: AddRoleForApiKeyInput - ): Promise { - // Validate the input using class-validator - const validatedInput = await validateObject(AddRoleForApiKeyInput, input); - - return this.authService.addRoleToApiKey(validatedInput.apiKeyId, Role[validatedInput.role]); - } - - @Mutation(() => Boolean) - @UsePermissions({ - action: AuthActionVerb.UPDATE, - resource: Resource.API_KEY, - possession: AuthPossession.ANY, - }) - async removeRoleFromApiKey( - @Args('input') - input: RemoveRoleFromApiKeyInput - ): Promise { - // Validate the input using class-validator - const validatedInput = await validateObject(RemoveRoleFromApiKeyInput, input); - return this.authService.removeRoleFromApiKey(validatedInput.apiKeyId, Role[validatedInput.role]); + async apiKeyPossiblePermissions(): Promise { + // Build all combinations of Resource and AuthActionVerb + const resources = Object.values(Resource); + const actions = Object.values(AuthActionVerb); + return resources.map((resource) => ({ + resource, + actions, + })); } } diff --git a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts index 8cd4e1f5c..da09bba2c 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts @@ -9,6 +9,11 @@ export class DockerMutations {} @ObjectType() export class VmMutations {} +@ObjectType({ + description: 'API Key related mutations', +}) +export class ApiKeyMutations {} + @ObjectType({ description: 'Parity check related mutations, WIP, response types and functionaliy will change', }) @@ -25,6 +30,9 @@ export class RootMutations { @Field(() => VmMutations, { description: 'VM related mutations' }) vm: VmMutations = new VmMutations(); + @Field(() => ApiKeyMutations, { description: 'API Key related mutations' }) + apiKey: ApiKeyMutations = new ApiKeyMutations(); + @Field(() => ParityCheckMutations, { description: 'Parity check related mutations' }) parityCheck: ParityCheckMutations = new ParityCheckMutations(); } diff --git a/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts b/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts index 29758be5c..16ebe2a41 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts @@ -1,6 +1,7 @@ import { Mutation, Resolver } from '@nestjs/graphql'; import { + ApiKeyMutations, ArrayMutations, DockerMutations, ParityCheckMutations, @@ -12,21 +13,26 @@ import { export class RootMutationsResolver { @Mutation(() => ArrayMutations, { name: 'array' }) array(): ArrayMutations { - return new ArrayMutations(); // You can pass context/state here if needed + return new ArrayMutations(); } @Mutation(() => DockerMutations, { name: 'docker' }) docker(): DockerMutations { - return new DockerMutations(); // You can pass context/state here if needed + return new DockerMutations(); } @Mutation(() => VmMutations, { name: 'vm' }) vm(): VmMutations { - return new VmMutations(); // You can pass context/state here if needed + return new VmMutations(); } @Mutation(() => ParityCheckMutations, { name: 'parityCheck' }) parityCheck(): ParityCheckMutations { - return new ParityCheckMutations(); // You can pass context/state here if needed + return new ParityCheckMutations(); + } + + @Mutation(() => ApiKeyMutations, { name: 'apiKey' }) + apiKey(): ApiKeyMutations { + return new ApiKeyMutations(); } } diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index 4af8c61d2..464fb7eee 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '@app/unraid-api/auth/auth.module.js'; +import { ApiKeyModule } from '@app/unraid-api/graph/resolvers/api-key/api-key.module.js'; import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js'; import { ArrayModule } from '@app/unraid-api/graph/resolvers/array/array.module.js'; import { ArrayMutationsResolver } from '@app/unraid-api/graph/resolvers/array/array.mutations.resolver.js'; @@ -34,9 +35,8 @@ import { SharesResolver } from '@app/unraid-api/graph/shares/shares.resolver.js' import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js'; @Module({ - imports: [ArrayModule, AuthModule, ConnectModule, CustomizationModule, DockerModule, DisksModule], + imports: [ArrayModule, ApiKeyModule, ConnectModule, CustomizationModule, DockerModule, DisksModule], providers: [ - ApiKeyResolver, CloudResolver, ConfigResolver, DisplayResolver, @@ -60,6 +60,6 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js'; VmsResolver, VmsService, ], - exports: [AuthModule, ApiKeyResolver], + exports: [ApiKeyModule], }) export class ResolversModule {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f61bb3022..ae157a0ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -694,7 +694,7 @@ importers: specifier: ^0.511.0 version: 0.511.0(vue@3.5.13(typescript@5.8.3)) reka-ui: - specifier: ^2.1.0 + specifier: ^2.1.1 version: 2.1.1(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3)) shadcn-vue: specifier: ^2.0.0 diff --git a/unraid-ui/package.json b/unraid-ui/package.json index 9f54f1e13..1be9af6e8 100644 --- a/unraid-ui/package.json +++ b/unraid-ui/package.json @@ -53,7 +53,7 @@ "clsx": "^2.1.1", "kebab-case": "^2.0.1", "lucide-vue-next": "^0.511.0", - "reka-ui": "^2.1.0", + "reka-ui": "^2.1.1", "shadcn-vue": "^2.0.0", "tailwind-merge": "^2.6.0", "vue-sonner": "^1.3.0" diff --git a/unraid-ui/src/components/common/accordion/Accordion.vue b/unraid-ui/src/components/common/accordion/Accordion.vue new file mode 100644 index 000000000..c380c1a31 --- /dev/null +++ b/unraid-ui/src/components/common/accordion/Accordion.vue @@ -0,0 +1,19 @@ + + + diff --git a/unraid-ui/src/components/common/accordion/AccordionContent.vue b/unraid-ui/src/components/common/accordion/AccordionContent.vue new file mode 100644 index 000000000..d8a5b3009 --- /dev/null +++ b/unraid-ui/src/components/common/accordion/AccordionContent.vue @@ -0,0 +1,21 @@ + + + diff --git a/unraid-ui/src/components/common/accordion/AccordionItem.vue b/unraid-ui/src/components/common/accordion/AccordionItem.vue new file mode 100644 index 000000000..96bafc111 --- /dev/null +++ b/unraid-ui/src/components/common/accordion/AccordionItem.vue @@ -0,0 +1,18 @@ + + + diff --git a/unraid-ui/src/components/common/accordion/AccordionTrigger.vue b/unraid-ui/src/components/common/accordion/AccordionTrigger.vue new file mode 100644 index 000000000..0b1e70c5d --- /dev/null +++ b/unraid-ui/src/components/common/accordion/AccordionTrigger.vue @@ -0,0 +1,30 @@ + + + diff --git a/unraid-ui/src/components/common/accordion/index.ts b/unraid-ui/src/components/common/accordion/index.ts new file mode 100644 index 000000000..821d3a143 --- /dev/null +++ b/unraid-ui/src/components/common/accordion/index.ts @@ -0,0 +1,4 @@ +export { default as Accordion } from './Accordion.vue'; +export { default as AccordionContent } from './AccordionContent.vue'; +export { default as AccordionItem } from './AccordionItem.vue'; +export { default as AccordionTrigger } from './AccordionTrigger.vue'; diff --git a/unraid-ui/src/index.ts b/unraid-ui/src/index.ts index 8d35ac504..f833e42aa 100644 --- a/unraid-ui/src/index.ts +++ b/unraid-ui/src/index.ts @@ -9,6 +9,12 @@ import { BrandLogoConnect, type BrandButtonProps, } from '@/components/brand'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/common/accordion'; // Components import { Badge, type BadgeProps } from '@/components/common/badge'; import { Button, buttonVariants, type ButtonProps } from '@/components/common/button'; @@ -80,6 +86,10 @@ import tailwindConfig from '../tailwind.config'; // Export export { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, Bar, Badge, BrandButton, diff --git a/unraid-ui/tailwind.config.ts b/unraid-ui/tailwind.config.ts index 45e41d174..2edad5d61 100644 --- a/unraid-ui/tailwind.config.ts +++ b/unraid-ui/tailwind.config.ts @@ -1,6 +1,7 @@ import tailwindRemToRem from '@unraid/tailwind-rem-to-rem'; import type { Config } from 'tailwindcss'; import tailwindcssAnimate from 'tailwindcss-animate'; +/* eslint-disable no-relative-import-paths/no-relative-import-paths */ import { unraidPreset } from './src/theme/preset'; export default { @@ -88,6 +89,28 @@ export default { '5': 'hsl(var(--chart-5))', }, }, + keyframes: { + 'accordion-down': { + from: { + height: '0', + }, + to: { + height: 'var(--reka-accordion-content-height)', + }, + }, + 'accordion-up': { + from: { + height: 'var(--reka-accordion-content-height)', + }, + to: { + height: '0', + }, + }, + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + }, }, }, } satisfies Partial; diff --git a/web/components/ApiKey/ApiKeyCreate.vue b/web/components/ApiKey/ApiKeyCreate.vue new file mode 100644 index 000000000..32331b3b7 --- /dev/null +++ b/web/components/ApiKey/ApiKeyCreate.vue @@ -0,0 +1,183 @@ + + \ No newline at end of file diff --git a/web/components/ApiKey/ApiKeyManager.vue b/web/components/ApiKey/ApiKeyManager.vue new file mode 100644 index 000000000..ce06a4499 --- /dev/null +++ b/web/components/ApiKey/ApiKeyManager.vue @@ -0,0 +1,125 @@ + + diff --git a/web/components/ApiKey/apikey.query.ts b/web/components/ApiKey/apikey.query.ts new file mode 100644 index 000000000..db441eef0 --- /dev/null +++ b/web/components/ApiKey/apikey.query.ts @@ -0,0 +1,55 @@ +import { graphql } from '~/composables/gql/gql'; + + +export const GET_API_KEYS = graphql(/* GraphQL */ ` + query ApiKeys { + apiKeys { + id + name + description + createdAt + roles + permissions { + resource + actions + } + } + } +`); + +export const CREATE_API_KEY = graphql(/* GraphQL */ ` + mutation CreateApiKey($input: CreateApiKeyInput!) { + apiKey { + create(input: $input) { + id + key + name + description + createdAt + roles + permissions { + resource + actions + } + } + } + } +`); + +export const DELETE_API_KEY = graphql(/* GraphQL */ ` + mutation DeleteApiKey($input: DeleteApiKeyInput!) { + apiKey { + delete(input: $input) + } + } +`); + +export const GET_API_KEY_META = graphql(/* GraphQL */ ` + query ApiKeyMeta { + apiKeyPossibleRoles + apiKeyPossiblePermissions { + resource + actions + } + } +`); diff --git a/web/composables/gql/gql.ts b/web/composables/gql/gql.ts index 9a26c97e3..f9818e13f 100644 --- a/web/composables/gql/gql.ts +++ b/web/composables/gql/gql.ts @@ -16,6 +16,10 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document- type Documents = { "\n query PartnerInfo {\n publicPartnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n }\n": typeof types.PartnerInfoDocument, "\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 query ApiKeys {\n apiKeys {\n id\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n }\n": typeof types.ApiKeysDocument, + "\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n }\n }\n": typeof types.CreateApiKeyDocument, + "\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 GetConnectSettingsForm {\n connect {\n id\n settings {\n id\n dataSchema\n uiSchema\n values {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n ssoUserIds\n }\n }\n }\n }\n": typeof types.GetConnectSettingsFormDocument, "\n mutation UpdateConnectSettings($input: ApiSettingsInput!) {\n updateApiSettings(input: $input) {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n ssoUserIds\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, @@ -45,6 +49,10 @@ type Documents = { const documents: Documents = { "\n query PartnerInfo {\n publicPartnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n }\n": types.PartnerInfoDocument, "\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 query ApiKeys {\n apiKeys {\n id\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n }\n": types.ApiKeysDocument, + "\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n }\n }\n": types.CreateApiKeyDocument, + "\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 GetConnectSettingsForm {\n connect {\n id\n settings {\n id\n dataSchema\n uiSchema\n values {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n ssoUserIds\n }\n }\n }\n }\n": types.GetConnectSettingsFormDocument, "\n mutation UpdateConnectSettings($input: ApiSettingsInput!) {\n updateApiSettings(input: $input) {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n ssoUserIds\n }\n }\n": types.UpdateConnectSettingsDocument, "\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": types.LogFilesDocument, @@ -94,6 +102,22 @@ export function graphql(source: "\n query PartnerInfo {\n publicPartnerInfo * 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 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 documents)["\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"]; +/** + * 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 ApiKeys {\n apiKeys {\n id\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n }\n"): (typeof documents)["\n query ApiKeys {\n apiKeys {\n id\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\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 CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n }\n }\n"): (typeof documents)["\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\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 DeleteApiKey($input: DeleteApiKeyInput!) {\n apiKey {\n delete(input: $input)\n }\n }\n"): (typeof documents)["\n mutation DeleteApiKey($input: DeleteApiKeyInput!) {\n apiKey {\n delete(input: $input)\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 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. */ diff --git a/web/composables/gql/graphql.ts b/web/composables/gql/graphql.ts index 551e69ba3..0d38408a0 100644 --- a/web/composables/gql/graphql.ts +++ b/web/composables/gql/graphql.ts @@ -131,6 +131,43 @@ export type ApiKey = Node & { roles: Array; }; +/** 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; + /** Delete one or more API keys */ + delete: Scalars['Boolean']['output']; + /** Remove a role from an API key */ + removeRole: Scalars['Boolean']['output']; +}; + + +/** API Key related mutations */ +export type ApiKeyMutationsAddRoleArgs = { + input: AddRoleForApiKeyInput; +}; + + +/** API Key related mutations */ +export type ApiKeyMutationsCreateArgs = { + input: CreateApiKeyInput; +}; + + +/** API Key related mutations */ +export type ApiKeyMutationsDeleteArgs = { + input: DeleteApiKeyInput; +}; + + +/** API Key related mutations */ +export type ApiKeyMutationsRemoveRoleArgs = { + input: RemoveRoleFromApiKeyInput; +}; + export type ApiKeyResponse = { __typename?: 'ApiKeyResponse'; error?: Maybe; @@ -497,6 +534,10 @@ export type Customization = { theme: Theme; }; +export type DeleteApiKeyInput = { + ids: Array; +}; + export type Devices = Node & { __typename?: 'Devices'; gpu: Array; @@ -850,7 +891,7 @@ export type MinigraphqlResponse = { export type Mutation = { __typename?: 'Mutation'; - addRoleForApiKey: Scalars['Boolean']['output']; + apiKey: ApiKeyMutations; archiveAll: NotificationOverview; /** Marks a notification as archived. */ archiveNotification: Notification; @@ -858,7 +899,6 @@ export type Mutation = { array: ArrayMutations; connectSignIn: Scalars['Boolean']['output']; connectSignOut: Scalars['Boolean']['output']; - createApiKey: ApiKeyWithSecret; /** Creates a new notification record */ createNotification: Notification; /** Deletes all archived notifications on server. */ @@ -869,7 +909,6 @@ export type Mutation = { parityCheck: ParityCheckMutations; /** Reads each notification to recompute & update the overview. */ recalculateOverview: NotificationOverview; - removeRoleFromApiKey: Scalars['Boolean']['output']; setAdditionalAllowedOrigins: Array; setDemo: Scalars['String']['output']; setupRemoteAccess: Scalars['Boolean']['output']; @@ -882,11 +921,6 @@ export type Mutation = { }; -export type MutationAddRoleForApiKeyArgs = { - input: AddRoleForApiKeyInput; -}; - - export type MutationArchiveAllArgs = { importance?: InputMaybe; }; @@ -907,11 +941,6 @@ export type MutationConnectSignInArgs = { }; -export type MutationCreateApiKeyArgs = { - input: CreateApiKeyInput; -}; - - export type MutationCreateNotificationArgs = { input: NotificationData; }; @@ -928,11 +957,6 @@ export type MutationEnableDynamicRemoteAccessArgs = { }; -export type MutationRemoveRoleFromApiKeyArgs = { - input: RemoveRoleFromApiKeyInput; -}; - - export type MutationSetAdditionalAllowedOriginsArgs = { input: AllowedOriginInput; }; @@ -1145,6 +1169,10 @@ export type PublicPartnerInfo = { export type Query = { __typename?: 'Query'; apiKey?: Maybe; + /** All possible permissions for API keys */ + apiKeyPossiblePermissions: Array; + /** All possible roles for API keys */ + apiKeyPossibleRoles: Array; apiKeys: Array; array: UnraidArray; cloud: Cloud; @@ -1799,6 +1827,30 @@ 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 ApiKeysQueryVariables = Exact<{ [key: string]: never; }>; + + +export type ApiKeysQuery = { __typename?: 'Query', apiKeys: Array<{ __typename?: 'ApiKey', id: string, name: string, description?: string | null, createdAt: string, roles: Array, permissions: Array<{ __typename?: 'Permission', resource: Resource, actions: Array }> }> }; + +export type CreateApiKeyMutationVariables = Exact<{ + input: CreateApiKeyInput; +}>; + + +export type CreateApiKeyMutation = { __typename?: 'Mutation', apiKey: { __typename?: 'ApiKeyMutations', create: { __typename?: 'ApiKeyWithSecret', id: string, key: string, name: string, description?: string | null, createdAt: string, roles: Array, permissions: Array<{ __typename?: 'Permission', resource: Resource, actions: Array }> } } }; + +export type DeleteApiKeyMutationVariables = Exact<{ + input: DeleteApiKeyInput; +}>; + + +export type DeleteApiKeyMutation = { __typename?: 'Mutation', apiKey: { __typename?: 'ApiKeyMutations', delete: boolean } }; + +export type ApiKeyMetaQueryVariables = Exact<{ [key: string]: never; }>; + + +export type ApiKeyMetaQuery = { __typename?: 'Query', apiKeyPossibleRoles: Array, apiKeyPossiblePermissions: Array<{ __typename?: 'Permission', resource: Resource, actions: Array }> }; + export type GetConnectSettingsFormQueryVariables = Exact<{ [key: string]: never; }>; @@ -1965,6 +2017,10 @@ export const NotificationCountFragmentFragmentDoc = {"kind":"Document","definiti 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; 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; 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; +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":"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; +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":"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; +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; +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; export const GetConnectSettingsFormDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetConnectSettingsForm"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connect"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"settings"},"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"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sandbox"}},{"kind":"Field","name":{"kind":"Name","value":"extraOrigins"}},{"kind":"Field","name":{"kind":"Name","value":"accessType"}},{"kind":"Field","name":{"kind":"Name","value":"forwardType"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"ssoUserIds"}}]}}]}}]}}]}}]} as unknown as DocumentNode; 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":"ApiSettingsInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateApiSettings"},"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":"sandbox"}},{"kind":"Field","name":{"kind":"Name","value":"extraOrigins"}},{"kind":"Field","name":{"kind":"Name","value":"accessType"}},{"kind":"Field","name":{"kind":"Name","value":"forwardType"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"ssoUserIds"}}]}}]}}]} as unknown as DocumentNode; 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; diff --git a/web/pages/apikey.vue b/web/pages/apikey.vue new file mode 100644 index 000000000..573afe171 --- /dev/null +++ b/web/pages/apikey.vue @@ -0,0 +1,10 @@ + + +