mirror of
https://github.com/unraid/api.git
synced 2026-02-08 08:59:06 -06:00
feat: API key management (#1407)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
9
.cursor/rules/web-graphql.mdc
Normal file
9
.cursor/rules/web-graphql.mdc
Normal file
@@ -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
|
||||
@@ -1,5 +1,5 @@
|
||||
[api]
|
||||
version="4.7.0"
|
||||
version="4.8.0"
|
||||
extraOrigins="https://google.com,https://test.com"
|
||||
[local]
|
||||
sandbox="yes"
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -132,3 +132,11 @@ export class RemoveRoleFromApiKeyInput {
|
||||
@IsEnum(Role)
|
||||
role!: Role;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class DeleteApiKeyInput {
|
||||
@Field(() => [PrefixedID])
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ApiKeyWithSecret> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
const validatedInput = await validateObject(DeleteApiKeyInput, input);
|
||||
await this.apiKeyService.deleteApiKeys(validatedInput.ids);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ApiKeyWithSecret> {
|
||||
// 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<Role[]> {
|
||||
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<boolean> {
|
||||
// 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<boolean> {
|
||||
// Validate the input using class-validator
|
||||
const validatedInput = await validateObject(RemoveRoleFromApiKeyInput, input);
|
||||
return this.authService.removeRoleFromApiKey(validatedInput.apiKeyId, Role[validatedInput.role]);
|
||||
async apiKeyPossiblePermissions(): Promise<Permission[]> {
|
||||
// Build all combinations of Resource and AuthActionVerb
|
||||
const resources = Object.values(Resource);
|
||||
const actions = Object.values(AuthActionVerb);
|
||||
return resources.map((resource) => ({
|
||||
resource,
|
||||
actions,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
19
unraid-ui/src/components/common/accordion/Accordion.vue
Normal file
19
unraid-ui/src/components/common/accordion/Accordion.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AccordionRoot,
|
||||
useForwardPropsEmits,
|
||||
type AccordionRootEmits,
|
||||
type AccordionRootProps,
|
||||
} from 'reka-ui';
|
||||
|
||||
const props = defineProps<AccordionRootProps>();
|
||||
const emits = defineEmits<AccordionRootEmits>();
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</AccordionRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { reactiveOmit } from '@vueuse/core';
|
||||
import { AccordionContent, type AccordionContentProps } from 'reka-ui';
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
|
||||
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes['class'] }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionContent
|
||||
v-bind="delegatedProps"
|
||||
class="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
>
|
||||
<div :class="cn('pb-4 pt-0', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</template>
|
||||
18
unraid-ui/src/components/common/accordion/AccordionItem.vue
Normal file
18
unraid-ui/src/components/common/accordion/AccordionItem.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { reactiveOmit } from '@vueuse/core';
|
||||
import { AccordionItem, useForwardProps, type AccordionItemProps } from 'reka-ui';
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
|
||||
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes['class'] }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class');
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionItem v-bind="forwardedProps" :class="cn('border-b', props.class)">
|
||||
<slot />
|
||||
</AccordionItem>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { reactiveOmit } from '@vueuse/core';
|
||||
import { ChevronDown } from 'lucide-vue-next';
|
||||
import { AccordionHeader, AccordionTrigger, type AccordionTriggerProps } from 'reka-ui';
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
|
||||
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionHeader class="flex">
|
||||
<AccordionTrigger
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<slot name="icon">
|
||||
<ChevronDown class="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</slot>
|
||||
</AccordionTrigger>
|
||||
</AccordionHeader>
|
||||
</template>
|
||||
4
unraid-ui/src/components/common/accordion/index.ts
Normal file
4
unraid-ui/src/components/common/accordion/index.ts
Normal file
@@ -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';
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Config>;
|
||||
|
||||
183
web/components/ApiKey/ApiKeyCreate.vue
Normal file
183
web/components/ApiKey/ApiKeyCreate.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useMutation } from '@vue/apollo-composable';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@unraid/ui';
|
||||
import { CREATE_API_KEY } from './apikey.query';
|
||||
|
||||
const props = defineProps<{
|
||||
possibleRoles: string[];
|
||||
possiblePermissions: { resource: string; actions: string[] }[];
|
||||
}>();
|
||||
const emit = defineEmits(['created', 'cancel']);
|
||||
|
||||
const newKeyName = ref('');
|
||||
const newKeyDescription = ref('');
|
||||
const newKeyRoles = ref<string[]>([]);
|
||||
const newKeyPermissions = ref<{ resource: string; actions: string[] }[]>([]);
|
||||
const { mutate: createApiKey, loading, error } = useMutation<
|
||||
{ apiKey: { create: { key: string } } },
|
||||
{ input: { name: string; description?: string; roles?: string[]; permissions?: { resource: string; actions: string[] }[] } }
|
||||
>(CREATE_API_KEY);
|
||||
const postCreateLoading = ref(false);
|
||||
|
||||
function togglePermission(resource: string, action: string, checked: boolean) {
|
||||
const perm = newKeyPermissions.value.find(p => p.resource === resource);
|
||||
if (checked) {
|
||||
if (perm) {
|
||||
if (!perm.actions.includes(action)) perm.actions.push(action);
|
||||
} else {
|
||||
newKeyPermissions.value.push({ resource, 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 !== resource);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function areAllPermissionsSelected() {
|
||||
return props.possiblePermissions.every(perm => {
|
||||
const selected = newKeyPermissions.value.find(p => p.resource === perm.resource)?.actions || [];
|
||||
return perm.actions.every(a => selected.includes(a));
|
||||
});
|
||||
}
|
||||
|
||||
function selectAllPermissions() {
|
||||
newKeyPermissions.value = props.possiblePermissions.map(perm => ({
|
||||
resource: perm.resource,
|
||||
actions: [...perm.actions],
|
||||
}));
|
||||
}
|
||||
|
||||
function clearAllPermissions() {
|
||||
newKeyPermissions.value = [];
|
||||
}
|
||||
|
||||
function areAllActionsSelected(resource: string) {
|
||||
const perm = props.possiblePermissions.find(p => p.resource === resource);
|
||||
if (!perm) return false;
|
||||
const selected = newKeyPermissions.value.find(p => p.resource === resource)?.actions || [];
|
||||
return perm.actions.every(a => selected.includes(a));
|
||||
}
|
||||
|
||||
function selectAllActions(resource: string) {
|
||||
const perm = props.possiblePermissions.find(p => p.resource === resource);
|
||||
if (!perm) return;
|
||||
const idx = newKeyPermissions.value.findIndex(p => p.resource === resource);
|
||||
if (idx !== -1) {
|
||||
newKeyPermissions.value[idx].actions = [...perm.actions];
|
||||
} else {
|
||||
newKeyPermissions.value.push({ resource, actions: [...perm.actions] });
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllActions(resource: string) {
|
||||
newKeyPermissions.value = newKeyPermissions.value.filter(p => p.resource !== resource);
|
||||
}
|
||||
|
||||
async function createKey() {
|
||||
const res = await createApiKey({
|
||||
input: {
|
||||
name: newKeyName.value,
|
||||
description: newKeyDescription.value,
|
||||
roles: newKeyRoles.value,
|
||||
permissions: newKeyPermissions.value.length ? newKeyPermissions.value : undefined,
|
||||
},
|
||||
});
|
||||
postCreateLoading.value = true;
|
||||
setTimeout(() => {
|
||||
emit('created', res?.data?.apiKey?.create ?? null);
|
||||
postCreateLoading.value = false;
|
||||
newKeyName.value = '';
|
||||
newKeyDescription.value = '';
|
||||
newKeyRoles.value = [];
|
||||
newKeyPermissions.value = [];
|
||||
}, 1000);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="mb-4 p-4 border rounded bg-muted">
|
||||
<div class="mb-2">
|
||||
<Label for="api-key-name">Name</Label>
|
||||
<Input id="api-key-name" v-model="newKeyName" placeholder="Name" class="mt-1" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<Label for="api-key-desc">Description</Label>
|
||||
<Input id="api-key-desc" v-model="newKeyDescription" placeholder="Description" class="mt-1" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<Label for="api-key-roles">Roles</Label>
|
||||
<Select v-model="newKeyRoles" multiple class="mt-1 w-full">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Roles" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="role in props.possibleRoles" :key="role" :value="role">{{ role }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<Accordion type="single" collapsible class="w-full mt-2">
|
||||
<AccordionItem value="permissions">
|
||||
<AccordionTrigger>Permissions</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div class="flex flex-row justify-end mb-2">
|
||||
<span class="mr-auto text-sm text-muted-foreground">
|
||||
Selected: {{ newKeyPermissions.reduce((sum, perm) => sum + perm.actions.length, 0) }}
|
||||
</span>
|
||||
<Button size="sm" variant="secondary" @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 props.possiblePermissions" :key="perm.resource" class="border rounded p-2">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="font-semibold">{{ perm.resource }}</span>
|
||||
<Button size="sm" variant="secondary" @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>{{ action }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<Button variant="primary" :disabled="loading || postCreateLoading" @click="createKey">
|
||||
<span v-if="loading || postCreateLoading">Creating...</span>
|
||||
<span v-else>Create</span>
|
||||
</Button>
|
||||
<Button variant="secondary" @click="$emit('cancel')">Cancel</Button>
|
||||
</div>
|
||||
<div v-if="error" class="text-red-500 mt-2 text-sm">
|
||||
{{ error.message }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
125
web/components/ApiKey/ApiKeyManager.vue
Normal file
125
web/components/ApiKey/ApiKeyManager.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watchEffect } from 'vue';
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
|
||||
import {
|
||||
Button,
|
||||
CardWrapper,
|
||||
PageContainer,
|
||||
} from '@unraid/ui';
|
||||
|
||||
import { DELETE_API_KEY, GET_API_KEY_META, GET_API_KEYS } from './apikey.query';
|
||||
import ApiKeyCreate from './ApiKeyCreate.vue';
|
||||
import { EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/solid';
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
roles: string[];
|
||||
permissions: { resource: string; actions: string[] }[];
|
||||
}
|
||||
|
||||
const { result, refetch } = useQuery<{ apiKeys: ApiKey[] }>(GET_API_KEYS);
|
||||
const apiKeys = ref<ApiKey[]>([]);
|
||||
|
||||
watchEffect(() => {
|
||||
apiKeys.value = result.value?.apiKeys || [];
|
||||
});
|
||||
|
||||
const metaQuery = useQuery(GET_API_KEY_META);
|
||||
const possibleRoles = ref<string[]>([]);
|
||||
const possiblePermissions = ref<{ resource: string; actions: string[] }[]>([]);
|
||||
watchEffect(() => {
|
||||
possibleRoles.value = metaQuery.result.value?.apiKeyPossibleRoles || [];
|
||||
possiblePermissions.value = metaQuery.result.value?.apiKeyPossiblePermissions || [];
|
||||
});
|
||||
|
||||
const showCreate = ref(false);
|
||||
const createdKey = ref<{ id: string; key: string } | null>(null);
|
||||
const showKey = ref(false);
|
||||
|
||||
const { mutate: deleteKey } = useMutation(DELETE_API_KEY);
|
||||
|
||||
const deleteError = ref<string | null>(null);
|
||||
|
||||
function toggleShowKey() {
|
||||
showKey.value = !showKey.value;
|
||||
}
|
||||
|
||||
function onCreated(key: { id: string; key: string } | null) {
|
||||
createdKey.value = key;
|
||||
showCreate.value = false;
|
||||
refetch();
|
||||
}
|
||||
|
||||
async function _deleteKey(_id: string) {
|
||||
if (!window.confirm('Are you sure you want to delete this API key? This action cannot be undone.')) return;
|
||||
deleteError.value = null;
|
||||
try {
|
||||
await deleteKey({ input: { ids: [_id] } });
|
||||
await refetch();
|
||||
} catch (err: unknown) {
|
||||
if (typeof err === 'object' && err !== null && 'message' in err && typeof (err as { message?: unknown }).message === 'string') {
|
||||
deleteError.value = (err as { message: string }).message;
|
||||
} else {
|
||||
deleteError.value = 'Failed to delete API key.';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<PageContainer>
|
||||
<CardWrapper>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold">API Keys</h2>
|
||||
<Button variant="primary" @click="showCreate = true">Create API Key</Button>
|
||||
</div>
|
||||
<div v-if="deleteError" class="mb-2 p-2 bg-red-100 text-red-700 border border-red-300 rounded">
|
||||
{{ deleteError }}
|
||||
</div>
|
||||
<ul v-if="apiKeys.length" class="space-y-2 mb-4">
|
||||
<li
|
||||
v-for="key in apiKeys"
|
||||
:key="key.id"
|
||||
class="flex items-center justify-between p-2 border rounded"
|
||||
>
|
||||
<div>
|
||||
<span class="font-medium">{{ key.name }}</span>
|
||||
<div v-if="key.roles.length" class="mt-1">
|
||||
<span class="font-semibold">Roles:</span>
|
||||
<span>{{ key.roles.join(', ') }}</span>
|
||||
</div>
|
||||
<div v-if="key.permissions.length" class="mt-1">
|
||||
<span class="font-semibold">Permissions:</span>
|
||||
<ul class="ml-2">
|
||||
<li v-for="perm in key.permissions" :key="perm.resource">
|
||||
<span class="font-medium">{{ perm.resource }}</span>
|
||||
<span v-if="perm.actions && perm.actions.length"> ({{ perm.actions.join(', ') }})</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="createdKey && createdKey.key && createdKey.id === key.id" class="mt-2 flex items-center gap-2">
|
||||
<span>API Key created:</span>
|
||||
<b>{{ showKey ? createdKey.key : '••••••••••••••••••••••••••••••••' }}</b>
|
||||
<button type="button" class="focus:outline-none" @click="toggleShowKey">
|
||||
<component :is="showKey ? EyeSlashIcon : EyeIcon" class="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="destructive" size="sm" @click="_deleteKey(key.id)">Delete</Button>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="showCreate" class="mb-4 p-4 border rounded bg-muted">
|
||||
<ApiKeyCreate
|
||||
v-if="showCreate"
|
||||
:possible-roles="possibleRoles"
|
||||
:possible-permissions="possiblePermissions"
|
||||
@created="onCreated"
|
||||
@cancel="showCreate = false"
|
||||
/>
|
||||
</div>
|
||||
</CardWrapper>
|
||||
</PageContainer>
|
||||
</template>
|
||||
55
web/components/ApiKey/apikey.query.ts
Normal file
55
web/components/ApiKey/apikey.query.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -131,6 +131,43 @@ export type ApiKey = Node & {
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
/** 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<Scalars['String']['output']>;
|
||||
@@ -497,6 +534,10 @@ export type Customization = {
|
||||
theme: Theme;
|
||||
};
|
||||
|
||||
export type DeleteApiKeyInput = {
|
||||
ids: Array<Scalars['PrefixedID']['input']>;
|
||||
};
|
||||
|
||||
export type Devices = Node & {
|
||||
__typename?: 'Devices';
|
||||
gpu: Array<Gpu>;
|
||||
@@ -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<Scalars['String']['output']>;
|
||||
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<NotificationImportance>;
|
||||
};
|
||||
@@ -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<ApiKey>;
|
||||
/** All possible permissions for API keys */
|
||||
apiKeyPossiblePermissions: Array<Permission>;
|
||||
/** All possible roles for API keys */
|
||||
apiKeyPossibleRoles: Array<Role>;
|
||||
apiKeys: Array<ApiKey>;
|
||||
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<Role>, permissions: Array<{ __typename?: 'Permission', resource: Resource, actions: Array<string> }> }> };
|
||||
|
||||
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<Role>, permissions: Array<{ __typename?: 'Permission', resource: Resource, actions: Array<string> }> } } };
|
||||
|
||||
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<Role>, apiKeyPossiblePermissions: Array<{ __typename?: 'Permission', resource: Resource, actions: Array<string> }> };
|
||||
|
||||
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<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 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":"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":"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 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 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<GetConnectSettingsFormQuery, GetConnectSettingsFormQueryVariables>;
|
||||
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<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>;
|
||||
|
||||
10
web/pages/apikey.vue
Normal file
10
web/pages/apikey.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import ApiKeyManager from '~/components/ApiKey/ApiKeyManager.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>API Key</h1>
|
||||
<ApiKeyManager />
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user