mirror of
https://github.com/unraid/api.git
synced 2025-12-31 05:29:48 -06:00
feat: add management page for API keys (#1408)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added ability to update existing API keys, including name, description, roles, and permissions, through the UI and GraphQL API. - Introduced a modal-based interface for creating and editing API keys with improved role and permission selection. - Added a new API Key Manager page and custom element for centralized API key management. - Enhanced API key listing with detailed views, role badges, permission counters, and copy-to-clipboard functionality. - Introduced reusable dialog components for consistent modal experiences. - Added plugin management capabilities with mutations to add or remove plugins. - Added comprehensive support for managing remote access, network URLs, and API key updates within the GraphQL schema. - **Bug Fixes** - Improved error handling and display for API key creation and update operations. - **Refactor** - Centralized API key modal and editing state management using a dedicated store. - Updated GraphQL queries and mutations to use reusable fragments for API key data. - Removed deprecated or redundant remote access and allowed origins configuration components and queries. - Simplified and updated input types for connect settings and remote access. - **Tests** - Added comprehensive tests for API key update logic and improved coverage for API key loading. - **Chores** - Updated configuration files and cleaned up unused schema and component files. - Added new dialog components and centralized exports for dialogs. - Improved ESLint configuration and import statements for better type handling. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -6,5 +6,5 @@
|
||||
],
|
||||
"sandbox": true,
|
||||
"ssoSubIds": [],
|
||||
"plugins": []
|
||||
"plugins": ["unraid-api-plugin-connect"]
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"wanaccess": false,
|
||||
"wanport": 0,
|
||||
"upnpEnabled": false,
|
||||
"apikey": "",
|
||||
"localApiKey": "",
|
||||
"email": "",
|
||||
"username": "",
|
||||
"avatar": "",
|
||||
"regWizTime": "",
|
||||
"accesstoken": "",
|
||||
"idtoken": "",
|
||||
"refreshtoken": "",
|
||||
"dynamicRemoteAccessType": "DISABLED",
|
||||
"ssoSubIds": []
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -226,6 +226,27 @@ type Share implements Node {
|
||||
luksStatus: String
|
||||
}
|
||||
|
||||
type AccessUrl {
|
||||
type: URL_TYPE!
|
||||
name: String
|
||||
ipv4: URL
|
||||
ipv6: URL
|
||||
}
|
||||
|
||||
enum URL_TYPE {
|
||||
LAN
|
||||
WIREGUARD
|
||||
WAN
|
||||
MDNS
|
||||
OTHER
|
||||
DEFAULT
|
||||
}
|
||||
|
||||
"""
|
||||
A field whose value conforms to the standard URL format as specified in RFC3986: https://www.ietf.org/rfc/rfc3986.txt.
|
||||
"""
|
||||
scalar URL
|
||||
|
||||
type DiskPartition {
|
||||
"""The name of the partition"""
|
||||
name: String!
|
||||
@@ -756,6 +777,9 @@ type ApiKeyMutations {
|
||||
|
||||
"""Delete one or more API keys"""
|
||||
delete(input: DeleteApiKeyInput!): Boolean!
|
||||
|
||||
"""Update an API key"""
|
||||
update(input: UpdateApiKeyInput!): ApiKeyWithSecret!
|
||||
}
|
||||
|
||||
input CreateApiKeyInput {
|
||||
@@ -789,6 +813,14 @@ input DeleteApiKeyInput {
|
||||
ids: [PrefixedID!]!
|
||||
}
|
||||
|
||||
input UpdateApiKeyInput {
|
||||
id: PrefixedID!
|
||||
name: String
|
||||
description: String
|
||||
roles: [Role!]
|
||||
permissions: [AddPermissionInput!]
|
||||
}
|
||||
|
||||
"""
|
||||
Parity check related mutations, WIP, response types and functionaliy will change
|
||||
"""
|
||||
@@ -1458,6 +1490,139 @@ type Plugin {
|
||||
hasCliModule: Boolean
|
||||
}
|
||||
|
||||
type AccessUrlObject {
|
||||
ipv4: String
|
||||
ipv6: String
|
||||
type: URL_TYPE!
|
||||
name: String
|
||||
}
|
||||
|
||||
type RemoteAccess {
|
||||
"""The type of WAN access used for Remote Access"""
|
||||
accessType: WAN_ACCESS_TYPE!
|
||||
|
||||
"""The type of port forwarding used for Remote Access"""
|
||||
forwardType: WAN_FORWARD_TYPE
|
||||
|
||||
"""The port used for Remote Access"""
|
||||
port: Int
|
||||
}
|
||||
|
||||
enum WAN_ACCESS_TYPE {
|
||||
DYNAMIC
|
||||
ALWAYS
|
||||
DISABLED
|
||||
}
|
||||
|
||||
enum WAN_FORWARD_TYPE {
|
||||
UPNP
|
||||
STATIC
|
||||
}
|
||||
|
||||
type DynamicRemoteAccessStatus {
|
||||
"""The type of dynamic remote access that is enabled"""
|
||||
enabledType: DynamicRemoteAccessType!
|
||||
|
||||
"""The type of dynamic remote access that is currently running"""
|
||||
runningType: DynamicRemoteAccessType!
|
||||
|
||||
"""Any error message associated with the dynamic remote access"""
|
||||
error: String
|
||||
}
|
||||
|
||||
enum DynamicRemoteAccessType {
|
||||
STATIC
|
||||
UPNP
|
||||
DISABLED
|
||||
}
|
||||
|
||||
type ConnectSettingsValues {
|
||||
"""The type of WAN access used for Remote Access"""
|
||||
accessType: WAN_ACCESS_TYPE!
|
||||
|
||||
"""The type of port forwarding used for Remote Access"""
|
||||
forwardType: WAN_FORWARD_TYPE
|
||||
|
||||
"""The port used for Remote Access"""
|
||||
port: Int
|
||||
}
|
||||
|
||||
type ConnectSettings implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""The data schema for the Connect settings"""
|
||||
dataSchema: JSON!
|
||||
|
||||
"""The UI schema for the Connect settings"""
|
||||
uiSchema: JSON!
|
||||
|
||||
"""The values for the Connect settings"""
|
||||
values: ConnectSettingsValues!
|
||||
}
|
||||
|
||||
type Connect implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""The status of dynamic remote access"""
|
||||
dynamicRemoteAccess: DynamicRemoteAccessStatus!
|
||||
|
||||
"""The settings for the Connect instance"""
|
||||
settings: ConnectSettings!
|
||||
}
|
||||
|
||||
type Network implements Node {
|
||||
id: PrefixedID!
|
||||
accessUrls: [AccessUrl!]
|
||||
}
|
||||
|
||||
type ApiKeyResponse {
|
||||
valid: Boolean!
|
||||
error: String
|
||||
}
|
||||
|
||||
type MinigraphqlResponse {
|
||||
status: MinigraphStatus!
|
||||
timeout: Int
|
||||
error: String
|
||||
}
|
||||
|
||||
"""The status of the minigraph"""
|
||||
enum MinigraphStatus {
|
||||
PRE_INIT
|
||||
CONNECTING
|
||||
CONNECTED
|
||||
PING_FAILURE
|
||||
ERROR_RETRYING
|
||||
}
|
||||
|
||||
type CloudResponse {
|
||||
status: String!
|
||||
ip: String
|
||||
error: String
|
||||
}
|
||||
|
||||
type RelayResponse {
|
||||
status: String!
|
||||
timeout: String
|
||||
error: String
|
||||
}
|
||||
|
||||
type Cloud {
|
||||
error: String
|
||||
apiKey: ApiKeyResponse!
|
||||
relay: RelayResponse
|
||||
minigraphql: MinigraphqlResponse!
|
||||
cloud: CloudResponse!
|
||||
allowedOrigins: [String!]!
|
||||
}
|
||||
|
||||
input AccessUrlObjectInput {
|
||||
ipv4: String
|
||||
ipv6: String
|
||||
type: URL_TYPE!
|
||||
name: String
|
||||
}
|
||||
|
||||
"\n### Description:\n\nID scalar type that prefixes the underlying ID with the server identifier on output and strips it on input.\n\nWe use this scalar type to ensure that the ID is unique across all servers, allowing the same underlying resource ID to be used across different server instances.\n\n#### Input Behavior:\n\nWhen providing an ID as input (e.g., in arguments or input objects), the server identifier prefix ('<serverId>:') is optional.\n\n- If the prefix is present (e.g., '123:456'), it will be automatically stripped, and only the underlying ID ('456') will be used internally.\n- If the prefix is absent (e.g., '456'), the ID will be used as-is.\n\nThis makes it flexible for clients, as they don't strictly need to know or provide the server ID.\n\n#### Output Behavior:\n\nWhen an ID is returned in the response (output), it will *always* be prefixed with the current server's unique identifier (e.g., '123:456').\n\n#### Example:\n\nNote: The server identifier is '123' in this example.\n\n##### Input (Prefix Optional):\n```graphql\n# Both of these are valid inputs resolving to internal ID '456'\n{\n someQuery(id: \"123:456\") { ... }\n anotherQuery(id: \"456\") { ... }\n}\n```\n\n##### Output (Prefix Always Added):\n```graphql\n# Assuming internal ID is '456'\n{\n \"data\": {\n \"someResource\": {\n \"id\": \"123:456\" \n }\n }\n}\n```\n "
|
||||
scalar PrefixedID
|
||||
|
||||
@@ -1504,6 +1669,10 @@ type Query {
|
||||
|
||||
"""List all installed plugins with their metadata"""
|
||||
plugins: [Plugin!]!
|
||||
remoteAccess: RemoteAccess!
|
||||
connect: Connect!
|
||||
network: Network!
|
||||
cloud: Cloud!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
@@ -1546,6 +1715,11 @@ type Mutation {
|
||||
Remove one or more plugins from the API. Returns false if restart was triggered automatically, true if manual restart is required.
|
||||
"""
|
||||
removePlugin(input: PluginManagementInput!): Boolean!
|
||||
updateApiSettings(input: ConnectSettingsInput!): ConnectSettingsValues!
|
||||
connectSignIn(input: ConnectSignInInput!): Boolean!
|
||||
connectSignOut: Boolean!
|
||||
setupRemoteAccess(input: SetupRemoteAccessInput!): Boolean!
|
||||
enableDynamicRemoteAccess(input: EnableDynamicRemoteAccessInput!): Boolean!
|
||||
}
|
||||
|
||||
input NotificationData {
|
||||
@@ -1587,6 +1761,75 @@ input PluginManagementInput {
|
||||
restart: Boolean! = true
|
||||
}
|
||||
|
||||
input ConnectSettingsInput {
|
||||
"""The type of WAN access to use for Remote Access"""
|
||||
accessType: WAN_ACCESS_TYPE
|
||||
|
||||
"""The type of port forwarding to use for Remote Access"""
|
||||
forwardType: WAN_FORWARD_TYPE
|
||||
|
||||
"""
|
||||
The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType. Ignored if accessType is DISABLED or forwardType is UPNP.
|
||||
"""
|
||||
port: Int
|
||||
}
|
||||
|
||||
input ConnectSignInInput {
|
||||
"""The API key for authentication"""
|
||||
apiKey: String!
|
||||
|
||||
"""The ID token for authentication"""
|
||||
idToken: String
|
||||
|
||||
"""User information for the sign-in"""
|
||||
userInfo: ConnectUserInfoInput
|
||||
|
||||
"""The access token for authentication"""
|
||||
accessToken: String
|
||||
|
||||
"""The refresh token for authentication"""
|
||||
refreshToken: String
|
||||
}
|
||||
|
||||
input ConnectUserInfoInput {
|
||||
"""The preferred username of the user"""
|
||||
preferred_username: String!
|
||||
|
||||
"""The email address of the user"""
|
||||
email: String!
|
||||
|
||||
"""The avatar URL of the user"""
|
||||
avatar: String
|
||||
}
|
||||
|
||||
input SetupRemoteAccessInput {
|
||||
"""The type of WAN access to use for Remote Access"""
|
||||
accessType: WAN_ACCESS_TYPE!
|
||||
|
||||
"""The type of port forwarding to use for Remote Access"""
|
||||
forwardType: WAN_FORWARD_TYPE
|
||||
|
||||
"""
|
||||
The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType. Ignored if accessType is DISABLED or forwardType is UPNP.
|
||||
"""
|
||||
port: Int
|
||||
}
|
||||
|
||||
input EnableDynamicRemoteAccessInput {
|
||||
"""The AccessURL Input for dynamic remote access"""
|
||||
url: AccessUrlInput!
|
||||
|
||||
"""Whether to enable or disable dynamic remote access"""
|
||||
enabled: Boolean!
|
||||
}
|
||||
|
||||
input AccessUrlInput {
|
||||
type: URL_TYPE!
|
||||
name: String
|
||||
ipv4: URL
|
||||
ipv6: URL
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
displaySubscription: Display!
|
||||
infoSubscription: Info!
|
||||
|
||||
@@ -476,17 +476,148 @@ describe('ApiKeyService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
let updateMockApiKey: ApiKeyWithSecret;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh copy of the mock data for update tests
|
||||
updateMockApiKey = {
|
||||
id: 'test-api-id',
|
||||
key: 'test-api-key',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ],
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([updateMockApiKey]);
|
||||
vi.spyOn(apiKeyService, 'saveApiKey').mockResolvedValue();
|
||||
apiKeyService.onModuleInit();
|
||||
});
|
||||
|
||||
it('should update name and description', async () => {
|
||||
const updatedName = 'Updated API Key';
|
||||
const updatedDescription = 'Updated Description';
|
||||
|
||||
const result = await apiKeyService.update({
|
||||
id: updateMockApiKey.id,
|
||||
name: updatedName,
|
||||
description: updatedDescription,
|
||||
});
|
||||
|
||||
expect(result.name).toBe(updatedName);
|
||||
expect(result.description).toBe(updatedDescription);
|
||||
expect(result.roles).toEqual(updateMockApiKey.roles);
|
||||
expect(result.permissions).toEqual(updateMockApiKey.permissions);
|
||||
expect(apiKeyService.saveApiKey).toHaveBeenCalledWith(result);
|
||||
});
|
||||
|
||||
it('should update roles', async () => {
|
||||
const updatedRoles = [Role.ADMIN];
|
||||
|
||||
const result = await apiKeyService.update({
|
||||
id: updateMockApiKey.id,
|
||||
roles: updatedRoles,
|
||||
});
|
||||
|
||||
expect(result.roles).toEqual(updatedRoles);
|
||||
expect(result.name).toBe(updateMockApiKey.name);
|
||||
expect(result.description).toBe(updateMockApiKey.description);
|
||||
expect(result.permissions).toEqual(updateMockApiKey.permissions);
|
||||
expect(apiKeyService.saveApiKey).toHaveBeenCalledWith(result);
|
||||
});
|
||||
|
||||
it('should update permissions', async () => {
|
||||
const updatedPermissions = [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ, AuthActionVerb.UPDATE],
|
||||
},
|
||||
];
|
||||
|
||||
const result = await apiKeyService.update({
|
||||
id: updateMockApiKey.id,
|
||||
permissions: updatedPermissions,
|
||||
});
|
||||
|
||||
expect(result.permissions).toEqual(updatedPermissions);
|
||||
expect(result.name).toBe(updateMockApiKey.name);
|
||||
expect(result.description).toBe(updateMockApiKey.description);
|
||||
expect(result.roles).toEqual(updateMockApiKey.roles);
|
||||
expect(apiKeyService.saveApiKey).toHaveBeenCalledWith(result);
|
||||
});
|
||||
|
||||
it('should throw error when API key not found', async () => {
|
||||
await expect(
|
||||
apiKeyService.update({
|
||||
id: 'non-existent-id',
|
||||
name: 'New Name',
|
||||
})
|
||||
).rejects.toThrow('API key not found');
|
||||
});
|
||||
|
||||
it('should throw error when invalid role is provided', async () => {
|
||||
await expect(
|
||||
apiKeyService.update({
|
||||
id: updateMockApiKey.id,
|
||||
roles: ['INVALID_ROLE' as Role],
|
||||
})
|
||||
).rejects.toThrow('Invalid role specified');
|
||||
});
|
||||
|
||||
it('should throw error when invalid name is provided', async () => {
|
||||
await expect(
|
||||
apiKeyService.update({
|
||||
id: updateMockApiKey.id,
|
||||
name: 'Invalid@Name',
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'API key name must contain only letters, numbers, and spaces (Unicode letters are supported)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadAllFromDisk', () => {
|
||||
let loadMockApiKey: ApiKeyWithSecret;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh copy of the mock data for loadAllFromDisk tests
|
||||
loadMockApiKey = {
|
||||
id: 'test-api-id',
|
||||
key: 'test-api-key',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ],
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should load and parse all JSON files', async () => {
|
||||
const mockFiles = ['key1.json', 'key2.json', 'notakey.txt'];
|
||||
const secondKey = { ...loadMockApiKey, id: 'second-id', key: 'second-key' };
|
||||
|
||||
vi.mocked(readdir).mockResolvedValue(mockFiles as any);
|
||||
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockApiKeyWithSecret));
|
||||
vi.mocked(readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify(loadMockApiKey))
|
||||
.mockResolvedValueOnce(JSON.stringify(secondKey));
|
||||
|
||||
const result = await apiKeyService.loadAllFromDisk();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual(mockApiKeyWithSecret);
|
||||
expect(result[0]).toEqual(loadMockApiKey);
|
||||
expect(result[1]).toEqual(secondKey);
|
||||
expect(readFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -508,14 +639,12 @@ describe('ApiKeyService', () => {
|
||||
'notakey.txt',
|
||||
] as any);
|
||||
vi.mocked(readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify(mockApiKeyWithSecret))
|
||||
.mockResolvedValueOnce(JSON.stringify(loadMockApiKey))
|
||||
.mockResolvedValueOnce(JSON.stringify({ invalid: 'structure' }))
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({ ...mockApiKeyWithSecret, id: 'unique-id', key: 'unique-key' })
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({ ...mockApiKeyWithSecret, id: 'unique-id', key: 'unique-key' })
|
||||
JSON.stringify({ ...loadMockApiKey, id: 'unique-id', key: 'unique-key' })
|
||||
);
|
||||
|
||||
const result = await apiKeyService.loadAllFromDisk();
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
|
||||
@@ -374,4 +374,40 @@ export class ApiKeyService implements OnModuleInit {
|
||||
throw errors;
|
||||
}
|
||||
}
|
||||
|
||||
async update({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
roles,
|
||||
permissions,
|
||||
}: {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
roles?: Role[];
|
||||
permissions?: Permission[] | AddPermissionInput[];
|
||||
}): Promise<ApiKeyWithSecret> {
|
||||
const apiKey = this.findByIdWithSecret(id);
|
||||
if (!apiKey) {
|
||||
throw new GraphQLError('API key not found');
|
||||
}
|
||||
if (name) {
|
||||
apiKey.name = this.sanitizeName(name.trim());
|
||||
}
|
||||
if (description !== undefined) {
|
||||
apiKey.description = description;
|
||||
}
|
||||
if (roles) {
|
||||
if (roles.some((role) => !ApiKeyService.validRoles.has(role))) {
|
||||
throw new GraphQLError('Invalid role specified');
|
||||
}
|
||||
apiKey.roles = roles;
|
||||
}
|
||||
if (permissions) {
|
||||
apiKey.permissions = permissions;
|
||||
}
|
||||
await this.saveApiKey(apiKey);
|
||||
return apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { createSandboxPlugin } from '@app/unraid-api/graph/sandbox-plugin.js';
|
||||
import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js';
|
||||
import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
|
||||
|
||||
console.log('ENVIRONMENT', ENVIRONMENT);
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
GlobalDepsModule,
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { AtLeastOneOf } from '@app/unraid-api/graph/resolvers/validation.utils.js';
|
||||
|
||||
@ObjectType()
|
||||
export class Permission {
|
||||
@Field(() => Resource)
|
||||
@@ -108,6 +110,46 @@ export class CreateApiKeyInput {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
overwrite?: boolean;
|
||||
|
||||
@AtLeastOneOf(['roles', 'permissions'], {
|
||||
message: 'At least one role or one permission is required to create an API key.',
|
||||
})
|
||||
_atLeastOne!: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class UpdateApiKeyInput {
|
||||
@Field(() => PrefixedID)
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@Field(() => [Role], { nullable: true })
|
||||
@IsArray()
|
||||
@IsEnum(Role, { each: true })
|
||||
@IsOptional()
|
||||
roles?: Role[];
|
||||
|
||||
@Field(() => [AddPermissionInput], { nullable: true })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AddPermissionInput)
|
||||
@IsOptional()
|
||||
permissions?: AddPermissionInput[];
|
||||
|
||||
@AtLeastOneOf(['roles', 'permissions'], {
|
||||
message: 'At least one role or one permission is required to update an API key.',
|
||||
})
|
||||
_atLeastOne!: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
|
||||
@@ -59,6 +59,7 @@ describe('ApiKeyMutationsResolver', () => {
|
||||
description: 'New API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
permissions: [],
|
||||
_atLeastOne: undefined,
|
||||
};
|
||||
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret);
|
||||
@@ -83,6 +84,7 @@ describe('ApiKeyMutationsResolver', () => {
|
||||
description: 'Should fail',
|
||||
roles: [Role.GUEST],
|
||||
permissions: [],
|
||||
_atLeastOne: undefined,
|
||||
};
|
||||
vi.spyOn(apiKeyService, 'create').mockRejectedValue(new Error('Create failed'));
|
||||
await expect(resolver.create(input)).rejects.toThrow('Create failed');
|
||||
@@ -94,6 +96,7 @@ describe('ApiKeyMutationsResolver', () => {
|
||||
description: 'Should fail sync',
|
||||
roles: [Role.GUEST],
|
||||
permissions: [],
|
||||
_atLeastOne: undefined,
|
||||
};
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret);
|
||||
vi.spyOn(authService, 'syncApiKeyRoles').mockRejectedValue(new Error('Sync failed'));
|
||||
@@ -106,6 +109,7 @@ describe('ApiKeyMutationsResolver', () => {
|
||||
description: 'No name',
|
||||
roles: [Role.GUEST],
|
||||
permissions: [],
|
||||
_atLeastOne: undefined,
|
||||
};
|
||||
await expect(resolver.create(input)).rejects.toThrow();
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
CreateApiKeyInput,
|
||||
DeleteApiKeyInput,
|
||||
RemoveRoleFromApiKeyInput,
|
||||
UpdateApiKeyInput,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key.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';
|
||||
@@ -32,8 +33,7 @@ export class ApiKeyMutationsResolver {
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => ApiKeyWithSecret, { description: 'Create an API key' })
|
||||
async create(@Args('input') unvalidatedInput: CreateApiKeyInput): Promise<ApiKeyWithSecret> {
|
||||
const input = await validateObject(CreateApiKeyInput, unvalidatedInput);
|
||||
async create(@Args('input') input: CreateApiKeyInput): Promise<ApiKeyWithSecret> {
|
||||
const apiKey = await this.apiKeyService.create({
|
||||
name: input.name,
|
||||
description: input.description ?? undefined,
|
||||
@@ -52,8 +52,7 @@ export class ApiKeyMutationsResolver {
|
||||
})
|
||||
@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]);
|
||||
return this.authService.addRoleToApiKey(input.apiKeyId, Role[input.role]);
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
@@ -63,8 +62,7 @@ export class ApiKeyMutationsResolver {
|
||||
})
|
||||
@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]);
|
||||
return this.authService.removeRoleFromApiKey(input.apiKeyId, Role[input.role]);
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
@@ -74,8 +72,19 @@ export class ApiKeyMutationsResolver {
|
||||
})
|
||||
@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);
|
||||
await this.apiKeyService.deleteApiKeys(input.ids);
|
||||
return true;
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => ApiKeyWithSecret, { description: 'Update an API key' })
|
||||
async update(@Args('input') input: UpdateApiKeyInput): Promise<ApiKeyWithSecret> {
|
||||
const apiKey = await this.apiKeyService.update(input);
|
||||
await this.authService.syncApiKeyRoles(apiKey.id, apiKey.roles);
|
||||
return apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { plainToClass } from 'class-transformer';
|
||||
import { validate, ValidationError } from 'class-validator';
|
||||
import {
|
||||
registerDecorator,
|
||||
validate,
|
||||
ValidationArguments,
|
||||
ValidationError,
|
||||
ValidationOptions,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* Validates an object against a class using class-validator
|
||||
@@ -32,3 +38,26 @@ export async function validateObject<T extends object>(type: new () => T, object
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validator to ensure at least one of the given properties is a non-empty array
|
||||
*/
|
||||
export function AtLeastOneOf(properties: string[], validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
name: 'atLeastOneOf',
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
options: validationOptions,
|
||||
validator: {
|
||||
validate(_: any, args: ValidationArguments) {
|
||||
const obj = args.object as any;
|
||||
return properties.some((prop) => Array.isArray(obj[prop]) && obj[prop].length > 0);
|
||||
},
|
||||
defaultMessage(args: ValidationArguments) {
|
||||
return `At least one of the following must be a non-empty array: ${properties.join(', ')}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { buildSchema } from 'graphql';
|
||||
|
||||
async function validateSchema(schemaFile = 'generated-schema.graphql') {
|
||||
try {
|
||||
// Read the generated schema file
|
||||
const schemaPath = join(process.cwd(), schemaFile);
|
||||
const schemaContent = readFileSync(schemaPath, 'utf-8');
|
||||
|
||||
// Try to build the schema
|
||||
const schema = buildSchema(schemaContent);
|
||||
|
||||
// If we get here, the schema is valid
|
||||
console.log(`✅ ${schemaFile} is valid!`);
|
||||
|
||||
// Print some basic schema information
|
||||
const queryType = schema.getQueryType();
|
||||
const mutationType = schema.getMutationType();
|
||||
const subscriptionType = schema.getSubscriptionType();
|
||||
|
||||
console.log('\nSchema Overview:');
|
||||
console.log('----------------');
|
||||
if (queryType) {
|
||||
console.log(`Query Type: ${queryType.name}`);
|
||||
console.log('Query Fields:', Object.keys(queryType.getFields()).join(', '));
|
||||
}
|
||||
if (mutationType) {
|
||||
console.log(`\nMutation Type: ${mutationType.name}`);
|
||||
console.log('Mutation Fields:', Object.keys(mutationType.getFields()).join(', '));
|
||||
}
|
||||
if (subscriptionType) {
|
||||
console.log(`\nSubscription Type: ${subscriptionType.name}`);
|
||||
console.log('Subscription Fields:', Object.keys(subscriptionType.getFields()).join(', '));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Schema validation failed!');
|
||||
console.error('\nError details:');
|
||||
console.error('----------------');
|
||||
console.error(error);
|
||||
|
||||
// If it's a GraphQL error, try to extract more information
|
||||
if (error instanceof Error) {
|
||||
const message = error.message;
|
||||
if (message.includes('Cannot determine a GraphQL output type')) {
|
||||
console.error('\nPossible causes:');
|
||||
console.error('1. Missing @Field() decorator on a type field');
|
||||
console.error('2. Unregistered enum type');
|
||||
console.error('3. Circular dependency in type definitions');
|
||||
console.error('\nLook for fields named "type" in your GraphQL types');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run the validation
|
||||
validateSchema('generated-schema.graphql').catch(console.error);
|
||||
validateSchema('generated-schema-new.graphql').catch(console.error);
|
||||
@@ -1 +1 @@
|
||||
1749572423916
|
||||
1750189490614
|
||||
@@ -1 +1 @@
|
||||
1749572423555
|
||||
1750189490326
|
||||
@@ -1 +1 @@
|
||||
1749572423759
|
||||
1750189490462
|
||||
@@ -1 +1 @@
|
||||
1749572424097
|
||||
1750189490730
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,4 +43,5 @@ export class ConnectResolver {
|
||||
public async settings(): Promise<ConnectSettings> {
|
||||
return {} as ConnectSettings;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
Menu="ManagementAccess:150"
|
||||
Title="API Keys"
|
||||
Icon="icon-u-key"
|
||||
Tag="key"
|
||||
---
|
||||
<unraid-i18n-host>
|
||||
<unraid-api-key-manager />
|
||||
</unraid-i18n-host>
|
||||
@@ -1,13 +1,16 @@
|
||||
import eslint from '@eslint/js';
|
||||
// @ts-ignore-error No Declaration For This Plugin
|
||||
// @ts-expect-error No Declaration For This Plugin
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import noRelativeImportPaths from 'eslint-plugin-no-relative-import-paths';
|
||||
import prettier from 'eslint-plugin-prettier';
|
||||
import vuePlugin from 'eslint-plugin-vue';
|
||||
import tseslint from 'typescript-eslint';
|
||||
// Import vue-eslint-parser as an ESM import
|
||||
import vueEslintParser from 'vue-eslint-parser';
|
||||
|
||||
// Common rules shared across file types
|
||||
const commonRules = {
|
||||
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
|
||||
'@typescript-eslint/no-unused-vars': ['off'],
|
||||
'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 1 }],
|
||||
'no-relative-import-paths/no-relative-import-paths': [
|
||||
@@ -120,7 +123,7 @@ export default [
|
||||
{
|
||||
files: ['**/*.vue'],
|
||||
languageOptions: {
|
||||
parser: require('vue-eslint-parser'),
|
||||
parser: vueEslintParser,
|
||||
parserOptions: {
|
||||
...commonLanguageOptions,
|
||||
parser: tseslint.parser,
|
||||
@@ -146,6 +149,10 @@ export default [
|
||||
|
||||
// Ignores
|
||||
{
|
||||
ignores: ['src/graphql/generated/client/**/*'],
|
||||
ignores: [
|
||||
'src/graphql/generated/client/**/*',
|
||||
'src/global.d.ts',
|
||||
'eslint.config.ts',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -19,3 +19,4 @@ export * from '@/components/common/toast';
|
||||
export * from '@/components/common/popover';
|
||||
export * from '@/components/modals';
|
||||
export * from '@/components/common/accordion';
|
||||
export * from '@/components/common/dialog';
|
||||
|
||||
@@ -12,7 +12,7 @@ const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionItem v-bind="forwardedProps" :class="cn('border-b', props.class)">
|
||||
<AccordionItem v-bind="forwardedProps" :class="cn(props.class)">
|
||||
<slot />
|
||||
</AccordionItem>
|
||||
</template>
|
||||
|
||||
@@ -16,7 +16,7 @@ const delegatedProps = reactiveOmit(props, 'class');
|
||||
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',
|
||||
'flex flex-1 items-center justify-between p-2 rounded-md font-medium transition-all border border-border hover:border-muted-foreground focus:border-muted-foreground [&[data-state=open]>svg]:rotate-180',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cva, VariantProps } from 'class-variance-authority';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
export const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full font-semibold leading-none transition-all duration-200 ease-in-out unraid-ui-badge-test',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cva, VariantProps } from 'class-variance-authority';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md text-base font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
@@ -13,7 +14,7 @@ export const buttonVariants = cva(
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
sm: 'rounded-md px-3 py-1',
|
||||
md: 'h-10 px-4 py-2',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
|
||||
15
unraid-ui/src/components/common/dialog/Dialog.vue
Normal file
15
unraid-ui/src/components/common/dialog/Dialog.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogRoot, useForwardPropsEmits } from 'reka-ui';
|
||||
import type { DialogRootEmits, DialogRootProps } from 'reka-ui';
|
||||
|
||||
const props = defineProps<DialogRootProps>();
|
||||
const emits = defineEmits<DialogRootEmits>();
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
14
unraid-ui/src/components/common/dialog/DialogClose.vue
Normal file
14
unraid-ui/src/components/common/dialog/DialogClose.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogClose, useForwardPropsEmits, type DialogCloseProps } from 'reka-ui';
|
||||
|
||||
const props = defineProps<DialogCloseProps>();
|
||||
const emits = defineEmits([]);
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose v-bind="forwarded">
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
51
unraid-ui/src/components/common/dialog/DialogContent.vue
Normal file
51
unraid-ui/src/components/common/dialog/DialogContent.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import useTeleport from '@/composables/useTeleport';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { reactiveOmit } from '@vueuse/core';
|
||||
import { X } from 'lucide-vue-next';
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
type DialogContentEmits,
|
||||
type DialogContentProps,
|
||||
} from 'reka-ui';
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>();
|
||||
const emits = defineEmits<DialogContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class');
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
|
||||
const { teleportTarget } = useTeleport();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal :to="teleportTarget">
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<DialogContent
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
18
unraid-ui/src/components/common/dialog/DialogDescription.vue
Normal file
18
unraid-ui/src/components/common/dialog/DialogDescription.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { reactiveOmit } from '@vueuse/core';
|
||||
import { DialogDescription, useForwardProps, type DialogDescriptionProps } from 'reka-ui';
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class');
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription v-bind="forwardedProps" :class="cn('text-sm text-muted-foreground', props.class)">
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
12
unraid-ui/src/components/common/dialog/DialogFooter.vue
Normal file
12
unraid-ui/src/components/common/dialog/DialogFooter.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes['class'] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
unraid-ui/src/components/common/dialog/DialogHeader.vue
Normal file
14
unraid-ui/src/components/common/dialog/DialogHeader.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class'];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import useTeleport from '@/composables/useTeleport';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { reactiveOmit } from '@vueuse/core';
|
||||
import { X } from 'lucide-vue-next';
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
type DialogContentEmits,
|
||||
type DialogContentProps,
|
||||
} from 'reka-ui';
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
|
||||
const { teleportTarget } = useTeleport();
|
||||
|
||||
const props = defineProps<
|
||||
DialogContentProps & { class?: HTMLAttributes['class'] } & { to?: string | HTMLElement }
|
||||
>();
|
||||
const emits = defineEmits<DialogContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class');
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal :to="teleportTarget ?? to">
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
v-bind="forwarded"
|
||||
@pointer-down-outside="
|
||||
(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (
|
||||
originalEvent.offsetX > target.clientWidth ||
|
||||
originalEvent.offsetY > target.clientHeight
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute top-3 right-3 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
21
unraid-ui/src/components/common/dialog/DialogTitle.vue
Normal file
21
unraid-ui/src/components/common/dialog/DialogTitle.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { reactiveOmit } from '@vueuse/core';
|
||||
import { DialogTitle, useForwardProps, type DialogTitleProps } from 'reka-ui';
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class');
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-lg font-semibold leading-none tracking-tight', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
11
unraid-ui/src/components/common/dialog/DialogTrigger.vue
Normal file
11
unraid-ui/src/components/common/dialog/DialogTrigger.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogTrigger, type DialogTriggerProps } from 'reka-ui';
|
||||
|
||||
const props = defineProps<DialogTriggerProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger v-bind="props">
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
9
unraid-ui/src/components/common/dialog/index.ts
Normal file
9
unraid-ui/src/components/common/dialog/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { default as Dialog } from './Dialog.vue';
|
||||
export { default as DialogClose } from './DialogClose.vue';
|
||||
export { default as DialogContent } from './DialogContent.vue';
|
||||
export { default as DialogDescription } from './DialogDescription.vue';
|
||||
export { default as DialogFooter } from './DialogFooter.vue';
|
||||
export { default as DialogHeader } from './DialogHeader.vue';
|
||||
export { default as DialogScrollContent } from './DialogScrollContent.vue';
|
||||
export { default as DialogTitle } from './DialogTitle.vue';
|
||||
export { default as DialogTrigger } from './DialogTrigger.vue';
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cva, VariantProps } from 'class-variance-authority';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
export const sheetVariants = cva(
|
||||
'fixed z-50 bg-background gap-4 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 border-border',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { badgeVariants } from '@/components/common/badge/badge.variants';
|
||||
import type { badgeVariants } from '@/components/common/badge/badge.variants';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import type { Component } from 'vue';
|
||||
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
*/
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useModalStore } from '~/store/modal';
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useToggle: vi.fn((value) => () => {
|
||||
value.value = !value.value;
|
||||
}),
|
||||
useToggle: (initial: boolean) => {
|
||||
const state = ref(initial)
|
||||
const toggle = () => { state.value = !state.value }
|
||||
return [state, toggle]
|
||||
}
|
||||
}));
|
||||
|
||||
describe('Modal Store', () => {
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* UnraidApiSettings store test coverage
|
||||
*/
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { WanAccessType, WanForwardType } from '~/composables/gql/graphql';
|
||||
import { useUnraidApiSettingsStore } from '~/store/unraidApiSettings';
|
||||
|
||||
const mockOrigins = ['http://example.com'];
|
||||
const mockRemoteAccess = {
|
||||
accessType: WanAccessType.ALWAYS,
|
||||
forwardType: WanForwardType.UPNP,
|
||||
port: 8080,
|
||||
};
|
||||
|
||||
const mockLoadFn = vi.fn();
|
||||
const mockMutateFn = vi.fn();
|
||||
|
||||
vi.mock('@vue/apollo-composable', () => ({
|
||||
useLazyQuery: () => ({
|
||||
load: mockLoadFn,
|
||||
result: {
|
||||
value: {
|
||||
extraAllowedOrigins: mockOrigins,
|
||||
remoteAccess: mockRemoteAccess,
|
||||
},
|
||||
},
|
||||
}),
|
||||
useMutation: () => ({
|
||||
mutate: mockMutateFn.mockImplementation((args) => {
|
||||
if (args?.input?.origins) {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
setAdditionalAllowedOrigins: args.input.origins,
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
setupRemoteAccess: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('UnraidApiSettings Store', () => {
|
||||
let store: ReturnType<typeof useUnraidApiSettingsStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
store = useUnraidApiSettingsStore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getAllowedOrigins', () => {
|
||||
it('should get origins successfully', async () => {
|
||||
const origins = await store.getAllowedOrigins();
|
||||
|
||||
expect(mockLoadFn).toHaveBeenCalled();
|
||||
expect(Array.isArray(origins)).toBe(true);
|
||||
expect(origins).toEqual(mockOrigins);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAllowedOrigins', () => {
|
||||
it('should set origins and return the updated list of allowed origins', async () => {
|
||||
const newOrigins = ['http://example.com', 'http://test.com'];
|
||||
const result = await store.setAllowedOrigins(newOrigins);
|
||||
|
||||
expect(mockMutateFn).toHaveBeenCalledWith({
|
||||
input: { origins: newOrigins },
|
||||
});
|
||||
expect(result).toEqual(newOrigins);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRemoteAccess', () => {
|
||||
it('should get remote access configuration successfully', async () => {
|
||||
const result = await store.getRemoteAccess();
|
||||
|
||||
expect(mockLoadFn).toHaveBeenCalled();
|
||||
expect(result).toBeDefined();
|
||||
|
||||
if (result) {
|
||||
expect(result).toEqual(mockRemoteAccess);
|
||||
expect(result.accessType).toBe(WanAccessType.ALWAYS);
|
||||
expect(result.forwardType).toBe(WanForwardType.UPNP);
|
||||
expect(result.port).toBe(8080);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupRemoteAccess', () => {
|
||||
it('should setup remote access successfully and return true', async () => {
|
||||
const input = {
|
||||
accessType: WanAccessType.ALWAYS,
|
||||
forwardType: WanForwardType.STATIC,
|
||||
port: 9090,
|
||||
};
|
||||
|
||||
const result = await store.setupRemoteAccess(input);
|
||||
|
||||
expect(mockMutateFn).toHaveBeenCalledWith({ input });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useMutation } from '@vue/apollo-composable';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogScrollContent,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
@@ -15,52 +23,99 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@unraid/ui';
|
||||
import { CREATE_API_KEY } from './apikey.query';
|
||||
import { extractGraphQLErrorMessage } from '~/helpers/functions';
|
||||
|
||||
const props = defineProps<{
|
||||
possibleRoles: string[];
|
||||
possiblePermissions: { resource: string; actions: string[] }[];
|
||||
}>();
|
||||
const emit = defineEmits(['created', 'cancel']);
|
||||
import type { ApolloError } from '@apollo/client/errors';
|
||||
import type { FragmentType } from '~/composables/gql/fragment-masking';
|
||||
import type { Resource, Role } from '~/composables/gql/graphql';
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
|
||||
import { useFragment } from '~/composables/gql/fragment-masking';
|
||||
import { useApiKeyStore } from '~/store/apiKey';
|
||||
import {
|
||||
API_KEY_FRAGMENT,
|
||||
API_KEY_FRAGMENT_WITH_KEY,
|
||||
CREATE_API_KEY,
|
||||
GET_API_KEY_META,
|
||||
UPDATE_API_KEY,
|
||||
} from './apikey.query';
|
||||
import PermissionCounter from './PermissionCounter.vue';
|
||||
|
||||
defineProps<{ t: ComposerTranslation }>();
|
||||
|
||||
const apiKeyStore = useApiKeyStore();
|
||||
const { modalVisible, editingKey } = storeToRefs(apiKeyStore);
|
||||
|
||||
const { result: apiKeyMetaResult } = useQuery(GET_API_KEY_META);
|
||||
const possibleRoles = computed(() => apiKeyMetaResult.value?.apiKeyPossibleRoles || []);
|
||||
const possiblePermissions = computed(() => apiKeyMetaResult.value?.apiKeyPossiblePermissions || []);
|
||||
|
||||
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 newKeyRoles = ref<Role[]>([]);
|
||||
const newKeyPermissions = ref<{ resource: Resource; actions: string[] }[]>([]);
|
||||
const { mutate: createApiKey, loading: createLoading, error: createError } = useMutation(CREATE_API_KEY);
|
||||
const { mutate: updateApiKey, loading: updateLoading, error: updateError } = useMutation(UPDATE_API_KEY);
|
||||
const postCreateLoading = ref(false);
|
||||
|
||||
const loading = computed<boolean>(() => createLoading.value || updateLoading.value);
|
||||
const error = computed<ApolloError | null>(() => createError.value || updateError.value);
|
||||
|
||||
watch(
|
||||
() => editingKey.value,
|
||||
(key) => {
|
||||
const fragmentKey = key
|
||||
? useFragment(API_KEY_FRAGMENT, key as FragmentType<typeof API_KEY_FRAGMENT>)
|
||||
: null;
|
||||
if (fragmentKey) {
|
||||
newKeyName.value = fragmentKey.name;
|
||||
newKeyDescription.value = fragmentKey.description || '';
|
||||
newKeyRoles.value = [...fragmentKey.roles];
|
||||
newKeyPermissions.value = fragmentKey.permissions
|
||||
? fragmentKey.permissions.map((p) => ({
|
||||
resource: p.resource as Resource,
|
||||
actions: [...p.actions],
|
||||
}))
|
||||
: [];
|
||||
} else {
|
||||
newKeyName.value = '';
|
||||
newKeyDescription.value = '';
|
||||
newKeyRoles.value = [];
|
||||
newKeyPermissions.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function togglePermission(resource: string, action: string, checked: boolean) {
|
||||
const perm = newKeyPermissions.value.find(p => p.resource === resource);
|
||||
const res = resource as Resource;
|
||||
const perm = newKeyPermissions.value.find((p) => p.resource === res);
|
||||
if (checked) {
|
||||
if (perm) {
|
||||
if (!perm.actions.includes(action)) perm.actions.push(action);
|
||||
} else {
|
||||
newKeyPermissions.value.push({ resource, actions: [action] });
|
||||
newKeyPermissions.value.push({ resource: res, actions: [action] });
|
||||
}
|
||||
} else {
|
||||
if (perm) {
|
||||
perm.actions = perm.actions.filter(a => a !== action);
|
||||
perm.actions = perm.actions.filter((a) => a !== action);
|
||||
if (perm.actions.length === 0) {
|
||||
newKeyPermissions.value = newKeyPermissions.value.filter(p => p.resource !== resource);
|
||||
newKeyPermissions.value = newKeyPermissions.value.filter((p) => p.resource !== res);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
return possiblePermissions.value.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,
|
||||
newKeyPermissions.value = possiblePermissions.value.map((perm) => ({
|
||||
resource: perm.resource as Resource,
|
||||
actions: [...perm.actions],
|
||||
}));
|
||||
}
|
||||
@@ -70,114 +125,212 @@ function clearAllPermissions() {
|
||||
}
|
||||
|
||||
function areAllActionsSelected(resource: string) {
|
||||
const perm = props.possiblePermissions.find(p => p.resource === resource);
|
||||
const perm = possiblePermissions.value.find((p) => p.resource === resource);
|
||||
if (!perm) return false;
|
||||
const selected = newKeyPermissions.value.find(p => p.resource === resource)?.actions || [];
|
||||
return perm.actions.every(a => selected.includes(a));
|
||||
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);
|
||||
const res = resource as Resource;
|
||||
const perm = possiblePermissions.value.find((p) => p.resource === res);
|
||||
if (!perm) return;
|
||||
const idx = newKeyPermissions.value.findIndex(p => p.resource === resource);
|
||||
const idx = newKeyPermissions.value.findIndex((p) => p.resource === res);
|
||||
if (idx !== -1) {
|
||||
newKeyPermissions.value[idx].actions = [...perm.actions];
|
||||
} else {
|
||||
newKeyPermissions.value.push({ resource, actions: [...perm.actions] });
|
||||
newKeyPermissions.value.push({ resource: res, actions: [...perm.actions] });
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllActions(resource: string) {
|
||||
newKeyPermissions.value = newKeyPermissions.value.filter(p => p.resource !== resource);
|
||||
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,
|
||||
},
|
||||
});
|
||||
const close = () => {
|
||||
apiKeyStore.hideModal();
|
||||
};
|
||||
|
||||
async function upsertKey() {
|
||||
postCreateLoading.value = true;
|
||||
setTimeout(() => {
|
||||
emit('created', res?.data?.apiKey?.create ?? null);
|
||||
postCreateLoading.value = false;
|
||||
try {
|
||||
const isEdit = !!editingKey.value?.id;
|
||||
let res;
|
||||
if (isEdit && editingKey.value) {
|
||||
res = await updateApiKey({
|
||||
input: {
|
||||
id: editingKey.value.id,
|
||||
name: newKeyName.value,
|
||||
description: newKeyDescription.value,
|
||||
roles: newKeyRoles.value,
|
||||
permissions: newKeyPermissions.value.length ? newKeyPermissions.value : undefined,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res = await createApiKey({
|
||||
input: {
|
||||
name: newKeyName.value,
|
||||
description: newKeyDescription.value,
|
||||
roles: newKeyRoles.value,
|
||||
permissions: newKeyPermissions.value.length ? newKeyPermissions.value : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const apiKeyResult = res?.data?.apiKey;
|
||||
if (isEdit && apiKeyResult && 'update' in apiKeyResult) {
|
||||
const fragmentData = useFragment(API_KEY_FRAGMENT_WITH_KEY, apiKeyResult.update);
|
||||
console.log('fragmentData', fragmentData);
|
||||
apiKeyStore.setCreatedKey(fragmentData);
|
||||
} else if (!isEdit && apiKeyResult && 'create' in apiKeyResult) {
|
||||
const fragmentData = useFragment(API_KEY_FRAGMENT_WITH_KEY, apiKeyResult.create);
|
||||
console.log('fragmentData', fragmentData);
|
||||
apiKeyStore.setCreatedKey(fragmentData);
|
||||
}
|
||||
|
||||
modalVisible.value = false;
|
||||
editingKey.value = null;
|
||||
newKeyName.value = '';
|
||||
newKeyDescription.value = '';
|
||||
newKeyRoles.value = [];
|
||||
newKeyPermissions.value = [];
|
||||
}, 1000);
|
||||
} finally {
|
||||
postCreateLoading.value = false;
|
||||
}
|
||||
}
|
||||
</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>
|
||||
<Dialog
|
||||
:open="modalVisible"
|
||||
@close="close"
|
||||
@update:open="
|
||||
(v) => {
|
||||
if (!v) close();
|
||||
}
|
||||
"
|
||||
>
|
||||
<DialogScrollContent class="max-w-800px">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ editingKey ? t('Edit API Key') : t('Create API Key') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
<form @submit.prevent="upsertKey">
|
||||
<div class="mb-2">
|
||||
<Label for="api-key-name">Name</Label>
|
||||
<Input id="api-key-name" v-model="newKeyName" placeholder="Name" class="mt-1" />
|
||||
</div>
|
||||
<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 possibleRoles" :key="role" :value="role">{{
|
||||
role
|
||||
}}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<Label for="api-key-permissions">Permissions</Label>
|
||||
<Accordion id="api-key-permissions" type="single" collapsible class="w-full mt-2">
|
||||
<AccordionItem value="permissions">
|
||||
<AccordionTrigger>
|
||||
<PermissionCounter
|
||||
:permissions="newKeyPermissions"
|
||||
:possible-permissions="possiblePermissions"
|
||||
/>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div class="flex flex-row justify-end my-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
@click="
|
||||
areAllPermissionsSelected() ? clearAllPermissions() : selectAllPermissions()
|
||||
"
|
||||
>
|
||||
{{ areAllPermissionsSelected() ? 'Select None' : 'Select All' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 mt-1">
|
||||
<div
|
||||
v-for="perm in possiblePermissions"
|
||||
:key="perm.resource"
|
||||
class="rounded-sm p-2 border"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="font-semibold">{{ perm.resource }}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
type="button"
|
||||
@click="
|
||||
areAllActionsSelected(perm.resource)
|
||||
? clearAllActions(perm.resource)
|
||||
: selectAllActions(perm.resource)
|
||||
"
|
||||
>
|
||||
{{ areAllActionsSelected(perm.resource) ? 'Select None' : 'Select All' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex gap-4 flex-wrap">
|
||||
<label
|
||||
v-for="action in perm.actions"
|
||||
:key="action"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="
|
||||
!!newKeyPermissions.find(
|
||||
(p) => p.resource === perm.resource && p.actions.includes(action)
|
||||
)
|
||||
"
|
||||
@change="
|
||||
(e: Event) =>
|
||||
togglePermission(
|
||||
perm.resource,
|
||||
action,
|
||||
(e.target as HTMLInputElement)?.checked
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span class="text-sm">{{ action }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
<div v-if="error" class="text-red-500 mt-2 text-sm">
|
||||
{{ extractGraphQLErrorMessage(error) }}
|
||||
</div>
|
||||
</form>
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" @click="close">Cancel</Button>
|
||||
<Button variant="primary" :disabled="loading || postCreateLoading" @click="upsertKey()">
|
||||
<span v-if="loading || postCreateLoading">
|
||||
{{ editingKey ? 'Saving...' : 'Creating...' }}
|
||||
</span>
|
||||
<span v-else>{{ editingKey ? 'Save' : 'Create' }}</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogScrollContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -1,31 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watchEffect } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
|
||||
import { ClipboardDocumentIcon, EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/solid';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Badge,
|
||||
Button,
|
||||
CardWrapper,
|
||||
Input,
|
||||
PageContainer,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@unraid/ui';
|
||||
import { extractGraphQLErrorMessage } from '~/helpers/functions';
|
||||
|
||||
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';
|
||||
import type { ApiKeyFragment, ApiKeyWithKeyFragment } from '~/composables/gql/graphql';
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
roles: string[];
|
||||
permissions: { resource: string; actions: string[] }[];
|
||||
}
|
||||
import { useFragment } from '~/composables/gql/fragment-masking';
|
||||
import { useApiKeyStore } from '~/store/apiKey';
|
||||
import { API_KEY_FRAGMENT, DELETE_API_KEY, GET_API_KEY_META, GET_API_KEYS } from './apikey.query';
|
||||
import PermissionCounter from './PermissionCounter.vue';
|
||||
|
||||
const { result, refetch } = useQuery<{ apiKeys: ApiKey[] }>(GET_API_KEYS);
|
||||
const apiKeys = ref<ApiKey[]>([]);
|
||||
const { result, refetch } = useQuery(GET_API_KEYS);
|
||||
|
||||
const apiKeyStore = useApiKeyStore();
|
||||
const { createdKey } = storeToRefs(apiKeyStore);
|
||||
const apiKeys = ref<(ApiKeyFragment | ApiKeyWithKeyFragment)[]>([]);
|
||||
|
||||
watchEffect(() => {
|
||||
apiKeys.value = result.value?.apiKeys || [];
|
||||
const baseKeys: (ApiKeyFragment | ApiKeyWithKeyFragment)[] =
|
||||
result.value?.apiKeys.map((key) => useFragment(API_KEY_FRAGMENT, key)) || [];
|
||||
console.log(createdKey.value);
|
||||
if (createdKey.value) {
|
||||
const existingKeyIndex = baseKeys.findIndex((key) => key.id === createdKey.value?.id);
|
||||
if (existingKeyIndex >= 0) {
|
||||
baseKeys[existingKeyIndex] = createdKey.value as ApiKeyFragment | ApiKeyWithKeyFragment;
|
||||
} else {
|
||||
baseKeys.unshift(createdKey.value as ApiKeyFragment | ApiKeyWithKeyFragment);
|
||||
}
|
||||
}
|
||||
|
||||
apiKeys.value = baseKeys;
|
||||
});
|
||||
|
||||
const metaQuery = useQuery(GET_API_KEY_META);
|
||||
@@ -36,90 +59,148 @@ watchEffect(() => {
|
||||
possiblePermissions.value = metaQuery.result.value?.apiKeyPossiblePermissions || [];
|
||||
});
|
||||
|
||||
const showCreate = ref(false);
|
||||
const createdKey = ref<{ id: string; key: string } | null>(null);
|
||||
const showKey = ref(false);
|
||||
const showKey = ref<Record<string, boolean>>({});
|
||||
const { copy, copied } = useClipboard();
|
||||
|
||||
const { mutate: deleteKey } = useMutation(DELETE_API_KEY);
|
||||
|
||||
const deleteError = ref<string | null>(null);
|
||||
|
||||
function toggleShowKey() {
|
||||
showKey.value = !showKey.value;
|
||||
function toggleShowKey(keyId: string) {
|
||||
showKey.value[keyId] = !showKey.value[keyId];
|
||||
}
|
||||
|
||||
function onCreated(key: { id: string; key: string } | null) {
|
||||
createdKey.value = key;
|
||||
showCreate.value = false;
|
||||
refetch();
|
||||
function openCreateModal(key: ApiKeyFragment | ApiKeyWithKeyFragment | null = null) {
|
||||
apiKeyStore.clearCreatedKey();
|
||||
apiKeyStore.showModal(key as ApiKeyFragment | null);
|
||||
}
|
||||
|
||||
async function _deleteKey(_id: string) {
|
||||
if (!window.confirm('Are you sure you want to delete this API key? This action cannot be undone.')) return;
|
||||
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.';
|
||||
}
|
||||
deleteError.value = extractGraphQLErrorMessage(err);
|
||||
}
|
||||
}
|
||||
|
||||
function hasKey(key: ApiKeyFragment | ApiKeyWithKeyFragment): key is ApiKeyWithKeyFragment {
|
||||
return 'key' in key && !!key.key;
|
||||
}
|
||||
|
||||
async function copyKeyValue(keyValue: string) {
|
||||
await copy(keyValue);
|
||||
}
|
||||
</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 class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold tracking-tight">API Keys</h2>
|
||||
<Button variant="primary" @click="openCreateModal(null)">Create API Key</Button>
|
||||
</div>
|
||||
<div v-if="deleteError" class="mb-2 p-2 bg-red-100 text-red-700 border border-red-300 rounded">
|
||||
<div
|
||||
v-if="deleteError"
|
||||
class="mb-4 p-3 rounded border border-destructive bg-destructive/10 text-destructive"
|
||||
>
|
||||
{{ 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>
|
||||
<ul v-if="apiKeys.length" class="flex flex-col gap-4 mb-6">
|
||||
<CardWrapper v-for="key in apiKeys" :key="key.id">
|
||||
<li class="flex flex-row items-start justify-between gap-4 p-4 list-none">
|
||||
<div class="flex-1 min-w-0">
|
||||
<header class="flex gap-2 justify-between items-start">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm truncate"><b>ID:</b> {{ key.id.split(':')[1] }}</span>
|
||||
<span class="text-sm truncate"><b>Name:</b> {{ key.name }}</span>
|
||||
<span v-if="key.description" class="text-sm truncate"
|
||||
><b>Description:</b> {{ key.description }}</span
|
||||
>
|
||||
<div v-if="key.roles.length" class="flex flex-wrap gap-2 items-center">
|
||||
<span class="text-sm"><b>Roles:</b></span>
|
||||
<Badge v-for="role in key.roles" :key="role" variant="blue" size="xs">{{
|
||||
role
|
||||
}}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-shrink-0">
|
||||
<Button variant="secondary" size="sm" @click="openCreateModal(key)">Edit</Button>
|
||||
<Button variant="destructive" size="sm" @click="_deleteKey(key.id)">Delete</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div v-if="key.permissions?.length" class="pt-2 w-full">
|
||||
<span class="text-sm"><b>Permissions:</b></span>
|
||||
<Accordion type="single" collapsible class="w-full">
|
||||
<AccordionItem :value="'permissions-' + key.id">
|
||||
<AccordionTrigger>
|
||||
<PermissionCounter
|
||||
:permissions="key.permissions"
|
||||
:possible-permissions="possiblePermissions"
|
||||
/>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div v-if="key.permissions?.length" class="flex flex-col gap-2 my-2">
|
||||
<div
|
||||
v-for="perm in key.permissions ?? []"
|
||||
:key="perm.resource"
|
||||
class="border rounded-sm p-2"
|
||||
>
|
||||
<div class="flex items-center gap-2 justify-between">
|
||||
<span class="font-semibold">{{ perm.resource }}</span>
|
||||
<PermissionCounter
|
||||
:permissions="[perm]"
|
||||
:possible-permissions="possiblePermissions"
|
||||
:hide-number="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<div v-if="hasKey(key)" class="mt-4 flex items-center gap-2">
|
||||
<span class="text-green-700 font-medium">API Key:</span>
|
||||
<div class="relative w-64">
|
||||
<Input
|
||||
:model-value="showKey[key.id] ? key.key : '••••••••••••••••••••••••••••••••'"
|
||||
class="w-full font-mono text-base px-2 py-1 bg-gray-50 border border-gray-200 rounded pr-10"
|
||||
readonly
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-2 flex items-center px-1 text-gray-500 hover:text-gray-700"
|
||||
tabindex="-1"
|
||||
@click="toggleShowKey(key.id)"
|
||||
>
|
||||
<component :is="showKey[key.id] ? EyeSlashIcon : EyeIcon" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip :delay-duration="0">
|
||||
<TooltipTrigger>
|
||||
<Button variant="ghost" size="icon" @click="copyKeyValue(key.key)">
|
||||
<ClipboardDocumentIcon class="w-5 h-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ copied ? 'Copied!' : 'Copy to clipboard...' }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</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>
|
||||
</li>
|
||||
</CardWrapper>
|
||||
</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>
|
||||
<ul v-else class="flex flex-col gap-4 mb-6">
|
||||
<li class="text-sm">No API keys found</li>
|
||||
</ul>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
59
web/components/ApiKey/PermissionCounter.vue
Normal file
59
web/components/ApiKey/PermissionCounter.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Badge } from '@unraid/ui';
|
||||
|
||||
import { actionVariant } from './actionVariant.js';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
permissions: { resource: string; actions: string[] }[];
|
||||
possiblePermissions?: { resource: string; actions: string[] }[];
|
||||
hideNumber?: boolean;
|
||||
label?: string;
|
||||
}>(),
|
||||
{
|
||||
label: '',
|
||||
possiblePermissions: () => [],
|
||||
hideNumber: false,
|
||||
}
|
||||
);
|
||||
|
||||
const possibleActions = computed(() => {
|
||||
if (!props.possiblePermissions) return [];
|
||||
return Array.from(new Set(props.possiblePermissions.flatMap((p) => p.actions)));
|
||||
});
|
||||
|
||||
const actionCounts = computed(() => {
|
||||
const actions = possibleActions.value;
|
||||
const counts: Record<string, number> = {};
|
||||
for (const action of actions) {
|
||||
counts[action] = props.permissions.reduce(
|
||||
(sum, perm) => sum + perm.actions.filter((a) => a === action).length,
|
||||
0
|
||||
);
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
const filteredActions = computed(() => {
|
||||
return possibleActions.value.filter((action) => actionCounts.value[action] > 0);
|
||||
});
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<span v-if="label">{{ label }}</span>
|
||||
<template v-if="possibleActions.length">
|
||||
<Badge
|
||||
v-for="action in filteredActions"
|
||||
:key="action"
|
||||
:variant="actionVariant(action)"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
<span v-if="!hideNumber">{{ action }}: {{ actionCounts[action] || 0 }}</span>
|
||||
<span v-else>{{ action }}</span>
|
||||
</Badge>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
30
web/components/ApiKey/actionVariant.ts
Normal file
30
web/components/ApiKey/actionVariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export function actionVariant(action: string):
|
||||
| 'black'
|
||||
| 'gray'
|
||||
| 'white'
|
||||
| 'custom'
|
||||
| 'red'
|
||||
| 'yellow'
|
||||
| 'green'
|
||||
| 'blue'
|
||||
| 'indigo'
|
||||
| 'purple'
|
||||
| 'pink'
|
||||
| 'orange'
|
||||
| 'transparent'
|
||||
| 'current'
|
||||
| null
|
||||
| undefined {
|
||||
switch (action) {
|
||||
case 'read':
|
||||
return 'blue';
|
||||
case 'create':
|
||||
return 'green';
|
||||
case 'update':
|
||||
return 'yellow';
|
||||
case 'delete':
|
||||
return 'pink';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,38 @@
|
||||
import { graphql } from '~/composables/gql/gql';
|
||||
|
||||
export const API_KEY_FRAGMENT = graphql(/* GraphQL */ `
|
||||
fragment ApiKey on ApiKey {
|
||||
id
|
||||
name
|
||||
description
|
||||
createdAt
|
||||
roles
|
||||
permissions {
|
||||
resource
|
||||
actions
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const API_KEY_FRAGMENT_WITH_KEY = graphql(/* GraphQL */ `
|
||||
fragment ApiKeyWithKey on ApiKeyWithSecret {
|
||||
id
|
||||
key
|
||||
name
|
||||
description
|
||||
createdAt
|
||||
roles
|
||||
permissions {
|
||||
resource
|
||||
actions
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const GET_API_KEYS = graphql(/* GraphQL */ `
|
||||
query ApiKeys {
|
||||
apiKeys {
|
||||
id
|
||||
name
|
||||
description
|
||||
createdAt
|
||||
roles
|
||||
permissions {
|
||||
resource
|
||||
actions
|
||||
}
|
||||
...ApiKey
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -21,16 +41,17 @@ export const CREATE_API_KEY = graphql(/* GraphQL */ `
|
||||
mutation CreateApiKey($input: CreateApiKeyInput!) {
|
||||
apiKey {
|
||||
create(input: $input) {
|
||||
id
|
||||
key
|
||||
name
|
||||
description
|
||||
createdAt
|
||||
roles
|
||||
permissions {
|
||||
resource
|
||||
actions
|
||||
}
|
||||
...ApiKeyWithKey
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const UPDATE_API_KEY = graphql(/* GraphQL */ `
|
||||
mutation UpdateApiKey($input: UpdateApiKeyInput!) {
|
||||
apiKey {
|
||||
update(input: $input) {
|
||||
...ApiKeyWithKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
web/components/ApiKeyPage.ce.vue
Normal file
11
web/components/ApiKeyPage.ce.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import ApiKeyManager from '~/components/ApiKey/ApiKeyManager.vue';
|
||||
</script>
|
||||
<template>
|
||||
<ApiKeyManager />
|
||||
</template>
|
||||
<style lang="postcss">
|
||||
/* Import unraid-ui globals first */
|
||||
@import '@unraid/ui/styles';
|
||||
@import '~/assets/main.css';
|
||||
</style>
|
||||
@@ -1,51 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { BrandButton, Input } from '@unraid/ui';
|
||||
|
||||
import { useUnraidApiSettingsStore } from '~/store/unraidApiSettings';
|
||||
|
||||
const apiSettingsStore = useUnraidApiSettingsStore();
|
||||
|
||||
const originsText = ref<string>('');
|
||||
const errors = ref<string[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
const allowedOriginsSettings = await apiSettingsStore.getAllowedOrigins();
|
||||
originsText.value = allowedOriginsSettings.join(', ');
|
||||
});
|
||||
|
||||
const origins = computed<string[]>(() => {
|
||||
console.log('originsText.value: ' + originsText.value);
|
||||
const newOrigins: string[] = [];
|
||||
if (originsText.value) {
|
||||
originsText.value.split(',').forEach((origin) => {
|
||||
try {
|
||||
const newUrl = new URL(origin.trim());
|
||||
newOrigins.push(newUrl.toString());
|
||||
} catch {
|
||||
errors.value.push(`Invalid origin: ${origin}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return newOrigins;
|
||||
});
|
||||
|
||||
const setAllowedOrigins = () => {
|
||||
apiSettingsStore.setAllowedOrigins(origins.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3 max-w-4xl">
|
||||
<Input
|
||||
v-model="originsText"
|
||||
type="text"
|
||||
placeholder="https://abc.myreverseproxy.com,https://xyz.rvrsprx.com"
|
||||
/>
|
||||
<BrandButton type="button" variant="outline" text="Apply" @click="setAllowedOrigins()">
|
||||
</BrandButton>
|
||||
<div v-for="(error, index) of errors" :key="index">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,60 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { WanAccessType, WanForwardType } from '~/composables/gql/graphql';
|
||||
import { useUnraidApiSettingsStore } from '~/store/unraidApiSettings';
|
||||
|
||||
const apiSettingsStore = useUnraidApiSettingsStore();
|
||||
|
||||
const accessType = ref<WanAccessType>(WanAccessType.DISABLED);
|
||||
const forwardType = ref<WanForwardType | null>(null);
|
||||
const port = ref<number | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
const remoteAccessSettings = await apiSettingsStore.getRemoteAccess();
|
||||
accessType.value =
|
||||
remoteAccessSettings?.accessType ?? WanAccessType.DISABLED;
|
||||
forwardType.value = remoteAccessSettings?.forwardType ?? null;
|
||||
port.value = remoteAccessSettings?.port ?? null;
|
||||
});
|
||||
|
||||
const setRemoteAccess = () => {
|
||||
apiSettingsStore.setupRemoteAccess({
|
||||
accessType: accessType.value,
|
||||
...(forwardType.value ? { forwardType: forwardType.value } : {}),
|
||||
...(port.value ? { port: port.value } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
watch(accessType, (newVal) => {
|
||||
if (newVal !== WanAccessType.DISABLED) {
|
||||
forwardType.value = WanForwardType.STATIC;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<h2>Setup Remote Access</h2>
|
||||
<label for="forwardType">Forward Type</label>
|
||||
<select id="accessType" v-model="accessType">
|
||||
<option v-for="(val, index) in Object.values(WanAccessType)" :key="index" :value="val">
|
||||
{{ val }}
|
||||
</option>
|
||||
</select>
|
||||
<template v-if="accessType !== WanAccessType.DISABLED">
|
||||
<label for="forwardType">Forward Type</label>
|
||||
<select id="forwardType" v-model="forwardType">
|
||||
<option v-for="(val, index) in Object.values(WanForwardType)" :key="index" :value="val">
|
||||
{{ val }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
<template v-if="forwardType === WanForwardType.STATIC && accessType !== WanAccessType.DISABLED">
|
||||
<label for="port">Port</label>
|
||||
<input id="port" v-model="port" type="number">
|
||||
</template>
|
||||
|
||||
<button @click="setRemoteAccess">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,12 +5,15 @@ import { storeToRefs } from 'pinia';
|
||||
import { useCallbackActionsStore } from '~/store/callbackActions';
|
||||
import { useTrialStore } from '~/store/trial';
|
||||
import { useUpdateOsStore } from '~/store/updateOs';
|
||||
import { useApiKeyStore } from '~/store/apiKey';
|
||||
import ApiKeyCreate from '~/components/ApiKey/ApiKeyCreate.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { callbackStatus } = storeToRefs(useCallbackActionsStore());
|
||||
const { trialModalVisible } = storeToRefs(useTrialStore());
|
||||
const { updateOsModalVisible, changelogModalVisible } = storeToRefs(useUpdateOsStore());
|
||||
const { modalVisible: apiKeyModalVisible } = storeToRefs(useApiKeyStore());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -20,6 +23,7 @@ const { updateOsModalVisible, changelogModalVisible } = storeToRefs(useUpdateOsS
|
||||
<UpdateOsCheckUpdateResponseModal :t="t" :open="updateOsModalVisible" />
|
||||
<UpdateOsChangelogModal :t="t" :open="changelogModalVisible" />
|
||||
<ActivationModal :t="t" />
|
||||
<ApiKeyCreate :open="apiKeyModalVisible" :t="t" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -16,8 +16,11 @@ 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 fragment ApiKey on ApiKey {\n id\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n": typeof types.ApiKeyFragmentDoc,
|
||||
"\n fragment ApiKeyWithKey on ApiKeyWithSecret {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n": typeof types.ApiKeyWithKeyFragmentDoc,
|
||||
"\n query ApiKeys {\n apiKeys {\n ...ApiKey\n }\n }\n": typeof types.ApiKeysDocument,
|
||||
"\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKeyWithKey\n }\n } \n }\n": typeof types.CreateApiKeyDocument,
|
||||
"\n mutation UpdateApiKey($input: UpdateApiKeyInput!) {\n apiKey {\n update(input: $input) {\n ...ApiKeyWithKey\n }\n }\n }\n": typeof types.UpdateApiKeyDocument,
|
||||
"\n mutation 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 Unified {\n settings {\n unified {\n id\n dataSchema\n uiSchema\n values\n }\n }\n }\n": typeof types.UnifiedDocument,
|
||||
@@ -45,16 +48,15 @@ type Documents = {
|
||||
"\n fragment PartialCloud on Cloud {\n error\n apiKey {\n valid\n error\n }\n cloud {\n status\n error\n }\n minigraphql {\n status\n error\n }\n relay {\n status\n error\n }\n }\n": typeof types.PartialCloudFragmentDoc,
|
||||
"\n query serverState {\n cloud {\n ...PartialCloud\n }\n config {\n error\n valid\n }\n info {\n os {\n hostname\n }\n }\n owner {\n avatar\n username\n }\n registration {\n state\n expiration\n keyFile {\n contents\n }\n updateExpiration\n }\n vars {\n regGen\n regState\n configError\n configValid\n }\n }\n": typeof types.ServerStateDocument,
|
||||
"\n query getTheme {\n publicTheme {\n name\n showBannerImage\n showBannerGradient\n headerBackgroundColor\n showHeaderDescription\n headerPrimaryTextColor\n headerSecondaryTextColor\n }\n }\n": typeof types.GetThemeDocument,
|
||||
"\n query getExtraAllowedOrigins {\n extraAllowedOrigins\n }\n": typeof types.GetExtraAllowedOriginsDocument,
|
||||
"\n query getRemoteAccess {\n remoteAccess {\n accessType\n forwardType\n port\n }\n }\n": typeof types.GetRemoteAccessDocument,
|
||||
"\n mutation setAdditionalAllowedOrigins($input: AllowedOriginInput!) {\n setAdditionalAllowedOrigins(input: $input)\n }\n": typeof types.SetAdditionalAllowedOriginsDocument,
|
||||
"\n mutation setupRemoteAccess($input: SetupRemoteAccessInput!) {\n setupRemoteAccess(input: $input)\n }\n": typeof types.SetupRemoteAccessDocument,
|
||||
};
|
||||
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 fragment ApiKey on ApiKey {\n id\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n": types.ApiKeyFragmentDoc,
|
||||
"\n fragment ApiKeyWithKey on ApiKeyWithSecret {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n": types.ApiKeyWithKeyFragmentDoc,
|
||||
"\n query ApiKeys {\n apiKeys {\n ...ApiKey\n }\n }\n": types.ApiKeysDocument,
|
||||
"\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKeyWithKey\n }\n } \n }\n": types.CreateApiKeyDocument,
|
||||
"\n mutation UpdateApiKey($input: UpdateApiKeyInput!) {\n apiKey {\n update(input: $input) {\n ...ApiKeyWithKey\n }\n }\n }\n": types.UpdateApiKeyDocument,
|
||||
"\n mutation 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 Unified {\n settings {\n unified {\n id\n dataSchema\n uiSchema\n values\n }\n }\n }\n": types.UnifiedDocument,
|
||||
@@ -82,10 +84,6 @@ const documents: Documents = {
|
||||
"\n fragment PartialCloud on Cloud {\n error\n apiKey {\n valid\n error\n }\n cloud {\n status\n error\n }\n minigraphql {\n status\n error\n }\n relay {\n status\n error\n }\n }\n": types.PartialCloudFragmentDoc,
|
||||
"\n query serverState {\n cloud {\n ...PartialCloud\n }\n config {\n error\n valid\n }\n info {\n os {\n hostname\n }\n }\n owner {\n avatar\n username\n }\n registration {\n state\n expiration\n keyFile {\n contents\n }\n updateExpiration\n }\n vars {\n regGen\n regState\n configError\n configValid\n }\n }\n": types.ServerStateDocument,
|
||||
"\n query getTheme {\n publicTheme {\n name\n showBannerImage\n showBannerGradient\n headerBackgroundColor\n showHeaderDescription\n headerPrimaryTextColor\n headerSecondaryTextColor\n }\n }\n": types.GetThemeDocument,
|
||||
"\n query getExtraAllowedOrigins {\n extraAllowedOrigins\n }\n": types.GetExtraAllowedOriginsDocument,
|
||||
"\n query getRemoteAccess {\n remoteAccess {\n accessType\n forwardType\n port\n }\n }\n": types.GetRemoteAccessDocument,
|
||||
"\n mutation setAdditionalAllowedOrigins($input: AllowedOriginInput!) {\n setAdditionalAllowedOrigins(input: $input)\n }\n": types.SetAdditionalAllowedOriginsDocument,
|
||||
"\n mutation setupRemoteAccess($input: SetupRemoteAccessInput!) {\n setupRemoteAccess(input: $input)\n }\n": types.SetupRemoteAccessDocument,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -113,11 +111,23 @@ export function graphql(source: "\n query ActivationCode {\n vars {\n r
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n 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"];
|
||||
export function graphql(source: "\n fragment ApiKey on ApiKey {\n id\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n"): (typeof documents)["\n fragment ApiKey on ApiKey {\n id\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n"];
|
||||
/**
|
||||
* 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"];
|
||||
export function graphql(source: "\n fragment ApiKeyWithKey on ApiKeyWithSecret {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n"): (typeof documents)["\n fragment ApiKeyWithKey on ApiKeyWithSecret {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n"];
|
||||
/**
|
||||
* 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 ...ApiKey\n }\n }\n"): (typeof documents)["\n query ApiKeys {\n apiKeys {\n ...ApiKey\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 ...ApiKeyWithKey\n }\n } \n }\n"): (typeof documents)["\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKeyWithKey\n }\n } \n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation UpdateApiKey($input: UpdateApiKeyInput!) {\n apiKey {\n update(input: $input) {\n ...ApiKeyWithKey\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateApiKey($input: UpdateApiKeyInput!) {\n apiKey {\n update(input: $input) {\n ...ApiKeyWithKey\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -226,22 +236,6 @@ export function graphql(source: "\n query serverState {\n cloud {\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 getTheme {\n publicTheme {\n name\n showBannerImage\n showBannerGradient\n headerBackgroundColor\n showHeaderDescription\n headerPrimaryTextColor\n headerSecondaryTextColor\n }\n }\n"): (typeof documents)["\n query getTheme {\n publicTheme {\n name\n showBannerImage\n showBannerGradient\n headerBackgroundColor\n showHeaderDescription\n headerPrimaryTextColor\n headerSecondaryTextColor\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 getExtraAllowedOrigins {\n extraAllowedOrigins\n }\n"): (typeof documents)["\n query getExtraAllowedOrigins {\n extraAllowedOrigins\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 getRemoteAccess {\n remoteAccess {\n accessType\n forwardType\n port\n }\n }\n"): (typeof documents)["\n query getRemoteAccess {\n remoteAccess {\n accessType\n forwardType\n port\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 setAdditionalAllowedOrigins($input: AllowedOriginInput!) {\n setAdditionalAllowedOrigins(input: $input)\n }\n"): (typeof documents)["\n mutation setAdditionalAllowedOrigins($input: AllowedOriginInput!) {\n setAdditionalAllowedOrigins(input: $input)\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 setupRemoteAccess($input: SetupRemoteAccessInput!) {\n setupRemoteAccess(input: $input)\n }\n"): (typeof documents)["\n mutation setupRemoteAccess($input: SetupRemoteAccessInput!) {\n setupRemoteAccess(input: $input)\n }\n"];
|
||||
|
||||
export function graphql(source: string) {
|
||||
return (documents as any)[source] ?? {};
|
||||
|
||||
@@ -129,14 +129,10 @@ export type AddRoleForApiKeyInput = {
|
||||
role: Role;
|
||||
};
|
||||
|
||||
export type AllowedOriginInput = {
|
||||
/** A list of origins allowed to interact with the API */
|
||||
origins: Array<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type ApiConfig = {
|
||||
__typename?: 'ApiConfig';
|
||||
extraOrigins: Array<Scalars['String']['output']>;
|
||||
plugins: Array<Scalars['String']['output']>;
|
||||
sandbox?: Maybe<Scalars['Boolean']['output']>;
|
||||
ssoSubIds: Array<Scalars['String']['output']>;
|
||||
version: Scalars['String']['output'];
|
||||
@@ -163,6 +159,8 @@ export type ApiKeyMutations = {
|
||||
delete: Scalars['Boolean']['output'];
|
||||
/** Remove a role from an API key */
|
||||
removeRole: Scalars['Boolean']['output'];
|
||||
/** Update an API key */
|
||||
update: ApiKeyWithSecret;
|
||||
};
|
||||
|
||||
|
||||
@@ -189,6 +187,12 @@ export type ApiKeyMutationsRemoveRoleArgs = {
|
||||
input: RemoveRoleFromApiKeyInput;
|
||||
};
|
||||
|
||||
|
||||
/** API Key related mutations */
|
||||
export type ApiKeyMutationsUpdateArgs = {
|
||||
input: UpdateApiKeyInput;
|
||||
};
|
||||
|
||||
export type ApiKeyResponse = {
|
||||
__typename?: 'ApiKeyResponse';
|
||||
error?: Maybe<Scalars['String']['output']>;
|
||||
@@ -206,21 +210,6 @@ export type ApiKeyWithSecret = Node & {
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
export type ApiSettingsInput = {
|
||||
/** The type of WAN access to use for Remote Access */
|
||||
accessType?: InputMaybe<WanAccessType>;
|
||||
/** A list of origins allowed to interact with the API */
|
||||
extraOrigins?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
/** The type of port forwarding to use for Remote Access */
|
||||
forwardType?: InputMaybe<WanForwardType>;
|
||||
/** The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType. Ignored if accessType is DISABLED or forwardType is UPNP. */
|
||||
port?: InputMaybe<Scalars['Int']['input']>;
|
||||
/** If true, the GraphQL sandbox will be enabled and available at /graphql. If false, the GraphQL sandbox will be disabled and only the production API will be available. */
|
||||
sandbox?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
/** A list of Unique Unraid Account ID's */
|
||||
ssoUserIds?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
};
|
||||
|
||||
export type ArrayCapacity = {
|
||||
__typename?: 'ArrayCapacity';
|
||||
/** Capacity in number of disks */
|
||||
@@ -478,20 +467,23 @@ export type ConnectSettings = Node & {
|
||||
values: ConnectSettingsValues;
|
||||
};
|
||||
|
||||
export type ConnectSettingsInput = {
|
||||
/** The type of WAN access to use for Remote Access */
|
||||
accessType?: InputMaybe<WanAccessType>;
|
||||
/** The type of port forwarding to use for Remote Access */
|
||||
forwardType?: InputMaybe<WanForwardType>;
|
||||
/** The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType. Ignored if accessType is DISABLED or forwardType is UPNP. */
|
||||
port?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
export type ConnectSettingsValues = {
|
||||
__typename?: 'ConnectSettingsValues';
|
||||
/** The type of WAN access used for Remote Access */
|
||||
accessType: WanAccessType;
|
||||
/** A list of origins allowed to interact with the API */
|
||||
extraOrigins: Array<Scalars['String']['output']>;
|
||||
/** The type of port forwarding used for Remote Access */
|
||||
forwardType?: Maybe<WanForwardType>;
|
||||
/** The port used for Remote Access */
|
||||
port?: Maybe<Scalars['Int']['output']>;
|
||||
/** If true, the GraphQL sandbox is enabled and available at /graphql. If false, the GraphQL sandbox is disabled and only the production API will be available. */
|
||||
sandbox: Scalars['Boolean']['output'];
|
||||
/** A list of Unique Unraid Account ID's */
|
||||
ssoUserIds: Array<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type ConnectSignInInput = {
|
||||
@@ -942,6 +934,8 @@ export type MinigraphqlResponse = {
|
||||
|
||||
export type Mutation = {
|
||||
__typename?: 'Mutation';
|
||||
/** Add one or more plugins to the API. Returns false if restart was triggered automatically, true if manual restart is required. */
|
||||
addPlugin: Scalars['Boolean']['output'];
|
||||
apiKey: ApiKeyMutations;
|
||||
archiveAll: NotificationOverview;
|
||||
/** Marks a notification as archived. */
|
||||
@@ -963,7 +957,8 @@ export type Mutation = {
|
||||
rclone: RCloneMutations;
|
||||
/** Reads each notification to recompute & update the overview. */
|
||||
recalculateOverview: NotificationOverview;
|
||||
setAdditionalAllowedOrigins: Array<Scalars['String']['output']>;
|
||||
/** Remove one or more plugins from the API. Returns false if restart was triggered automatically, true if manual restart is required. */
|
||||
removePlugin: Scalars['Boolean']['output'];
|
||||
setupRemoteAccess: Scalars['Boolean']['output'];
|
||||
unarchiveAll: NotificationOverview;
|
||||
unarchiveNotifications: NotificationOverview;
|
||||
@@ -975,6 +970,11 @@ export type Mutation = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationAddPluginArgs = {
|
||||
input: PluginManagementInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationArchiveAllArgs = {
|
||||
importance?: InputMaybe<NotificationImportance>;
|
||||
};
|
||||
@@ -1016,8 +1016,8 @@ export type MutationInitiateFlashBackupArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationSetAdditionalAllowedOriginsArgs = {
|
||||
input: AllowedOriginInput;
|
||||
export type MutationRemovePluginArgs = {
|
||||
input: PluginManagementInput;
|
||||
};
|
||||
|
||||
|
||||
@@ -1042,7 +1042,7 @@ export type MutationUnreadNotificationArgs = {
|
||||
|
||||
|
||||
export type MutationUpdateApiSettingsArgs = {
|
||||
input: ApiSettingsInput;
|
||||
input: ConnectSettingsInput;
|
||||
};
|
||||
|
||||
|
||||
@@ -1212,6 +1212,27 @@ export type Permission = {
|
||||
resource: Resource;
|
||||
};
|
||||
|
||||
export type Plugin = {
|
||||
__typename?: 'Plugin';
|
||||
/** Whether the plugin has an API module */
|
||||
hasApiModule?: Maybe<Scalars['Boolean']['output']>;
|
||||
/** Whether the plugin has a CLI module */
|
||||
hasCliModule?: Maybe<Scalars['Boolean']['output']>;
|
||||
/** The name of the plugin package */
|
||||
name: Scalars['String']['output'];
|
||||
/** The version of the plugin package */
|
||||
version: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type PluginManagementInput = {
|
||||
/** Whether to treat plugins as bundled plugins. Bundled plugins are installed to node_modules at build time and controlled via config only. */
|
||||
bundled?: Scalars['Boolean']['input'];
|
||||
/** Array of plugin package names to add or remove */
|
||||
names: Array<Scalars['String']['input']>;
|
||||
/** Whether to restart the API after the operation. When false, a restart has already been queued. */
|
||||
restart?: Scalars['Boolean']['input'];
|
||||
};
|
||||
|
||||
export type ProfileModel = Node & {
|
||||
__typename?: 'ProfileModel';
|
||||
avatar: Scalars['String']['output'];
|
||||
@@ -1247,7 +1268,6 @@ export type Query = {
|
||||
disks: Array<Disk>;
|
||||
display: Display;
|
||||
docker: Docker;
|
||||
extraAllowedOrigins: Array<Scalars['String']['output']>;
|
||||
flash: Flash;
|
||||
info: Info;
|
||||
logFile: LogFileContent;
|
||||
@@ -1259,6 +1279,8 @@ export type Query = {
|
||||
online: Scalars['Boolean']['output'];
|
||||
owner: Owner;
|
||||
parityHistory: Array<ParityCheck>;
|
||||
/** List all installed plugins with their metadata */
|
||||
plugins: Array<Plugin>;
|
||||
publicPartnerInfo?: Maybe<PublicPartnerInfo>;
|
||||
publicTheme: Theme;
|
||||
rclone: RCloneBackupSettings;
|
||||
@@ -1637,6 +1659,14 @@ export type UnraidArray = Node & {
|
||||
state: ArrayState;
|
||||
};
|
||||
|
||||
export type UpdateApiKeyInput = {
|
||||
description?: InputMaybe<Scalars['String']['input']>;
|
||||
id: Scalars['PrefixedID']['input'];
|
||||
name?: InputMaybe<Scalars['String']['input']>;
|
||||
permissions?: InputMaybe<Array<AddPermissionInput>>;
|
||||
roles?: InputMaybe<Array<Role>>;
|
||||
};
|
||||
|
||||
export type UpdateSettingsResponse = {
|
||||
__typename?: 'UpdateSettingsResponse';
|
||||
/** Whether a restart is required for the changes to take effect */
|
||||
@@ -1983,17 +2013,37 @@ export type ActivationCodeQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
export type ActivationCodeQuery = { __typename?: 'Query', vars: { __typename?: 'Vars', regState?: RegistrationState | null }, customization?: { __typename?: 'Customization', activationCode?: { __typename?: 'ActivationCode', code?: string | null, partnerName?: string | null, serverName?: string | null, sysModel?: string | null, comment?: string | null, header?: string | null, headermetacolor?: string | null, background?: string | null, showBannerGradient?: boolean | null, theme?: string | null } | null, partnerInfo?: { __typename?: 'PublicPartnerInfo', hasPartnerLogo: boolean, partnerName?: string | null, partnerUrl?: string | null, partnerLogoUrl?: string | null } | null } | null };
|
||||
|
||||
export type ApiKeyFragment = { __typename?: 'ApiKey', id: string, name: string, description?: string | null, createdAt: string, roles: Array<Role>, permissions: Array<{ __typename?: 'Permission', resource: Resource, actions: Array<string> }> } & { ' $fragmentName'?: 'ApiKeyFragment' };
|
||||
|
||||
export type ApiKeyWithKeyFragment = { __typename?: 'ApiKeyWithSecret', id: string, key: string, name: string, description?: string | null, createdAt: string, roles: Array<Role>, permissions: Array<{ __typename?: 'Permission', resource: Resource, actions: Array<string> }> } & { ' $fragmentName'?: 'ApiKeyWithKeyFragment' };
|
||||
|
||||
export type 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 ApiKeysQuery = { __typename?: 'Query', apiKeys: Array<(
|
||||
{ __typename?: 'ApiKey' }
|
||||
& { ' $fragmentRefs'?: { 'ApiKeyFragment': ApiKeyFragment } }
|
||||
)> };
|
||||
|
||||
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 CreateApiKeyMutation = { __typename?: 'Mutation', apiKey: { __typename?: 'ApiKeyMutations', create: (
|
||||
{ __typename?: 'ApiKeyWithSecret' }
|
||||
& { ' $fragmentRefs'?: { 'ApiKeyWithKeyFragment': ApiKeyWithKeyFragment } }
|
||||
) } };
|
||||
|
||||
export type UpdateApiKeyMutationVariables = Exact<{
|
||||
input: UpdateApiKeyInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type UpdateApiKeyMutation = { __typename?: 'Mutation', apiKey: { __typename?: 'ApiKeyMutations', update: (
|
||||
{ __typename?: 'ApiKeyWithSecret' }
|
||||
& { ' $fragmentRefs'?: { 'ApiKeyWithKeyFragment': ApiKeyWithKeyFragment } }
|
||||
) } };
|
||||
|
||||
export type DeleteApiKeyMutationVariables = Exact<{
|
||||
input: DeleteApiKeyInput;
|
||||
@@ -2170,37 +2220,16 @@ export type GetThemeQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
export type GetThemeQuery = { __typename?: 'Query', publicTheme: { __typename?: 'Theme', name: ThemeName, showBannerImage: boolean, showBannerGradient: boolean, headerBackgroundColor: string, showHeaderDescription: boolean, headerPrimaryTextColor: string, headerSecondaryTextColor?: string | null } };
|
||||
|
||||
export type GetExtraAllowedOriginsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetExtraAllowedOriginsQuery = { __typename?: 'Query', extraAllowedOrigins: Array<string> };
|
||||
|
||||
export type GetRemoteAccessQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetRemoteAccessQuery = { __typename?: 'Query', remoteAccess: { __typename?: 'RemoteAccess', accessType: WanAccessType, forwardType?: WanForwardType | null, port?: number | null } };
|
||||
|
||||
export type SetAdditionalAllowedOriginsMutationVariables = Exact<{
|
||||
input: AllowedOriginInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type SetAdditionalAllowedOriginsMutation = { __typename?: 'Mutation', setAdditionalAllowedOrigins: Array<string> };
|
||||
|
||||
export type SetupRemoteAccessMutationVariables = Exact<{
|
||||
input: SetupRemoteAccessInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type SetupRemoteAccessMutation = { __typename?: 'Mutation', setupRemoteAccess: boolean };
|
||||
|
||||
export const ApiKeyFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKey"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<ApiKeyFragment, unknown>;
|
||||
export const ApiKeyWithKeyFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKeyWithKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKeyWithSecret"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<ApiKeyWithKeyFragment, unknown>;
|
||||
export const NotificationFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NotificationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Notification"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"importance"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"formattedTimestamp"}}]}}]} as unknown as DocumentNode<NotificationFragmentFragment, unknown>;
|
||||
export const NotificationCountFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NotificationCountFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NotificationCounts"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"total"}},{"kind":"Field","name":{"kind":"Name","value":"info"}},{"kind":"Field","name":{"kind":"Name","value":"warning"}},{"kind":"Field","name":{"kind":"Name","value":"alert"}}]}}]} as unknown as DocumentNode<NotificationCountFragmentFragment, unknown>;
|
||||
export const PartialCloudFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PartialCloud"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Cloud"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cloud"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"minigraphql"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"relay"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]} as unknown as DocumentNode<PartialCloudFragment, unknown>;
|
||||
export const PartnerInfoDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PartnerInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publicPartnerInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasPartnerLogo"}},{"kind":"Field","name":{"kind":"Name","value":"partnerName"}},{"kind":"Field","name":{"kind":"Name","value":"partnerUrl"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoUrl"}}]}}]}}]} as unknown as DocumentNode<PartnerInfoQuery, PartnerInfoQueryVariables>;
|
||||
export const 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 ApiKeysDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ApiKeys"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKeys"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApiKey"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKey"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<ApiKeysQuery, ApiKeysQueryVariables>;
|
||||
export const CreateApiKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateApiKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateApiKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApiKeyWithKey"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKeyWithKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKeyWithSecret"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<CreateApiKeyMutation, CreateApiKeyMutationVariables>;
|
||||
export const UpdateApiKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateApiKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateApiKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"update"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApiKeyWithKey"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKeyWithKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKeyWithSecret"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<UpdateApiKeyMutation, UpdateApiKeyMutationVariables>;
|
||||
export const 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 UnifiedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Unified"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"settings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unified"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSchema"}},{"kind":"Field","name":{"kind":"Name","value":"uiSchema"}},{"kind":"Field","name":{"kind":"Name","value":"values"}}]}}]}}]}}]} as unknown as DocumentNode<UnifiedQuery, UnifiedQueryVariables>;
|
||||
@@ -2224,8 +2253,4 @@ export const ListRCloneRemotesDocument = {"kind":"Document","definitions":[{"kin
|
||||
export const ConnectSignInDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ConnectSignIn"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ConnectSignInInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectSignIn"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<ConnectSignInMutation, ConnectSignInMutationVariables>;
|
||||
export const SignOutDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SignOut"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectSignOut"}}]}}]} as unknown as DocumentNode<SignOutMutation, SignOutMutationVariables>;
|
||||
export const ServerStateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"serverState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cloud"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PartialCloud"}}]}},{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}}]}},{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"os"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hostname"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"owner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"registration"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"expiration"}},{"kind":"Field","name":{"kind":"Name","value":"keyFile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contents"}}]}},{"kind":"Field","name":{"kind":"Name","value":"updateExpiration"}}]}},{"kind":"Field","name":{"kind":"Name","value":"vars"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"regGen"}},{"kind":"Field","name":{"kind":"Name","value":"regState"}},{"kind":"Field","name":{"kind":"Name","value":"configError"}},{"kind":"Field","name":{"kind":"Name","value":"configValid"}}]}}]}},{"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<ServerStateQuery, ServerStateQueryVariables>;
|
||||
export const GetThemeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"getTheme"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publicTheme"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerImage"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerGradient"}},{"kind":"Field","name":{"kind":"Name","value":"headerBackgroundColor"}},{"kind":"Field","name":{"kind":"Name","value":"showHeaderDescription"}},{"kind":"Field","name":{"kind":"Name","value":"headerPrimaryTextColor"}},{"kind":"Field","name":{"kind":"Name","value":"headerSecondaryTextColor"}}]}}]}}]} as unknown as DocumentNode<GetThemeQuery, GetThemeQueryVariables>;
|
||||
export const GetExtraAllowedOriginsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"getExtraAllowedOrigins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"extraAllowedOrigins"}}]}}]} as unknown as DocumentNode<GetExtraAllowedOriginsQuery, GetExtraAllowedOriginsQueryVariables>;
|
||||
export const GetRemoteAccessDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"getRemoteAccess"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"remoteAccess"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"accessType"}},{"kind":"Field","name":{"kind":"Name","value":"forwardType"}},{"kind":"Field","name":{"kind":"Name","value":"port"}}]}}]}}]} as unknown as DocumentNode<GetRemoteAccessQuery, GetRemoteAccessQueryVariables>;
|
||||
export const SetAdditionalAllowedOriginsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"setAdditionalAllowedOrigins"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AllowedOriginInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setAdditionalAllowedOrigins"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<SetAdditionalAllowedOriginsMutation, SetAdditionalAllowedOriginsMutationVariables>;
|
||||
export const SetupRemoteAccessDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"setupRemoteAccess"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SetupRemoteAccessInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setupRemoteAccess"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<SetupRemoteAccessMutation, SetupRemoteAccessMutationVariables>;
|
||||
export const GetThemeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"getTheme"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publicTheme"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerImage"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerGradient"}},{"kind":"Field","name":{"kind":"Name","value":"headerBackgroundColor"}},{"kind":"Field","name":{"kind":"Name","value":"showHeaderDescription"}},{"kind":"Field","name":{"kind":"Name","value":"headerPrimaryTextColor"}},{"kind":"Field","name":{"kind":"Name","value":"headerSecondaryTextColor"}}]}}]}}]} as unknown as DocumentNode<GetThemeQuery, GetThemeQueryVariables>;
|
||||
@@ -1,2 +1,21 @@
|
||||
/** Output key + value as string for each item in the object. Adds new line after each item. */
|
||||
export const OBJ_TO_STR = (obj: object): string => Object.entries(obj).reduce((str, [p, val]) => `${str}${p}: ${val}\n`, '');
|
||||
export const OBJ_TO_STR = (obj: object): string => Object.entries(obj).reduce((str, [p, val]) => `${str}${p}: ${val}\n`, '');
|
||||
|
||||
/**
|
||||
* Extracts a meaningful error message from a GraphQL error or generic error object.
|
||||
*/
|
||||
export function extractGraphQLErrorMessage(err: unknown): string {
|
||||
let message = 'An unknown error occurred.';
|
||||
const e = err as { graphQLErrors?: unknown; message?: string };
|
||||
const graphQLErrors = Array.isArray(e?.graphQLErrors) ? e.graphQLErrors : undefined;
|
||||
if (graphQLErrors && graphQLErrors.length) {
|
||||
const gqlError = graphQLErrors[0] as { extensions?: { originalError?: { message?: string[] } }; message?: string };
|
||||
message =
|
||||
gqlError?.extensions?.originalError?.message?.[0] ||
|
||||
gqlError?.message ||
|
||||
message;
|
||||
} else if (typeof err === 'object' && err !== null && 'message' in err && typeof e.message === 'string') {
|
||||
message = e.message;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
@@ -156,6 +156,10 @@ export default defineNuxtConfig({
|
||||
name: 'UnraidThemeSwitcher',
|
||||
path: '@/components/ThemeSwitcher.ce',
|
||||
},
|
||||
{
|
||||
name: 'UnraidApiKeyManager',
|
||||
path: '@/components/ApiKeyPage.ce',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -3,8 +3,5 @@ import ApiKeyManager from '~/components/ApiKey/ApiKeyManager.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>API Key</h1>
|
||||
<ApiKeyManager />
|
||||
</div>
|
||||
<ApiKeyManager />
|
||||
</template>
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { SendPayloads } from '@unraid/shared-callbacks';
|
||||
import LogViewerCe from '~/components/Logs/LogViewer.ce.vue';
|
||||
import SsoButtonCe from '~/components/SsoButton.ce.vue';
|
||||
import { useThemeStore } from '~/store/theme';
|
||||
import ModalsCe from '~/components/Modals.ce.vue';
|
||||
import ConnectSettingsCe from '~/components/ConnectSettings/ConnectSettings.ce.vue';
|
||||
|
||||
const serverStore = useDummyServerStore();
|
||||
const { serverState } = storeToRefs(serverStore);
|
||||
|
||||
@@ -58,8 +58,8 @@ onBeforeMount(() => {
|
||||
<h3 class="text-lg font-semibold font-mono">SSOSignInButtonCe</h3>
|
||||
<unraid-sso-button :ssoenabled="serverState.ssoEnabled" />
|
||||
<hr class="border-black dark:border-white" />
|
||||
<h3 class="text-lg font-semibold font-mono">ActivationCodeCe</h3>
|
||||
<unraid-welcome-modal />
|
||||
<h3 class="text-lg font-semibold font-mono">ApiKeyManagerCe</h3>
|
||||
<unraid-api-key-manager />
|
||||
</unraid-i18n-host>
|
||||
<Toaster rich-colors close-button />
|
||||
</client-only>
|
||||
|
||||
40
web/store/apiKey.ts
Normal file
40
web/store/apiKey.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ref } from 'vue';
|
||||
import { createPinia, defineStore, setActivePinia } from 'pinia';
|
||||
|
||||
import type { ApiKeyFragment, ApiKeyWithKeyFragment } from '~/composables/gql/graphql';
|
||||
|
||||
setActivePinia(createPinia());
|
||||
|
||||
export const useApiKeyStore = defineStore('apiKey', () => {
|
||||
const modalVisible = ref(false);
|
||||
const editingKey = ref<ApiKeyFragment | null>(null);
|
||||
const createdKey = ref<ApiKeyWithKeyFragment | null>(null);
|
||||
|
||||
function showModal(key: ApiKeyFragment | null = null) {
|
||||
editingKey.value = key;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function hideModal() {
|
||||
modalVisible.value = false;
|
||||
editingKey.value = null;
|
||||
}
|
||||
|
||||
function setCreatedKey(key: ApiKeyWithKeyFragment | null) {
|
||||
createdKey.value = key;
|
||||
}
|
||||
|
||||
function clearCreatedKey() {
|
||||
createdKey.value = null;
|
||||
}
|
||||
|
||||
return {
|
||||
modalVisible,
|
||||
editingKey,
|
||||
createdKey,
|
||||
showModal,
|
||||
hideModal,
|
||||
setCreatedKey,
|
||||
clearCreatedKey,
|
||||
};
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ref } from 'vue';
|
||||
import { createPinia, defineStore, setActivePinia } from 'pinia';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
|
||||
@@ -9,7 +8,7 @@ import { useToggle } from '@vueuse/core';
|
||||
setActivePinia(createPinia());
|
||||
|
||||
export const useModalStore = defineStore('modal', () => {
|
||||
const modalVisible = ref<boolean>(true);
|
||||
const [modalVisible, modalToggle] = useToggle(true);
|
||||
|
||||
const modalHide = () => {
|
||||
modalVisible.value = false;
|
||||
@@ -17,7 +16,6 @@ export const useModalStore = defineStore('modal', () => {
|
||||
const modalShow = () => {
|
||||
modalVisible.value = true;
|
||||
};
|
||||
const modalToggle = useToggle(modalVisible);
|
||||
|
||||
return {
|
||||
modalVisible,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { graphql } from '~/composables/gql/gql';
|
||||
|
||||
export const GET_ALLOWED_ORIGINS = graphql(/* GraphQL */ `
|
||||
query getExtraAllowedOrigins {
|
||||
extraAllowedOrigins
|
||||
}
|
||||
`);
|
||||
|
||||
export const GET_REMOTE_ACCESS = graphql(/* GraphQL */ `
|
||||
query getRemoteAccess {
|
||||
remoteAccess {
|
||||
accessType
|
||||
forwardType
|
||||
port
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const SET_ADDITIONAL_ALLOWED_ORIGINS = graphql(/* GraphQL */ `
|
||||
mutation setAdditionalAllowedOrigins($input: AllowedOriginInput!) {
|
||||
setAdditionalAllowedOrigins(input: $input)
|
||||
}
|
||||
`);
|
||||
|
||||
export const SETUP_REMOTE_ACCESS = graphql(/* GraphQL */ `
|
||||
mutation setupRemoteAccess($input: SetupRemoteAccessInput!) {
|
||||
setupRemoteAccess(input: $input)
|
||||
}
|
||||
`);
|
||||
@@ -1,46 +0,0 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { useLazyQuery, useMutation } from '@vue/apollo-composable';
|
||||
|
||||
import type { SetupRemoteAccessInput } from '~/composables/gql/graphql';
|
||||
|
||||
import {
|
||||
GET_ALLOWED_ORIGINS,
|
||||
GET_REMOTE_ACCESS,
|
||||
SET_ADDITIONAL_ALLOWED_ORIGINS,
|
||||
SETUP_REMOTE_ACCESS,
|
||||
} from '~/store/unraidApiSettings.fragment';
|
||||
|
||||
export const useUnraidApiSettingsStore = defineStore('unraidApiSettings', () => {
|
||||
const { load: loadOrigins, result: origins } = useLazyQuery(GET_ALLOWED_ORIGINS);
|
||||
|
||||
const { mutate: mutateOrigins } = useMutation(SET_ADDITIONAL_ALLOWED_ORIGINS);
|
||||
const { load: loadRemoteAccess, result: remoteAccessResult } = useLazyQuery(GET_REMOTE_ACCESS);
|
||||
|
||||
const { mutate: setupRemoteAccessMutation } = useMutation(SETUP_REMOTE_ACCESS);
|
||||
const getAllowedOrigins = async () => {
|
||||
await loadOrigins();
|
||||
return origins?.value?.extraAllowedOrigins ?? [];
|
||||
};
|
||||
|
||||
const setAllowedOrigins = async (origins: string[]) => {
|
||||
const result = await mutateOrigins({ input: { origins } });
|
||||
return result?.data?.setAdditionalAllowedOrigins;
|
||||
};
|
||||
|
||||
const getRemoteAccess = async () => {
|
||||
await loadRemoteAccess();
|
||||
return remoteAccessResult?.value?.remoteAccess;
|
||||
};
|
||||
|
||||
const setupRemoteAccess = async (input: SetupRemoteAccessInput) => {
|
||||
const response = await setupRemoteAccessMutation({ input });
|
||||
return response?.data?.setupRemoteAccess;
|
||||
};
|
||||
|
||||
return {
|
||||
getAllowedOrigins,
|
||||
setAllowedOrigins,
|
||||
getRemoteAccess,
|
||||
setupRemoteAccess,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user