feat: generated UI API key management + OAuth-like API Key Flows (#1609)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* API Key Authorization flow with consent screen, callback support, and
a Tools page.
* Schema-driven API Key creation UI with permission presets, templates,
and Developer Authorization Link.
* Effective Permissions preview and a new multi-select permission
control.

* **UI Improvements**
* Mask/toggle API keys, copy-to-clipboard with toasts, improved select
labels, new label styles, tab wrapping, and accordionized color
controls.

* **Documentation**
  * Public guide for the API Key authorization flow and scopes added.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Eli Bosley
2025-08-27 12:37:39 -04:00
committed by GitHub
parent 6947b5d4af
commit 674323fd87
119 changed files with 7996 additions and 1459 deletions
+2 -1
View File
@@ -156,4 +156,5 @@ Enables GraphQL playground at `http://tower.local/graphql`
## Development Memories
- We are using tailwind v4 we do not need a tailwind config anymore
- always search the internet for tailwind v4 documentation when making tailwind related style changes
- always search the internet for tailwind v4 documentation when making tailwind related style changes
- never run or restart the API server or web server. I will handle the lifecylce, simply wait and ask me to do this for you
@@ -0,0 +1,100 @@
# API Key Authorization Flow
This document describes the self-service API key creation flow for third-party applications.
## Overview
Applications can request API access to an Unraid server by redirecting users to a special authorization page where users can review requested permissions and create an API key with one click.
## Flow
1. **Application initiates request**: The app redirects the user to:
```
https://[unraid-server]/ApiKeyAuthorize?name=MyApp&scopes=docker:read,vm:*&redirect_uri=https://myapp.com/callback&state=abc123
```
2. **User authentication**: If not already logged in, the user is redirected to login first (standard Unraid auth)
3. **Consent screen**: User sees:
- Application name and description
- Requested permissions (with checkboxes to approve/deny specific scopes)
- API key name field (pre-filled)
- Authorize & Cancel buttons
4. **API key creation**: Upon authorization:
- API key is created with approved scopes
- Key is displayed to the user
- If `redirect_uri` is provided, user is redirected back with the key
5. **Callback**: App receives the API key:
```
https://myapp.com/callback?api_key=xxx&state=abc123
```
## Query Parameters
- `name` (required): Name of the requesting application
- `description` (optional): Description of the application
- `scopes` (required): Comma-separated list of requested scopes
- `redirect_uri` (optional): URL to redirect after authorization
- `state` (optional): Opaque value for maintaining state
## Scope Format
Scopes follow the pattern: `resource:action`
### Examples:
- `docker:read` - Read access to Docker
- `vm:*` - Full access to VMs
- `system:update` - Update access to system
- `role:viewer` - Viewer role access
- `role:admin` - Admin role access
### Available Resources:
- `docker`, `vm`, `system`, `share`, `user`, `network`, `disk`, etc.
### Available Actions:
- `create`, `read`, `update`, `delete` or `*` for all
## Security Considerations
1. **HTTPS required**: Redirect URIs must use HTTPS (except localhost for development)
2. **User consent**: Users explicitly approve each permission
3. **Session-based**: Uses existing Unraid authentication session
4. **One-time display**: API keys are shown once and must be saved securely
## Example Integration
```javascript
// JavaScript example
const unraidServer = 'tower.local';
const appName = 'My Docker Manager';
const scopes = 'docker:*,system:read';
const redirectUri = 'https://myapp.com/unraid/callback';
const state = generateRandomState();
// Store state for verification
sessionStorage.setItem('oauth_state', state);
// Redirect user to authorization page
window.location.href =
`https://${unraidServer}/ApiKeyAuthorize?` +
`name=${encodeURIComponent(appName)}&` +
`scopes=${encodeURIComponent(scopes)}&` +
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
`state=${encodeURIComponent(state)}`;
// Handle callback
const urlParams = new URLSearchParams(window.location.search);
const apiKey = urlParams.get('api_key');
const returnedState = urlParams.get('state');
if (returnedState === sessionStorage.getItem('oauth_state')) {
// Save API key securely
saveApiKey(apiKey);
}
```
+129 -82
View File
@@ -4,14 +4,11 @@
"""Directive to document required permissions for fields"""
directive @usePermissions(
"""The action verb required for access"""
action: AuthActionVerb
"""The action required for access (must be a valid AuthAction enum value)"""
action: String
"""The resource required for access"""
"""The resource required for access (must be a valid Resource enum value)"""
resource: String
"""The possession type required for access"""
possession: AuthPossession
) on FIELD_DEFINITION
type ParityCheck {
@@ -615,7 +612,9 @@ enum ConfigErrorState {
type Permission {
resource: Resource!
actions: [String!]!
"""Actions allowed on this resource"""
actions: [AuthAction!]!
}
"""Available resources for permissions"""
@@ -651,8 +650,36 @@ enum Resource {
WELCOME
}
"""Authentication actions with possession (e.g., create:any, read:own)"""
enum AuthAction {
"""Create any resource"""
CREATE_ANY
"""Create own resource"""
CREATE_OWN
"""Read any resource"""
READ_ANY
"""Read own resource"""
READ_OWN
"""Update any resource"""
UPDATE_ANY
"""Update own resource"""
UPDATE_OWN
"""Delete any resource"""
DELETE_ANY
"""Delete own resource"""
DELETE_OWN
}
type ApiKey implements Node {
id: PrefixedID!
key: String!
name: String!
description: String
roles: [Role!]!
@@ -662,20 +689,90 @@ type ApiKey implements Node {
"""Available roles for API keys and users"""
enum Role {
"""Full administrative access to all resources"""
ADMIN
USER
"""Internal Role for Unraid Connect"""
CONNECT
"""Basic read access to user profile only"""
GUEST
"""Read-only access to all resources"""
VIEWER
}
type ApiKeyWithSecret implements Node {
type SsoSettings implements Node {
id: PrefixedID!
name: String!
description: String
roles: [Role!]!
createdAt: String!
permissions: [Permission!]!
key: String!
"""List of configured OIDC providers"""
oidcProviders: [OidcProvider!]!
}
type UnifiedSettings implements Node & FormSchema {
id: PrefixedID!
"""The data schema for the settings"""
dataSchema: JSON!
"""The UI schema for the settings"""
uiSchema: JSON!
"""The current values of the settings"""
values: JSON!
}
interface FormSchema {
"""The data schema for the form"""
dataSchema: JSON!
"""The UI schema for the form"""
uiSchema: JSON!
"""The current values of the form"""
values: JSON!
}
"""
The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
"""
scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf")
type ApiKeyFormSettings implements Node & FormSchema {
id: PrefixedID!
"""The data schema for the API key form"""
dataSchema: JSON!
"""The UI schema for the API key form"""
uiSchema: JSON!
"""The current values of the API key form"""
values: JSON!
}
type UpdateSettingsResponse {
"""Whether a restart is required for the changes to take effect"""
restartRequired: Boolean!
"""The updated settings values"""
values: JSON!
"""Warning messages about configuration issues found during validation"""
warnings: [String!]
}
type Settings implements Node {
id: PrefixedID!
"""A view of all settings"""
unified: UnifiedSettings!
"""SSO settings"""
sso: SsoSettings!
"""The API setting values"""
api: ApiConfig!
}
type RCloneDrive {
@@ -686,11 +783,6 @@ type RCloneDrive {
options: JSON!
}
"""
The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
"""
scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf")
type RCloneBackupConfigForm {
id: ID!
dataSchema: JSON!
@@ -792,7 +884,7 @@ type VmMutations {
"""API Key related mutations"""
type ApiKeyMutations {
"""Create an API key"""
create(input: CreateApiKeyInput!): ApiKeyWithSecret!
create(input: CreateApiKeyInput!): ApiKey!
"""Add a role to an API key"""
addRole(input: AddRoleForApiKeyInput!): Boolean!
@@ -804,7 +896,7 @@ type ApiKeyMutations {
delete(input: DeleteApiKeyInput!): Boolean!
"""Update an API key"""
update(input: UpdateApiKeyInput!): ApiKeyWithSecret!
update(input: UpdateApiKeyInput!): ApiKey!
}
input CreateApiKeyInput {
@@ -821,7 +913,7 @@ input CreateApiKeyInput {
input AddPermissionInput {
resource: Resource!
actions: [String!]!
actions: [AuthAction!]!
}
input AddRoleForApiKeyInput {
@@ -1727,50 +1819,6 @@ type ApiConfig {
plugins: [String!]!
}
type SsoSettings implements Node {
id: PrefixedID!
"""List of configured OIDC providers"""
oidcProviders: [OidcProvider!]!
}
type UnifiedSettings implements Node {
id: PrefixedID!
"""The data schema for the settings"""
dataSchema: JSON!
"""The UI schema for the settings"""
uiSchema: JSON!
"""The current values of the settings"""
values: JSON!
}
type UpdateSettingsResponse {
"""Whether a restart is required for the changes to take effect"""
restartRequired: Boolean!
"""The updated settings values"""
values: JSON!
"""Warning messages about configuration issues found during validation"""
warnings: [String!]
}
type Settings implements Node {
id: PrefixedID!
"""A view of all settings"""
unified: UnifiedSettings!
"""SSO settings"""
sso: SsoSettings!
"""The API setting values"""
api: ApiConfig!
}
type OidcAuthorizationRule {
"""The claim to check (e.g., email, sub, groups, hd)"""
claim: String!
@@ -2243,6 +2291,20 @@ type Query {
"""All possible permissions for API keys"""
apiKeyPossiblePermissions: [Permission!]!
"""Get the actual permissions that would be granted by a set of roles"""
getPermissionsForRoles(roles: [Role!]!): [Permission!]!
"""
Preview the effective permissions for a combination of roles and explicit permissions
"""
previewEffectivePermissions(roles: [Role!], permissions: [AddPermissionInput!]): [Permission!]!
"""Get all available authentication actions with possession"""
getAvailableAuthActions: [AuthAction!]!
"""Get JSON Schema for API key creation form"""
getApiKeyCreationFormSchema: ApiKeyFormSettings!
config: Config!
flash: Flash!
logFiles: [LogFile!]!
@@ -2538,19 +2600,4 @@ type Subscription {
systemMetricsCpu: CpuUtilization!
systemMetricsMemory: MemoryUtilization!
upsUpdates: UPSDevice!
}
"""Available authentication action verbs"""
enum AuthActionVerb {
CREATE
UPDATE
DELETE
READ
}
"""Available authentication possession types"""
enum AuthPossession {
ANY
OWN
OWN_ANY
}
+133 -57
View File
@@ -2,15 +2,14 @@ import { Logger } from '@nestjs/common';
import { readdir, readFile, writeFile } from 'fs/promises';
import { join } from 'path';
import { Resource, Role } from '@unraid/shared/graphql.model.js';
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
import { ensureDir, ensureDirSync } from 'fs-extra';
import { AuthActionVerb } from 'nest-authz';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { environment } from '@app/environment.js';
import { getters } from '@app/store/index.js';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { ApiKey, ApiKeyWithSecret } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
import { ApiKey } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
// Mock the store and its modules
vi.mock('@app/store/index.js', () => ({
@@ -48,28 +47,14 @@ describe('ApiKeyService', () => {
const mockApiKey: ApiKey = {
id: 'test-api-id',
key: 'test-secret-key',
name: 'Test API Key',
description: 'Test API Key Description',
roles: [Role.GUEST],
permissions: [
{
resource: Resource.CONNECT,
actions: [AuthActionVerb.READ],
},
],
createdAt: new Date().toISOString(),
};
const mockApiKeyWithSecret: ApiKeyWithSecret = {
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],
actions: [AuthAction.READ_ANY],
},
],
createdAt: new Date().toISOString(),
@@ -130,21 +115,23 @@ describe('ApiKeyService', () => {
});
describe('create', () => {
it('should create ApiKeyWithSecret with generated key', async () => {
it('should create ApiKey with generated key', async () => {
const saveSpy = vi.spyOn(apiKeyService, 'saveApiKey').mockResolvedValue();
const { key, id, description, roles } = mockApiKeyWithSecret;
const { id, description, roles } = mockApiKey;
const name = 'Test API Key';
const result = await apiKeyService.create({ name, description: description ?? '', roles });
expect(result).toMatchObject({
id,
key,
name: name,
description,
roles,
createdAt: expect.any(String),
});
expect(result.key).toBeDefined();
expect(typeof result.key).toBe('string');
expect(result.key.length).toBeGreaterThan(0);
expect(saveSpy).toHaveBeenCalledWith(result);
});
@@ -177,8 +164,8 @@ describe('ApiKeyService', () => {
describe('findAll', () => {
it('should return all API keys', async () => {
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
mockApiKeyWithSecret,
{ ...mockApiKeyWithSecret, id: 'second-id' },
mockApiKey,
{ ...mockApiKey, id: 'second-id' },
]);
await apiKeyService.onModuleInit();
@@ -191,7 +178,7 @@ describe('ApiKeyService', () => {
permissions: [
{
resource: Resource.CONNECT,
actions: [AuthActionVerb.READ],
actions: [AuthAction.READ_ANY],
},
],
};
@@ -202,7 +189,7 @@ describe('ApiKeyService', () => {
permissions: [
{
resource: Resource.CONNECT,
actions: [AuthActionVerb.READ],
actions: [AuthAction.READ_ANY],
},
],
};
@@ -219,17 +206,17 @@ describe('ApiKeyService', () => {
describe('findById', () => {
it('should return API key by id when found', async () => {
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKeyWithSecret]);
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKey]);
await apiKeyService.onModuleInit();
const result = await apiKeyService.findById(mockApiKeyWithSecret.id);
const result = await apiKeyService.findById(mockApiKey.id);
expect(result).toMatchObject({ ...mockApiKey, createdAt: expect.any(String) });
});
it('should return null if API key not found', async () => {
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
{ ...mockApiKeyWithSecret, id: 'different-id' },
{ ...mockApiKey, id: 'different-id' },
]);
await apiKeyService.onModuleInit();
@@ -241,12 +228,12 @@ describe('ApiKeyService', () => {
describe('findByIdWithSecret', () => {
it('should return API key with secret when found', async () => {
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKeyWithSecret]);
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKey]);
await apiKeyService.onModuleInit();
const result = await apiKeyService.findByIdWithSecret(mockApiKeyWithSecret.id);
const result = await apiKeyService.findByIdWithSecret(mockApiKey.id);
expect(result).toEqual(mockApiKeyWithSecret);
expect(result).toEqual(mockApiKey);
});
it('should return null when API key not found', async () => {
@@ -274,23 +261,20 @@ describe('ApiKeyService', () => {
describe('findByKey', () => {
it('should return API key by key value when multiple keys exist', async () => {
const differentKey = { ...mockApiKeyWithSecret, key: 'different-key' };
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
differentKey,
mockApiKeyWithSecret,
]);
const differentKey = { ...mockApiKey, key: 'different-key' };
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([differentKey, mockApiKey]);
await apiKeyService.onModuleInit();
const result = await apiKeyService.findByKey(mockApiKeyWithSecret.key);
const result = await apiKeyService.findByKey(mockApiKey.key);
expect(result).toEqual(mockApiKeyWithSecret);
expect(result).toEqual(mockApiKey);
});
it('should return null if key not found in any file', async () => {
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
{ ...mockApiKeyWithSecret, key: 'different-key-1' },
{ ...mockApiKeyWithSecret, key: 'different-key-2' },
{ ...mockApiKey, key: 'different-key-1' },
{ ...mockApiKey, key: 'different-key-2' },
]);
await apiKeyService.onModuleInit();
@@ -314,21 +298,21 @@ describe('ApiKeyService', () => {
it('should save API key to file', async () => {
vi.mocked(writeFile).mockResolvedValue(undefined);
await apiKeyService.saveApiKey(mockApiKeyWithSecret);
await apiKeyService.saveApiKey(mockApiKey);
const writeFileCalls = vi.mocked(writeFile).mock.calls;
expect(writeFileCalls.length).toBe(1);
const [filePath, fileContent] = writeFileCalls[0] ?? [];
const expectedPath = join(mockBasePath, `${mockApiKeyWithSecret.id}.json`);
const expectedPath = join(mockBasePath, `${mockApiKey.id}.json`);
expect(filePath).toBe(expectedPath);
if (typeof fileContent === 'string') {
const savedApiKey = JSON.parse(fileContent);
expect(savedApiKey).toEqual(mockApiKeyWithSecret);
expect(savedApiKey).toEqual(mockApiKey);
} else {
throw new Error('File content should be a string');
}
@@ -337,16 +321,16 @@ describe('ApiKeyService', () => {
it('should throw GraphQLError on write error', async () => {
vi.mocked(writeFile).mockRejectedValue(new Error('Write failed'));
await expect(apiKeyService.saveApiKey(mockApiKeyWithSecret)).rejects.toThrow(
await expect(apiKeyService.saveApiKey(mockApiKey)).rejects.toThrow(
'Failed to save API key: Write failed'
);
});
it('should throw GraphQLError on invalid API key structure', async () => {
const invalidApiKey = {
...mockApiKeyWithSecret,
...mockApiKey,
name: '', // Invalid: name cannot be empty
} as ApiKeyWithSecret;
} as ApiKey;
await expect(apiKeyService.saveApiKey(invalidApiKey)).rejects.toThrow(
'Failed to save API key: Invalid data structure'
@@ -355,10 +339,10 @@ describe('ApiKeyService', () => {
it('should throw GraphQLError when roles and permissions array is empty', async () => {
const invalidApiKey = {
...mockApiKeyWithSecret,
...mockApiKey,
permissions: [],
roles: [],
} as ApiKeyWithSecret;
} as ApiKey;
await expect(apiKeyService.saveApiKey(invalidApiKey)).rejects.toThrow(
'At least one of permissions or roles must be specified'
@@ -367,7 +351,7 @@ describe('ApiKeyService', () => {
});
describe('update', () => {
let updateMockApiKey: ApiKeyWithSecret;
let updateMockApiKey: ApiKey;
beforeEach(() => {
// Create a fresh copy of the mock data for update tests
@@ -380,7 +364,7 @@ describe('ApiKeyService', () => {
permissions: [
{
resource: Resource.CONNECT,
actions: [AuthActionVerb.READ],
actions: [AuthAction.READ_ANY],
},
],
createdAt: new Date().toISOString(),
@@ -427,7 +411,7 @@ describe('ApiKeyService', () => {
const updatedPermissions = [
{
resource: Resource.CONNECT,
actions: [AuthActionVerb.READ, AuthActionVerb.UPDATE],
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
},
];
@@ -474,7 +458,7 @@ describe('ApiKeyService', () => {
});
describe('loadAllFromDisk', () => {
let loadMockApiKey: ApiKeyWithSecret;
let loadMockApiKey: ApiKey;
beforeEach(() => {
// Create a fresh copy of the mock data for loadAllFromDisk tests
@@ -487,7 +471,7 @@ describe('ApiKeyService', () => {
permissions: [
{
resource: Resource.CONNECT,
actions: [AuthActionVerb.READ],
actions: [AuthAction.READ_ANY],
},
],
createdAt: new Date().toISOString(),
@@ -550,15 +534,62 @@ describe('ApiKeyService', () => {
key: 'unique-key',
});
});
it('should normalize permission actions to lowercase when loading from disk', async () => {
const apiKeyWithMixedCaseActions = {
...loadMockApiKey,
permissions: [
{
resource: Resource.DOCKER,
actions: ['READ:ANY', 'Update:Any', 'create:any', 'DELETE:ANY'], // Mixed case actions
},
{
resource: Resource.ARRAY,
actions: ['Read:Any'], // Mixed case
},
],
};
vi.mocked(readdir).mockResolvedValue(['key1.json'] as any);
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify(apiKeyWithMixedCaseActions));
const result = await apiKeyService.loadAllFromDisk();
expect(result).toHaveLength(1);
// All actions should be normalized to lowercase
expect(result[0].permissions[0].actions).toEqual([
AuthAction.READ_ANY,
AuthAction.UPDATE_ANY,
AuthAction.CREATE_ANY,
AuthAction.DELETE_ANY,
]);
expect(result[0].permissions[1].actions).toEqual([AuthAction.READ_ANY]);
});
it('should normalize roles to uppercase when loading from disk', async () => {
const apiKeyWithMixedCaseRoles = {
...loadMockApiKey,
roles: ['admin', 'Viewer', 'CONNECT'], // Mixed case roles
};
vi.mocked(readdir).mockResolvedValue(['key1.json'] as any);
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify(apiKeyWithMixedCaseRoles));
const result = await apiKeyService.loadAllFromDisk();
expect(result).toHaveLength(1);
// All roles should be normalized to uppercase
expect(result[0].roles).toEqual(['ADMIN', 'VIEWER', 'CONNECT']);
});
});
describe('loadApiKeyFile', () => {
it('should load and parse a valid API key file', async () => {
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockApiKeyWithSecret));
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockApiKey));
const result = await apiKeyService['loadApiKeyFile']('test.json');
expect(result).toEqual(mockApiKeyWithSecret);
expect(result).toEqual(mockApiKey);
expect(readFile).toHaveBeenCalledWith(join(mockBasePath, 'test.json'), 'utf8');
});
@@ -592,7 +623,7 @@ describe('ApiKeyService', () => {
expect.stringContaining('Error validating API key file test.json')
);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('An instance of ApiKeyWithSecret has failed the validation')
expect.stringContaining('An instance of ApiKey has failed the validation')
);
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('property key'));
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('property id'));
@@ -603,5 +634,50 @@ describe('ApiKeyService', () => {
expect.stringContaining('property permissions')
);
});
it('should normalize legacy action formats when loading API keys', async () => {
const legacyApiKey = {
...mockApiKey,
permissions: [
{
resource: Resource.DOCKER,
actions: ['create', 'READ', 'Update', 'DELETE'], // Mixed case legacy verbs
},
{
resource: Resource.VMS,
actions: ['READ_ANY', 'UPDATE_OWN'], // GraphQL enum style
},
{
resource: Resource.CONNECT,
actions: ['read:own', 'update:any'], // Casbin colon format
},
],
};
vi.mocked(readFile).mockResolvedValue(JSON.stringify(legacyApiKey));
const result = await apiKeyService['loadApiKeyFile']('legacy.json');
expect(result).not.toBeNull();
expect(result?.permissions).toEqual([
{
resource: Resource.DOCKER,
actions: [
AuthAction.CREATE_ANY,
AuthAction.READ_ANY,
AuthAction.UPDATE_ANY,
AuthAction.DELETE_ANY,
],
},
{
resource: Resource.VMS,
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_OWN],
},
{
resource: Resource.CONNECT,
actions: [AuthAction.READ_OWN, AuthAction.UPDATE_ANY],
},
]);
});
});
});
+38 -41
View File
@@ -3,12 +3,12 @@ import crypto from 'crypto';
import { readdir, readFile, unlink, writeFile } from 'fs/promises';
import { join } from 'path';
import { Resource, Role } from '@unraid/shared/graphql.model.js';
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
import { normalizeLegacyActions } from '@unraid/shared/util/permissions.js';
import { watch } from 'chokidar';
import { ValidationError } from 'class-validator';
import { ensureDirSync } from 'fs-extra';
import { GraphQLError } from 'graphql';
import { AuthActionVerb } from 'nest-authz';
import { v4 as uuidv4 } from 'uuid';
import { environment } from '@app/environment.js';
@@ -16,7 +16,6 @@ import { getters } from '@app/store/index.js';
import {
AddPermissionInput,
ApiKey,
ApiKeyWithSecret,
Permission,
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js';
@@ -26,7 +25,7 @@ import { batchProcess } from '@app/utils.js';
export class ApiKeyService implements OnModuleInit {
private readonly logger = new Logger(ApiKeyService.name);
protected readonly basePath: string;
protected memoryApiKeys: Array<ApiKeyWithSecret> = [];
protected memoryApiKeys: Array<ApiKey> = [];
private static readonly validRoles: Set<Role> = new Set(Object.values(Role));
constructor() {
@@ -41,18 +40,8 @@ export class ApiKeyService implements OnModuleInit {
}
}
public convertApiKeyWithSecretToApiKey(key: ApiKeyWithSecret): ApiKey {
const { key: _, ...rest } = key;
return rest;
}
public async findAll(): Promise<ApiKey[]> {
return Promise.all(
this.memoryApiKeys.map(async (key) => {
const keyWithoutSecret = this.convertApiKeyWithSecretToApiKey(key);
return keyWithoutSecret;
})
);
return this.memoryApiKeys;
}
private setupWatch() {
@@ -76,17 +65,18 @@ export class ApiKeyService implements OnModuleInit {
public getAllValidPermissions(): Permission[] {
return Object.values(Resource).map((res) => ({
resource: res,
actions: Object.values(AuthActionVerb),
actions: Object.values(AuthAction),
}));
}
public convertPermissionsStringArrayToPermissions(permissions: string[]): Permission[] {
return permissions.reduce<Array<Permission>>((acc, permission) => {
const [resource, action] = permission.split(':');
const [resource, ...actionParts] = permission.split(':');
const action = actionParts.join(':'); // Handle actions like "read:any"
const validatedResource = Resource[resource.toUpperCase() as keyof typeof Resource] ?? null;
// Pull the actual enum value from the graphql schema
const validatedAction =
AuthActionVerb[action.toUpperCase() as keyof typeof AuthActionVerb] ?? null;
AuthAction[action.toUpperCase().replace(':', '_') as keyof typeof AuthAction] ?? null;
if (validatedAction && validatedResource) {
const existingEntry = acc.find((p) => p.resource === validatedResource);
if (existingEntry) {
@@ -119,7 +109,7 @@ export class ApiKeyService implements OnModuleInit {
roles?: Role[];
permissions?: Permission[] | AddPermissionInput[];
overwrite?: boolean;
}): Promise<ApiKeyWithSecret> {
}): Promise<ApiKey> {
const trimmedName = name?.trim();
const sanitizedName = this.sanitizeName(trimmedName);
@@ -139,7 +129,7 @@ export class ApiKeyService implements OnModuleInit {
if (!overwrite && existingKey) {
return existingKey;
}
const apiKey: Partial<ApiKeyWithSecret> = {
const apiKey: Partial<ApiKey> = {
id: uuidv4(),
key: this.generateApiKey(),
name: sanitizedName,
@@ -152,18 +142,18 @@ export class ApiKeyService implements OnModuleInit {
// Update createdAt date
apiKey.createdAt = new Date().toISOString();
await this.saveApiKey(apiKey as ApiKeyWithSecret);
await this.saveApiKey(apiKey as ApiKey);
return apiKey as ApiKeyWithSecret;
return apiKey as ApiKey;
}
async loadAllFromDisk(): Promise<ApiKeyWithSecret[]> {
async loadAllFromDisk(): Promise<ApiKey[]> {
const files = await readdir(this.basePath).catch((error) => {
this.logger.error(`Failed to read API key directory: ${error}`);
throw new Error('Failed to list API keys');
});
const apiKeys: ApiKeyWithSecret[] = [];
const apiKeys: ApiKey[] = [];
const jsonFiles = files.filter((file) => file.includes('.json'));
for (const file of jsonFiles) {
@@ -186,7 +176,7 @@ export class ApiKeyService implements OnModuleInit {
* @param file The file to load
* @returns The API key with secret
*/
private async loadApiKeyFile(file: string): Promise<ApiKeyWithSecret | null> {
private async loadApiKeyFile(file: string): Promise<ApiKey | null> {
try {
const content = await readFile(join(this.basePath, file), 'utf8');
@@ -196,7 +186,17 @@ export class ApiKeyService implements OnModuleInit {
if (parsedContent.roles) {
parsedContent.roles = parsedContent.roles.map((role: string) => role.toUpperCase());
}
return await validateObject(ApiKeyWithSecret, parsedContent);
// Normalize permission actions to AuthAction enum values
// Uses shared helper to handle all legacy formats
if (parsedContent.permissions) {
parsedContent.permissions = parsedContent.permissions.map((permission: any) => ({
...permission,
actions: normalizeLegacyActions(permission.actions || []),
}));
}
return await validateObject(ApiKey, parsedContent);
} catch (error) {
if (error instanceof SyntaxError) {
this.logger.error(`Corrupted key file: ${file}`);
@@ -216,12 +216,7 @@ export class ApiKeyService implements OnModuleInit {
async findById(id: string): Promise<ApiKey | null> {
try {
const key = this.findByField('id', id);
if (key) {
return this.convertApiKeyWithSecretToApiKey(key);
}
return null;
return this.findByField('id', id);
} catch (error) {
if (error instanceof ValidationError) {
this.logApiKeyValidationError(id, error);
@@ -231,17 +226,17 @@ export class ApiKeyService implements OnModuleInit {
}
}
public findByIdWithSecret(id: string): ApiKeyWithSecret | null {
public findByIdWithSecret(id: string): ApiKey | null {
return this.findByField('id', id);
}
public findByField(field: keyof ApiKeyWithSecret, value: string): ApiKeyWithSecret | null {
public findByField(field: keyof ApiKey, value: string): ApiKey | null {
if (!value) return null;
return this.memoryApiKeys.find((k) => k[field] === value) ?? null;
}
findByKey(key: string): ApiKeyWithSecret | null {
findByKey(key: string): ApiKey | null {
return this.findByField('key', key);
}
@@ -254,9 +249,9 @@ export class ApiKeyService implements OnModuleInit {
Errors: ${JSON.stringify(error.constraints, null, 2)}`);
}
public async saveApiKey(apiKey: ApiKeyWithSecret): Promise<void> {
public async saveApiKey(apiKey: ApiKey): Promise<void> {
try {
const validatedApiKey = await validateObject(ApiKeyWithSecret, apiKey);
const validatedApiKey = await validateObject(ApiKey, apiKey);
if (!validatedApiKey.permissions?.length && !validatedApiKey.roles?.length) {
throw new GraphQLError('At least one of permissions or roles must be specified');
}
@@ -266,7 +261,7 @@ export class ApiKeyService implements OnModuleInit {
.reduce((acc, key) => {
acc[key] = validatedApiKey[key];
return acc;
}, {} as ApiKeyWithSecret);
}, {} as ApiKey);
await writeFile(
join(this.basePath, `${validatedApiKey.id}.json`),
@@ -334,7 +329,7 @@ export class ApiKeyService implements OnModuleInit {
description?: string;
roles?: Role[];
permissions?: Permission[] | AddPermissionInput[];
}): Promise<ApiKeyWithSecret> {
}): Promise<ApiKey> {
const apiKey = this.findByIdWithSecret(id);
if (!apiKey) {
throw new GraphQLError('API key not found');
@@ -345,13 +340,15 @@ export class ApiKeyService implements OnModuleInit {
if (description !== undefined) {
apiKey.description = description;
}
if (roles) {
if (roles !== undefined) {
// Handle both empty array (to clear roles) and populated array
if (roles.some((role) => !ApiKeyService.validRoles.has(role))) {
throw new GraphQLError('Invalid role specified');
}
apiKey.roles = roles;
}
if (permissions) {
if (permissions !== undefined) {
// Handle both empty array (to clear permissions) and populated array
apiKey.permissions = permissions;
}
await this.saveApiKey(apiKey);
+269 -16
View File
@@ -1,14 +1,14 @@
import { UnauthorizedException } from '@nestjs/common';
import { Resource, Role } from '@unraid/shared/graphql.model.js';
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
import { newEnforcer } from 'casbin';
import { AuthActionVerb, AuthZService } from 'nest-authz';
import { AuthZService } from 'nest-authz';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
import { ApiKey, ApiKeyWithSecret } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
import { ApiKey } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
import { UserAccount } from '@app/unraid-api/graph/user/user.model.js';
import { FastifyRequest } from '@app/unraid-api/types/fastify.js';
@@ -19,15 +19,6 @@ describe('AuthService', () => {
let cookieService: CookieService;
const mockApiKey: ApiKey = {
id: '10f356da-1e9e-43b8-9028-a26a645539a6',
name: 'Test API Key',
description: 'Test API Key Description',
roles: [Role.GUEST, Role.CONNECT],
createdAt: new Date().toISOString(),
permissions: [],
};
const mockApiKeyWithSecret: ApiKeyWithSecret = {
id: 'test-api-id',
key: 'test-api-key',
name: 'Test API Key',
@@ -36,7 +27,7 @@ describe('AuthService', () => {
permissions: [
{
resource: Resource.CONNECT,
actions: [AuthActionVerb.READ.toUpperCase()],
actions: [AuthAction.READ_ANY],
},
],
createdAt: new Date().toISOString(),
@@ -98,6 +89,43 @@ describe('AuthService', () => {
);
});
it('should validate API key with only permissions (no roles)', async () => {
const apiKeyWithOnlyPermissions: ApiKey = {
...mockApiKey,
roles: [], // No roles, only permissions
permissions: [
{
resource: Resource.DOCKER,
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
},
{
resource: Resource.VMS,
actions: [AuthAction.READ_ANY],
},
],
};
vi.spyOn(apiKeyService, 'findByKey').mockResolvedValue(apiKeyWithOnlyPermissions);
vi.spyOn(authService, 'syncApiKeyRoles').mockResolvedValue(undefined);
vi.spyOn(authService, 'syncApiKeyPermissions').mockResolvedValue(undefined);
vi.spyOn(authzService, 'getRolesForUser').mockResolvedValue([]);
const result = await authService.validateApiKeyCasbin('test-api-key');
expect(result).toEqual({
id: apiKeyWithOnlyPermissions.id,
name: apiKeyWithOnlyPermissions.name,
description: apiKeyWithOnlyPermissions.description,
roles: [],
permissions: apiKeyWithOnlyPermissions.permissions,
});
expect(authService.syncApiKeyRoles).toHaveBeenCalledWith(apiKeyWithOnlyPermissions.id, []);
expect(authService.syncApiKeyPermissions).toHaveBeenCalledWith(
apiKeyWithOnlyPermissions.id,
apiKeyWithOnlyPermissions.permissions
);
});
it('should throw UnauthorizedException when session user is missing', async () => {
vi.spyOn(cookieService, 'hasValidAuthCookie').mockResolvedValue(true);
vi.spyOn(authService, 'getSessionUser').mockResolvedValue(null as unknown as UserAccount);
@@ -196,7 +224,7 @@ describe('AuthService', () => {
vi.spyOn(apiKeyService, 'findById').mockResolvedValue(mockApiKeyWithoutRole);
vi.spyOn(apiKeyService, 'findByIdWithSecret').mockResolvedValue({
...mockApiKeyWithSecret,
...mockApiKey,
roles: [Role.ADMIN],
});
vi.spyOn(apiKeyService, 'saveApiKey').mockResolvedValue();
@@ -208,7 +236,7 @@ describe('AuthService', () => {
expect(apiKeyService.findById).toHaveBeenCalledWith(apiKeyId);
expect(apiKeyService.findByIdWithSecret).toHaveBeenCalledWith(apiKeyId);
expect(apiKeyService.saveApiKey).toHaveBeenCalledWith({
...mockApiKeyWithSecret,
...mockApiKey,
roles: [Role.ADMIN, role],
});
expect(authzService.addRoleForUser).toHaveBeenCalledWith(apiKeyId, role);
@@ -227,7 +255,7 @@ describe('AuthService', () => {
it('should remove role from API key', async () => {
const apiKey = { ...mockApiKey, roles: [Role.ADMIN, Role.GUEST] };
const apiKeyWithSecret = {
...mockApiKeyWithSecret,
...mockApiKey,
roles: [Role.ADMIN, Role.GUEST],
};
@@ -256,4 +284,229 @@ describe('AuthService', () => {
);
});
});
describe('VIEWER role API_KEY access restriction', () => {
it('should deny VIEWER role access to API_KEY resource', async () => {
// Test that VIEWER role cannot access API_KEY resource
const mockCasbinPermissions = Object.values(Resource)
.filter((resource) => resource !== Resource.API_KEY)
.map((resource) => ['VIEWER', resource, AuthAction.READ_ANY]);
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
mockCasbinPermissions
);
const result = await authService.getImplicitPermissionsForRole(Role.VIEWER);
// VIEWER should have read access to all resources EXCEPT API_KEY
expect(result).toBeInstanceOf(Map);
expect(result.size).toBeGreaterThan(0);
// Should NOT have API_KEY in the permissions
expect(result.has(Resource.API_KEY)).toBe(false);
// Should have read access to other resources
expect(result.get(Resource.DOCKER)).toEqual([AuthAction.READ_ANY]);
expect(result.get(Resource.ARRAY)).toEqual([AuthAction.READ_ANY]);
expect(result.get(Resource.CONFIG)).toEqual([AuthAction.READ_ANY]);
expect(result.get(Resource.ME)).toEqual([AuthAction.READ_ANY]);
});
it('should allow ADMIN role access to API_KEY resource', async () => {
// Test that ADMIN role CAN access API_KEY resource
const mockCasbinPermissions = [
['ADMIN', '*', '*'], // Admin has wildcard access
];
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
mockCasbinPermissions
);
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
// ADMIN should have access to API_KEY through wildcard
expect(result).toBeInstanceOf(Map);
expect(result.has(Resource.API_KEY)).toBe(true);
expect(result.get(Resource.API_KEY)).toContain(AuthAction.CREATE_ANY);
expect(result.get(Resource.API_KEY)).toContain(AuthAction.READ_ANY);
expect(result.get(Resource.API_KEY)).toContain(AuthAction.UPDATE_ANY);
expect(result.get(Resource.API_KEY)).toContain(AuthAction.DELETE_ANY);
});
});
describe('getImplicitPermissionsForRole', () => {
it('should return permissions for a role', async () => {
const mockCasbinPermissions = [
['ADMIN', 'DOCKER', 'READ'],
['ADMIN', 'DOCKER', 'UPDATE'],
['ADMIN', 'VMS', 'READ'],
];
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
mockCasbinPermissions
);
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(2);
expect(result.get(Resource.DOCKER)).toEqual([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]);
expect(result.get(Resource.VMS)).toEqual([AuthAction.READ_ANY]);
});
it('should handle wildcard permissions for admin role', async () => {
const mockCasbinPermissions = [
['ADMIN', '*', '*'],
['ADMIN', 'ME', 'READ'], // Inherited from GUEST
];
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
mockCasbinPermissions
);
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
expect(result).toBeInstanceOf(Map);
expect(result.size).toBeGreaterThan(0);
// Should have expanded CRUD actions with proper format for all resources
expect(result.get(Resource.DOCKER)).toContain(AuthAction.CREATE_ANY);
expect(result.get(Resource.DOCKER)).toContain(AuthAction.READ_ANY);
expect(result.get(Resource.DOCKER)).toContain(AuthAction.UPDATE_ANY);
expect(result.get(Resource.DOCKER)).toContain(AuthAction.DELETE_ANY);
expect(result.get(Resource.VMS)).toContain(AuthAction.CREATE_ANY);
expect(result.get(Resource.VMS)).toContain(AuthAction.READ_ANY);
expect(result.get(Resource.VMS)).toContain(AuthAction.UPDATE_ANY);
expect(result.get(Resource.VMS)).toContain(AuthAction.DELETE_ANY);
expect(result.get(Resource.ME)).toContain(AuthAction.READ_ANY);
expect(result.get(Resource.ME)).toContain(AuthAction.CREATE_ANY); // Also gets CRUD from wildcard
expect(result.has('*' as any)).toBe(false); // Still shouldn't have literal wildcard
});
it('should handle connect role with wildcard resource and specific action', async () => {
const mockCasbinPermissions = [
['CONNECT', '*', 'READ'],
['CONNECT', 'CONNECT__REMOTE_ACCESS', 'UPDATE'],
['CONNECT', 'ME', 'READ'], // Inherited from GUEST
];
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
mockCasbinPermissions
);
const result = await authService.getImplicitPermissionsForRole(Role.CONNECT);
expect(result).toBeInstanceOf(Map);
expect(result.size).toBeGreaterThan(0);
// All resources should have READ
expect(result.get(Resource.DOCKER)).toContain(AuthAction.READ_ANY);
expect(result.get(Resource.VMS)).toContain(AuthAction.READ_ANY);
expect(result.get(Resource.ARRAY)).toContain(AuthAction.READ_ANY);
// CONNECT__REMOTE_ACCESS should have both READ and UPDATE
expect(result.get(Resource.CONNECT__REMOTE_ACCESS)).toContain(AuthAction.READ_ANY);
expect(result.get(Resource.CONNECT__REMOTE_ACCESS)).toContain(AuthAction.UPDATE_ANY);
});
it('should expand resource-specific wildcard actions to CRUD', async () => {
const mockCasbinPermissions = [
['DOCKER_MANAGER', 'DOCKER', '*'],
['DOCKER_MANAGER', 'ARRAY', 'READ'],
];
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
mockCasbinPermissions
);
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
expect(result).toBeInstanceOf(Map);
// Docker should have all CRUD actions with proper format
expect(result.get(Resource.DOCKER)).toEqual(
expect.arrayContaining([
AuthAction.CREATE_ANY,
AuthAction.READ_ANY,
AuthAction.UPDATE_ANY,
AuthAction.DELETE_ANY,
])
);
// Array should only have READ
expect(result.get(Resource.ARRAY)).toEqual([AuthAction.READ_ANY]);
});
it('should skip invalid resources', async () => {
const mockCasbinPermissions = [
['ADMIN', 'INVALID_RESOURCE', 'READ'],
['ADMIN', 'DOCKER', 'UPDATE'],
['ADMIN', '', 'READ'],
] as string[][];
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
mockCasbinPermissions
);
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(1);
expect(result.get(Resource.DOCKER)).toEqual([AuthAction.UPDATE_ANY]);
});
it('should handle empty permissions', async () => {
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue([]);
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(0);
});
it('should handle malformed permission entries', async () => {
const mockCasbinPermissions = [
['ADMIN'], // Too short
['ADMIN', 'DOCKER'], // Missing action
['ADMIN', 'DOCKER', 'READ', 'EXTRA'], // Extra fields are ok
['ADMIN', 'VMS', 'UPDATE'],
];
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
mockCasbinPermissions
);
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(2);
expect(result.get(Resource.DOCKER)).toEqual([AuthAction.READ_ANY]);
expect(result.get(Resource.VMS)).toEqual([AuthAction.UPDATE_ANY]);
});
it('should not duplicate actions for the same resource', async () => {
const mockCasbinPermissions = [
['ADMIN', 'DOCKER', 'READ'],
['ADMIN', 'DOCKER', 'READ'],
['ADMIN', 'DOCKER', 'UPDATE'],
['ADMIN', 'DOCKER', 'UPDATE'],
];
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
mockCasbinPermissions
);
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(1);
expect(result.get(Resource.DOCKER)).toEqual([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]);
});
it('should handle errors gracefully', async () => {
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockRejectedValue(
new Error('Casbin error')
);
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(0);
});
});
});
+94 -7
View File
@@ -1,6 +1,12 @@
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { Role } from '@unraid/shared/graphql.model.js';
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
import {
convertPermissionSetsToArrays,
expandWildcardAction,
parseActionToAuthAction,
reconcileWildcardPermissions,
} from '@unraid/shared/util/permissions.js';
import { AuthZService } from 'nest-authz';
import { getters } from '@app/store/index.js';
@@ -111,12 +117,36 @@ export class AuthService {
await this.authzService.deletePermissionsForUser(apiKeyId);
// Create array of permission-action pairs for processing
const permissionActions = permissions.flatMap((permission) =>
(permission.actions || []).map((action) => ({
resource: permission.resource,
action,
}))
);
// Filter out any permissions with empty or undefined resources
const permissionActions = permissions
.filter((permission) => permission.resource && permission.resource.trim() !== '')
.flatMap((permission) =>
(permission.actions || [])
.filter((action) => action && String(action).trim() !== '')
.flatMap((action) => {
const actionStr = String(action);
// Handle wildcard - expand to all CRUD actions
if (actionStr === '*' || actionStr.toLowerCase() === '*') {
return expandWildcardAction().map((expandedAction) => ({
resource: permission.resource,
action: expandedAction,
}));
}
// Use the shared helper to parse and validate the action
const parsedAction = parseActionToAuthAction(actionStr);
// Only include valid AuthAction values
return parsedAction
? [
{
resource: permission.resource,
action: parsedAction,
},
]
: [];
})
);
const { errors, errorOccurred: errorOccured } = await batchProcess(
permissionActions,
@@ -227,6 +257,63 @@ export class AuthService {
return Boolean(token) && token === getters.emhttp().var.csrfToken;
}
/**
* Get implicit permissions for a role (including inherited permissions)
*/
public async getImplicitPermissionsForRole(role: Role): Promise<Map<Resource, AuthAction[]>> {
// Use Set internally for efficient deduplication, with '*' as a special key for wildcards
const permissionsWithSets = new Map<Resource | '*', Set<AuthAction>>();
// Load permissions from Casbin, defaulting to empty array on error
let casbinPermissions: string[][] = [];
try {
casbinPermissions = await this.authzService.getImplicitPermissionsForUser(role);
} catch (error) {
this.logger.error(`Failed to get permissions for role ${role}:`, error);
}
// Parse the Casbin permissions format: [["role", "resource", "action"], ...]
for (const perm of casbinPermissions) {
if (perm.length < 3) continue;
const resourceStr = perm[1];
const action = perm[2];
if (!resourceStr) continue;
// Skip invalid resources (except wildcard)
if (resourceStr !== '*' && !Object.values(Resource).includes(resourceStr as Resource)) {
this.logger.debug(`Skipping invalid resource from Casbin: ${resourceStr}`);
continue;
}
// Initialize Set if needed
if (!permissionsWithSets.has(resourceStr as Resource | '*')) {
permissionsWithSets.set(resourceStr as Resource | '*', new Set());
}
const actionsSet = permissionsWithSets.get(resourceStr as Resource | '*')!;
// Handle wildcard or parse to valid AuthAction
if (action === '*') {
// Expand wildcard action to CRUD operations
expandWildcardAction().forEach((a) => actionsSet.add(a));
} else {
// Use shared helper to parse and validate action
const parsedAction = parseActionToAuthAction(action);
if (parsedAction) {
actionsSet.add(parsedAction);
} else {
this.logger.debug(`Skipping invalid action from Casbin: ${action}`);
}
}
}
// Reconcile wildcard permissions and convert to final format
reconcileWildcardPermissions(permissionsWithSets);
return convertPermissionSetsToArrays(permissionsWithSets);
}
/**
* Returns a user object representing a session.
* Note: Does NOT perform validation.
+3 -3
View File
@@ -12,7 +12,7 @@ g = _, _
e = some(where (p.eft == allow))
[matchers]
m = (regexMatch(r.sub, p.sub) || g(r.sub, p.sub)) && \
regexMatch(lower(r.obj), lower(p.obj)) && \
(regexMatch(lower(r.act), lower(p.act)) || p.act == '*' || regexMatch(lower(r.act), lower(concat(p.act, ':.*'))))
m = (r.sub == p.sub || g(r.sub, p.sub)) && \
(r.obj == p.obj || p.obj == '*') && \
(r.act == p.act || p.act == '*')
`;
@@ -0,0 +1,566 @@
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
import { Model as CasbinModel, newEnforcer, StringAdapter } from 'casbin';
import { beforeEach, describe, expect, it } from 'vitest';
import { CASBIN_MODEL } from '@app/unraid-api/auth/casbin/model.js';
import { BASE_POLICY } from '@app/unraid-api/auth/casbin/policy.js';
describe('Comprehensive Casbin Permissions Tests', () => {
describe('All UsePermissions decorator combinations', () => {
// Test all resource/action combinations used in the codebase
const testCases = [
// API_KEY permissions
{
resource: Resource.API_KEY,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN],
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
},
{
resource: Resource.API_KEY,
action: AuthAction.CREATE_ANY,
allowedRoles: [Role.ADMIN],
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
},
{
resource: Resource.API_KEY,
action: AuthAction.UPDATE_ANY,
allowedRoles: [Role.ADMIN],
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
},
{
resource: Resource.API_KEY,
action: AuthAction.DELETE_ANY,
allowedRoles: [Role.ADMIN],
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
},
// PERMISSION resource (for listing possible permissions)
{
resource: Resource.PERMISSION,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
// ARRAY permissions
{
resource: Resource.ARRAY,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
{
resource: Resource.ARRAY,
action: AuthAction.UPDATE_ANY,
allowedRoles: [Role.ADMIN],
deniedRoles: [Role.VIEWER, Role.GUEST],
},
// CONFIG permissions
{
resource: Resource.CONFIG,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
{
resource: Resource.CONFIG,
action: AuthAction.UPDATE_ANY,
allowedRoles: [Role.ADMIN],
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
},
// DOCKER permissions
{
resource: Resource.DOCKER,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
{
resource: Resource.DOCKER,
action: AuthAction.UPDATE_ANY,
allowedRoles: [Role.ADMIN],
deniedRoles: [Role.VIEWER, Role.GUEST],
},
// VMS permissions
{
resource: Resource.VMS,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
{
resource: Resource.VMS,
action: AuthAction.UPDATE_ANY,
allowedRoles: [Role.ADMIN],
deniedRoles: [Role.VIEWER, Role.GUEST],
},
// FLASH permissions (includes rclone operations)
{
resource: Resource.FLASH,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
{
resource: Resource.FLASH,
action: AuthAction.CREATE_ANY,
allowedRoles: [Role.ADMIN],
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
},
{
resource: Resource.FLASH,
action: AuthAction.DELETE_ANY,
allowedRoles: [Role.ADMIN],
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
},
// INFO permissions (system information)
{
resource: Resource.INFO,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
// LOGS permissions
{
resource: Resource.LOGS,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
// ME permissions (current user info)
{
resource: Resource.ME,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT, Role.GUEST],
deniedRoles: [],
},
// NOTIFICATIONS permissions
{
resource: Resource.NOTIFICATIONS,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
// Other read-only resources for VIEWER
{
resource: Resource.DISK,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
{
resource: Resource.DISPLAY,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
{
resource: Resource.ONLINE,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
{
resource: Resource.OWNER,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
{
resource: Resource.REGISTRATION,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
{
resource: Resource.SERVERS,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
{
resource: Resource.SERVICES,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
{
resource: Resource.SHARE,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
{
resource: Resource.VARS,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
{
resource: Resource.CUSTOMIZATIONS,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
{
resource: Resource.ACTIVATION_CODE,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
// CONNECT special permission for remote access
{
resource: Resource.CONNECT__REMOTE_ACCESS,
action: AuthAction.READ_ANY,
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
deniedRoles: [Role.GUEST],
},
{
resource: Resource.CONNECT__REMOTE_ACCESS,
action: AuthAction.UPDATE_ANY,
allowedRoles: [Role.ADMIN, Role.CONNECT],
deniedRoles: [Role.VIEWER, Role.GUEST],
},
];
testCases.forEach(({ resource, action, allowedRoles, deniedRoles }) => {
describe(`${resource} - ${action}`, () => {
let enforcer: any;
beforeEach(async () => {
const model = new CasbinModel();
model.loadModelFromText(CASBIN_MODEL);
const adapter = new StringAdapter(BASE_POLICY);
enforcer = await newEnforcer(model, adapter);
});
allowedRoles.forEach((role) => {
it(`should allow ${role} to ${action} ${resource}`, async () => {
const result = await enforcer.enforce(role, resource, action);
expect(result).toBe(true);
});
});
deniedRoles.forEach((role) => {
it(`should deny ${role} to ${action} ${resource}`, async () => {
const result = await enforcer.enforce(role, resource, action);
expect(result).toBe(false);
});
});
});
});
});
describe('Action matching and normalization', () => {
let enforcer: any;
beforeEach(async () => {
const model = new CasbinModel();
model.loadModelFromText(CASBIN_MODEL);
const adapter = new StringAdapter(BASE_POLICY);
enforcer = await newEnforcer(model, adapter);
});
it('should match actions exactly as stored (uppercase)', async () => {
// Our policies store actions as uppercase (e.g., 'READ_ANY')
// The matcher now requires exact matching for security
// Uppercase actions should work
const adminUpperResult = await enforcer.enforce(
Role.ADMIN,
Resource.DOCKER,
AuthAction.READ_ANY
);
expect(adminUpperResult).toBe(true);
const viewerUpperResult = await enforcer.enforce(
Role.VIEWER,
Resource.DOCKER,
AuthAction.READ_ANY
);
expect(viewerUpperResult).toBe(true);
// For non-wildcard roles, lowercase actions won't match
const viewerLowerResult = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, 'read:any');
expect(viewerLowerResult).toBe(false);
// Mixed case won't match for VIEWER either
const viewerMixedResult = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, 'Read_Any');
expect(viewerMixedResult).toBe(false);
// GUEST also requires exact lowercase
const guestUpperResult = await enforcer.enforce(Role.GUEST, Resource.ME, 'READ:ANY');
expect(guestUpperResult).toBe(false);
const guestLowerResult = await enforcer.enforce(
Role.GUEST,
Resource.ME,
AuthAction.READ_ANY
);
expect(guestLowerResult).toBe(true);
});
it('should allow wildcard actions for ADMIN regardless of case', async () => {
// ADMIN has wildcard permissions (*, *, *) which match any action
const adminWildcardActions = [
'read:any',
'create:any',
'update:any',
'delete:any',
'READ:ANY', // Even uppercase works due to wildcard
'ANYTHING', // Any action works due to wildcard
];
for (const action of adminWildcardActions) {
const result = await enforcer.enforce(Role.ADMIN, Resource.DOCKER, action);
expect(result).toBe(true);
}
});
it('should NOT match different actions even with correct case', async () => {
// VIEWER should not be able to UPDATE even with correct lowercase
const result = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, AuthAction.UPDATE_ANY);
expect(result).toBe(false);
// VIEWER should not be able to DELETE
const deleteResult = await enforcer.enforce(
Role.VIEWER,
Resource.DOCKER,
AuthAction.DELETE_ANY
);
expect(deleteResult).toBe(false);
// VIEWER should not be able to CREATE
const createResult = await enforcer.enforce(
Role.VIEWER,
Resource.DOCKER,
AuthAction.CREATE_ANY
);
expect(createResult).toBe(false);
});
it('should ensure actions are normalized when stored', async () => {
// This test documents that our auth service normalizes actions to uppercase
// when syncing permissions, ensuring consistency
// The BASE_POLICY uses AuthAction.READ_ANY which is 'READ_ANY' (uppercase)
expect(BASE_POLICY).toContain('READ_ANY');
expect(BASE_POLICY).not.toContain('read:any');
// All our stored policies should be uppercase
const policies = await enforcer.getPolicy();
for (const policy of policies) {
const action = policy[2]; // Third element is the action
if (action && action !== '*') {
// All non-wildcard actions should be uppercase
expect(action).toBe(action.toUpperCase());
}
}
});
});
describe('Wildcard permissions', () => {
let enforcer: any;
beforeEach(async () => {
const model = new CasbinModel();
model.loadModelFromText(CASBIN_MODEL);
const adapter = new StringAdapter(BASE_POLICY);
enforcer = await newEnforcer(model, adapter);
});
it('should allow ADMIN wildcard access to all resources and actions', async () => {
const resources = Object.values(Resource);
const actions = [
AuthAction.READ_ANY,
AuthAction.CREATE_ANY,
AuthAction.UPDATE_ANY,
AuthAction.DELETE_ANY,
];
for (const resource of resources) {
for (const action of actions) {
const result = await enforcer.enforce(Role.ADMIN, resource, action);
expect(result).toBe(true);
}
}
});
it('should allow CONNECT read access to most resources but NOT API_KEY', async () => {
const resources = Object.values(Resource).filter(
(r) => r !== Resource.CONNECT__REMOTE_ACCESS && r !== Resource.API_KEY
);
for (const resource of resources) {
// Should be able to read most resources
const readResult = await enforcer.enforce(Role.CONNECT, resource, AuthAction.READ_ANY);
expect(readResult).toBe(true);
// Should NOT be able to write (except CONNECT__REMOTE_ACCESS)
const updateResult = await enforcer.enforce(
Role.CONNECT,
resource,
AuthAction.UPDATE_ANY
);
expect(updateResult).toBe(false);
}
// CONNECT should NOT be able to read API_KEY
const apiKeyRead = await enforcer.enforce(
Role.CONNECT,
Resource.API_KEY,
AuthAction.READ_ANY
);
expect(apiKeyRead).toBe(false);
// CONNECT should NOT be able to perform any action on API_KEY
const apiKeyCreate = await enforcer.enforce(
Role.CONNECT,
Resource.API_KEY,
AuthAction.CREATE_ANY
);
expect(apiKeyCreate).toBe(false);
const apiKeyUpdate = await enforcer.enforce(
Role.CONNECT,
Resource.API_KEY,
AuthAction.UPDATE_ANY
);
expect(apiKeyUpdate).toBe(false);
const apiKeyDelete = await enforcer.enforce(
Role.CONNECT,
Resource.API_KEY,
AuthAction.DELETE_ANY
);
expect(apiKeyDelete).toBe(false);
// Special case: CONNECT can update CONNECT__REMOTE_ACCESS
const remoteAccessUpdate = await enforcer.enforce(
Role.CONNECT,
Resource.CONNECT__REMOTE_ACCESS,
AuthAction.UPDATE_ANY
);
expect(remoteAccessUpdate).toBe(true);
});
it('should explicitly deny CONNECT role from accessing API_KEY to prevent secret exposure', async () => {
// CONNECT should NOT be able to read API_KEY (which would expose secrets)
const apiKeyRead = await enforcer.enforce(
Role.CONNECT,
Resource.API_KEY,
AuthAction.READ_ANY
);
expect(apiKeyRead).toBe(false);
// Verify all API_KEY operations are denied for CONNECT
const actions = ['create:any', 'read:any', 'update:any', 'delete:any'];
for (const action of actions) {
const result = await enforcer.enforce(Role.CONNECT, Resource.API_KEY, action);
expect(result).toBe(false);
}
// Verify ADMIN can still access API_KEY
const adminApiKeyRead = await enforcer.enforce(
Role.ADMIN,
Resource.API_KEY,
AuthAction.READ_ANY
);
expect(adminApiKeyRead).toBe(true);
});
});
describe('Role inheritance', () => {
let enforcer: any;
beforeEach(async () => {
const model = new CasbinModel();
model.loadModelFromText(CASBIN_MODEL);
const adapter = new StringAdapter(BASE_POLICY);
enforcer = await newEnforcer(model, adapter);
});
it('should inherit GUEST permissions for VIEWER', async () => {
// VIEWER inherits from GUEST, so should have ME access
const result = await enforcer.enforce(Role.VIEWER, Resource.ME, AuthAction.READ_ANY);
expect(result).toBe(true);
});
it('should inherit GUEST permissions for CONNECT', async () => {
// CONNECT inherits from GUEST, so should have ME access
const result = await enforcer.enforce(Role.CONNECT, Resource.ME, AuthAction.READ_ANY);
expect(result).toBe(true);
});
it('should inherit GUEST permissions for ADMIN', async () => {
// ADMIN inherits from GUEST, so should have ME access
const result = await enforcer.enforce(Role.ADMIN, Resource.ME, AuthAction.READ_ANY);
expect(result).toBe(true);
});
});
describe('Edge cases and security', () => {
it('should deny access with empty action', async () => {
const model = new CasbinModel();
model.loadModelFromText(CASBIN_MODEL);
const adapter = new StringAdapter(BASE_POLICY);
const enforcer = await newEnforcer(model, adapter);
const result = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, '');
expect(result).toBe(false);
});
it('should deny access with empty resource', async () => {
const model = new CasbinModel();
model.loadModelFromText(CASBIN_MODEL);
const adapter = new StringAdapter(BASE_POLICY);
const enforcer = await newEnforcer(model, adapter);
const result = await enforcer.enforce(Role.VIEWER, '', AuthAction.READ_ANY);
expect(result).toBe(false);
});
it('should deny access with undefined role', async () => {
const model = new CasbinModel();
model.loadModelFromText(CASBIN_MODEL);
const adapter = new StringAdapter(BASE_POLICY);
const enforcer = await newEnforcer(model, adapter);
const result = await enforcer.enforce(
'UNDEFINED_ROLE',
Resource.DOCKER,
AuthAction.READ_ANY
);
expect(result).toBe(false);
});
it('should deny access with malformed action', async () => {
const model = new CasbinModel();
model.loadModelFromText(CASBIN_MODEL);
const adapter = new StringAdapter(BASE_POLICY);
const enforcer = await newEnforcer(model, adapter);
const malformedActions = [
'read', // Missing possession
':any', // Missing verb
'read:', // Empty possession
'read:own', // Different possession format
'READ', // Uppercase without possession
];
for (const action of malformedActions) {
const result = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, action);
expect(result).toBe(false);
}
});
});
});
@@ -0,0 +1,147 @@
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
import { Model as CasbinModel, newEnforcer, StringAdapter } from 'casbin';
import { describe, expect, it } from 'vitest';
import { CASBIN_MODEL } from '@app/unraid-api/auth/casbin/model.js';
import { BASE_POLICY } from '@app/unraid-api/auth/casbin/policy.js';
describe('Casbin Policy - VIEWER role restrictions', () => {
it('should validate matcher does not allow empty policies', async () => {
// Test that empty policies don't match everything
const model = new CasbinModel();
model.loadModelFromText(CASBIN_MODEL);
// Test with a policy that has an empty object
const emptyPolicy = `p, VIEWER, , ${AuthAction.READ_ANY}`;
const adapter = new StringAdapter(emptyPolicy);
const enforcer = await newEnforcer(model, adapter);
// Empty policy should not match a real resource
const canReadApiKey = await enforcer.enforce(Role.VIEWER, Resource.API_KEY, AuthAction.READ_ANY);
expect(canReadApiKey).toBe(false);
});
it('should deny VIEWER role access to API_KEY resource', async () => {
// Create enforcer with actual policy
const model = new CasbinModel();
model.loadModelFromText(CASBIN_MODEL);
const adapter = new StringAdapter(BASE_POLICY);
const enforcer = await newEnforcer(model, adapter);
// Test that VIEWER cannot access API_KEY with any action
const canReadApiKey = await enforcer.enforce(Role.VIEWER, Resource.API_KEY, AuthAction.READ_ANY);
const canCreateApiKey = await enforcer.enforce(
Role.VIEWER,
Resource.API_KEY,
AuthAction.CREATE_ANY
);
const canUpdateApiKey = await enforcer.enforce(
Role.VIEWER,
Resource.API_KEY,
AuthAction.UPDATE_ANY
);
const canDeleteApiKey = await enforcer.enforce(
Role.VIEWER,
Resource.API_KEY,
AuthAction.DELETE_ANY
);
expect(canReadApiKey).toBe(false);
expect(canCreateApiKey).toBe(false);
expect(canUpdateApiKey).toBe(false);
expect(canDeleteApiKey).toBe(false);
});
it('should allow VIEWER role access to other resources', async () => {
// Create enforcer with actual policy
const model = new CasbinModel();
model.loadModelFromText(CASBIN_MODEL);
const adapter = new StringAdapter(BASE_POLICY);
const enforcer = await newEnforcer(model, adapter);
// Test that VIEWER can read other resources
const canReadDocker = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, AuthAction.READ_ANY);
const canReadArray = await enforcer.enforce(Role.VIEWER, Resource.ARRAY, AuthAction.READ_ANY);
const canReadConfig = await enforcer.enforce(Role.VIEWER, Resource.CONFIG, AuthAction.READ_ANY);
const canReadVms = await enforcer.enforce(Role.VIEWER, Resource.VMS, AuthAction.READ_ANY);
expect(canReadDocker).toBe(true);
expect(canReadArray).toBe(true);
expect(canReadConfig).toBe(true);
expect(canReadVms).toBe(true);
// But VIEWER cannot write to these resources
const canUpdateDocker = await enforcer.enforce(
Role.VIEWER,
Resource.DOCKER,
AuthAction.UPDATE_ANY
);
const canDeleteArray = await enforcer.enforce(
Role.VIEWER,
Resource.ARRAY,
AuthAction.DELETE_ANY
);
expect(canUpdateDocker).toBe(false);
expect(canDeleteArray).toBe(false);
});
it('should allow ADMIN role full access to API_KEY resource', async () => {
// Create enforcer with actual policy
const model = new CasbinModel();
model.loadModelFromText(CASBIN_MODEL);
const adapter = new StringAdapter(BASE_POLICY);
const enforcer = await newEnforcer(model, adapter);
// Test that ADMIN can access API_KEY with all actions
const canReadApiKey = await enforcer.enforce(Role.ADMIN, Resource.API_KEY, AuthAction.READ_ANY);
const canCreateApiKey = await enforcer.enforce(
Role.ADMIN,
Resource.API_KEY,
AuthAction.CREATE_ANY
);
const canUpdateApiKey = await enforcer.enforce(
Role.ADMIN,
Resource.API_KEY,
AuthAction.UPDATE_ANY
);
const canDeleteApiKey = await enforcer.enforce(
Role.ADMIN,
Resource.API_KEY,
AuthAction.DELETE_ANY
);
expect(canReadApiKey).toBe(true);
expect(canCreateApiKey).toBe(true);
expect(canUpdateApiKey).toBe(true);
expect(canDeleteApiKey).toBe(true);
});
it('should ensure VIEWER permissions exclude API_KEY in generated policy', () => {
// Verify that the generated policy string doesn't contain VIEWER + API_KEY combination
expect(BASE_POLICY).toContain(`p, ${Role.VIEWER}, ${Resource.DOCKER}, ${AuthAction.READ_ANY}`);
expect(BASE_POLICY).toContain(`p, ${Role.VIEWER}, ${Resource.ARRAY}, ${AuthAction.READ_ANY}`);
expect(BASE_POLICY).not.toContain(
`p, ${Role.VIEWER}, ${Resource.API_KEY}, ${AuthAction.READ_ANY}`
);
// Count VIEWER permissions - should be total resources minus API_KEY
const viewerPermissionLines = BASE_POLICY.split('\n').filter((line) =>
line.startsWith(`p, ${Role.VIEWER},`)
);
const totalResources = Object.values(Resource).length;
expect(viewerPermissionLines.length).toBe(totalResources - 1); // All resources except API_KEY
});
it('should inherit GUEST permissions for VIEWER role', async () => {
// Create enforcer with actual policy
const model = new CasbinModel();
model.loadModelFromText(CASBIN_MODEL);
const adapter = new StringAdapter(BASE_POLICY);
const enforcer = await newEnforcer(model, adapter);
// VIEWER inherits from GUEST, so should have access to ME resource
const canReadMe = await enforcer.enforce(Role.VIEWER, Resource.ME, AuthAction.READ_ANY);
expect(canReadMe).toBe(true);
});
});
+15 -7
View File
@@ -1,18 +1,26 @@
import { Resource, Role } from '@unraid/shared/graphql.model.js';
import { AuthAction } from 'nest-authz';
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
// Generate VIEWER permissions for all resources except API_KEY
const viewerPermissions = Object.values(Resource)
.filter((resource) => resource !== Resource.API_KEY)
.map((resource) => `p, ${Role.VIEWER}, ${resource}, ${AuthAction.READ_ANY}`)
.join('\n');
export const BASE_POLICY = `
# Admin permissions
# Admin permissions - full access
p, ${Role.ADMIN}, *, *
# Connect Permissions
p, ${Role.CONNECT}, *, ${AuthAction.READ_ANY}
# Connect permissions - inherits from VIEWER plus can manage remote access
p, ${Role.CONNECT}, ${Resource.CONNECT__REMOTE_ACCESS}, ${AuthAction.UPDATE_ANY}
# Guest permissions
# Guest permissions - basic profile access
p, ${Role.GUEST}, ${Resource.ME}, ${AuthAction.READ_ANY}
# Viewer permissions - read-only access to all resources except API_KEY
${viewerPermissions}
# Role inheritance
g, ${Role.ADMIN}, ${Role.GUEST}
g, ${Role.CONNECT}, ${Role.GUEST}
g, ${Role.CONNECT}, ${Role.VIEWER}
g, ${Role.VIEWER}, ${Role.GUEST}
`;
@@ -1,5 +1,4 @@
import { Resource, Role } from '@unraid/shared/graphql.model.js';
import { AuthActionVerb } from 'nest-authz';
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
import { Command, CommandRunner, InquirerService, Option } from 'nest-commander';
import type { DeleteApiKeyAnswers } from '@app/unraid-api/cli/apikey/delete-api-key.questions.js';
@@ -75,7 +74,7 @@ export class ApiKeyCommand extends CommandRunner {
flags: '-p, --permissions <permissions>',
description: `Comma separated list of permissions to assign to the key (in the form of "resource:action")
RESOURCES: ${Object.values(Resource).join(', ')}
ACTIONS: ${Object.values(AuthActionVerb).join(', ')}`,
ACTIONS: ${Object.values(AuthAction).join(', ')}`,
})
parsePermissions(permissions: string): Array<Permission> {
return this.apiKeyService.convertPermissionsStringArrayToPermissions(
+83 -33
View File
@@ -120,7 +120,7 @@ export type ActivationCode = {
};
export type AddPermissionInput = {
actions: Array<Scalars['String']['input']>;
actions: Array<AuthAction>;
resource: Resource;
};
@@ -143,24 +143,36 @@ export type ApiKey = Node & {
createdAt: Scalars['String']['output'];
description?: Maybe<Scalars['String']['output']>;
id: Scalars['PrefixedID']['output'];
key: Scalars['String']['output'];
name: Scalars['String']['output'];
permissions: Array<Permission>;
roles: Array<Role>;
};
export type ApiKeyFormSettings = FormSchema & Node & {
__typename?: 'ApiKeyFormSettings';
/** The data schema for the API key form */
dataSchema: Scalars['JSON']['output'];
id: Scalars['PrefixedID']['output'];
/** The UI schema for the API key form */
uiSchema: Scalars['JSON']['output'];
/** The current values of the API key form */
values: Scalars['JSON']['output'];
};
/** API Key related mutations */
export type ApiKeyMutations = {
__typename?: 'ApiKeyMutations';
/** Add a role to an API key */
addRole: Scalars['Boolean']['output'];
/** Create an API key */
create: ApiKeyWithSecret;
create: ApiKey;
/** Delete one or more API keys */
delete: Scalars['Boolean']['output'];
/** Remove a role from an API key */
removeRole: Scalars['Boolean']['output'];
/** Update an API key */
update: ApiKeyWithSecret;
update: ApiKey;
};
@@ -199,17 +211,6 @@ export type ApiKeyResponse = {
valid: Scalars['Boolean']['output'];
};
export type ApiKeyWithSecret = Node & {
__typename?: 'ApiKeyWithSecret';
createdAt: Scalars['String']['output'];
description?: Maybe<Scalars['String']['output']>;
id: Scalars['PrefixedID']['output'];
key: Scalars['String']['output'];
name: Scalars['String']['output'];
permissions: Array<Permission>;
roles: Array<Role>;
};
export type ArrayCapacity = {
__typename?: 'ArrayCapacity';
/** Capacity in number of disks */
@@ -370,19 +371,24 @@ export enum ArrayStateInputState {
STOP = 'STOP'
}
/** Available authentication action verbs */
export enum AuthActionVerb {
CREATE = 'CREATE',
DELETE = 'DELETE',
READ = 'READ',
UPDATE = 'UPDATE'
}
/** Available authentication possession types */
export enum AuthPossession {
ANY = 'ANY',
OWN = 'OWN',
OWN_ANY = 'OWN_ANY'
/** Authentication actions with possession (e.g., create:any, read:own) */
export enum AuthAction {
/** Create any resource */
CREATE_ANY = 'CREATE_ANY',
/** Create own resource */
CREATE_OWN = 'CREATE_OWN',
/** Delete any resource */
DELETE_ANY = 'DELETE_ANY',
/** Delete own resource */
DELETE_OWN = 'DELETE_OWN',
/** Read any resource */
READ_ANY = 'READ_ANY',
/** Read own resource */
READ_OWN = 'READ_OWN',
/** Update any resource */
UPDATE_ANY = 'UPDATE_ANY',
/** Update own resource */
UPDATE_OWN = 'UPDATE_OWN'
}
/** Operators for authorization rule matching */
@@ -776,6 +782,15 @@ export type FlashBackupStatus = {
status: Scalars['String']['output'];
};
export type FormSchema = {
/** The data schema for the form */
dataSchema: Scalars['JSON']['output'];
/** The UI schema for the form */
uiSchema: Scalars['JSON']['output'];
/** The current values of the form */
values: Scalars['JSON']['output'];
};
export type Info = Node & {
__typename?: 'Info';
/** Motherboard information */
@@ -1053,7 +1068,7 @@ export type InfoVersions = Node & {
core: CoreVersions;
id: Scalars['PrefixedID']['output'];
/** Software package versions */
packages: PackageVersions;
packages?: Maybe<PackageVersions>;
};
export type InitiateFlashBackupInput = {
@@ -1519,7 +1534,7 @@ export type ParityCheck = {
/** Speed of the parity check, in MB/s */
speed?: Maybe<Scalars['String']['output']>;
/** Status of the parity check */
status?: Maybe<Scalars['String']['output']>;
status: ParityCheckStatus;
};
/** Parity check related mutations, WIP, response types and functionaliy will change */
@@ -1541,9 +1556,19 @@ export type ParityCheckMutationsStartArgs = {
correct: Scalars['Boolean']['input'];
};
export enum ParityCheckStatus {
CANCELLED = 'CANCELLED',
COMPLETED = 'COMPLETED',
FAILED = 'FAILED',
NEVER_RUN = 'NEVER_RUN',
PAUSED = 'PAUSED',
RUNNING = 'RUNNING'
}
export type Permission = {
__typename?: 'Permission';
actions: Array<Scalars['String']['output']>;
/** Actions allowed on this resource */
actions: Array<AuthAction>;
resource: Resource;
};
@@ -1613,6 +1638,12 @@ export type Query = {
disks: Array<Disk>;
docker: Docker;
flash: Flash;
/** Get JSON Schema for API key creation form */
getApiKeyCreationFormSchema: ApiKeyFormSettings;
/** Get all available authentication actions with possession */
getAvailableAuthActions: Array<AuthAction>;
/** Get the actual permissions that would be granted by a set of roles */
getPermissionsForRoles: Array<Permission>;
info: Info;
isInitialSetup: Scalars['Boolean']['output'];
isSSOEnabled: Scalars['Boolean']['output'];
@@ -1632,6 +1663,8 @@ export type Query = {
parityHistory: Array<ParityCheck>;
/** List all installed plugins with their metadata */
plugins: Array<Plugin>;
/** Preview the effective permissions for a combination of roles and explicit permissions */
previewEffectivePermissions: Array<Permission>;
/** Get public OIDC provider information for login buttons */
publicOidcProviders: Array<PublicOidcProvider>;
publicPartnerInfo?: Maybe<PublicPartnerInfo>;
@@ -1665,6 +1698,11 @@ export type QueryDiskArgs = {
};
export type QueryGetPermissionsForRolesArgs = {
roles: Array<Role>;
};
export type QueryLogFileArgs = {
lines?: InputMaybe<Scalars['Int']['input']>;
path: Scalars['String']['input'];
@@ -1677,6 +1715,12 @@ export type QueryOidcProviderArgs = {
};
export type QueryPreviewEffectivePermissionsArgs = {
permissions?: InputMaybe<Array<AddPermissionInput>>;
roles?: InputMaybe<Array<Role>>;
};
export type QueryUpsDeviceByIdArgs = {
id: Scalars['String']['input'];
};
@@ -1869,10 +1913,14 @@ export enum Resource {
/** Available roles for API keys and users */
export enum Role {
/** Full administrative access to all resources */
ADMIN = 'ADMIN',
/** Internal Role for Unraid Connect */
CONNECT = 'CONNECT',
/** Basic read access to user profile only */
GUEST = 'GUEST',
USER = 'USER'
/** Read-only access to all resources */
VIEWER = 'VIEWER'
}
export type Server = Node & {
@@ -2149,7 +2197,7 @@ export enum UrlType {
WIREGUARD = 'WIREGUARD'
}
export type UnifiedSettings = Node & {
export type UnifiedSettings = FormSchema & Node & {
__typename?: 'UnifiedSettings';
/** The data schema for the settings */
dataSchema: Scalars['JSON']['output'];
@@ -2173,6 +2221,8 @@ export type UnraidArray = Node & {
id: Scalars['PrefixedID']['output'];
/** Parity disks in the current array */
parities: Array<ArrayDisk>;
/** Current parity check status */
parityCheckStatus: ParityCheck;
/** Current array state */
state: ArrayState;
};
@@ -2527,7 +2577,7 @@ export type GetSsoUsersQuery = { __typename?: 'Query', settings: { __typename?:
export type SystemReportQueryVariables = Exact<{ [key: string]: never; }>;
export type SystemReportQuery = { __typename?: 'Query', info: { __typename?: 'Info', id: any, machineId?: string | null, system: { __typename?: 'InfoSystem', manufacturer?: string | null, model?: string | null, version?: string | null, sku?: string | null, serial?: string | null, uuid?: string | null }, versions: { __typename?: 'InfoVersions', core: { __typename?: 'CoreVersions', unraid?: string | null, kernel?: string | null }, packages: { __typename?: 'PackageVersions', openssl?: string | null } } }, config: { __typename?: 'Config', id: any, valid?: boolean | null, error?: string | null }, server?: { __typename?: 'Server', id: any, name: string } | null };
export type SystemReportQuery = { __typename?: 'Query', info: { __typename?: 'Info', id: any, machineId?: string | null, system: { __typename?: 'InfoSystem', manufacturer?: string | null, model?: string | null, version?: string | null, sku?: string | null, serial?: string | null, uuid?: string | null }, versions: { __typename?: 'InfoVersions', core: { __typename?: 'CoreVersions', unraid?: string | null, kernel?: string | null }, packages?: { __typename?: 'PackageVersions', openssl?: string | null } | null } }, config: { __typename?: 'Config', id: any, valid?: boolean | null, error?: string | null }, server?: { __typename?: 'Server', id: any, name: string } | null };
export type ConnectStatusQueryVariables = Exact<{ [key: string]: never; }>;
@@ -0,0 +1,3 @@
// All enum registrations have been moved to @unraid/shared/graphql.model.js
// Just re-export AuthAction for convenience
export { AuthAction } from '@unraid/shared/graphql.model.js';
+3 -52
View File
@@ -1,52 +1,3 @@
import { DirectiveLocation, GraphQLDirective, GraphQLEnumType, GraphQLString } from 'graphql';
import { AuthActionVerb, AuthPossession } from 'nest-authz';
// Create GraphQL enum types for auth action verbs and possessions
export const AuthActionVerbEnum = new GraphQLEnumType({
name: 'AuthActionVerb',
description: 'Available authentication action verbs',
values: Object.entries(AuthActionVerb)
.filter(([key]) => isNaN(Number(key))) // Filter out numeric keys
.reduce(
(acc, [key]) => {
acc[key] = { value: key };
return acc;
},
{} as Record<string, { value: string }>
),
});
export const AuthPossessionEnum = new GraphQLEnumType({
name: 'AuthPossession',
description: 'Available authentication possession types',
values: Object.entries(AuthPossession)
.filter(([key]) => isNaN(Number(key))) // Filter out numeric keys
.reduce(
(acc, [key]) => {
acc[key] = { value: key };
return acc;
},
{} as Record<string, { value: string }>
),
});
// Create the auth directive
export const AuthDirective = new GraphQLDirective({
name: 'auth',
description: 'Directive to control access to fields based on authentication',
locations: [DirectiveLocation.FIELD_DEFINITION],
args: {
action: {
type: AuthActionVerbEnum,
description: 'The action verb required for access',
},
resource: {
type: GraphQLString,
description: 'The resource required for access',
},
possession: {
type: AuthPossessionEnum,
description: 'The possession type required for access',
},
},
});
// Resource and Role enums are already registered in @unraid/shared/graphql.model.js
// Just re-export them here for convenience
export { Resource, Role } from '@unraid/shared/graphql.model.js';
+4
View File
@@ -12,6 +12,10 @@ import { NoUnusedVariablesRule } from 'graphql';
import { ENVIRONMENT } from '@app/environment.js';
import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js';
// Import enum registrations to ensure they're registered with GraphQL
import '@app/unraid-api/graph/auth/auth-action.enum.js';
import { createDynamicIntrospectionPlugin } from '@app/unraid-api/graph/introspection-plugin.js';
import { ResolversModule } from '@app/unraid-api/graph/resolvers/resolvers.module.js';
import { createSandboxPlugin } from '@app/unraid-api/graph/sandbox-plugin.js';
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { Query, Resolver } from '@nestjs/graphql';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { ApiKeyFormService } from '@app/unraid-api/graph/resolvers/api-key/api-key-form.service.js';
import { ApiKeyFormSettings } from '@app/unraid-api/graph/resolvers/settings/settings.model.js';
@Injectable()
@Resolver()
export class ApiKeyFormResolver {
constructor(private apiKeyFormService: ApiKeyFormService) {}
@Query(() => ApiKeyFormSettings, {
description: 'Get JSON Schema for API key creation form',
})
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.API_KEY,
})
getApiKeyCreationFormSchema(): ApiKeyFormSettings {
return this.apiKeyFormService.getApiKeyCreationFormSchema();
}
}
@@ -0,0 +1,247 @@
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
import { beforeEach, describe, expect, it } from 'vitest';
import {
ApiKeyFormData,
ApiKeyFormService,
} from '@app/unraid-api/graph/resolvers/api-key/api-key-form.service.js';
describe('ApiKeyFormService', () => {
let service: ApiKeyFormService;
beforeEach(() => {
service = new ApiKeyFormService();
});
describe('convertFormDataToPermissions', () => {
describe('basic functionality', () => {
it('should merge roles and custom permissions', () => {
const formData: ApiKeyFormData = {
name: 'Test Key',
roles: [Role.ADMIN],
customPermissions: [
{
resources: [Resource.NETWORK],
actions: [AuthAction.READ_ANY],
},
],
};
const result = service.convertFormDataToPermissions(formData);
expect(result.roles).toEqual([Role.ADMIN]);
expect(result.permissions).toContainEqual({
resource: Resource.NETWORK,
actions: [AuthAction.READ_ANY],
});
});
it('should handle only roles when others are not provided', () => {
const formData: ApiKeyFormData = {
name: 'Test Key',
roles: [Role.GUEST, Role.VIEWER],
};
const result = service.convertFormDataToPermissions(formData);
expect(result.roles).toEqual([Role.GUEST, Role.VIEWER]);
expect(result.permissions).toEqual([]);
});
it('should handle multiple roles', () => {
const formData: ApiKeyFormData = {
name: 'Test Key',
roles: [Role.GUEST, Role.VIEWER, Role.ADMIN],
};
const result = service.convertFormDataToPermissions(formData);
expect(result.roles).toEqual([Role.GUEST, Role.VIEWER, Role.ADMIN]);
expect(result.permissions).toEqual([]);
});
it('should handle only custom permissions when others are not provided', () => {
const formData: ApiKeyFormData = {
name: 'Test Key',
customPermissions: [
{
resources: [Resource.ARRAY, Resource.DISK],
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
},
],
};
const result = service.convertFormDataToPermissions(formData);
expect(result.roles).toEqual([]);
expect(result.permissions).toContainEqual({
resource: Resource.ARRAY,
actions: expect.arrayContaining([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]),
});
expect(result.permissions).toContainEqual({
resource: Resource.DISK,
actions: expect.arrayContaining([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]),
});
});
it('should handle empty form data', () => {
const formData: ApiKeyFormData = {
name: 'Test Key',
};
const result = service.convertFormDataToPermissions(formData);
expect(result.roles).toEqual([]);
expect(result.permissions).toEqual([]);
});
});
describe('custom permissions handling', () => {
it('should merge custom permissions with same resource', () => {
const formData: ApiKeyFormData = {
name: 'Test Key',
customPermissions: [
{
resources: [Resource.DOCKER],
actions: [AuthAction.READ_ANY],
},
{
resources: [Resource.DOCKER],
actions: [AuthAction.UPDATE_ANY, AuthAction.DELETE_ANY],
},
],
};
const result = service.convertFormDataToPermissions(formData);
expect(result.permissions).toEqual([
{
resource: Resource.DOCKER,
actions: expect.arrayContaining([
AuthAction.READ_ANY,
AuthAction.UPDATE_ANY,
AuthAction.DELETE_ANY,
]),
},
]);
});
it('should deduplicate actions when merging', () => {
const formData: ApiKeyFormData = {
name: 'Test Key',
customPermissions: [
{
resources: [Resource.NETWORK],
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
},
{
resources: [Resource.NETWORK],
actions: [AuthAction.READ_ANY, AuthAction.DELETE_ANY],
},
],
};
const result = service.convertFormDataToPermissions(formData);
const networkPermission = result.permissions.find(
(p) => p.resource === Resource.NETWORK
);
expect(networkPermission?.actions).toHaveLength(3);
expect(networkPermission?.actions).toContain(AuthAction.READ_ANY);
expect(networkPermission?.actions).toContain(AuthAction.UPDATE_ANY);
expect(networkPermission?.actions).toContain(AuthAction.DELETE_ANY);
});
});
describe('edge cases', () => {
it('should handle resources as non-array in custom permissions', () => {
const formData: ApiKeyFormData = {
name: 'Test Key',
customPermissions: [
{
resources: Resource.DOCKER as any,
actions: [AuthAction.READ_ANY],
},
],
};
const result = service.convertFormDataToPermissions(formData);
expect(result.permissions).toEqual([
{
resource: Resource.DOCKER,
actions: [AuthAction.READ_ANY],
},
]);
});
it('should handle actions as non-array in custom permissions', () => {
const formData: ApiKeyFormData = {
name: 'Test Key',
customPermissions: [
{
resources: [Resource.DOCKER],
actions: AuthAction.READ_ANY as any,
},
],
};
const result = service.convertFormDataToPermissions(formData);
expect(result.permissions).toEqual([
{
resource: Resource.DOCKER,
actions: [AuthAction.READ_ANY],
},
]);
});
it('should handle empty arrays gracefully', () => {
const formData: ApiKeyFormData = {
name: 'Test Key',
roles: [],
customPermissions: [],
};
const result = service.convertFormDataToPermissions(formData);
expect(result.roles).toEqual([]);
expect(result.permissions).toEqual([]);
});
it('should handle both roles and custom permissions together', () => {
const formData: ApiKeyFormData = {
name: 'Test Key',
roles: [Role.VIEWER],
customPermissions: [
{
resources: [Resource.DOCKER, Resource.VMS],
actions: [AuthAction.READ_ANY],
},
{
resources: [Resource.NETWORK],
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
},
],
};
const result = service.convertFormDataToPermissions(formData);
expect(result.roles).toEqual([Role.VIEWER]);
expect(result.permissions).toHaveLength(3);
expect(result.permissions).toContainEqual({
resource: Resource.DOCKER,
actions: [AuthAction.READ_ANY],
});
expect(result.permissions).toContainEqual({
resource: Resource.VMS,
actions: [AuthAction.READ_ANY],
});
expect(result.permissions).toContainEqual({
resource: Resource.NETWORK,
actions: expect.arrayContaining([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]),
});
});
});
});
});
@@ -0,0 +1,374 @@
import { Injectable } from '@nestjs/common';
import type { JsonSchema, LabelElement, UISchemaElement } from '@jsonforms/core';
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
import { mergeSettingSlices } from '@unraid/shared/jsonforms/settings.js';
import { normalizeAction } from '@unraid/shared/util/permissions.js';
import { capitalCase } from 'change-case';
import type { SettingSlice } from '@app/unraid-api/types/json-forms.js';
import {
createLabeledControl,
createSimpleLabeledControl,
} from '@app/unraid-api/graph/utils/form-utils.js';
// Helper to get GraphQL enum names for JSON Schema
// GraphQL expects the enum names (keys) not the values
function getAuthActionEnumNames(): string[] {
// Get only the "_ANY" actions (not "_OWN")
// e.g., CREATE_ANY, READ_ANY, UPDATE_ANY, DELETE_ANY
return Object.keys(AuthAction).filter((key) => key === key.toUpperCase() && key.endsWith('_ANY'));
}
// Helper to create labels for AuthAction enum dynamically
function getAuthActionLabels(): Record<string, string> {
const labels: Record<string, string> = {};
for (const enumName of getAuthActionEnumNames()) {
// Convert CREATE_ANY -> Create (All)
// Convert READ_OWN -> Read (Own)
const [verb, possession] = enumName.split('_');
const verbLabel = capitalCase(verb.toLowerCase());
const possessionLabel = possession === 'ANY' ? 'All' : 'Own';
labels[enumName] = `${verbLabel} (${possessionLabel})`;
}
return labels;
}
export interface ApiKeyFormData {
name: string;
description?: string;
roles?: Role[];
permissionPresets?: string; // Single preset selection from dropdown
customPermissions?: Array<{
resources: Resource[]; // Form uses array for multi-select
actions: string[];
}>;
expiresAt?: string;
}
@Injectable()
export class ApiKeyFormService {
/**
* Generate form schema for API key creation
*/
getApiKeyCreationFormSchema(): {
id: string;
dataSchema: Record<string, any>;
uiSchema: Record<string, any>;
values: Record<string, any>;
} {
const slice = this.createApiKeyCreationSlice();
const merged = mergeSettingSlices([slice]);
return {
id: 'api-key-creation-form',
dataSchema: {
type: 'object',
required: ['name'],
properties: merged.properties,
},
uiSchema: {
type: 'VerticalLayout',
elements: merged.elements,
},
values: {},
};
}
private createApiKeyCreationSlice(): SettingSlice {
const slice: SettingSlice = {
properties: {
name: {
type: 'string',
title: 'API Key Name',
description: 'A descriptive name for this API key',
minLength: 1,
maxLength: 100,
},
description: {
type: 'string',
title: 'Description',
description: 'Optional description of what this key is used for',
maxLength: 500,
},
roles: {
type: 'array',
title: 'Roles',
description: 'Select one or more roles to grant pre-defined permission sets',
items: {
type: 'string',
enum: this.getAvailableRoles(),
},
uniqueItems: true,
},
permissionPresets: {
type: 'string',
title: 'Add Permission Preset',
description: 'Quick add common permission sets',
enum: [
'none',
'docker_manager',
'vm_manager',
'monitoring',
'backup_manager',
'network_admin',
],
default: 'none',
},
customPermissions: {
type: 'array',
title: 'Permissions',
description: 'Configure specific permissions',
items: {
type: 'object',
properties: {
resources: {
type: 'array',
title: 'Resources',
items: {
type: 'string',
enum: this.getAvailableResources(),
},
uniqueItems: true,
minItems: 1,
default: [this.getAvailableResources()[0]], // Set a default value as array
},
actions: {
type: 'array',
title: 'Actions',
items: {
type: 'string',
enum: getAuthActionEnumNames(),
},
uniqueItems: true,
minItems: 1,
default: ['READ_ANY'], // Set a default action
},
},
required: ['resources', 'actions'],
},
},
// Commenting out expiration date until date picker is implemented
// expiresAt: {
// type: 'string',
// format: 'date-time',
// title: 'Expiration Date',
// description: 'Optional expiration date for this API key',
// },
},
elements: [
createLabeledControl({
scope: '#/properties/name',
label: 'API Key Name',
description: 'A descriptive name for this API key',
layoutType: 'VerticalLayout',
controlOptions: {
inputType: 'text',
},
}),
createLabeledControl({
scope: '#/properties/description',
label: 'Description',
description: 'Optional description of what this key is used for',
layoutType: 'VerticalLayout',
controlOptions: {
multi: true,
rows: 3,
},
}),
// Permissions section header
{
type: 'Label',
text: 'Permissions Configuration',
options: {
format: 'title',
},
} as LabelElement,
{
type: 'Label',
text: 'Select any combination of roles, permission groups, and custom permissions to define what this API key can access.',
options: {
format: 'description',
},
} as LabelElement,
// Roles selection
createLabeledControl({
scope: '#/properties/roles',
label: 'Roles',
description: 'Select one or more roles to grant pre-defined permission sets',
layoutType: 'VerticalLayout',
controlOptions: {
multiple: true,
labels: this.getAvailableRoles().reduce(
(acc, role) => ({
...acc,
[role]: capitalCase(role),
}),
{}
),
descriptions: this.getRoleDescriptions(),
},
}),
// Separator for permissions
{
type: 'Label',
text: 'Permissions',
options: {
format: 'subtitle',
},
} as LabelElement,
{
type: 'Label',
text: 'Use the preset dropdown for common permission sets, or manually add custom permissions. You can select multiple resources that share the same actions.',
options: {
format: 'description',
},
} as LabelElement,
// Permission preset dropdown
createLabeledControl({
scope: '#/properties/permissionPresets',
label: 'Quick Add Presets',
description: 'Select a preset to quickly add common permission sets',
layoutType: 'VerticalLayout',
controlOptions: {
labels: {
none: '-- Select a preset --',
docker_manager: 'Docker Manager (Full Docker Control)',
vm_manager: 'VM Manager (Full VM Control)',
monitoring: 'Monitoring (Read-only System Info)',
backup_manager: 'Backup Manager (Flash & Share Control)',
network_admin: 'Network Admin (Network & Services Control)',
},
},
}),
// Custom permissions array - following OIDC pattern exactly
{
type: 'Control',
scope: '#/properties/customPermissions',
options: {
elementLabelFormat: 'Permission Entry',
itemTypeName: 'Permission',
detail: {
type: 'VerticalLayout',
elements: [
createSimpleLabeledControl({
scope: '#/properties/resources',
label: 'Resources:',
description: 'Select the resources to grant permissions for',
controlOptions: {
multiple: true,
labels: this.getAvailableResources().reduce(
(acc, resource) => ({
...acc,
[resource]: capitalCase(
resource.toLowerCase().replace(/_/g, ' ')
),
}),
{}
),
},
}),
createSimpleLabeledControl({
scope: '#/properties/actions',
label: 'Actions:',
description: 'Select the actions allowed on this resource',
controlOptions: {
multiple: true,
labels: getAuthActionLabels(),
},
}),
],
},
},
} as UISchemaElement,
// Note: Datetime inputs are not currently supported in the renderer
// Would need to implement a date picker component
// For now, commenting out the expiration date field
// createLabeledControl({
// scope: '#/properties/expiresAt',
// label: 'Expiration Date:',
// description: 'Optional expiration date for this API key',
// controlOptions: {
// inputType: 'datetime-local',
// },
// }),
],
};
return slice;
}
private getAvailableRoles(): Role[] {
return [Role.ADMIN, Role.VIEWER, Role.CONNECT, Role.GUEST];
}
private getRoleDescriptions(): Record<Role, string> {
return {
[Role.ADMIN]: 'Full administrative access to all resources',
[Role.VIEWER]: 'Read-only access to all resources',
[Role.CONNECT]: 'Internal Role for Unraid Connect',
[Role.GUEST]: 'Basic read access to user profile only',
};
}
private getAvailableResources(): Resource[] {
return Object.values(Resource);
}
/**
* Convert form data back to permissions for API key creation
* The form provides: name, description, roles, and customPermissions
* Note: permissionPresets is only a UI helper that adds to customPermissions
*/
convertFormDataToPermissions(formData: ApiKeyFormData): {
roles: Role[];
permissions: Array<{ resource: Resource; actions: AuthAction[] }>;
} {
const roles: Role[] = [];
const permissions = new Map<Resource, Set<AuthAction>>();
// 1. Add roles if provided
if (formData.roles && formData.roles.length > 0) {
roles.push(...formData.roles);
}
// 2. Add custom permissions if provided
// This includes permissions added via the preset dropdown
if (formData.customPermissions && formData.customPermissions.length > 0) {
for (const perm of formData.customPermissions) {
// Handle resources as an array (form uses multi-select)
const resources = Array.isArray(perm.resources)
? perm.resources
: [perm.resources as Resource];
// Handle actions as an array and normalize them
const rawActions = Array.isArray(perm.actions) ? perm.actions : [perm.actions];
const normalizedActions: AuthAction[] = [];
for (const rawAction of rawActions) {
const normalized = normalizeAction(rawAction);
if (normalized) {
normalizedActions.push(normalized);
}
}
for (const resource of resources) {
if (!permissions.has(resource)) {
permissions.set(resource, new Set());
}
normalizedActions.forEach((action) => permissions.get(resource)!.add(action));
}
}
}
return {
roles,
permissions: Array.from(permissions.entries()).map(([resource, actions]) => ({
resource,
actions: Array.from(actions),
})),
};
}
}
@@ -0,0 +1,120 @@
import { Injectable } from '@nestjs/common';
import { Args, Query, Resolver } from '@nestjs/graphql';
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import {
expandWildcardAction,
mergePermissionsIntoMap,
parseActionToAuthAction,
} from '@unraid/shared/util/permissions.js';
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
import {
AddPermissionInput,
Permission,
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
@Injectable()
@Resolver()
export class ApiKeyPermissionsResolver {
constructor(private authService: AuthService) {}
@Query(() => [Permission], {
description: 'Get the actual permissions that would be granted by a set of roles',
})
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.PERMISSION,
})
async getPermissionsForRoles(
@Args('roles', { type: () => [Role] }) roles: Role[]
): Promise<Permission[]> {
// Get the implicit permissions for each role from Casbin
const allPermissions = new Map<Resource, Set<AuthAction>>();
for (const role of roles) {
// Query Casbin for what permissions this role actually has
const rolePermissions = await this.authService.getImplicitPermissionsForRole(role);
mergePermissionsIntoMap(allPermissions, rolePermissions);
}
// Convert to Permission array
const permissions: Permission[] = [];
for (const [resource, actions] of allPermissions) {
permissions.push({
resource,
actions: Array.from(actions),
});
}
return permissions;
}
@Query(() => [Permission], {
description:
'Preview the effective permissions for a combination of roles and explicit permissions',
})
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.PERMISSION,
})
async previewEffectivePermissions(
@Args('roles', { type: () => [Role], nullable: true }) roles?: Role[],
@Args('permissions', { type: () => [AddPermissionInput], nullable: true })
permissions?: AddPermissionInput[]
): Promise<Permission[]> {
const effectivePermissions = new Map<Resource, Set<AuthAction>>();
// Add permissions from roles
for (const role of roles ?? []) {
const rolePermissions = await this.authService.getImplicitPermissionsForRole(role);
mergePermissionsIntoMap(effectivePermissions, rolePermissions);
}
// Add explicit permissions
if (permissions && permissions.length > 0) {
for (const perm of permissions) {
if (!effectivePermissions.has(perm.resource)) {
effectivePermissions.set(perm.resource, new Set());
}
const resourceActions = effectivePermissions.get(perm.resource)!;
perm.actions.forEach((action) => {
const actionStr = String(action);
// Handle wildcard - expand to all CRUD actions
if (actionStr === '*' || actionStr.toLowerCase() === '*') {
expandWildcardAction().forEach((expandedAction) => {
resourceActions.add(expandedAction);
});
} else {
// Use the shared helper to parse and validate the action
const parsedAction = parseActionToAuthAction(actionStr);
if (parsedAction) {
resourceActions.add(parsedAction);
}
}
});
}
}
// Convert to Permission array
const result: Permission[] = [];
for (const [resource, actions] of effectivePermissions) {
result.push({
resource,
actions: Array.from(actions),
});
}
return result;
}
@Query(() => [AuthAction], {
description: 'Get all available authentication actions with possession',
})
getAvailableAuthActions(): AuthAction[] {
return Object.values(AuthAction);
}
}
@@ -1,6 +1,6 @@
import { Field, InputType, ObjectType } from '@nestjs/graphql';
import { Node, Resource, Role } from '@unraid/shared/graphql.model.js';
import { AuthAction, Node, Resource, Role } from '@unraid/shared/graphql.model.js';
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
import { Transform, Type } from 'class-transformer';
import {
@@ -22,15 +22,21 @@ export class Permission {
@IsEnum(Resource)
resource!: Resource;
@Field(() => [String])
@Field(() => [AuthAction], {
description: 'Actions allowed on this resource',
})
@IsArray()
@IsString({ each: true })
@IsEnum(AuthAction, { each: true })
@ArrayMinSize(1)
actions!: string[];
actions!: AuthAction[];
}
@ObjectType({ implements: () => Node })
export class ApiKey extends Node {
@Field()
@IsString()
key!: string;
@Field()
@IsString()
@IsNotEmpty()
@@ -58,24 +64,17 @@ export class ApiKey extends Node {
permissions!: Permission[];
}
@ObjectType()
export class ApiKeyWithSecret extends ApiKey {
@Field()
@IsString()
key!: string;
}
@InputType()
export class AddPermissionInput {
@Field(() => Resource)
@IsEnum(Resource)
resource!: Resource;
@Field(() => [String])
@Field(() => [AuthAction])
@IsArray()
@IsString({ each: true })
@IsEnum(AuthAction, { each: true })
@ArrayMinSize(1)
actions!: string[];
actions!: AuthAction[];
}
@InputType()
@@ -3,12 +3,23 @@ import { Module } from '@nestjs/common';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { AuthModule } from '@app/unraid-api/auth/auth.module.js';
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
import { ApiKeyFormResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key-form.resolver.js';
import { ApiKeyFormService } from '@app/unraid-api/graph/resolvers/api-key/api-key-form.service.js';
import { ApiKeyPermissionsResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key-permissions.resolver.js';
import { ApiKeyMutationsResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.mutation.js';
import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js';
@Module({
imports: [AuthModule],
providers: [ApiKeyResolver, ApiKeyService, AuthService, ApiKeyMutationsResolver],
exports: [ApiKeyResolver, ApiKeyService],
providers: [
ApiKeyResolver,
ApiKeyService,
AuthService,
ApiKeyMutationsResolver,
ApiKeyPermissionsResolver,
ApiKeyFormService,
ApiKeyFormResolver,
],
exports: [ApiKeyResolver, ApiKeyService, ApiKeyFormService],
})
export class ApiKeyModule {}
@@ -8,7 +8,6 @@ import { AuthService } from '@app/unraid-api/auth/auth.service.js';
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
import {
ApiKey,
ApiKeyWithSecret,
CreateApiKeyInput,
DeleteApiKeyInput,
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
@@ -23,16 +22,7 @@ describe('ApiKeyMutationsResolver', () => {
const mockApiKey: ApiKey = {
id: 'test-api-id',
name: 'Test API Key',
description: 'Test API Key Description',
roles: [Role.GUEST],
createdAt: new Date().toISOString(),
permissions: [],
};
const mockApiKeyWithSecret: ApiKeyWithSecret = {
id: 'test-api-id',
key: 'test-api-key',
key: 'test-secret-key',
name: 'Test API Key',
description: 'Test API Key Description',
roles: [Role.GUEST],
@@ -61,12 +51,12 @@ describe('ApiKeyMutationsResolver', () => {
permissions: [],
};
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret);
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKey);
vi.spyOn(authService, 'syncApiKeyRoles').mockResolvedValue();
const result = await resolver.create(input);
expect(result).toEqual(mockApiKeyWithSecret);
expect(result).toEqual(mockApiKey);
expect(apiKeyService.create).toHaveBeenCalledWith({
name: input.name,
description: input.description,
@@ -95,7 +85,7 @@ describe('ApiKeyMutationsResolver', () => {
roles: [Role.GUEST],
permissions: [],
};
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret);
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKey);
vi.spyOn(authService, 'syncApiKeyRoles').mockRejectedValue(new Error('Sync failed'));
await expect(resolver.create(input)).rejects.toThrow('Sync failed');
});
@@ -1,24 +1,19 @@
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
import { Resource, Role } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
import {
AddRoleForApiKeyInput,
ApiKeyWithSecret,
ApiKey,
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';
@Resolver(() => ApiKeyMutations)
export class ApiKeyMutationsResolver {
@@ -28,12 +23,11 @@ export class ApiKeyMutationsResolver {
) {}
@UsePermissions({
action: AuthActionVerb.CREATE,
action: AuthAction.CREATE_ANY,
resource: Resource.API_KEY,
possession: AuthPossession.ANY,
})
@ResolveField(() => ApiKeyWithSecret, { description: 'Create an API key' })
async create(@Args('input') input: CreateApiKeyInput): Promise<ApiKeyWithSecret> {
@ResolveField(() => ApiKey, { description: 'Create an API key' })
async create(@Args('input') input: CreateApiKeyInput): Promise<ApiKey> {
const apiKey = await this.apiKeyService.create({
name: input.name,
description: input.description ?? undefined,
@@ -46,9 +40,8 @@ export class ApiKeyMutationsResolver {
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.API_KEY,
possession: AuthPossession.ANY,
})
@ResolveField(() => Boolean, { description: 'Add a role to an API key' })
async addRole(@Args('input') input: AddRoleForApiKeyInput): Promise<boolean> {
@@ -56,9 +49,8 @@ export class ApiKeyMutationsResolver {
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.API_KEY,
possession: AuthPossession.ANY,
})
@ResolveField(() => Boolean, { description: 'Remove a role from an API key' })
async removeRole(@Args('input') input: RemoveRoleFromApiKeyInput): Promise<boolean> {
@@ -66,9 +58,8 @@ export class ApiKeyMutationsResolver {
}
@UsePermissions({
action: AuthActionVerb.DELETE,
action: AuthAction.DELETE_ANY,
resource: Resource.API_KEY,
possession: AuthPossession.ANY,
})
@ResolveField(() => Boolean, { description: 'Delete one or more API keys' })
async delete(@Args('input') input: DeleteApiKeyInput): Promise<boolean> {
@@ -77,12 +68,11 @@ export class ApiKeyMutationsResolver {
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.API_KEY,
possession: AuthPossession.ANY,
})
@ResolveField(() => ApiKeyWithSecret, { description: 'Update an API key' })
async update(@Args('input') input: UpdateApiKeyInput): Promise<ApiKeyWithSecret> {
@ResolveField(() => ApiKey, { description: 'Update an API key' })
async update(@Args('input') input: UpdateApiKeyInput): Promise<ApiKey> {
const apiKey = await this.apiKeyService.update(input);
await this.authService.syncApiKeyRoles(apiKey.id, apiKey.roles);
return apiKey;
@@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
import { ApiKey, ApiKeyWithSecret } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
import { ApiKey } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js';
describe('ApiKeyResolver', () => {
@@ -18,16 +18,7 @@ describe('ApiKeyResolver', () => {
const mockApiKey: ApiKey = {
id: 'test-api-id',
name: 'Test API Key',
description: 'Test API Key Description',
roles: [Role.GUEST],
createdAt: new Date().toISOString(),
permissions: [],
};
const mockApiKeyWithSecret: ApiKeyWithSecret = {
id: 'test-api-id',
key: 'test-api-key',
key: 'test-secret-key',
name: 'Test API Key',
description: 'Test API Key Description',
roles: [Role.GUEST],
@@ -44,7 +35,7 @@ describe('ApiKeyResolver', () => {
authzService = new AuthZService(enforcer);
cookieService = new CookieService();
authService = new AuthService(cookieService, apiKeyService, authzService);
resolver = new ApiKeyResolver(authService, apiKeyService);
resolver = new ApiKeyResolver(apiKeyService);
});
describe('apiKeys', () => {
@@ -1,29 +1,20 @@
import { Args, Query, Resolver } from '@nestjs/graphql';
import { Resource, Role } from '@unraid/shared/graphql.model.js';
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
import { ApiKey, Permission } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
@Resolver(() => ApiKey)
export class ApiKeyResolver {
constructor(
private authService: AuthService,
private apiKeyService: ApiKeyService
) {}
constructor(private apiKeyService: ApiKeyService) {}
@Query(() => [ApiKey])
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.API_KEY,
possession: AuthPossession.ANY,
})
async apiKeys(): Promise<ApiKey[]> {
return this.apiKeyService.findAll();
@@ -31,9 +22,8 @@ export class ApiKeyResolver {
@Query(() => ApiKey, { nullable: true })
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.API_KEY,
possession: AuthPossession.ANY,
})
async apiKey(
@Args('id', { type: () => PrefixedID })
@@ -44,9 +34,8 @@ export class ApiKeyResolver {
@Query(() => [Role], { description: 'All possible roles for API keys' })
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.PERMISSION,
possession: AuthPossession.ANY,
})
async apiKeyPossibleRoles(): Promise<Role[]> {
return Object.values(Role);
@@ -54,14 +43,13 @@ export class ApiKeyResolver {
@Query(() => [Permission], { description: 'All possible permissions for API keys' })
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.PERMISSION,
possession: AuthPossession.ANY,
})
async apiKeyPossiblePermissions(): Promise<Permission[]> {
// Build all combinations of Resource and AuthActionVerb
// Build all combinations of Resource and AuthAction
const resources = Object.values(Resource);
const actions = Object.values(AuthActionVerb);
const actions = Object.values(AuthAction);
return resources.map((resource) => ({
resource,
actions,
@@ -1,13 +1,9 @@
import { BadRequestException } from '@nestjs/common';
import { Args, Mutation, ResolveField, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import {
ArrayDisk,
@@ -27,9 +23,8 @@ export class ArrayMutationsResolver {
@ResolveField(() => UnraidArray, { description: 'Set array state' })
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.ARRAY,
possession: AuthPossession.ANY,
})
public async setState(@Args('input') input: ArrayStateInput): Promise<UnraidArray> {
return this.arrayService.updateArrayState(input);
@@ -37,9 +32,8 @@ export class ArrayMutationsResolver {
@ResolveField(() => UnraidArray, { description: 'Add new disk to array' })
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.ARRAY,
possession: AuthPossession.ANY,
})
public async addDiskToArray(@Args('input') input: ArrayDiskInput): Promise<UnraidArray> {
return this.arrayService.addDiskToArray(input);
@@ -50,9 +44,8 @@ export class ArrayMutationsResolver {
"Remove existing disk from array. NOTE: The array must be stopped before running this otherwise it'll throw an error.",
})
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.ARRAY,
possession: AuthPossession.ANY,
})
public async removeDiskFromArray(@Args('input') input: ArrayDiskInput): Promise<UnraidArray> {
return this.arrayService.removeDiskFromArray(input);
@@ -60,9 +53,8 @@ export class ArrayMutationsResolver {
@ResolveField(() => ArrayDisk, { description: 'Mount a disk in the array' })
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.ARRAY,
possession: AuthPossession.ANY,
})
public async mountArrayDisk(@Args('id', { type: () => PrefixedID }) id: string): Promise<ArrayDisk> {
const array = await this.arrayService.mountArrayDisk(id);
@@ -80,9 +72,8 @@ export class ArrayMutationsResolver {
@ResolveField(() => ArrayDisk, { description: 'Unmount a disk from the array' })
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.ARRAY,
possession: AuthPossession.ANY,
})
public async unmountArrayDisk(
@Args('id', { type: () => PrefixedID }) id: string
@@ -102,9 +93,8 @@ export class ArrayMutationsResolver {
@ResolveField(() => Boolean, { description: 'Clear statistics for a disk in the array' })
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.ARRAY,
possession: AuthPossession.ANY,
})
public async clearArrayDiskStatistics(
@Args('id', { type: () => PrefixedID }) id: string
@@ -1,11 +1,7 @@
import { Query, Resolver, Subscription } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { UnraidArray } from '@app/unraid-api/graph/resolvers/array/array.model.js';
@@ -17,9 +13,8 @@ export class ArrayResolver {
@Query(() => UnraidArray)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.ARRAY,
possession: AuthPossession.ANY,
})
public async array() {
return this.arrayService.getArrayData();
@@ -27,9 +22,8 @@ export class ArrayResolver {
@Subscription(() => UnraidArray)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.ARRAY,
possession: AuthPossession.ANY,
})
public async arraySubscription() {
return createSubscription(PUBSUB_CHANNEL.ARRAY);
@@ -1,11 +1,7 @@
import { Args, Mutation, ResolveField, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { GraphQLJSON } from 'graphql-scalars';
import { ParityService } from '@app/unraid-api/graph/resolvers/array/parity.service.js';
@@ -19,9 +15,8 @@ export class ParityCheckMutationsResolver {
constructor(private readonly parityService: ParityService) {}
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.ARRAY,
possession: AuthPossession.ANY,
})
@ResolveField(() => GraphQLJSON, { description: 'Start a parity check' })
async start(@Args('correct') correct: boolean): Promise<object> {
@@ -32,9 +27,8 @@ export class ParityCheckMutationsResolver {
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.ARRAY,
possession: AuthPossession.ANY,
})
@ResolveField(() => GraphQLJSON, { description: 'Pause a parity check' })
async pause(): Promise<object> {
@@ -45,9 +39,8 @@ export class ParityCheckMutationsResolver {
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.ARRAY,
possession: AuthPossession.ANY,
})
@ResolveField(() => GraphQLJSON, { description: 'Resume a parity check' })
async resume(): Promise<object> {
@@ -58,9 +51,8 @@ export class ParityCheckMutationsResolver {
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.ARRAY,
possession: AuthPossession.ANY,
})
@ResolveField(() => GraphQLJSON, { description: 'Cancel a parity check' })
async cancel(): Promise<object> {
@@ -1,11 +1,7 @@
import { Query, Resolver, Subscription } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { PubSub } from 'graphql-subscriptions';
import { PUBSUB_CHANNEL } from '@app/core/pubsub.js';
@@ -23,9 +19,8 @@ export class ParityResolver {
) {}
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.ARRAY,
possession: AuthPossession.ANY,
})
@Query(() => [ParityCheck])
async parityHistory(): Promise<ParityCheck[]> {
@@ -33,9 +28,8 @@ export class ParityResolver {
}
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.ARRAY,
possession: AuthPossession.ANY,
})
@Subscription(() => ParityCheck)
parityHistorySubscription() {
@@ -1,11 +1,7 @@
import { Query, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { getters } from '@app/store/index.js';
import { Config } from '@app/unraid-api/graph/resolvers/config/config.model.js';
@@ -14,9 +10,8 @@ import { Config } from '@app/unraid-api/graph/resolvers/config/config.model.js';
export class ConfigResolver {
@Query(() => Config)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.CONFIG,
possession: AuthPossession.ANY,
})
public async config(): Promise<Config> {
const emhttp = getters.emhttp();
@@ -1,11 +1,7 @@
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { Public } from '@app/unraid-api/auth/public.decorator.js'; // Import Public decorator
@@ -23,9 +19,8 @@ export class CustomizationResolver {
// Authenticated query
@Query(() => Customization, { nullable: true })
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.CUSTOMIZATIONS,
possession: AuthPossession.ANY,
})
async customization(): Promise<Customization | null> {
// We return an empty object because the fields are resolved by @ResolveField
@@ -52,9 +47,8 @@ export class CustomizationResolver {
@ResolveField(() => ActivationCode, { nullable: true, name: 'activationCode' })
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.ACTIVATION_CODE,
possession: AuthPossession.ANY,
})
async activationCode(): Promise<ActivationCode | null> {
return this.customizationService.getActivationData();
@@ -1,12 +1,8 @@
import { Args, Int, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { Disk } from '@app/unraid-api/graph/resolvers/disks/disks.model.js';
import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js';
@@ -17,9 +13,8 @@ export class DisksResolver {
@Query(() => [Disk])
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.DISK,
possession: AuthPossession.ANY,
})
public async disks() {
return this.disksService.getDisks();
@@ -27,9 +22,8 @@ export class DisksResolver {
@Query(() => Disk)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.DISK,
possession: AuthPossession.ANY,
})
public async disk(@Args('id', { type: () => PrefixedID }) id: string) {
return this.disksService.getDisk(id);
@@ -1,11 +1,7 @@
import { Query, Resolver, Subscription } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { Display } from '@app/unraid-api/graph/resolvers/info/display/display.model.js';
@@ -16,9 +12,8 @@ export class DisplayResolver {
constructor(private readonly displayService: DisplayService) {}
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.DISPLAY,
possession: AuthPossession.ANY,
})
@Query(() => Display)
public async display(): Promise<Display> {
@@ -27,9 +22,8 @@ export class DisplayResolver {
@Subscription(() => Display)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.DISPLAY,
possession: AuthPossession.ANY,
})
public async displaySubscription() {
return createSubscription(PUBSUB_CHANNEL.DISPLAY);
@@ -1,12 +1,8 @@
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
@@ -21,9 +17,8 @@ export class DockerMutationsResolver {
@ResolveField(() => DockerContainer, { description: 'Start a container' })
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
possession: AuthPossession.ANY,
})
public async start(@Args('id', { type: () => PrefixedID }) id: string) {
return this.dockerService.start(id);
@@ -31,9 +26,8 @@ export class DockerMutationsResolver {
@ResolveField(() => DockerContainer, { description: 'Stop a container' })
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
possession: AuthPossession.ANY,
})
public async stop(@Args('id', { type: () => PrefixedID }) id: string) {
return this.dockerService.stop(id);
@@ -1,11 +1,7 @@
import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js';
import {
@@ -25,9 +21,8 @@ export class DockerResolver {
) {}
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
possession: AuthPossession.ANY,
})
@Query(() => Docker)
public docker() {
@@ -37,9 +32,8 @@ export class DockerResolver {
}
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
possession: AuthPossession.ANY,
})
@ResolveField(() => [DockerContainer])
public async containers(
@@ -49,9 +43,8 @@ export class DockerResolver {
}
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
possession: AuthPossession.ANY,
})
@ResolveField(() => [DockerNetwork])
public async networks(
@@ -61,9 +54,8 @@ export class DockerResolver {
}
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
possession: AuthPossession.ANY,
})
@ResolveField(() => ResolvedOrganizerV1)
public async organizer() {
@@ -71,9 +63,8 @@ export class DockerResolver {
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
possession: AuthPossession.ANY,
})
@Mutation(() => ResolvedOrganizerV1)
public async createDockerFolder(
@@ -90,9 +81,8 @@ export class DockerResolver {
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
possession: AuthPossession.ANY,
})
@Mutation(() => ResolvedOrganizerV1)
public async setDockerFolderChildren(
@@ -107,9 +97,8 @@ export class DockerResolver {
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
possession: AuthPossession.ANY,
})
@Mutation(() => ResolvedOrganizerV1)
public async deleteDockerEntries(@Args('entryIds', { type: () => [String] }) entryIds: string[]) {
@@ -120,9 +109,8 @@ export class DockerResolver {
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
possession: AuthPossession.ANY,
})
@Mutation(() => ResolvedOrganizerV1)
public async moveDockerEntriesToFolder(
@@ -1,11 +1,7 @@
import { Query, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { getters } from '@app/store/index.js';
import { Flash } from '@app/unraid-api/graph/resolvers/flash/flash.model.js';
@@ -14,9 +10,8 @@ import { Flash } from '@app/unraid-api/graph/resolvers/flash/flash.model.js';
export class FlashResolver {
@Query(() => Flash)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.FLASH,
possession: AuthPossession.ANY,
})
public async flash() {
const emhttp = getters.emhttp();
@@ -1,11 +1,7 @@
import { GraphQLISODateTime, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { baseboard as getBaseboard, system as getSystem } from 'systeminformation';
import { getMachineId } from '@app/core/utils/misc/get-machine-id.js';
@@ -35,9 +31,8 @@ export class InfoResolver {
@Query(() => Info)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.INFO,
possession: AuthPossession.ANY,
})
public async info(): Promise<Partial<Info>> {
return {
@@ -1,11 +1,7 @@
import { Args, Int, Query, Resolver, Subscription } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { LogFile, LogFileContent } from '@app/unraid-api/graph/resolvers/logs/logs.model.js';
@@ -17,9 +13,8 @@ export class LogsResolver {
@Query(() => [LogFile])
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.LOGS,
possession: AuthPossession.ANY,
})
async logFiles(): Promise<LogFile[]> {
return this.logsService.listLogFiles();
@@ -27,9 +22,8 @@ export class LogsResolver {
@Query(() => LogFileContent)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.LOGS,
possession: AuthPossession.ANY,
})
async logFile(
@Args('path') path: string,
@@ -41,9 +35,8 @@ export class LogsResolver {
@Subscription(() => LogFileContent, { name: 'logFile' })
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.LOGS,
possession: AuthPossession.ANY,
})
async logFileSubscription(@Args('path') path: string) {
// Start watching the file
@@ -1,12 +1,8 @@
import { OnModuleInit } from '@nestjs/common';
import { Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { CpuUtilization } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js';
@@ -50,9 +46,8 @@ export class MetricsResolver implements OnModuleInit {
@Query(() => Metrics)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.INFO,
possession: AuthPossession.ANY,
})
public async metrics(): Promise<Partial<Metrics>> {
return {
@@ -75,9 +70,8 @@ export class MetricsResolver implements OnModuleInit {
resolve: (value) => value.systemMetricsCpu,
})
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.INFO,
possession: AuthPossession.ANY,
})
public async systemMetricsCpuSubscription() {
return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION);
@@ -88,9 +82,8 @@ export class MetricsResolver implements OnModuleInit {
resolve: (value) => value.systemMetricsMemory,
})
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.INFO,
possession: AuthPossession.ANY,
})
public async systemMetricsMemorySubscription() {
return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.MEMORY_UTILIZATION);
@@ -1,12 +1,8 @@
import { Args, Mutation, Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { AppError } from '@app/core/errors/app-error.js';
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
@@ -31,9 +27,8 @@ export class NotificationsResolver {
@Query(() => Notifications, { description: 'Get all notifications' })
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.NOTIFICATIONS,
possession: AuthPossession.ANY,
})
public async notifications(): Promise<Notifications> {
return {
@@ -153,9 +148,8 @@ export class NotificationsResolver {
@Subscription(() => Notification)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.NOTIFICATIONS,
possession: AuthPossession.ANY,
})
async notificationAdded() {
return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_ADDED);
@@ -163,9 +157,8 @@ export class NotificationsResolver {
@Subscription(() => NotificationOverview)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.NOTIFICATIONS,
possession: AuthPossession.ANY,
})
async notificationsOverview() {
return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW);
@@ -1,11 +1,7 @@
import { Query, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { Online } from '@app/unraid-api/graph/resolvers/online/online.model.js';
@@ -13,9 +9,8 @@ import { Online } from '@app/unraid-api/graph/resolvers/online/online.model.js';
export class OnlineResolver {
@Query(() => Boolean)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.ONLINE,
possession: AuthPossession.ANY,
})
public async online() {
return true;
@@ -1,12 +1,8 @@
import { ConfigService } from '@nestjs/config';
import { Query, Resolver, Subscription } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { Owner } from '@app/unraid-api/graph/resolvers/owner/owner.model.js';
@@ -17,9 +13,8 @@ export class OwnerResolver {
constructor(private readonly configService: ConfigService) {}
@Query(() => Owner)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.OWNER,
possession: AuthPossession.ANY,
})
public async owner() {
const config = this.configService.get('connect.config');
@@ -40,9 +35,8 @@ export class OwnerResolver {
@Subscription(() => Owner)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.OWNER,
possession: AuthPossession.ANY,
})
public ownerSubscription() {
return createSubscription(PUBSUB_CHANNEL.OWNER);
@@ -255,6 +255,7 @@ export function getProviderConfigSlice({
description: option.Help || undefined,
controlOptions: controlOptions,
rule: providerRule,
passScopeToLayout: true,
});
return labeledControl;
@@ -1,12 +1,8 @@
import { Logger } from '@nestjs/common';
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { RCloneMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js';
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
@@ -27,9 +23,8 @@ export class RCloneMutationsResolver {
@ResolveField(() => RCloneRemote, { description: 'Create a new RClone remote' })
@UsePermissions({
action: AuthActionVerb.CREATE,
action: AuthAction.CREATE_ANY,
resource: Resource.FLASH,
possession: AuthPossession.ANY,
})
async createRCloneRemote(@Args('input') input: CreateRCloneRemoteInput): Promise<RCloneRemote> {
try {
@@ -48,9 +43,8 @@ export class RCloneMutationsResolver {
@ResolveField(() => Boolean, { description: 'Delete an existing RClone remote' })
@UsePermissions({
action: AuthActionVerb.DELETE,
action: AuthAction.DELETE_ANY,
resource: Resource.FLASH,
possession: AuthPossession.ANY,
})
async deleteRCloneRemote(@Args('input') input: DeleteRCloneRemoteInput): Promise<boolean> {
try {
@@ -1,12 +1,8 @@
import { Logger } from '@nestjs/common';
import { Args, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
import { RCloneFormService } from '@app/unraid-api/graph/resolvers/rclone/rclone-form.service.js';
@@ -31,9 +27,8 @@ export class RCloneBackupSettingsResolver {
@Query(() => RCloneBackupSettings)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.FLASH,
possession: AuthPossession.ANY,
})
async rclone(): Promise<RCloneBackupSettings> {
return {} as RCloneBackupSettings;
@@ -1,11 +1,7 @@
import { Query, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { getKeyFile } from '@app/core/utils/misc/get-key-file.js';
import { getters } from '@app/store/index.js';
@@ -19,9 +15,8 @@ import {
export class RegistrationResolver {
@Query(() => Registration, { nullable: true })
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.REGISTRATION,
possession: AuthPossession.ANY,
})
public async registration(): Promise<Registration | null> {
const emhttp = getters.emhttp();
@@ -2,12 +2,8 @@ import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Query, Resolver, Subscription } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { getters } from '@app/store/index.js';
@@ -24,9 +20,8 @@ export class ServerResolver {
constructor(private readonly configService: ConfigService) {}
@Query(() => ServerModel, { nullable: true })
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.SERVERS,
possession: AuthPossession.ANY,
})
public async server(): Promise<ServerModel | null> {
return this.getLocalServer()[0] || null;
@@ -34,9 +29,8 @@ export class ServerResolver {
@Query(() => [ServerModel])
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.SERVERS,
possession: AuthPossession.ANY,
})
public async servers(): Promise<ServerModel[]> {
return this.getLocalServer();
@@ -44,9 +38,8 @@ export class ServerResolver {
@Subscription(() => ServerModel)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.SERVERS,
possession: AuthPossession.ANY,
})
public async serversSubscription() {
return createSubscription(PUBSUB_CHANNEL.SERVERS);
@@ -1,4 +1,4 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Field, InterfaceType, ObjectType } from '@nestjs/graphql';
import { Node } from '@unraid/shared/graphql.model.js';
import { IsObject, ValidateNested } from 'class-validator';
@@ -6,10 +6,25 @@ import { GraphQLJSON } from 'graphql-scalars';
import { SsoSettings } from '@app/unraid-api/graph/resolvers/settings/sso-settings.model.js';
@InterfaceType()
export abstract class FormSchema {
@Field(() => GraphQLJSON, { description: 'The data schema for the form' })
@IsObject()
dataSchema!: Record<string, any>;
@Field(() => GraphQLJSON, { description: 'The UI schema for the form' })
@IsObject()
uiSchema!: Record<string, any>;
@Field(() => GraphQLJSON, { description: 'The current values of the form' })
@IsObject()
values!: Record<string, any>;
}
@ObjectType({
implements: () => Node,
implements: () => [Node, FormSchema],
})
export class UnifiedSettings extends Node {
export class UnifiedSettings extends Node implements FormSchema {
@Field(() => GraphQLJSON, { description: 'The data schema for the settings' })
@IsObject()
dataSchema!: Record<string, any>;
@@ -23,6 +38,23 @@ export class UnifiedSettings extends Node {
values!: Record<string, any>;
}
@ObjectType({
implements: () => [Node, FormSchema],
})
export class ApiKeyFormSettings extends Node implements FormSchema {
@Field(() => GraphQLJSON, { description: 'The data schema for the API key form' })
@IsObject()
dataSchema!: Record<string, any>;
@Field(() => GraphQLJSON, { description: 'The UI schema for the API key form' })
@IsObject()
uiSchema!: Record<string, any>;
@Field(() => GraphQLJSON, { description: 'The current values of the API key form' })
@IsObject()
values!: Record<string, any>;
}
@ObjectType()
export class UpdateSettingsResponse {
@Field(() => Boolean, {
@@ -1,14 +1,10 @@
import { Logger } from '@nestjs/common';
import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { ApiConfig } from '@unraid/shared/services/api-config.js';
import { UserSettingsService } from '@unraid/shared/services/user-settings.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { GraphQLJSON } from 'graphql-scalars';
import { LifecycleService } from '@app/unraid-api/app/lifecycle.service.js';
@@ -101,9 +97,8 @@ export class UnifiedSettingsResolver {
@Mutation(() => UpdateSettingsResponse)
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.CONFIG,
possession: AuthPossession.ANY,
})
async updateSettings(
@Args('input', { type: () => GraphQLJSON }) input: Record<string, unknown>
@@ -1,12 +1,9 @@
import { Logger } from '@nestjs/common';
import { Args, Query, Resolver } from '@nestjs/graphql';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { Public } from '@app/unraid-api/auth/public.decorator.js';
import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/oidc-config.service.js';
@@ -73,9 +70,8 @@ export class SsoResolver {
@Query(() => [OidcProvider], { description: 'Get all configured OIDC providers (admin only)' })
@UsePermissions({
action: AuthActionVerb.READ,
resource: 'sso',
possession: AuthPossession.ANY,
action: AuthAction.READ_ANY,
resource: Resource.CONFIG,
})
public async oidcProviders(): Promise<OidcProvider[]> {
return this.oidcConfig.getProviders();
@@ -83,9 +79,8 @@ export class SsoResolver {
@Query(() => OidcProvider, { nullable: true, description: 'Get a specific OIDC provider by ID' })
@UsePermissions({
action: AuthActionVerb.READ,
resource: 'sso',
possession: AuthPossession.ANY,
action: AuthAction.READ_ANY,
resource: Resource.CONFIG,
})
public async oidcProvider(
@Args('id', { type: () => PrefixedID }) id: string
@@ -97,9 +92,8 @@ export class SsoResolver {
description: 'Validate an OIDC session token (internal use for CLI validation)',
})
@UsePermissions({
action: AuthActionVerb.READ,
resource: 'sso',
possession: AuthPossession.ANY,
action: AuthAction.READ_ANY,
resource: Resource.CONFIG,
})
public async validateOidcSession(@Args('token') token: string): Promise<OidcSessionValidation> {
return await this.oidcSessionService.validateSession(token);
@@ -1,11 +1,7 @@
import { Query, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { getters } from '@app/store/index.js';
import { Public } from '@app/unraid-api/auth/public.decorator.js';
@@ -16,9 +12,8 @@ import { Vars } from '@app/unraid-api/graph/resolvers/vars/vars.model.js';
export class VarsResolver {
@Query(() => Vars)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.VARS,
possession: AuthPossession.ANY,
})
public async vars() {
return {
@@ -1,12 +1,8 @@
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { VmMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js';
import { VmsService } from '@app/unraid-api/graph/resolvers/vms/vms.service.js';
@@ -19,9 +15,8 @@ export class VmMutationsResolver {
constructor(private readonly vmsService: VmsService) {}
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.VMS,
possession: AuthPossession.ANY,
})
@ResolveField(() => Boolean, { description: 'Start a virtual machine' })
async start(@Args('id', { type: () => PrefixedID }) id: string): Promise<boolean> {
@@ -29,9 +24,8 @@ export class VmMutationsResolver {
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.VMS,
possession: AuthPossession.ANY,
})
@ResolveField(() => Boolean, { description: 'Stop a virtual machine' })
async stop(@Args('id', { type: () => PrefixedID }) id: string): Promise<boolean> {
@@ -39,9 +33,8 @@ export class VmMutationsResolver {
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.VMS,
possession: AuthPossession.ANY,
})
@ResolveField(() => Boolean, { description: 'Pause a virtual machine' })
async pause(@Args('id', { type: () => PrefixedID }) id: string): Promise<boolean> {
@@ -49,9 +42,8 @@ export class VmMutationsResolver {
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.VMS,
possession: AuthPossession.ANY,
})
@ResolveField(() => Boolean, { description: 'Resume a virtual machine' })
async resume(@Args('id', { type: () => PrefixedID }) id: string): Promise<boolean> {
@@ -59,9 +51,8 @@ export class VmMutationsResolver {
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.VMS,
possession: AuthPossession.ANY,
})
@ResolveField(() => Boolean, { description: 'Force stop a virtual machine' })
async forceStop(@Args('id', { type: () => PrefixedID }) id: string): Promise<boolean> {
@@ -69,9 +60,8 @@ export class VmMutationsResolver {
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.VMS,
possession: AuthPossession.ANY,
})
@ResolveField(() => Boolean, { description: 'Reboot a virtual machine' })
async reboot(@Args('id', { type: () => PrefixedID }) id: string): Promise<boolean> {
@@ -79,9 +69,8 @@ export class VmMutationsResolver {
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.VMS,
possession: AuthPossession.ANY,
})
@ResolveField(() => Boolean, { description: 'Reset a virtual machine' })
async reset(@Args('id', { type: () => PrefixedID }) id: string): Promise<boolean> {
@@ -1,11 +1,7 @@
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { VmDomain, Vms } from '@app/unraid-api/graph/resolvers/vms/vms.model.js';
import { VmsService } from '@app/unraid-api/graph/resolvers/vms/vms.service.js';
@@ -16,9 +12,8 @@ export class VmsResolver {
@Query(() => Vms, { description: 'Get information about all VMs on the system' })
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.VMS,
possession: AuthPossession.ANY,
})
public async vms() {
return {
@@ -1,12 +1,8 @@
import { ConfigService } from '@nestjs/config';
import { Query, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { bootTimestamp } from '@app/common/dashboard/boot-timestamp.js';
import { API_VERSION } from '@app/environment.js';
@@ -48,9 +44,8 @@ export class ServicesResolver {
@Query(() => [Service])
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.SERVICES,
possession: AuthPossession.ANY,
})
public services(): Service[] {
const dynamicRemoteAccess = this.getDynamicRemoteAccessService();
@@ -1,11 +1,7 @@
import { Query, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { getShares } from '@app/core/utils/shares/get-shares.js';
import { Share } from '@app/unraid-api/graph/resolvers/array/array.model.js';
@@ -16,9 +12,8 @@ export class SharesResolver {
@Query(() => [Share])
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.SHARE,
possession: AuthPossession.ANY,
})
public async shares() {
const userShares = getShares('users');
@@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { Resource, Role } from '@unraid/shared/graphql.model.js';
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
import { AuthZService } from 'nest-authz';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -47,7 +47,7 @@ describe('MeResolver', () => {
permissions: [
{
resource: Resource.ME,
actions: ['read'],
actions: [AuthAction.READ_ANY],
},
],
};
@@ -1,11 +1,7 @@
import { Query, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { GraphqlUser } from '@app/unraid-api/auth/user.decorator.js';
import { UserAccount } from '@app/unraid-api/graph/user/user.model.js';
@@ -16,9 +12,8 @@ export class MeResolver {
@Query(() => UserAccount)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.ME,
possession: AuthPossession.ANY,
})
public async me(@GraphqlUser() user: UserAccount): Promise<UserAccount> {
return user;
+28 -18
View File
@@ -53,34 +53,44 @@ export function createLabeledControl({
controlOptions,
labelOptions,
layoutOptions,
layoutType = 'UnraidSettingsLayout',
rule,
passScopeToLayout = false,
}: {
scope: string;
label: string;
description?: string;
controlOptions: ControlElement['options'];
controlOptions?: ControlElement['options'];
labelOptions?: LabelElement['options'];
layoutOptions?: Layout['options'];
layoutType?: 'UnraidSettingsLayout' | 'VerticalLayout' | 'HorizontalLayout';
rule?: Rule;
passScopeToLayout?: boolean;
}): Layout {
const layout: Layout & { scope?: string } = {
type: 'UnraidSettingsLayout', // Use the specific Unraid layout type
scope: scope, // Apply scope to the layout for potential rules/visibility
const elements: Array<LabelElement | ControlElement> = [
{
type: 'Label',
text: label,
options: { ...labelOptions, description },
} as LabelElement,
{
type: 'Control',
scope: scope,
options: controlOptions,
} as ControlElement,
];
const layout: Layout = {
type: layoutType,
options: layoutOptions,
elements: [
{
type: 'Label',
text: label,
scope: scope, // Scope might be needed for specific label behaviors
options: { ...labelOptions, description },
} as LabelElement,
{
type: 'Control',
scope: scope,
options: controlOptions,
} as ControlElement,
],
};
elements,
} as Layout;
// Optionally add scope to the layout itself (for backward compatibility)
if (passScopeToLayout) {
(layout as any).scope = scope;
}
// Conditionally add the rule to the layout if provided
if (rule) {
layout.rule = rule;
+4 -11
View File
@@ -2,11 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { AuthAction, UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { LifecycleService } from '@app/unraid-api/app/lifecycle.service.js';
import { PluginManagementService } from '@app/unraid-api/plugin/plugin-management.service.js';
@@ -23,9 +19,8 @@ export class PluginResolver {
@Query(() => [Plugin], { description: 'List all installed plugins with their metadata' })
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.CONFIG,
possession: AuthPossession.ANY,
})
async plugins(): Promise<Plugin[]> {
const plugins = await PluginService.getPlugins();
@@ -47,9 +42,8 @@ export class PluginResolver {
'Add one or more plugins to the API. Returns false if restart was triggered automatically, true if manual restart is required.',
})
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.CONFIG,
possession: AuthPossession.ANY,
})
async addPlugin(@Args('input') input: PluginManagementInput): Promise<boolean> {
if (input.bundled) {
@@ -76,9 +70,8 @@ export class PluginResolver {
'Remove one or more plugins from the API. Returns false if restart was triggered automatically, true if manual restart is required.',
})
@UsePermissions({
action: AuthActionVerb.DELETE,
action: AuthAction.DELETE_ANY,
resource: Resource.CONFIG,
possession: AuthPossession.ANY,
})
async removePlugin(@Args('input') input: PluginManagementInput): Promise<boolean> {
if (input.bundled) {
+4 -6
View File
@@ -1,7 +1,7 @@
import { Controller, Get, Logger, Param, Query, Req, Res, UnauthorizedException } from '@nestjs/common';
import { Resource } from '@unraid/shared/graphql.model.js';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import type { FastifyReply, FastifyRequest } from '@app/unraid-api/types/fastify.js';
import { Public } from '@app/unraid-api/auth/public.decorator.js';
@@ -24,9 +24,8 @@ export class RestController {
@Get('/graphql/api/logs')
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.LOGS,
possession: AuthPossession.ANY,
})
async getLogs(@Res() res: FastifyReply) {
try {
@@ -40,9 +39,8 @@ export class RestController {
@Get('/graphql/api/customizations/:type')
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.CUSTOMIZATIONS,
possession: AuthPossession.ANY,
})
async getCustomizations(@Param('type') type: string, @Res() res: FastifyReply) {
if (type !== 'banner' && type !== 'case') {
@@ -1,9 +1,8 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ApiKey, ApiKeyWithSecret, Permission, Role } from '@unraid/shared/graphql.model.js';
import { ApiKeyService } from '@unraid/shared/services/api-key.js';
import { ApiKey, AuthAction, Permission, Resource, Role } from '@unraid/shared/graphql.model.js';
import { ApiKeyService, CreatePermissionsInput } from '@unraid/shared/services/api-key.js';
import { API_KEY_SERVICE_TOKEN } from '@unraid/shared/tokens.js';
import { AuthActionVerb } from 'nest-authz';
@Injectable()
export class ConnectApiKeyService implements ApiKeyService {
@@ -22,15 +21,15 @@ export class ConnectApiKeyService implements ApiKeyService {
return this.apiKeyService.findById(id);
}
findByIdWithSecret(id: string): ApiKeyWithSecret | null {
findByIdWithSecret(id: string): ApiKey | null {
return this.apiKeyService.findByIdWithSecret(id);
}
findByField(field: keyof ApiKeyWithSecret, value: string): ApiKeyWithSecret | null {
findByField(field: keyof ApiKey, value: string): ApiKey | null {
return this.apiKeyService.findByField(field, value);
}
findByKey(key: string): ApiKeyWithSecret | null {
findByKey(key: string): ApiKey | null {
return this.apiKeyService.findByKey(key);
}
@@ -38,9 +37,9 @@ export class ConnectApiKeyService implements ApiKeyService {
name: string;
description?: string;
roles?: Role[];
permissions?: Permission[] | { resource: string; actions: AuthActionVerb[] }[];
permissions?: CreatePermissionsInput;
overwrite?: boolean;
}): Promise<ApiKeyWithSecret> {
}): Promise<ApiKey> {
return this.apiKeyService.create(input);
}
@@ -67,7 +66,7 @@ export class ConnectApiKeyService implements ApiKeyService {
/**
* Creates a local API key specifically for Connect
*/
public async createLocalConnectApiKey(): Promise<ApiKeyWithSecret | null> {
public async createLocalConnectApiKey(): Promise<ApiKey | null> {
try {
return await this.create({
name: ConnectApiKeyService.CONNECT_API_KEY_NAME,
@@ -1,9 +1,7 @@
import { Query, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
@@ -22,9 +20,8 @@ export class CloudResolver {
) {}
@Query(() => Cloud)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.CLOUD,
possession: AuthPossession.ANY,
})
public async cloud(): Promise<Cloud> {
const minigraphql = this.cloudService.checkMothershipClient();
@@ -1,10 +1,8 @@
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { AccessUrl } from '@unraid/shared/network.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
@@ -16,9 +14,8 @@ export class NetworkResolver {
constructor(private readonly urlResolverService: UrlResolverService) {}
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.NETWORK,
possession: AuthPossession.ANY,
})
@Query(() => Network)
public async network(): Promise<Network> {
@@ -3,12 +3,11 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { type Layout } from '@jsonforms/core';
import { Resource } from '@unraid/shared/graphql.model.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { DataSlice } from '@unraid/shared/jsonforms/settings.js';
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { GraphQLJSON } from 'graphql-scalars';
import { AuthActionVerb, AuthPossession } from 'nest-authz';
import { EVENTS } from '../helper/nest-tokens.js';
import { ConnectSettingsService } from './connect-settings.service.js';
@@ -62,9 +61,8 @@ export class ConnectSettingsResolver {
@Query(() => RemoteAccess)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.CONNECT,
possession: AuthPossession.ANY,
})
public async remoteAccess(): Promise<RemoteAccess> {
return this.connectSettingsService.dynamicRemoteAccessSettings();
@@ -72,9 +70,8 @@ export class ConnectSettingsResolver {
@Mutation(() => ConnectSettingsValues)
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.CONFIG,
possession: AuthPossession.ANY,
})
public async updateApiSettings(@Args('input') settings: ConnectSettingsInput) {
this.logger.verbose(`Attempting to update API settings: ${JSON.stringify(settings, null, 2)}`);
@@ -92,9 +89,8 @@ export class ConnectSettingsResolver {
@Mutation(() => Boolean)
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.CONNECT,
possession: AuthPossession.ANY,
})
public async connectSignIn(@Args('input') input: ConnectSignInInput): Promise<boolean> {
return this.connectSettingsService.signIn(input);
@@ -102,9 +98,8 @@ export class ConnectSettingsResolver {
@Mutation(() => Boolean)
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.CONNECT,
possession: AuthPossession.ANY,
})
public async connectSignOut() {
this.eventEmitter.emit(EVENTS.LOGOUT, { reason: 'Manual Sign Out Using API' });
@@ -113,9 +108,8 @@ export class ConnectSettingsResolver {
@Mutation(() => Boolean)
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.CONNECT,
possession: AuthPossession.ANY,
})
public async setupRemoteAccess(@Args('input') input: SetupRemoteAccessInput): Promise<boolean> {
await this.connectSettingsService.syncSettings({
@@ -128,9 +122,8 @@ export class ConnectSettingsResolver {
@Mutation(() => Boolean)
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.CONNECT__REMOTE_ACCESS,
possession: AuthPossession.ANY,
})
public async enableDynamicRemoteAccess(
@Args('input') dynamicRemoteAccessInput: EnableDynamicRemoteAccessInput
@@ -2,10 +2,8 @@ import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
@@ -19,9 +17,8 @@ export class ConnectResolver {
@Query(() => Connect)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.CONNECT,
possession: AuthPossession.ANY,
})
public connect(): Connect {
return {
+107
View File
@@ -0,0 +1,107 @@
// This file contains only the enum definitions without any NestJS dependencies
// Safe to import in both frontend and backend
// Define our own AuthAction enum with matching keys and values
// This ensures GraphQL schema and runtime values are identical
export enum AuthAction {
CREATE_ANY = 'CREATE_ANY',
CREATE_OWN = 'CREATE_OWN',
READ_ANY = 'READ_ANY',
READ_OWN = 'READ_OWN',
UPDATE_ANY = 'UPDATE_ANY',
UPDATE_OWN = 'UPDATE_OWN',
DELETE_ANY = 'DELETE_ANY',
DELETE_OWN = 'DELETE_OWN',
}
// Define Resource enum
export enum Resource {
/** Activation code management and validation */
ACTIVATION_CODE = 'ACTIVATION_CODE',
/** API key management and administration */
API_KEY = 'API_KEY',
/** Array operations and disk management */
ARRAY = 'ARRAY',
/** Cloud storage and backup services */
CLOUD = 'CLOUD',
/** System configuration and settings */
CONFIG = 'CONFIG',
/** Unraid Connect service management */
CONNECT = 'CONNECT',
/** Remote access functionality for Connect */
CONNECT__REMOTE_ACCESS = 'CONNECT__REMOTE_ACCESS',
/** System customization and theming */
CUSTOMIZATIONS = 'CUSTOMIZATIONS',
/** Dashboard and system overview */
DASHBOARD = 'DASHBOARD',
/** Individual disk operations and management */
DISK = 'DISK',
/** Display and UI settings */
DISPLAY = 'DISPLAY',
/** Docker container management */
DOCKER = 'DOCKER',
/** Flash drive operations and settings */
FLASH = 'FLASH',
/** System information and status */
INFO = 'INFO',
/** System logs and logging */
LOGS = 'LOGS',
/** Current user profile and settings */
ME = 'ME',
/** Network configuration and management */
NETWORK = 'NETWORK',
/** System notifications and alerts */
NOTIFICATIONS = 'NOTIFICATIONS',
/** Online services and connectivity */
ONLINE = 'ONLINE',
/** Operating system operations and updates */
OS = 'OS',
/** System ownership and licensing */
OWNER = 'OWNER',
/** Permission management and administration */
PERMISSION = 'PERMISSION',
/** System registration and activation */
REGISTRATION = 'REGISTRATION',
/** My Servers management and configuration */
SERVERS = 'SERVERS',
/** System services and daemons */
SERVICES = 'SERVICES',
/** File share management */
SHARE = 'SHARE',
/** System variables and environment */
VARS = 'VARS',
/** Virtual machine management */
VMS = 'VMS',
/** Welcome and onboarding features */
WELCOME = 'WELCOME',
}
export enum Role {
/** Full administrative access to all resources */
ADMIN = 'ADMIN',
/** Read access to all resources with remote access management */
CONNECT = 'CONNECT',
/** Basic read access to user profile only */
GUEST = 'GUEST',
/** Read-only access to all resources */
VIEWER = 'VIEWER',
}
// Simple interfaces without decorators
export interface ApiKey {
id: string;
name: string;
description?: string;
roles?: Role[];
permissions?: Permission[];
createdAt: string;
}
export interface ApiKeyWithSecret extends ApiKey {
key: string;
}
export interface Permission {
resource: Resource;
actions: AuthAction[];
}
+53 -57
View File
@@ -1,49 +1,16 @@
// This file is for backend use only - contains NestJS decorators
import { Field, InterfaceType, registerEnumType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
import { PrefixedID } from './prefixed-id-scalar.js';
import { AuthActionVerb } from 'nest-authz';
// Register enums
export enum Resource {
ACTIVATION_CODE = 'ACTIVATION_CODE',
API_KEY = 'API_KEY',
ARRAY = 'ARRAY',
CLOUD = 'CLOUD',
CONFIG = 'CONFIG',
CONNECT = 'CONNECT',
CONNECT__REMOTE_ACCESS = 'CONNECT__REMOTE_ACCESS',
CUSTOMIZATIONS = 'CUSTOMIZATIONS',
DASHBOARD = 'DASHBOARD',
DISK = 'DISK',
DISPLAY = 'DISPLAY',
DOCKER = 'DOCKER',
FLASH = 'FLASH',
INFO = 'INFO',
LOGS = 'LOGS',
ME = 'ME',
NETWORK = 'NETWORK',
NOTIFICATIONS = 'NOTIFICATIONS',
ONLINE = 'ONLINE',
OS = 'OS',
OWNER = 'OWNER',
PERMISSION = 'PERMISSION',
REGISTRATION = 'REGISTRATION',
SERVERS = 'SERVERS',
SERVICES = 'SERVICES',
SHARE = 'SHARE',
VARS = 'VARS',
VMS = 'VMS',
WELCOME = 'WELCOME',
}
// Import enums from the shared file
import { AuthAction, Resource, Role } from './graphql-enums.js';
export enum Role {
ADMIN = 'ADMIN',
USER = 'USER',
CONNECT = 'CONNECT',
GUEST = 'GUEST',
}
// Re-export for convenience
export { AuthAction, Resource, Role };
// Re-export types from graphql-enums
export type { ApiKey, ApiKeyWithSecret, Permission } from './graphql-enums.js';
@InterfaceType()
export class Node {
@@ -61,22 +28,51 @@ registerEnumType(Resource, {
registerEnumType(Role, {
name: 'Role',
description: 'Available roles for API keys and users',
valuesMap: {
ADMIN: {
description: 'Full administrative access to all resources',
},
CONNECT: {
description: 'Internal Role for Unraid Connect',
},
GUEST: {
description: 'Basic read access to user profile only',
},
VIEWER: {
description: 'Read-only access to all resources',
},
},
});
export interface ApiKey {
id: string;
name: string;
description?: string;
roles?: Role[];
permissions?: Permission[];
createdAt: string;
}
// Register AuthAction enum for GraphQL
registerEnumType(AuthAction, {
name: 'AuthAction',
description: 'Authentication actions with possession (e.g., create:any, read:own)',
valuesMap: {
CREATE_ANY: {
description: 'Create any resource',
},
CREATE_OWN: {
description: 'Create own resource',
},
READ_ANY: {
description: 'Read any resource',
},
READ_OWN: {
description: 'Read own resource',
},
UPDATE_ANY: {
description: 'Update any resource',
},
UPDATE_OWN: {
description: 'Update own resource',
},
DELETE_ANY: {
description: 'Delete any resource',
},
DELETE_OWN: {
description: 'Delete own resource',
},
},
});
export interface ApiKeyWithSecret extends ApiKey {
key: string;
}
export interface Permission {
resource: Resource;
actions: AuthActionVerb[];
}
+1
View File
@@ -3,4 +3,5 @@ export { SocketConfigService } from './services/socket-config.service.js';
export * from './graphql.model.js';
export * from './tokens.js';
export * from './use-permissions.directive.js';
export * from './util/permissions.js';
export type { InternalGraphQLClientFactory } from './types/internal-graphql-client.factory.js';
+14 -9
View File
@@ -1,6 +1,10 @@
import { ApiKey, ApiKeyWithSecret, Permission } from '../graphql.model.js';
import { Role } from '../graphql.model.js';
import { AuthActionVerb } from 'nest-authz';
import { ApiKey, Permission } from '../graphql.model.js';
import { Role, AuthAction, Resource } from '../graphql.model.js';
/**
* Input type for creating API key permissions
*/
export type CreatePermissionsInput = Permission[] | Array<{ resource: Resource; actions: AuthAction[] }>;
export interface ApiKeyService {
/**
@@ -9,19 +13,20 @@ export interface ApiKeyService {
findById(id: string): Promise<ApiKey | null>;
/**
* Find an API key by its ID, including the secret key
* Find an API key by its ID
* Note: This returns ApiKey without the secret for security
*/
findByIdWithSecret(id: string): ApiKeyWithSecret | null;
findByIdWithSecret(id: string): ApiKey | null;
/**
* Find an API key by a specific field
*/
findByField(field: keyof ApiKeyWithSecret, value: string): ApiKeyWithSecret | null;
findByField(field: keyof ApiKey, value: string): ApiKey | null;
/**
* Find an API key by its secret key
*/
findByKey(key: string): ApiKeyWithSecret | null;
findByKey(key: string): ApiKey | null;
/**
* Create a new API key
@@ -30,9 +35,9 @@ export interface ApiKeyService {
name: string;
description?: string;
roles?: Role[];
permissions?: Permission[] | { resource: string; actions: AuthActionVerb[] }[];
permissions?: CreatePermissionsInput;
overwrite?: boolean;
}): Promise<ApiKeyWithSecret>;
}): Promise<ApiKey>;
/**
* Get all valid permissions that can be assigned to an API key
@@ -0,0 +1,314 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { AuthAction, Resource } from './graphql-enums.js';
import { UsePermissions } from './use-permissions.directive.js';
// Mock NestJS dependencies
vi.mock('nest-authz', () => ({
UsePermissions: vi.fn(() => vi.fn()),
}));
vi.mock('@nestjs/graphql', () => ({
Directive: vi.fn(() => vi.fn()),
}));
beforeEach(() => {
vi.clearAllMocks();
});
describe('UsePermissions Directive', () => {
describe('Resource Validation', () => {
it('should accept valid Resource enum values', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.API_KEY,
});
expect(() => {
decorator({}, 'testMethod', {});
}).not.toThrow();
});
it('should accept valid Resource enum values', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.API_KEY,
});
expect(() => {
decorator({}, 'testMethod', {});
}).not.toThrow();
});
it('should accept Resource enum values', () => {
const decorator = UsePermissions({
action: AuthAction.CREATE_ANY,
resource: Resource.ACTIVATION_CODE,
});
expect(() => {
decorator({}, 'testMethod', {});
}).not.toThrow();
});
it('should reject invalid resource values at runtime', () => {
// TypeScript prevents this at compile time, but we can test runtime validation
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: 'INVALID_RESOURCE' as any as Resource,
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow(/Invalid Resource enum value/);
});
it('should reject typos in resource names at runtime', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: 'API_KEYS' as any as Resource, // typo: should be API_KEY
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow(/Invalid Resource enum value: API_KEYS/);
});
it('should provide helpful error message listing valid resources', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: 'INVALID' as any as Resource,
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow(/Must be one of:/);
});
});
describe('SDL Injection Protection', () => {
it('should reject resources with special characters', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: 'API_KEY", malicious: "true' as any as Resource,
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow(/Invalid Resource enum value/);
});
it('should reject resources with GraphQL directive injection attempts', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: 'API_KEY") @skipAuth' as any as Resource,
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow(/Invalid Resource enum value/);
});
it('should reject resources with invalid lowercase names', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: 'api_key' as any as Resource, // lowercase not matching enum
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow(/Invalid Resource enum value/);
});
it('should validate SDL escape function rejects invalid characters', () => {
// This tests the escapeForSDL function indirectly
const decorator = UsePermissions({
action: AuthAction.READ_ANY, // Use the proper enum value
resource: Resource.API_KEY,
});
// The action should pass validation
expect(() => {
decorator({}, 'testMethod', {});
}).not.toThrow();
});
});
describe('Action Validation', () => {
it('should accept valid AuthAction enum values', () => {
const decorator = UsePermissions({
action: AuthAction.CREATE_OWN,
resource: Resource.API_KEY,
});
expect(() => {
decorator({}, 'testMethod', {});
}).not.toThrow();
});
it('should reject invalid AuthAction values', () => {
const decorator = UsePermissions({
action: 'invalid:action' as AuthAction,
resource: Resource.API_KEY,
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow('Invalid AuthAction enum value: invalid:action');
});
it('should reject invalid action combinations in old format', () => {
const decorator = UsePermissions({
action: 'invalid' as any,
possession: 'any' as any,
resource: Resource.API_KEY,
} as any);
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow('Invalid AuthAction enum value: invalid');
});
it('should provide helpful error message listing valid actions', () => {
const decorator = UsePermissions({
action: 'bad:action' as AuthAction,
resource: Resource.API_KEY,
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow(/Must be one of:/);
});
});
describe('Legacy Format Support', () => {
it('should support old format with separate verb and possession', () => {
const decorator = UsePermissions({
action: 'CREATE' as any,
possession: 'ANY' as any,
resource: Resource.API_KEY,
} as any);
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow('Invalid AuthAction enum value: CREATE');
});
it('should normalize verb and possession to AuthAction', () => {
const decorator = UsePermissions({
action: 'READ' as any,
possession: 'OWN' as any,
resource: Resource.DASHBOARD,
} as any);
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow('Invalid AuthAction enum value: READ');
});
});
describe('Error Message Clarity', () => {
it('should provide clear error for invalid resource', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: 'WRONG' as any as Resource,
});
try {
decorator({}, 'testMethod', {});
} catch (error: any) {
expect(error.message).toContain('Invalid Resource enum value: WRONG');
expect(error.message).toContain('Must be one of:');
expect(error.message).toContain('API_KEY');
expect(error.message).toContain('DASHBOARD');
}
});
it('should provide clear error for invalid action', () => {
const decorator = UsePermissions({
action: 'wrong:action' as AuthAction,
resource: Resource.API_KEY,
});
try {
decorator({}, 'testMethod', {});
} catch (error: any) {
expect(error.message).toContain('Invalid AuthAction enum value: wrong:action');
expect(error.message).toContain('Must be one of:');
expect(error.message).toContain('CREATE_ANY');
expect(error.message).toContain('READ_OWN');
}
});
it('should provide clear error for invalid action combination', () => {
const decorator = UsePermissions({
action: 'invalid' as any,
possession: 'wrong' as any,
resource: Resource.API_KEY,
} as any);
try {
decorator({}, 'testMethod', {});
} catch (error: any) {
expect(error.message).toContain('Invalid AuthAction enum value: invalid');
expect(error.message).toContain('Must be one of:');
}
});
});
describe('Security Edge Cases', () => {
it('should handle resources with double underscores', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.CONNECT__REMOTE_ACCESS,
});
expect(() => {
decorator({}, 'testMethod', {});
}).not.toThrow();
});
it('should reject null or undefined resources', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: null as any,
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow();
});
it('should reject empty string resources', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: '' as any as Resource,
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow(/Invalid Resource enum value: /);
});
it('should reject resources with newlines', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: 'API_KEY\n@skipAuth' as any as Resource,
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow(/Invalid Resource enum value/);
});
it('should reject resources with backslashes', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: 'API_KEY\\", another: "value' as any as Resource,
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow(/Invalid Resource enum value/);
});
});
});
@@ -4,80 +4,123 @@ import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils';
import {
DirectiveLocation,
GraphQLDirective,
GraphQLEnumType,
GraphQLSchema,
GraphQLString,
} from 'graphql';
import { AuthActionVerb, AuthPossession, UsePermissions as NestAuthzUsePermissions } from 'nest-authz';
import { UsePermissions as NestAuthzUsePermissions } from 'nest-authz';
// Import from graphql-enums.js to avoid NestJS dependencies
import { AuthAction, Resource } from './graphql-enums.js';
// Re-export the types from nest-authz
export { AuthActionVerb, AuthPossession };
// Re-export the types for convenience
export { AuthAction, Resource };
const buildGraphQLEnum = (
enumObj: Record<string, string | number>,
name: string,
description: string
) => {
const values = Object.entries(enumObj)
.filter(([key]) => isNaN(Number(key)))
.reduce(
(acc, [key]) => {
acc[key] = { value: key };
return acc;
},
{} as Record<string, { value: string }>
);
return new GraphQLEnumType({ name, description, values });
};
// Create GraphQL enum types for auth action verbs and possessions
const AuthActionVerbEnum = buildGraphQLEnum(
AuthActionVerb,
'AuthActionVerb',
'Available authentication action verbs'
);
const AuthPossessionEnum = buildGraphQLEnum(
AuthPossession,
'AuthPossession',
'Available authentication possession types'
);
// Create the auth directive
/**
* GraphQL Directive Definition for @usePermissions
*
* IMPORTANT: GraphQL directives MUST use scalar types (String, Int, Boolean) for their arguments
* according to the GraphQL specification. This is why action and resource are defined as GraphQLString
* even though we use enum types in TypeScript.
*
* Type safety is enforced at:
* 1. Compile-time: TypeScript decorator requires AuthAction and Resource enum types
* 2. Runtime: The decorator validates that string values match valid enum values
*
* The generated schema will show:
* directive @usePermissions(action: String, resource: String) on FIELD_DEFINITION
*
* But the actual usage in code requires proper enum types for type safety.
*/
export const UsePermissionsDirective = new GraphQLDirective({
name: 'usePermissions',
description: 'Directive to document required permissions for fields',
locations: [DirectiveLocation.FIELD_DEFINITION],
args: {
action: {
type: AuthActionVerbEnum,
description: 'The action verb required for access',
type: GraphQLString,
description: 'The action required for access (must be a valid AuthAction enum value)',
},
resource: {
type: GraphQLString,
description: 'The resource required for access',
},
possession: {
type: AuthPossessionEnum,
description: 'The possession type required for access',
description: 'The resource required for access (must be a valid Resource enum value)',
},
},
});
// Create a decorator that combines both the GraphQL directive and UsePermissions
export const UsePermissions = (permissions: {
action: AuthActionVerb;
resource: string;
possession: AuthPossession;
}) => {
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
// Apply UsePermissions for actual authorization
NestAuthzUsePermissions(permissions)(target, propertyKey, descriptor);
/**
* Permissions interface for the UsePermissions decorator
*/
export interface Permissions {
action: AuthAction;
resource: Resource;
}
// Apply GraphQL directive using NestJS's @Directive decorator
/**
* UsePermissions Decorator
*
* Applies permission-based authorization to GraphQL resolvers and adds schema documentation.
*
* @example
* ```typescript
* @Query(() => [User])
* @UsePermissions({
* action: AuthAction.READ_ANY,
* resource: Resource.USERS
* })
* async getUsers() { ... }
* ```
*
* The decorator:
* 1. Enforces TypeScript type safety with enum types
* 2. Validates enum values at runtime
* 3. Applies nest-authz authorization checks
* 4. Adds @usePermissions directive to GraphQL schema
*
* Note: While the GraphQL schema shows String types for the directive,
* TypeScript ensures only valid enum values can be used.
*/
export function UsePermissions(permissions: Permissions): MethodDecorator {
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
const finalAction = permissions.action;
const finalResource = permissions.resource;
// Runtime validation as a safety check
if (!Object.values(AuthAction).includes(finalAction)) {
throw new Error(
`Invalid AuthAction enum value: ${finalAction}. Must be one of: ${Object.values(AuthAction).join(', ')}`
);
}
if (!Object.values(Resource).includes(finalResource)) {
throw new Error(
`Invalid Resource enum value: ${finalResource}. Must be one of: ${Object.values(Resource).join(', ')}`
);
}
// Escape values for safe SDL injection
const escapeForSDL = (value: string): string => {
// Validate that the value only contains expected characters
// Allow letters, digits, underscores, colons, and hyphens (for actions like "READ_ANY", plugin-style values)
const allowedPattern = /^[A-Za-z0-9_:-]+$/;
if (!allowedPattern.test(value)) {
throw new Error(
`Invalid characters in permission value: "${value}". Only letters, digits, underscores, colons, and hyphens are allowed.`
);
}
// Escape special characters for GraphQL string literals
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
};
const escapedAction = escapeForSDL(finalAction);
const escapedResource = escapeForSDL(finalResource);
// Apply UsePermissions for actual authorization
NestAuthzUsePermissions({ action: finalAction, resource: finalResource })(target, propertyKey, descriptor);
// Apply GraphQL directive using NestJS's @Directive decorator with escaped values
Directive(
`@usePermissions(action: ${permissions.action.toUpperCase()}, resource: "${permissions.resource}", possession: ${permissions.possession.toUpperCase()})`
`@usePermissions(action: "${escapedAction}", resource: "${escapedResource}")`
)(target, propertyKey, descriptor);
return descriptor;
@@ -93,10 +136,9 @@ export function usePermissionsSchemaTransformer(schema: GraphQLSchema) {
const {
action: actionValue,
resource: resourceValue,
possession: possessionValue,
} = usePermissionsDirective;
if (!actionValue || !resourceValue || !possessionValue) {
if (!actionValue || !resourceValue) {
console.warn(
`UsePermissions directive on ${typeName}.${fieldName} is missing required arguments.`
);
@@ -108,8 +150,7 @@ export function usePermissionsSchemaTransformer(schema: GraphQLSchema) {
#### Required Permissions:
- Action: **${actionValue}**
- Resource: **${resourceValue}**
- Possession: **${possessionValue}**`;
- Resource: **${resourceValue}**`;
const descriptionDoc = fieldConfig.description
? `
@@ -123,4 +164,4 @@ ${fieldConfig.description}`
return fieldConfig;
},
});
}
}
@@ -0,0 +1,221 @@
import { describe, it, expect } from 'vitest';
import {
parseActionToAuthAction,
parseResourceToEnum,
parseRoleToEnum,
convertScopesToPermissions,
convertPermissionsToScopes,
normalizeLegacyAction
} from '../permissions.js';
import { AuthAction, Resource, Role } from '../../graphql-enums.js';
describe('permissions utilities', () => {
describe('parseActionToAuthAction', () => {
it('handles valid AuthAction enum values', () => {
expect(parseActionToAuthAction('READ_ANY')).toBe(AuthAction.READ_ANY);
expect(parseActionToAuthAction('CREATE_OWN')).toBe(AuthAction.CREATE_OWN);
expect(parseActionToAuthAction('UPDATE_ANY')).toBe(AuthAction.UPDATE_ANY);
expect(parseActionToAuthAction('DELETE_OWN')).toBe(AuthAction.DELETE_OWN);
});
it('handles legacy colon format', () => {
expect(parseActionToAuthAction('read:any')).toBe(AuthAction.READ_ANY);
expect(parseActionToAuthAction('create:own')).toBe(AuthAction.CREATE_OWN);
expect(parseActionToAuthAction('update:any')).toBe(AuthAction.UPDATE_ANY);
expect(parseActionToAuthAction('delete:own')).toBe(AuthAction.DELETE_OWN);
});
it('handles simple verbs with default possession', () => {
expect(parseActionToAuthAction('read')).toBe(AuthAction.READ_ANY);
expect(parseActionToAuthAction('create')).toBe(AuthAction.CREATE_ANY);
expect(parseActionToAuthAction('update')).toBe(AuthAction.UPDATE_ANY);
expect(parseActionToAuthAction('delete')).toBe(AuthAction.DELETE_ANY);
});
it('handles simple verbs with OWN as default', () => {
expect(parseActionToAuthAction('read', 'OWN')).toBe(AuthAction.READ_OWN);
expect(parseActionToAuthAction('create', 'OWN')).toBe(AuthAction.CREATE_OWN);
expect(parseActionToAuthAction('update', 'OWN')).toBe(AuthAction.UPDATE_OWN);
expect(parseActionToAuthAction('delete', 'OWN')).toBe(AuthAction.DELETE_OWN);
});
it('handles mixed case input', () => {
expect(parseActionToAuthAction('Read')).toBe(AuthAction.READ_ANY);
expect(parseActionToAuthAction('CREATE')).toBe(AuthAction.CREATE_ANY);
expect(parseActionToAuthAction('Update:Any')).toBe(AuthAction.UPDATE_ANY);
expect(parseActionToAuthAction('DELETE:OWN')).toBe(AuthAction.DELETE_OWN);
});
it('handles null and undefined', () => {
expect(parseActionToAuthAction(null)).toBe(null);
expect(parseActionToAuthAction(undefined)).toBe(null);
expect(parseActionToAuthAction('')).toBe(null);
});
it('returns null for invalid actions', () => {
expect(parseActionToAuthAction('invalid')).toBe(null);
expect(parseActionToAuthAction('read:invalid')).toBe(null);
expect(parseActionToAuthAction('invalid:any')).toBe(null);
});
it('ensures backward compatibility for old API keys', () => {
// Old API keys might use these formats
expect(parseActionToAuthAction('read')).toBe(AuthAction.READ_ANY);
expect(parseActionToAuthAction('write')).toBe(null); // 'write' is not a valid verb
expect(parseActionToAuthAction('create')).toBe(AuthAction.CREATE_ANY);
expect(parseActionToAuthAction('update')).toBe(AuthAction.UPDATE_ANY);
expect(parseActionToAuthAction('delete')).toBe(AuthAction.DELETE_ANY);
});
});
describe('parseResourceToEnum', () => {
it('parses valid resources', () => {
expect(parseResourceToEnum('DOCKER')).toBe(Resource.DOCKER);
expect(parseResourceToEnum('API_KEY')).toBe(Resource.API_KEY);
expect(parseResourceToEnum('ARRAY')).toBe(Resource.ARRAY);
});
it('handles case insensitive input', () => {
expect(parseResourceToEnum('docker')).toBe(Resource.DOCKER);
expect(parseResourceToEnum('Docker')).toBe(Resource.DOCKER);
expect(parseResourceToEnum('DOCKER')).toBe(Resource.DOCKER);
});
it('returns null for invalid resources', () => {
expect(parseResourceToEnum('invalid')).toBe(null);
expect(parseResourceToEnum('')).toBe(null);
});
});
describe('parseRoleToEnum', () => {
it('parses valid roles', () => {
expect(parseRoleToEnum('ADMIN')).toBe(Role.ADMIN);
expect(parseRoleToEnum('VIEWER')).toBe(Role.VIEWER);
expect(parseRoleToEnum('CONNECT')).toBe(Role.CONNECT);
expect(parseRoleToEnum('GUEST')).toBe(Role.GUEST);
});
it('handles case insensitive input', () => {
expect(parseRoleToEnum('admin')).toBe(Role.ADMIN);
expect(parseRoleToEnum('Admin')).toBe(Role.ADMIN);
expect(parseRoleToEnum('ADMIN')).toBe(Role.ADMIN);
});
it('returns null for invalid roles', () => {
expect(parseRoleToEnum('invalid')).toBe(null);
expect(parseRoleToEnum('')).toBe(null);
});
});
describe('convertScopesToPermissions', () => {
it('converts role scopes', () => {
const result = convertScopesToPermissions(['role:admin', 'role:viewer']);
expect(result.roles).toEqual([Role.ADMIN, Role.VIEWER]);
expect(result.permissions).toEqual([]);
});
it('converts permission scopes with actions', () => {
const result = convertScopesToPermissions([
'docker:read:any',
'docker:update:any',
'vms:create:own'
]);
expect(result.roles).toEqual([]);
expect(result.permissions).toHaveLength(2);
const dockerPerm = result.permissions.find(p => p.resource === Resource.DOCKER);
expect(dockerPerm?.actions).toContain(AuthAction.READ_ANY);
expect(dockerPerm?.actions).toContain(AuthAction.UPDATE_ANY);
const vmsPerm = result.permissions.find(p => p.resource === Resource.VMS);
expect(vmsPerm?.actions).toEqual([AuthAction.CREATE_OWN]);
});
it('handles wildcard actions', () => {
const result = convertScopesToPermissions(['docker:*']);
expect(result.permissions).toHaveLength(1);
expect(result.permissions[0].resource).toBe(Resource.DOCKER);
expect(result.permissions[0].actions).toContain(AuthAction.CREATE_ANY);
expect(result.permissions[0].actions).toContain(AuthAction.READ_ANY);
expect(result.permissions[0].actions).toContain(AuthAction.UPDATE_ANY);
expect(result.permissions[0].actions).toContain(AuthAction.DELETE_ANY);
});
it('merges permissions for same resource', () => {
const result = convertScopesToPermissions([
'docker:read:any',
'docker:update:any'
]);
expect(result.permissions).toHaveLength(1);
expect(result.permissions[0].resource).toBe(Resource.DOCKER);
expect(result.permissions[0].actions).toHaveLength(2);
expect(result.permissions[0].actions).toContain(AuthAction.READ_ANY);
expect(result.permissions[0].actions).toContain(AuthAction.UPDATE_ANY);
});
it('handles legacy simple verb format', () => {
const result = convertScopesToPermissions([
'docker:read',
'vms:create',
'array:update'
]);
expect(result.permissions).toHaveLength(3);
const dockerPerm = result.permissions.find(p => p.resource === Resource.DOCKER);
expect(dockerPerm?.actions).toEqual([AuthAction.READ_ANY]);
const vmsPerm = result.permissions.find(p => p.resource === Resource.VMS);
expect(vmsPerm?.actions).toEqual([AuthAction.CREATE_ANY]);
const arrayPerm = result.permissions.find(p => p.resource === Resource.ARRAY);
expect(arrayPerm?.actions).toEqual([AuthAction.UPDATE_ANY]);
});
});
describe('convertPermissionsToScopes', () => {
it('converts roles to scopes', () => {
const scopes = convertPermissionsToScopes([], [Role.ADMIN, Role.VIEWER]);
expect(scopes).toContain('role:admin');
expect(scopes).toContain('role:viewer');
});
it('converts permissions to scopes', () => {
const scopes = convertPermissionsToScopes([
{
resource: Resource.DOCKER,
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY]
},
{
resource: Resource.VMS,
actions: [AuthAction.CREATE_OWN]
}
]);
expect(scopes).toContain('docker:read_any');
expect(scopes).toContain('docker:update_any');
expect(scopes).toContain('vms:create_own');
});
});
describe('normalizeLegacyAction', () => {
it('handles simple verbs', () => {
expect(normalizeLegacyAction('create')).toBe(AuthAction.CREATE_ANY);
expect(normalizeLegacyAction('read')).toBe(AuthAction.READ_ANY);
expect(normalizeLegacyAction('update')).toBe(AuthAction.UPDATE_ANY);
expect(normalizeLegacyAction('delete')).toBe(AuthAction.DELETE_ANY);
});
it('handles uppercase with underscore', () => {
expect(normalizeLegacyAction('CREATE_ANY')).toBe(AuthAction.CREATE_ANY);
expect(normalizeLegacyAction('READ_OWN')).toBe(AuthAction.READ_OWN);
});
it('handles lowercase with colon', () => {
expect(normalizeLegacyAction('create:any')).toBe(AuthAction.CREATE_ANY);
expect(normalizeLegacyAction('read:own')).toBe(AuthAction.READ_OWN);
});
it('returns null for invalid actions', () => {
expect(normalizeLegacyAction('invalid')).toBe(null);
});
});
});
@@ -0,0 +1,84 @@
import { AuthAction, Resource, Role } from '../graphql-enums.js';
import { convertScopesToPermissions } from './permissions.js';
import { describe, expect, it } from 'vitest';
describe('convertScopesToPermissions', () => {
it('should correctly handle actions with colons like read:any', () => {
const scopes = [
'API_KEY:read:any',
'DASHBOARD:create:own',
'NETWORK:update:any'
];
const result = convertScopesToPermissions(scopes);
expect(result.permissions).toHaveLength(3);
const apiKeyPerm = result.permissions.find(p => p.resource === Resource.API_KEY);
expect(apiKeyPerm).toBeDefined();
expect(apiKeyPerm?.actions).toContain(AuthAction.READ_ANY);
const dashboardPerm = result.permissions.find(p => p.resource === Resource.DASHBOARD);
expect(dashboardPerm).toBeDefined();
expect(dashboardPerm?.actions).toContain(AuthAction.CREATE_OWN);
const networkPerm = result.permissions.find(p => p.resource === Resource.NETWORK);
expect(networkPerm).toBeDefined();
expect(networkPerm?.actions).toContain(AuthAction.UPDATE_ANY);
});
it('should handle wildcard actions', () => {
const scopes = ['DOCKER:*'];
const result = convertScopesToPermissions(scopes);
expect(result.permissions).toHaveLength(1);
const dockerPerm = result.permissions[0];
expect(dockerPerm.resource).toBe(Resource.DOCKER);
expect(dockerPerm.actions).toContain(AuthAction.CREATE_ANY);
expect(dockerPerm.actions).toContain(AuthAction.READ_ANY);
expect(dockerPerm.actions).toContain(AuthAction.UPDATE_ANY);
expect(dockerPerm.actions).toContain(AuthAction.DELETE_ANY);
});
it('should handle role scopes', () => {
const scopes = ['role:ADMIN', 'role:VIEWER'];
const result = convertScopesToPermissions(scopes);
expect(result.roles).toHaveLength(2);
expect(result.roles).toContain(Role.ADMIN);
expect(result.roles).toContain(Role.VIEWER);
expect(result.permissions).toHaveLength(0);
});
it('should merge permissions for the same resource', () => {
const scopes = [
'VMS:read:any',
'VMS:update:any'
];
const result = convertScopesToPermissions(scopes);
expect(result.permissions).toHaveLength(1);
const vmsPerm = result.permissions[0];
expect(vmsPerm.resource).toBe(Resource.VMS);
expect(vmsPerm.actions).toHaveLength(2);
expect(vmsPerm.actions).toContain(AuthAction.READ_ANY);
expect(vmsPerm.actions).toContain(AuthAction.UPDATE_ANY);
});
it('should handle invalid scope formats gracefully', () => {
const scopes = [
'INVALID_SCOPE', // No colon
':action', // Empty resource
'RESOURCE:', // Empty action
'UNKNOWN:read:any' // Unknown resource
];
const result = convertScopesToPermissions(scopes);
expect(result.permissions).toHaveLength(0);
expect(result.roles).toHaveLength(0);
});
});
@@ -0,0 +1,234 @@
import { describe, expect, it } from 'vitest';
import { AuthAction } from '../graphql-enums.js';
import { normalizeLegacyAction, normalizeLegacyActions, parseActionToAuthAction } from './permissions.js';
describe('normalizeLegacyAction', () => {
describe('simple verb format (legacy)', () => {
it('should convert simple verbs to AuthAction enum values', () => {
expect(normalizeLegacyAction('create')).toBe(AuthAction.CREATE_ANY);
expect(normalizeLegacyAction('read')).toBe(AuthAction.READ_ANY);
expect(normalizeLegacyAction('update')).toBe(AuthAction.UPDATE_ANY);
expect(normalizeLegacyAction('delete')).toBe(AuthAction.DELETE_ANY);
});
it('should handle uppercase simple verbs', () => {
expect(normalizeLegacyAction('CREATE')).toBe(AuthAction.CREATE_ANY);
expect(normalizeLegacyAction('READ')).toBe(AuthAction.READ_ANY);
expect(normalizeLegacyAction('UPDATE')).toBe(AuthAction.UPDATE_ANY);
expect(normalizeLegacyAction('DELETE')).toBe(AuthAction.DELETE_ANY);
});
it('should handle mixed case simple verbs', () => {
expect(normalizeLegacyAction('Create')).toBe(AuthAction.CREATE_ANY);
expect(normalizeLegacyAction('Read')).toBe(AuthAction.READ_ANY);
expect(normalizeLegacyAction('Update')).toBe(AuthAction.UPDATE_ANY);
expect(normalizeLegacyAction('Delete')).toBe(AuthAction.DELETE_ANY);
});
});
describe('uppercase underscore format (GraphQL enum style)', () => {
it('should convert CREATE_ANY format to AuthAction enums', () => {
expect(normalizeLegacyAction('CREATE_ANY')).toBe(AuthAction.CREATE_ANY);
expect(normalizeLegacyAction('READ_ANY')).toBe(AuthAction.READ_ANY);
expect(normalizeLegacyAction('UPDATE_ANY')).toBe(AuthAction.UPDATE_ANY);
expect(normalizeLegacyAction('DELETE_ANY')).toBe(AuthAction.DELETE_ANY);
});
it('should convert CREATE_OWN format to AuthAction enums', () => {
expect(normalizeLegacyAction('CREATE_OWN')).toBe(AuthAction.CREATE_OWN);
expect(normalizeLegacyAction('READ_OWN')).toBe(AuthAction.READ_OWN);
expect(normalizeLegacyAction('UPDATE_OWN')).toBe(AuthAction.UPDATE_OWN);
expect(normalizeLegacyAction('DELETE_OWN')).toBe(AuthAction.DELETE_OWN);
});
it('should handle mixed case underscore format', () => {
expect(normalizeLegacyAction('Create_Any')).toBe(AuthAction.CREATE_ANY);
expect(normalizeLegacyAction('Read_Own')).toBe(AuthAction.READ_OWN);
});
});
describe('already correct format (Casbin style)', () => {
it('should convert lowercase:colon format to enums', () => {
expect(normalizeLegacyAction('create:any')).toBe(AuthAction.CREATE_ANY);
expect(normalizeLegacyAction('read:any')).toBe(AuthAction.READ_ANY);
expect(normalizeLegacyAction('update:any')).toBe(AuthAction.UPDATE_ANY);
expect(normalizeLegacyAction('delete:any')).toBe(AuthAction.DELETE_ANY);
});
it('should normalize uppercase:colon to enums', () => {
expect(normalizeLegacyAction('CREATE:ANY')).toBe(AuthAction.CREATE_ANY);
expect(normalizeLegacyAction('READ:OWN')).toBe(AuthAction.READ_OWN);
});
it('should handle :own possession correctly', () => {
expect(normalizeLegacyAction('create:own')).toBe(AuthAction.CREATE_OWN);
expect(normalizeLegacyAction('read:own')).toBe(AuthAction.READ_OWN);
expect(normalizeLegacyAction('update:own')).toBe(AuthAction.UPDATE_OWN);
expect(normalizeLegacyAction('delete:own')).toBe(AuthAction.DELETE_OWN);
});
});
describe('edge cases', () => {
it('should return null for unknown actions', () => {
expect(normalizeLegacyAction('unknown')).toBeNull();
expect(normalizeLegacyAction('UNKNOWN')).toBeNull();
expect(normalizeLegacyAction('some_other_action')).toBeNull();
});
it('should return null for empty string', () => {
expect(normalizeLegacyAction('')).toBeNull();
});
it('should return null for wildcards (not a valid AuthAction)', () => {
expect(normalizeLegacyAction('*')).toBeNull();
});
});
});
describe('integration with parseActionToAuthAction', () => {
it('should produce valid AuthAction enum values after normalization', () => {
// Test that normalized actions can be parsed to valid enum values
const testCases = [
{ input: 'create', normalized: AuthAction.CREATE_ANY, expected: AuthAction.CREATE_ANY },
{ input: 'CREATE_ANY', normalized: AuthAction.CREATE_ANY, expected: AuthAction.CREATE_ANY },
{ input: 'read:own', normalized: AuthAction.READ_OWN, expected: AuthAction.READ_OWN },
{ input: 'UPDATE_OWN', normalized: AuthAction.UPDATE_OWN, expected: AuthAction.UPDATE_OWN },
];
for (const testCase of testCases) {
const normalized = normalizeLegacyAction(testCase.input);
expect(normalized).not.toBeNull();
expect(normalized).toBe(testCase.normalized);
// Since we've asserted normalized is not null, we can safely use it
if (normalized !== null) {
const parsed = parseActionToAuthAction(normalized);
expect(parsed).toBe(testCase.expected);
}
}
});
it('should handle all AuthAction enum values', () => {
// Ensure all enum values can round-trip through normalization
const allActions = Object.values(AuthAction);
for (const action of allActions) {
// The enum value itself should normalize correctly
const normalized = normalizeLegacyAction(action);
expect(normalized).not.toBeNull();
if (normalized !== null) {
const parsed = parseActionToAuthAction(normalized);
expect(parsed).toBe(action);
}
}
});
});
describe('normalizeLegacyActions (array helper)', () => {
it('should normalize an array of mixed format actions', () => {
const mixedActions = [
'create', // Simple verb
'READ_ANY', // Uppercase underscore
'update:own', // Already correct
'DELETE', // Uppercase simple verb
'invalid_action', // Invalid action
'', // Empty string
];
const normalized = normalizeLegacyActions(mixedActions);
expect(normalized).toEqual([
AuthAction.CREATE_ANY,
AuthAction.READ_ANY,
AuthAction.UPDATE_OWN,
AuthAction.DELETE_ANY,
// invalid_action and empty string are filtered out
]);
});
it('should handle empty array', () => {
expect(normalizeLegacyActions([])).toEqual([]);
});
it('should filter out all invalid actions', () => {
const invalidActions = ['invalid', 'unknown', 'some_other'];
expect(normalizeLegacyActions(invalidActions)).toEqual([]);
});
it('should preserve all valid actions', () => {
const validActions = [
'create:any',
'read:any',
'update:any',
'delete:any',
'create:own',
'read:own',
'update:own',
'delete:own',
];
const normalized = normalizeLegacyActions(validActions);
expect(normalized).toEqual([
AuthAction.CREATE_ANY,
AuthAction.READ_ANY,
AuthAction.UPDATE_ANY,
AuthAction.DELETE_ANY,
AuthAction.CREATE_OWN,
AuthAction.READ_OWN,
AuthAction.UPDATE_OWN,
AuthAction.DELETE_OWN,
]);
});
});
describe('API key loading scenarios', () => {
it('should handle legacy simple verb format from old API keys', () => {
const legacyActions = ['create', 'read', 'update', 'delete'];
const normalized = normalizeLegacyActions(legacyActions);
expect(normalized).toEqual([
AuthAction.CREATE_ANY,
AuthAction.READ_ANY,
AuthAction.UPDATE_ANY,
AuthAction.DELETE_ANY,
]);
});
it('should handle mixed format from partially migrated API keys', () => {
const mixedActions = [
'CREATE_ANY',
'read:any',
'update',
'DELETE_OWN'
];
const normalized = normalizeLegacyActions(mixedActions);
expect(normalized).toEqual([
AuthAction.CREATE_ANY,
AuthAction.READ_ANY,
AuthAction.UPDATE_ANY,
AuthAction.DELETE_OWN,
]);
});
it('should handle current Casbin format', () => {
const currentActions = [
'create:any',
'read:any',
'update:any',
'delete:any'
];
const normalized = normalizeLegacyActions(currentActions);
expect(normalized).toEqual([
AuthAction.CREATE_ANY,
AuthAction.READ_ANY,
AuthAction.UPDATE_ANY,
AuthAction.DELETE_ANY,
]);
});
});
@@ -0,0 +1,378 @@
// Import from graphql-enums to avoid NestJS dependencies
import { Resource, Role, AuthAction } from '../graphql-enums.js';
export interface ScopeConversion {
permissions: Array<{ resource: Resource; actions: AuthAction[] }>;
roles: Role[];
}
/**
* Normalize an action string to AuthAction enum value
* Handles various input formats:
* - Full AuthAction values: 'READ_ANY', 'CREATE_OWN'
* - Lowercase with colon: 'read:any', 'create:own' (legacy)
* - Simple verbs: 'read', 'create' (defaults to '_ANY')
* - Mixed case: 'Read', 'CREATE'
*
* @param action - The action string to normalize
* @param defaultPossession - Default possession if not specified ('ANY' or 'OWN')
* @returns The normalized action as AuthAction or null if invalid
*/
export function parseActionToAuthAction(action: string | null | undefined, defaultPossession: 'ANY' | 'OWN' = 'ANY'): AuthAction | null {
if (!action) return null;
// First check if it's already a valid AuthAction value
if (Object.values(AuthAction).includes(action as AuthAction)) {
return action as AuthAction;
}
// Normalize the input - handle both underscore and colon formats
let normalized = action.trim().toUpperCase();
// Convert colon format (read:any) to underscore format (READ_ANY)
if (normalized.includes(':')) {
const parts = normalized.split(':');
if (parts.length === 2) {
const [verb, possession] = parts;
// Only accept valid possessions
if (possession !== 'ANY' && possession !== 'OWN') {
return null;
}
normalized = `${verb}_${possession}`;
} else {
return null;
}
}
// Check if normalized version is valid
if (Object.values(AuthAction).includes(normalized as AuthAction)) {
return normalized as AuthAction;
}
// Handle simple verbs without possession
const simpleVerbs = ['CREATE', 'READ', 'UPDATE', 'DELETE'];
const parts = normalized.split('_');
const verb = parts[0];
// If there's already a possession part, don't add default
if (parts.length === 1 && simpleVerbs.includes(verb)) {
const withPossession = `${verb}_${defaultPossession}` as AuthAction;
if (Object.values(AuthAction).includes(withPossession)) {
return withPossession;
}
}
return null;
}
/**
* Convenience function to parse action to enum (alias for backward compatibility)
* @deprecated Use parseActionToAuthAction instead
*/
export const parseActionToEnum = parseActionToAuthAction;
/**
* Parse a resource string to Resource enum
* Handles special cases and variations
*
* @param resourceStr - The resource string to parse
* @returns The Resource enum value or null if invalid
*/
export function parseResourceToEnum(resourceStr: string): Resource | null {
const normalized = resourceStr.trim().toUpperCase();
// Direct enum lookup
const directMatch = Resource[normalized as keyof typeof Resource];
if (directMatch) {
return directMatch;
}
return null;
}
/**
* Parse a role string to Role enum
*
* @param roleStr - The role string to parse
* @returns The Role enum value or null if invalid
*/
export function parseRoleToEnum(roleStr: string): Role | null {
const normalized = roleStr.trim().toUpperCase();
const role = Role[normalized as keyof typeof Role];
return role || null;
}
/**
* Convert scope strings to permissions and roles
* Scopes can be in format:
* - "role:admin" for roles
* - "docker:read" for resource permissions
* - "docker:*" for all actions on a resource
*
* @param scopes - Array of scope strings
* @returns Object containing parsed permissions and roles
*/
export function convertScopesToPermissions(scopes: string[]): ScopeConversion {
const permissions: Array<{ resource: Resource; actions: AuthAction[] }> = [];
const roles: Role[] = [];
for (const scope of scopes) {
if (scope.startsWith('role:')) {
// Handle role scope
const roleStr = scope.substring(5);
const role = parseRoleToEnum(roleStr);
if (role) {
roles.push(role);
} else {
console.warn(`Unknown role in scope: ${scope}`);
}
} else {
// Handle permission scope - split only on first colon
const colonIndex = scope.indexOf(':');
if (colonIndex === -1) {
console.warn(`Invalid scope format (missing colon): ${scope}`);
continue;
}
const resourceStr = scope.substring(0, colonIndex);
const actionStr = scope.substring(colonIndex + 1).trim();
if (resourceStr && actionStr) {
const resource = parseResourceToEnum(resourceStr);
if (!resource) {
console.warn(`Unknown resource in scope: ${scope}`);
continue;
}
// Handle wildcard or specific action
let actions: AuthAction[];
if (actionStr === '*') {
actions = [
AuthAction.CREATE_ANY,
AuthAction.READ_ANY,
AuthAction.UPDATE_ANY,
AuthAction.DELETE_ANY
];
} else {
// Actions like "read:any" should be preserved as-is
const action = parseActionToAuthAction(actionStr);
if (action) {
actions = [action];
} else {
console.warn(`Unknown action in scope: ${scope}`);
continue;
}
}
// Merge with existing permissions for the same resource
const existing = permissions.find(p => p.resource === resource);
if (existing) {
actions.forEach(a => {
if (!existing.actions.includes(a)) {
existing.actions.push(a);
}
});
} else {
permissions.push({ resource, actions });
}
} else {
console.warn(`Invalid scope format: ${scope}`);
}
}
}
return { permissions, roles };
}
/**
* Convert permissions and roles back to scope strings
* Inverse of convertScopesToPermissions
*
* @param permissions - Array of resource/action pairs
* @param roles - Array of roles
* @returns Array of scope strings
*/
export function convertPermissionsToScopes(
permissions: Array<{ resource: Resource; actions: AuthAction[] }>,
roles: Role[] = []
): string[] {
const scopes: string[] = [];
// Add role scopes
for (const role of roles) {
scopes.push(`role:${role.toLowerCase()}`);
}
// Add permission scopes
for (const perm of permissions) {
const resourceStr = perm.resource.toLowerCase();
for (const action of perm.actions) {
const actionStr = action.toLowerCase();
scopes.push(`${resourceStr}:${actionStr}`);
}
}
return scopes;
}
/**
* Create a scope string from a role
* @param role - The role to convert
* @returns Scope string like "role:admin"
*/
export function roleToScope(role: Role | string): string {
return `role:${role.toLowerCase()}`;
}
/**
* Create a scope string from resource and action
* @param resource - The resource
* @param action - The action (can be verb, AuthAction, or wildcard)
* @returns Scope string like "docker:read" or "docker:*"
*/
export function permissionToScope(resource: Resource | string, action: string): string {
return `${resource.toLowerCase()}:${action.toLowerCase()}`;
}
/**
* Check if a scope string represents a role
* @param scope - The scope string to check
* @returns True if the scope is a role scope
*/
export function isRoleScope(scope: string): boolean {
return scope.startsWith('role:');
}
/**
* Extract the role from a role scope string
* @param scope - The scope string like "role:admin"
* @returns The role name or null if not a role scope
*/
export function getRoleFromScope(scope: string): string | null {
if (!isRoleScope(scope)) return null;
return scope.substring(5);
}
/**
* Normalize an action string to AuthAction format
* @param action - The action string to normalize
* @returns The normalized AuthAction or null if parsing fails
*/
export function normalizeAction(action: string): AuthAction | null {
return parseActionToAuthAction(action);
}
/**
* Normalize legacy action formats to AuthAction enum values
* Handles multiple formats:
* - Simple verbs: "create", "read", "update", "delete" -> AuthAction.CREATE_ANY, etc.
* - Uppercase with underscore: "CREATE_ANY", "READ_ANY" -> AuthAction.CREATE_ANY, etc.
* - Already correct: "create:any", "read:any" -> AuthAction.CREATE_ANY, etc.
*
* @param action - The action string to normalize
* @returns The normalized AuthAction enum value or null if invalid
*/
export function normalizeLegacyAction(action: string): AuthAction | null {
const actionLower = action.toLowerCase();
let normalizedString: string;
// If it's already in lowercase:colon format, use it
if (actionLower.includes(':')) {
normalizedString = actionLower;
}
// If it's in uppercase_underscore format, convert to lowercase:colon
else if (action.includes('_')) {
normalizedString = actionLower.replace('_', ':');
}
// If it's a simple verb without possession, add ":any" as default
else if (['create', 'read', 'update', 'delete'].includes(actionLower)) {
normalizedString = `${actionLower}:any`;
}
// Otherwise just use lowercase (for unknown actions)
else {
normalizedString = actionLower;
}
// Convert the normalized string to AuthAction enum
return parseActionToAuthAction(normalizedString);
}
/**
* Normalize an array of legacy action strings to AuthAction enum values
* Filters out any invalid actions that can't be normalized
*
* @param actions - Array of action strings in various formats
* @returns Array of valid AuthAction enum values
*/
export function normalizeLegacyActions(actions: string[]): AuthAction[] {
return actions
.map(action => normalizeLegacyAction(action))
.filter((action): action is AuthAction => action !== null);
}
/**
* Expand wildcard action (*) to all CRUD actions
* @returns Array of all CRUD AuthAction values
*/
export function expandWildcardAction(): AuthAction[] {
return [AuthAction.CREATE_ANY, AuthAction.READ_ANY, AuthAction.UPDATE_ANY, AuthAction.DELETE_ANY];
}
/**
* Reconcile wildcard permissions by expanding them to all resources
* @param permissionsWithSets - Map of resources to action sets, may include wildcard resource
*/
export function reconcileWildcardPermissions(permissionsWithSets: Map<Resource | '*', Set<AuthAction>>): void {
if (permissionsWithSets.has('*' as Resource | '*')) {
const wildcardActions = permissionsWithSets.get('*' as Resource | '*')!;
permissionsWithSets.delete('*' as Resource | '*');
// Apply wildcard actions to ALL resources (not just existing ones)
for (const resource of Object.values(Resource)) {
if (!permissionsWithSets.has(resource)) {
permissionsWithSets.set(resource, new Set());
}
const actionsSet = permissionsWithSets.get(resource)!;
wildcardActions.forEach((action) => actionsSet.add(action));
}
}
}
/**
* Merge permissions from source map into target map
* @param targetMap - Map to merge permissions into
* @param sourceMap - Map to merge permissions from
*/
export function mergePermissionsIntoMap(
targetMap: Map<Resource, Set<AuthAction>>,
sourceMap: Map<Resource, AuthAction[]>
): void {
for (const [resource, actions] of sourceMap) {
if (!targetMap.has(resource)) {
targetMap.set(resource, new Set());
}
const actionsSet = targetMap.get(resource)!;
actions.forEach((action) => actionsSet.add(action));
}
}
/**
* Convert permission sets to arrays, filtering out wildcards
* @param permissionsWithSets - Map of resources to action sets
* @returns Map of resources to action arrays (excludes wildcard resource)
*/
export function convertPermissionSetsToArrays(
permissionsWithSets: Map<Resource | '*', Set<AuthAction>>
): Map<Resource, AuthAction[]> {
const result = new Map<Resource, AuthAction[]>();
for (const [resource, actionsSet] of permissionsWithSets) {
if (resource !== '*') {
result.set(resource as Resource, Array.from(actionsSet));
}
}
return result;
}
@@ -0,0 +1,7 @@
Menu="WebGui"
Title="API Key Authorization"
Icon="icon-u-shield-keyhole"
Tag="key"
Cond="false"
---
<unraid-api-key-authorize />
+4 -4
View File
@@ -13383,8 +13383,8 @@ packages:
vue-component-type-helpers@3.0.4:
resolution: {integrity: sha512-WtR3kPk8vqKYfCK/HGyT47lK/T3FaVyWxaCNuosaHYE8h9/k0lYRZ/PI/+T/z2wP+uuNKmL6z30rOcBboOu/YA==}
vue-component-type-helpers@3.0.5:
resolution: {integrity: sha512-uoNZaJ+a1/zppa/Vgmi8zIOP2PHXDN2rT8NyF+zQRK6ZG94lNB9prcV0GdLJbY9i9lrD47JOVIH92SaiA7oJ1A==}
vue-component-type-helpers@3.0.6:
resolution: {integrity: sha512-6CRM8X7EJqWCJOiKPvSLQG+hJPb/Oy2gyJx3pLjUEhY7PuaCthQu3e0zAGI1lqUBobrrk9IT0K8sG2GsCluxoQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -17892,7 +17892,7 @@ snapshots:
storybook: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.1(@types/node@22.17.1)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1))
type-fest: 2.19.0
vue: 3.5.18(typescript@5.9.2)
vue-component-type-helpers: 3.0.5
vue-component-type-helpers: 3.0.6
'@stylistic/eslint-plugin@5.2.2(eslint@9.33.0(jiti@2.5.1))':
dependencies:
@@ -27724,7 +27724,7 @@ snapshots:
vue-component-type-helpers@3.0.4: {}
vue-component-type-helpers@3.0.5: {}
vue-component-type-helpers@3.0.6: {}
vue-demi@0.14.10(vue@3.5.18(typescript@5.9.2)):
dependencies:
+99
View File
@@ -0,0 +1,99 @@
import { toast as sonnerToast } from 'vue-sonner';
/**
* Composable for toast notifications using vue-sonner
* Provides a consistent API for showing toast messages
*/
export function useToast() {
/**
* Show a default toast notification
* @param message - The message to display
*/
const toast = (message: string) => {
sonnerToast(message);
};
/**
* Show a success toast
* @param message - The success message to display
*/
const success = (message: string) => {
sonnerToast.success(message);
};
/**
* Show an error toast
* @param message - The error message to display
*/
const error = (message: string) => {
sonnerToast.error(message);
};
/**
* Show a warning toast
* @param message - The warning message to display
*/
const warning = (message: string) => {
sonnerToast.warning(message);
};
/**
* Show an info toast
* @param message - The info message to display
*/
const info = (message: string) => {
sonnerToast.info(message);
};
/**
* Show a loading toast
* @param message - The loading message to display
*/
const loading = (message: string) => {
sonnerToast.loading(message);
};
/**
* Show a promise-based toast that updates based on promise state
* @param promise - The promise to track
* @param messages - Messages for different states
* @returns The toast ID with an unwrap method to get the original promise
*/
const promise = <T>(
promiseToHandle: Promise<T>,
messages: {
loading: string;
success: string | ((data: T) => string);
error: string | ((error: unknown) => string);
}
) => {
// Return vue-sonner's promise return type which includes the toast ID and unwrap method
return sonnerToast.promise(promiseToHandle, messages);
};
/**
* Dismiss a specific toast or all toasts
* @param toastId - Optional toast ID to dismiss. If not provided, dismisses all toasts
*/
const dismiss = (toastId?: string | number) => {
sonnerToast.dismiss(toastId);
};
return {
// Allow calling the composable directly as toast()
toast,
// Named methods
success,
error,
warning,
info,
loading,
promise,
dismiss,
// Also expose the raw sonner toast for advanced usage
sonner: sonnerToast,
};
}
// Export type for the return value
export type ToastInstance = ReturnType<typeof useToast>;
+4
View File
@@ -51,8 +51,12 @@ const labelClass = computed(() => {
switch (labelFormat.value) {
case 'title':
return 'text-xl font-semibold mb-2'; // Example styling for title
case 'subtitle':
return 'text-base font-semibold mb-1'; // Styling for subtitle
case 'heading':
return 'text-lg font-semibold mt-4 mb-1'; // Example styling for heading
case 'description':
return 'text-sm text-muted-foreground'; // Description format should not be bold
default:
return 'font-semibold'; // Default label styling
}
+219
View File
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { Badge } from '@/components/common/badge';
import { Button } from '@/components/common/button';
import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRoot,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ChevronDownIcon, XMarkIcon } from '@heroicons/vue/24/outline';
import type { ControlElement } from '@jsonforms/core';
import { useJsonFormsControl } from '@jsonforms/vue';
import type { RendererProps } from '@jsonforms/vue';
import { computed, ref } from 'vue';
const props = defineProps<RendererProps<ControlElement>>();
const { control, handleChange } = useJsonFormsControl(props);
// Handle array data
const selectedValues = computed({
get: () => {
const data = control.value.data ?? [];
return Array.isArray(data) ? data : [];
},
set: (values: string[]) => {
handleChange(control.value.path, values);
},
});
// Get available options from schema
const options = computed(() => {
const schema = control.value.schema;
let enumValues: string[] = [];
// Check for enum in schema.items (for array types)
if (
schema.type === 'array' &&
typeof schema.items === 'object' &&
!Array.isArray(schema.items) &&
'enum' in schema.items
) {
enumValues = schema.items.enum as string[];
}
// Fallback to direct enum
else if ('enum' in schema) {
enumValues = schema.enum as string[];
}
const labels: Record<string, string> | undefined = control.value.uischema.options?.labels;
const descriptions: Record<string, string> | undefined = control.value.uischema.options?.descriptions;
return enumValues.map((value) => ({
value,
label: labels?.[value] || value,
description: descriptions?.[value],
}));
});
// Display format from options
const displayFormat = computed(() => control.value.uischema.options?.format || 'dropdown');
// Check if readonly
const isReadonly = computed(() => control.value.uischema.options?.readonly || !control.value.enabled);
// Remove a specific value (for chips display)
const removeValue = (value: string) => {
if (isReadonly.value) return;
selectedValues.value = selectedValues.value.filter((v) => v !== value);
};
// Clear all selections
const clearAll = () => {
if (isReadonly.value) return;
selectedValues.value = [];
};
const dropdownOpen = ref(false);
// Placeholder text
const placeholder = computed(() => {
const customPlaceholder = control.value.uischema.options?.placeholder;
if (customPlaceholder) return customPlaceholder;
return options.value.length > 0 ? 'Select items...' : 'No items available';
});
</script>
<template>
<!-- Chips display format -->
<div v-if="displayFormat === 'chips' || displayFormat === 'array'" class="space-y-2">
<div class="flex flex-wrap gap-2">
<Badge
v-for="value in selectedValues"
:key="value"
:variant="isReadonly ? 'gray' : 'blue'"
size="sm"
>
<span>{{ options.find((o) => o.value === value)?.label || value }}</span>
<button
v-if="!isReadonly"
@click.stop="removeValue(value)"
class="ml-1 hover:text-destructive"
type="button"
>
<XMarkIcon class="h-3 w-3" />
</button>
</Badge>
<span v-if="selectedValues.length === 0" class="text-muted-foreground text-sm">
{{ isReadonly ? 'None selected' : placeholder }}
</span>
</div>
<!-- Add button for editable chips -->
<DropdownMenuRoot v-if="!isReadonly && displayFormat === 'chips'" v-model:open="dropdownOpen">
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="h-8">
<span>Add Item</span>
<ChevronDownIcon class="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-56 max-h-[20rem] overflow-y-auto">
<DropdownMenuLabel>Select Items</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
v-for="option in options"
:key="option.value"
:model-value="selectedValues.includes(option.value)"
@update:model-value="
(checked) => {
if (checked && !selectedValues.includes(option.value)) {
selectedValues = [...selectedValues, option.value];
} else if (!checked && selectedValues.includes(option.value)) {
selectedValues = selectedValues.filter((v) => v !== option.value);
}
}
"
@select.prevent
>
<div class="flex flex-col">
<span>{{ option.label }}</span>
<span v-if="option.description" class="text-xs text-muted-foreground">
{{ option.description }}
</span>
</div>
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenuRoot>
</div>
<!-- Dropdown format -->
<div v-else>
<DropdownMenuRoot v-model:open="dropdownOpen">
<DropdownMenuTrigger as-child>
<Button
variant="outline"
role="combobox"
:aria-expanded="dropdownOpen"
:disabled="isReadonly"
class="w-full justify-between"
>
<span class="truncate">
<template v-if="selectedValues.length === 0">
{{ placeholder }}
</template>
<template v-else-if="selectedValues.length === 1">
{{ options.find((o) => o.value === selectedValues[0])?.label || selectedValues[0] }}
</template>
<template v-else> {{ selectedValues.length }} items selected </template>
</span>
<ChevronDownIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-full max-h-[20rem] overflow-y-auto">
<DropdownMenuLabel class="flex justify-between items-center">
<span>Select Items</span>
<button
v-if="selectedValues.length > 0"
@click="clearAll"
class="text-xs text-muted-foreground hover:text-foreground"
type="button"
>
Clear all
</button>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
v-for="option in options"
:key="option.value"
:model-value="selectedValues.includes(option.value)"
@update:model-value="
(checked) => {
if (checked && !selectedValues.includes(option.value)) {
selectedValues = [...selectedValues, option.value];
} else if (!checked && selectedValues.includes(option.value)) {
selectedValues = selectedValues.filter((v) => v !== option.value);
}
}
"
@select.prevent
>
<div class="flex flex-col">
<span>{{ option.label }}</span>
<span v-if="option.description" class="text-xs text-muted-foreground">
{{ option.description }}
</span>
</div>
</DropdownMenuCheckboxItem>
<template v-if="selectedValues.length > 0">
<DropdownMenuSeparator />
<div class="px-2 py-2 text-sm text-muted-foreground">{{ selectedValues.length }} selected</div>
</template>
</DropdownMenuContent>
</DropdownMenuRoot>
</div>
</template>
+2 -2
View File
@@ -169,7 +169,7 @@ const updateItem = (index: number, newValue: unknown) => {
<div class="w-full">
<Tabs v-if="items.length > 0" v-model="activeTab" class="w-full">
<div class="flex items-center gap-2 mb-4">
<TabsList class="flex-1">
<TabsList class="flex-1 flex-wrap">
<TabsTrigger
v-for="(item, index) in items"
:key="index"
@@ -196,7 +196,7 @@ const updateItem = (index: number, newValue: unknown) => {
:value="String(index)"
class="mt-0 w-full"
>
<div class="border rounded-lg p-6 w-full">
<div class="border rounded-lg p-1 sm:p-6 w-full">
<div class="flex justify-end mb-4">
<Button
v-if="!isItemProtected(item)"
+5 -2
View File
@@ -20,10 +20,11 @@ const selected = computed(() => control.value.data);
const options = computed(() => {
const enumValues: string[] = control.value.schema.enum || [];
const tooltips: string[] | undefined = control.value.uischema.options?.tooltips;
const labels: Record<string, string> | undefined = control.value.uischema.options?.labels;
return enumValues.map((value, index) => ({
value,
label: value,
label: labels?.[value] || value,
tooltip: tooltips && tooltips[index] ? tooltips[index] : undefined,
}));
});
@@ -41,7 +42,9 @@ const onChange = (value: unknown) => {
@update:model-value="onChange"
>
<SelectTrigger>
<SelectValue v-if="selected">{{ selected }}</SelectValue>
<SelectValue v-if="selected">{{
options.find((o) => o.value === selected)?.label || selected
}}</SelectValue>
<span v-else>{{ control.schema.default ?? 'Select an option' }}</span>
</SelectTrigger>
+17
View File
@@ -5,6 +5,7 @@ import HorizontalLayout from '@/forms/HorizontalLayout.vue';
import inputFieldRenderer from '@/forms/InputField.vue';
import LabelRenderer from '@/forms/LabelRenderer.vue';
import MissingRenderer from '@/forms/MissingRenderer.vue';
import MultiSelect from '@/forms/MultiSelect.vue';
import numberFieldRenderer from '@/forms/NumberField.vue';
import ObjectArrayField from '@/forms/ObjectArrayField.vue';
import PreconditionsLabel from '@/forms/PreconditionsLabel.vue';
@@ -55,6 +56,12 @@ const isObjectArray = (schema: JsonSchema): boolean => {
return schema.type === 'array' && items?.type === 'object';
};
const isEnumArray = (schema: JsonSchema): boolean => {
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return false;
const items = schema.items as JsonSchema;
return schema.type === 'array' && items?.enum !== undefined;
};
const isStringOrAnyOfString = (schema: JsonSchema): boolean => {
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return false;
// Exclude enum fields - they should use select renderer
@@ -102,6 +109,16 @@ export const jsonFormsRenderers: JsonFormsRendererRegistryEntry[] = [
renderer: markRaw(withErrorWrapper(switchRenderer)),
tester: rankWith(4, and(isBooleanControl, optionIs('format', 'toggle'))),
},
// MultiSelect for array enums or when multiple option is set
{
renderer: markRaw(withErrorWrapper(MultiSelect)),
tester: rankWith(8, and(isControl, schemaMatches(isEnumArray))),
},
{
renderer: markRaw(withErrorWrapper(MultiSelect)),
tester: rankWith(8, and(isControl, optionIs('multiple', true))),
},
// Regular select for single enums
{
renderer: markRaw(withErrorWrapper(selectRenderer)),
tester: rankWith(6, isEnumControl),
+2
View File
@@ -13,3 +13,5 @@ export * from '@/lib/utils';
// Composables
export { default as useTeleport } from '@/composables/useTeleport';
export { useToast } from '@/composables/useToast';
export type { ToastInstance } from '@/composables/useToast';
+205
View File
@@ -0,0 +1,205 @@
import { describe, it, expect } from 'vitest';
import { encodePermissionsToScopes, decodeScopesToPermissions } from '../utils/authorizationScopes';
import { AuthAction, Resource } from '../composables/gql/graphql';
describe('authorizationScopes', () => {
describe('encodePermissionsToScopes', () => {
describe('duplicate handling', () => {
it('should deduplicate action verbs when multiple permissions have duplicate actions', () => {
const permissions = [
{
resource: Resource.DOCKER,
actions: [AuthAction.READ_ANY, AuthAction.READ_OWN, AuthAction.UPDATE_ANY]
}
];
const scopes = encodePermissionsToScopes([], permissions);
// Should produce "docker:read_any+read_own+update_any" with distinct actions preserved
expect(scopes).toHaveLength(1);
expect(scopes[0]).toBe('docker:read_any+read_own+update_any');
});
it('should deduplicate resource names when grouped', () => {
const permissions = [
{
resource: Resource.DOCKER,
actions: [AuthAction.READ_ANY]
},
{
resource: Resource.DOCKER,
actions: [AuthAction.READ_OWN]
}
];
const scopes = encodePermissionsToScopes([], permissions);
// Should produce "docker:read_any+read_own" merging both permissions
expect(scopes).toHaveLength(1);
expect(scopes[0]).toBe('docker:read_any+read_own');
});
it('should handle multiple duplicate resources and actions correctly', () => {
const permissions = [
{
resource: Resource.DOCKER,
actions: [AuthAction.READ_ANY, AuthAction.READ_OWN, AuthAction.UPDATE_ANY]
},
{
resource: Resource.VMS,
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_OWN, AuthAction.UPDATE_ANY]
},
{
resource: Resource.DOCKER,
actions: [AuthAction.READ_OWN, AuthAction.UPDATE_OWN]
}
];
const scopes = encodePermissionsToScopes([], permissions);
// Docker: READ_ANY, READ_OWN, UPDATE_ANY, UPDATE_OWN (merged from both)
// VMS: READ_ANY, UPDATE_OWN, UPDATE_ANY
// Different action sets, so separate scopes
expect(scopes).toHaveLength(2);
const scopeStrings = scopes.sort();
expect(scopeStrings).toContain('docker:read_any+read_own+update_any+update_own');
expect(scopeStrings).toContain('vms:read_any+update_any+update_own');
});
it('should maintain consistent sorting for actions and resources', () => {
const permissions = [
{
resource: Resource.VMS,
actions: [AuthAction.UPDATE_ANY, AuthAction.READ_ANY]
},
{
resource: Resource.DOCKER,
actions: [AuthAction.UPDATE_ANY, AuthAction.READ_ANY]
}
];
const scopes = encodePermissionsToScopes([], permissions);
// Should sort resources (docker before vms) and actions alphabetically
expect(scopes).toHaveLength(1);
expect(scopes[0]).toBe('docker+vms:read_any+update_any');
});
it('should handle all CRUD actions without creating wildcard', () => {
const permissions = [
{
resource: Resource.DOCKER,
actions: [
AuthAction.CREATE_ANY, AuthAction.CREATE_OWN,
AuthAction.READ_ANY, AuthAction.READ_OWN,
AuthAction.UPDATE_ANY, AuthAction.UPDATE_OWN,
AuthAction.DELETE_ANY, AuthAction.DELETE_OWN
]
}
];
const scopes = encodePermissionsToScopes([], permissions);
// Should just list all actions, no wildcard conversion
expect(scopes).toHaveLength(1);
expect(scopes[0]).toBe('docker:create_any+create_own+delete_any+delete_own+read_any+read_own+update_any+update_own');
});
it('should handle edge case with empty actions after deduplication', () => {
const permissions = [
{
resource: Resource.DOCKER,
actions: []
}
];
const scopes = encodePermissionsToScopes([], permissions);
// Should not produce any scope for empty actions
expect(scopes).toHaveLength(0);
});
it('should deduplicate resources across multiple permissions with same action set', () => {
const permissions = [
{
resource: Resource.DOCKER,
actions: [AuthAction.READ_ANY]
},
{
resource: Resource.VMS,
actions: [AuthAction.READ_OWN]
},
{
resource: Resource.DOCKER,
actions: [AuthAction.READ_OWN]
},
{
resource: Resource.VMS,
actions: [AuthAction.READ_ANY]
}
];
const scopes = encodePermissionsToScopes([], permissions);
// Both DOCKER and VMS have READ_ANY and READ_OWN, should group without duplicates
expect(scopes).toHaveLength(1);
expect(scopes[0]).toBe('docker+vms:read_any+read_own');
});
it('should produce deterministic output for same input regardless of order', () => {
const permissions1 = [
{ resource: Resource.VMS, actions: [AuthAction.UPDATE_ANY, AuthAction.READ_ANY] },
{ resource: Resource.DOCKER, actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY] }
];
const permissions2 = [
{ resource: Resource.DOCKER, actions: [AuthAction.UPDATE_ANY, AuthAction.READ_ANY] },
{ resource: Resource.VMS, actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY] }
];
const scopes1 = encodePermissionsToScopes([], permissions1);
const scopes2 = encodePermissionsToScopes([], permissions2);
expect(scopes1).toEqual(scopes2);
expect(scopes1[0]).toBe('docker+vms:read_any+update_any');
});
});
describe('roundtrip encoding/decoding', () => {
it('should maintain permissions through encode/decode cycle with duplicates', () => {
const originalPermissions = [
{
resource: Resource.DOCKER,
actions: [AuthAction.READ_ANY, AuthAction.READ_OWN, AuthAction.UPDATE_ANY]
},
{
resource: Resource.VMS,
actions: [AuthAction.READ_OWN, AuthAction.UPDATE_OWN, AuthAction.UPDATE_ANY]
}
];
const scopes = encodePermissionsToScopes([], originalPermissions);
const { permissions: decoded } = decodeScopesToPermissions(scopes);
// Now possession is preserved in the encoding
expect(decoded).toHaveLength(2);
const dockerPerm = decoded.find(p => p.resource === Resource.DOCKER);
const vmsPerm = decoded.find(p => p.resource === Resource.VMS);
// Docker should have its specific actions preserved
expect(dockerPerm?.actions).toContain(AuthAction.READ_ANY);
expect(dockerPerm?.actions).toContain(AuthAction.READ_OWN);
expect(dockerPerm?.actions).toContain(AuthAction.UPDATE_ANY);
// VMS should have its specific actions preserved
expect(vmsPerm?.actions).toContain(AuthAction.READ_OWN);
expect(vmsPerm?.actions).toContain(AuthAction.UPDATE_OWN);
expect(vmsPerm?.actions).toContain(AuthAction.UPDATE_ANY);
// The scopes should be separate since they have different action sets
expect(scopes).toHaveLength(2);
});
});
});
});
+67 -11
View File
@@ -6,7 +6,7 @@ import { nextTick } from 'vue';
import { setActivePinia } from 'pinia';
import { mount } from '@vue/test-utils';
import { Input, Label, Select, SelectTrigger, Switch } from '@unraid/ui';
import { Input, Label, Select, Switch } from '@unraid/ui';
import { createTestingPinia } from '@pinia/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -68,6 +68,12 @@ describe('ColorSwitcher', () => {
const wrapper = mount(ColorSwitcher, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
stubs: {
Accordion: { template: '<div><slot /></div>' },
AccordionItem: { template: '<div><slot /></div>' },
AccordionTrigger: { template: '<div><slot /></div>' },
AccordionContent: { template: '<div><slot /></div>' },
},
},
});
@@ -80,13 +86,22 @@ describe('ColorSwitcher', () => {
const switches = wrapper.findAllComponents(Switch);
expect(switches).toHaveLength(3);
expect(wrapper.findComponent(SelectTrigger).exists()).toBe(true);
expect(wrapper.findComponent(Select).exists()).toBe(true);
});
it('updates theme store when theme selection changes', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn });
setActivePinia(pinia);
themeStore = useThemeStore();
const wrapper = mount(ColorSwitcher, {
global: {
plugins: [pinia],
stubs: {
Accordion: { template: '<div><slot /></div>' },
AccordionItem: { template: '<div><slot /></div>' },
AccordionTrigger: { template: '<div><slot /></div>' },
AccordionContent: { template: '<div><slot /></div>' },
Select: {
template: '<div/>',
props: ['modelValue'],
@@ -96,9 +111,13 @@ describe('ColorSwitcher', () => {
},
});
// Clear mocks after initial mount
vi.clearAllMocks();
const selectComponent = wrapper.findComponent(Select);
await selectComponent.vm.$emit('update:modelValue', 'black');
await nextTick();
await nextTick();
expect(themeStore.setTheme).toHaveBeenCalledTimes(2);
@@ -114,8 +133,20 @@ describe('ColorSwitcher', () => {
});
it('updates theme store when color inputs change', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn });
setActivePinia(pinia);
themeStore = useThemeStore();
const wrapper = mount(ColorSwitcher, {
global: {},
global: {
plugins: [pinia],
stubs: {
Accordion: { template: '<div><slot /></div>' },
AccordionItem: { template: '<div><slot /></div>' },
AccordionTrigger: { template: '<div><slot /></div>' },
AccordionContent: { template: '<div><slot /></div>' },
},
},
});
const inputs = wrapper.findAllComponents(Input);
@@ -123,19 +154,25 @@ describe('ColorSwitcher', () => {
const secondaryTextInput = inputs[1];
const bgInput = inputs[2];
await primaryTextInput.setValue('#ff0000');
// Clear mocks after initial mount
vi.clearAllMocks();
await primaryTextInput.vm.$emit('update:modelValue', '#ff0000');
await nextTick();
await nextTick();
expect(themeStore.setTheme).toHaveBeenCalledTimes(2);
expect(themeStore.setTheme).toHaveBeenCalledWith(expect.objectContaining({ textColor: '#ff0000' }));
await secondaryTextInput.setValue('#00ff00');
await secondaryTextInput.vm.$emit('update:modelValue', '#00ff00');
await nextTick();
await nextTick();
expect(themeStore.setTheme).toHaveBeenCalledTimes(3);
expect(themeStore.setTheme).toHaveBeenCalledWith(expect.objectContaining({ metaColor: '#00ff00' }));
await bgInput.setValue('#0000ff');
await bgInput.vm.$emit('update:modelValue', '#0000ff');
await nextTick();
await nextTick();
expect(themeStore.setTheme).toHaveBeenCalledTimes(4);
@@ -154,7 +191,15 @@ describe('ColorSwitcher', () => {
it('updates theme store when switches change', async () => {
const wrapper = mount(ColorSwitcher, {
global: {},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
stubs: {
Accordion: { template: '<div><slot /></div>' },
AccordionItem: { template: '<div><slot /></div>' },
AccordionTrigger: { template: '<div><slot /></div>' },
AccordionContent: { template: '<div><slot /></div>' },
},
},
});
themeStore = useThemeStore();
@@ -189,12 +234,23 @@ describe('ColorSwitcher', () => {
});
it('enables gradient automatically when banner is enabled', async () => {
const wrapper = mount(ColorSwitcher, {
global: {},
});
// Create a single Pinia instance to be shared between component and store
const pinia = createTestingPinia({ createSpy: vi.fn });
setActivePinia(pinia);
themeStore = useThemeStore();
const wrapper = mount(ColorSwitcher, {
global: {
plugins: [pinia],
stubs: {
Accordion: { template: '<div><slot /></div>' },
AccordionItem: { template: '<div><slot /></div>' },
AccordionTrigger: { template: '<div><slot /></div>' },
AccordionContent: { template: '<div><slot /></div>' },
},
},
});
const switches = wrapper.findAllComponents(Switch);
const gradientSwitch = switches[0];
const bannerSwitch = switches[2];
@@ -1,6 +1,8 @@
import { ref } from 'vue';
import { setActivePinia } from 'pinia';
import { mount } from '@vue/test-utils';
import { provideApolloClient } from '@vue/apollo-composable';
import { ApolloClient, InMemoryCache } from '@apollo/client/core';
import { createTestingPinia } from '@pinia/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -93,6 +95,22 @@ describe('UserProfile.ce.vue', () => {
let consoleSpies: Array<ReturnType<typeof vi.spyOn>> = [];
beforeEach(() => {
// Create a mock Apollo Client
const mockApolloClient = new ApolloClient({
cache: new InMemoryCache(),
defaultOptions: {
query: {
fetchPolicy: 'no-cache',
},
watchQuery: {
fetchPolicy: 'no-cache',
},
},
});
// Provide the Apollo client globally
provideApolloClient(mockApolloClient);
// Suppress all console outputs
consoleSpies = [
vi.spyOn(console, 'log').mockImplementation(() => {}),
@@ -0,0 +1,175 @@
import { describe, it, expect } from 'vitest';
import { useApiKeyAuthorization } from '~/composables/useApiKeyAuthorization';
import { AuthAction, Resource, Role } from '~/composables/gql/graphql';
describe('useApiKeyAuthorization', () => {
describe('parameter parsing', () => {
it('should parse query parameters correctly', () => {
const params = new URLSearchParams('?name=TestApp&scopes=docker:read,vms:*&redirect_uri=https://example.com&state=abc123');
const { authParams } = useApiKeyAuthorization(params);
expect(authParams.value.name).toBe('TestApp');
expect(authParams.value.scopes).toEqual(['docker:read', 'vms:*']);
expect(authParams.value.redirectUri).toBe('https://example.com');
expect(authParams.value.state).toBe('abc123');
});
it('should handle missing parameters with defaults', () => {
const params = new URLSearchParams('');
const { authParams } = useApiKeyAuthorization(params);
expect(authParams.value.name).toBe('Unknown Application');
expect(authParams.value.scopes).toEqual([]);
expect(authParams.value.redirectUri).toBe('');
expect(authParams.value.state).toBe('');
});
});
describe('formatPermissions', () => {
it('should format role scopes correctly', () => {
const params = new URLSearchParams('?scopes=role:admin,role:viewer');
const { formattedPermissions } = useApiKeyAuthorization(params);
expect(formattedPermissions.value).toEqual([
{
scope: 'role:admin',
name: 'ADMIN',
description: 'Grant admin role access',
isRole: true,
},
{
scope: 'role:viewer',
name: 'VIEWER',
description: 'Grant viewer role access',
isRole: true,
},
]);
});
it('should format resource:action scopes correctly', () => {
const params = new URLSearchParams('?scopes=docker:read,vms:*');
const { formattedPermissions } = useApiKeyAuthorization(params);
expect(formattedPermissions.value).toEqual([
{
scope: 'docker:read',
name: 'Docker - Read',
description: 'Read access to Docker',
isRole: false,
},
{
scope: 'vms:*',
name: 'Vms - Full',
description: 'Full access to Vms',
isRole: false,
},
]);
});
});
describe('convertScopesToPermissions', () => {
it('should convert role scopes to roles', () => {
const params = new URLSearchParams('?scopes=role:admin');
const { convertScopesToPermissions } = useApiKeyAuthorization(params);
const result = convertScopesToPermissions(['role:admin']);
expect(result.roles).toContain(Role.ADMIN);
expect(result.permissions).toEqual([]);
});
it('should convert resource scopes to permissions', () => {
const params = new URLSearchParams('?scopes=docker:read');
const { convertScopesToPermissions } = useApiKeyAuthorization(params);
const result = convertScopesToPermissions(['docker:read']);
expect(result.permissions).toEqual([
{
resource: Resource.DOCKER,
actions: [AuthAction.READ_ANY],
},
]);
expect(result.roles).toEqual([]);
});
it('should handle wildcard actions', () => {
const params = new URLSearchParams('?scopes=vms:*');
const { convertScopesToPermissions } = useApiKeyAuthorization(params);
const result = convertScopesToPermissions(['vms:*']);
expect(result.permissions).toEqual([
{
resource: Resource.VMS,
actions: [AuthAction.CREATE_ANY, AuthAction.READ_ANY, AuthAction.UPDATE_ANY, AuthAction.DELETE_ANY],
},
]);
});
it('should merge multiple actions for same resource', () => {
const params = new URLSearchParams('');
const { convertScopesToPermissions } = useApiKeyAuthorization(params);
const result = convertScopesToPermissions(['docker:read', 'docker:update']);
expect(result.permissions).toEqual([
{
resource: Resource.DOCKER,
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
},
]);
});
});
describe('redirect URI validation', () => {
it('should accept HTTPS URLs', () => {
const params = new URLSearchParams('?redirect_uri=https://example.com/callback');
const { hasValidRedirectUri } = useApiKeyAuthorization(params);
expect(hasValidRedirectUri.value).toBe(true);
});
it('should accept localhost URLs', () => {
const params = new URLSearchParams('?redirect_uri=http://localhost:3000/callback');
const { hasValidRedirectUri } = useApiKeyAuthorization(params);
expect(hasValidRedirectUri.value).toBe(true);
});
it('should accept HTTP URLs (non-localhost)', () => {
const params = new URLSearchParams('?redirect_uri=http://example.com/callback');
const { hasValidRedirectUri } = useApiKeyAuthorization(params);
expect(hasValidRedirectUri.value).toBe(true);
});
it('should reject invalid URLs', () => {
const params = new URLSearchParams('?redirect_uri=not-a-url');
const { hasValidRedirectUri } = useApiKeyAuthorization(params);
expect(hasValidRedirectUri.value).toBe(false);
});
});
describe('buildCallbackUrl', () => {
it('should build callback URL with API key', () => {
const params = new URLSearchParams('');
const { buildCallbackUrl } = useApiKeyAuthorization(params);
const url = buildCallbackUrl('https://example.com/callback', 'test-key', undefined, 'state123');
expect(url).toBe('https://example.com/callback?api_key=test-key&state=state123');
});
it('should build callback URL with error', () => {
const params = new URLSearchParams('');
const { buildCallbackUrl } = useApiKeyAuthorization(params);
const url = buildCallbackUrl('https://example.com/callback', undefined, 'access_denied', 'state123');
expect(url).toBe('https://example.com/callback?error=access_denied&state=state123');
});
it('should throw for invalid redirect URI', () => {
const params = new URLSearchParams('');
const { buildCallbackUrl } = useApiKeyAuthorization(params);
expect(() => buildCallbackUrl('not-a-url', 'key')).toThrow('Invalid redirect URI');
});
});
});
@@ -0,0 +1,342 @@
import { describe, it, expect } from 'vitest';
import { useAuthorizationLink } from '~/composables/useAuthorizationLink.js';
import { Role, Resource, AuthAction } from '~/composables/gql/graphql.js';
// Mock window.location for the tests
Object.defineProperty(window, 'location', {
value: {
search: '',
},
writable: true,
});
describe('useAuthorizationLink', () => {
it('should convert role scopes to form data', () => {
const params = new URLSearchParams({
name: 'MyApp',
description: 'My test application',
scopes: 'role:admin,role:viewer',
redirect_uri: 'https://example.com/callback',
});
const { formData, displayAppName, hasPermissions, permissionsSummary } = useAuthorizationLink(params);
expect(formData.value).toEqual({
name: 'MyApp',
description: 'My test application',
roles: [Role.ADMIN, Role.VIEWER],
customPermissions: [],
});
expect(displayAppName.value).toBe('MyApp');
expect(hasPermissions.value).toBe(true);
expect(permissionsSummary.value).toBe('2 role(s)');
});
it('should group resources by their action sets', () => {
const params = new URLSearchParams({
name: 'Docker Manager',
scopes: 'docker:read_any,docker:update_any,vms:read_any',
});
const { formData, hasPermissions, permissionsSummary } = useAuthorizationLink(params);
// docker has read_any+update_any, vms only has read_any - these should be separate groups
expect(formData.value.customPermissions!).toHaveLength(2);
// Find the group with just READ_ANY
const readOnlyGroup = formData.value.customPermissions!.find(
p => p.actions.length === 1 && p.actions[0] === AuthAction.READ_ANY
);
expect(readOnlyGroup).toBeDefined();
expect(readOnlyGroup?.resources).toEqual([Resource.VMS]);
// Find the group with READ_ANY and UPDATE_ANY
const readUpdateGroup = formData.value.customPermissions!.find(
p => p.actions.length === 2 &&
p.actions.includes(AuthAction.READ_ANY) &&
p.actions.includes(AuthAction.UPDATE_ANY)
);
expect(readUpdateGroup).toBeDefined();
expect(readUpdateGroup?.resources).toEqual([Resource.DOCKER]);
expect(hasPermissions.value).toBe(true);
expect(permissionsSummary.value).toBe('3 permission(s)');
});
it('should handle mixed role and permission scopes', () => {
const params = new URLSearchParams({
name: 'Mixed Access App',
scopes: 'role:admin,docker:read_any,vms:*',
});
const { formData, hasPermissions, permissionsSummary } = useAuthorizationLink(params);
expect(formData.value.roles).toEqual([Role.ADMIN]);
expect(formData.value.customPermissions!).toHaveLength(2);
// Docker should have just read permission
const dockerGroup = formData.value.customPermissions!.find(
p => p.resources.includes(Resource.DOCKER)
);
expect(dockerGroup).toBeDefined();
expect(dockerGroup?.actions).toEqual([AuthAction.READ_ANY]);
// VMs should have all CRUD permissions from wildcard
const vmsGroup = formData.value.customPermissions!.find(
p => p.resources.includes(Resource.VMS)
);
expect(vmsGroup).toBeDefined();
expect(vmsGroup?.actions).toContain(AuthAction.CREATE_ANY);
expect(vmsGroup?.actions).toContain(AuthAction.READ_ANY);
expect(vmsGroup?.actions).toContain(AuthAction.UPDATE_ANY);
expect(vmsGroup?.actions).toContain(AuthAction.DELETE_ANY);
expect(hasPermissions.value).toBe(true);
expect(permissionsSummary.value).toBe('1 role(s), 2 permission(s)');
});
it('should handle wildcard permissions correctly', () => {
const params = new URLSearchParams({
name: 'Full Access App',
scopes: 'docker:*',
});
const { hasPermissions, permissionsSummary } = useAuthorizationLink(params);
expect(hasPermissions.value).toBe(true);
expect(permissionsSummary.value).toBe('1 permission(s)');
});
it('should handle empty scopes gracefully', () => {
const params = new URLSearchParams({
name: 'No Permissions App',
scopes: '',
});
const { formData, hasPermissions, permissionsSummary } = useAuthorizationLink(params);
expect(formData.value).toEqual({
name: 'No Permissions App',
description: '',
roles: [],
customPermissions: [],
});
expect(hasPermissions.value).toBe(false);
expect(permissionsSummary.value).toBe('');
});
it('should handle app names ending with " API Key"', () => {
const params = new URLSearchParams({
name: 'MyApp API Key',
scopes: 'role:viewer',
});
const { formData, displayAppName } = useAuthorizationLink(params);
expect(displayAppName.value).toBe('MyApp');
// Name should be used as-is without appending
expect(formData.value.name).toBe('MyApp API Key');
});
it('should handle invalid scopes gracefully', () => {
const params = new URLSearchParams({
name: 'Invalid Scopes App',
scopes: 'role:invalid_role,unknown_resource:read,docker:invalid_action',
});
const { hasPermissions, permissionsSummary } = useAuthorizationLink(params);
expect(hasPermissions.value).toBe(true); // Has scopes, even if invalid
expect(permissionsSummary.value).toBe('1 role(s), 2 permission(s)');
});
it('should use default values when parameters are missing', () => {
const params = new URLSearchParams(); // Empty params
const { formData, displayAppName } = useAuthorizationLink(params);
expect(formData.value.name).toBe('Unknown Application');
expect(displayAppName.value).toBe('Unknown Application');
});
describe('permission grouping and preservation', () => {
it('should group multiple resources with same actions into single permission group', () => {
const params = new URLSearchParams({
name: 'Multi-Resource Reader',
scopes: 'connect:read_any,disk:read_any,docker:read_any',
});
const { formData } = useAuthorizationLink(params);
// All have same action (read), so should be in one group
expect(formData.value.customPermissions!).toHaveLength(1);
expect(formData.value.customPermissions![0]).toEqual({
resources: [Resource.CONNECT, Resource.DISK, Resource.DOCKER],
actions: [AuthAction.READ_ANY],
});
});
it('should create separate groups for resources with different action sets', () => {
const params = new URLSearchParams({
name: 'Mixed Actions App',
scopes: 'docker:read_any,docker:update_any,vms:create_any,vms:delete_any',
});
const { formData } = useAuthorizationLink(params);
// Docker has read+update, VMs has create+delete - these should be separate
expect(formData.value.customPermissions!).toHaveLength(2);
const dockerGroup = formData.value.customPermissions!.find(
p => p.resources.includes(Resource.DOCKER)
);
expect(dockerGroup).toBeDefined();
expect(dockerGroup?.actions).toContain(AuthAction.READ_ANY);
expect(dockerGroup?.actions).toContain(AuthAction.UPDATE_ANY);
const vmsGroup = formData.value.customPermissions!.find(
p => p.resources.includes(Resource.VMS)
);
expect(vmsGroup).toBeDefined();
expect(vmsGroup?.actions).toContain(AuthAction.CREATE_ANY);
expect(vmsGroup?.actions).toContain(AuthAction.DELETE_ANY);
});
it('should handle duplicate scopes correctly', () => {
const params = new URLSearchParams({
name: 'Duplicate Scopes App',
scopes: 'docker:read_any,docker:read_any,vms:update_any,vms:update_any',
});
const { formData } = useAuthorizationLink(params);
// Docker has read, VMs has update - different actions so separate groups
expect(formData.value.customPermissions!).toHaveLength(2);
const readGroup = formData.value.customPermissions!.find(
p => p.actions.includes(AuthAction.READ_ANY)
);
expect(readGroup?.resources).toEqual([Resource.DOCKER]);
const updateGroup = formData.value.customPermissions!.find(
p => p.actions.includes(AuthAction.UPDATE_ANY)
);
expect(updateGroup?.resources).toEqual([Resource.VMS]);
});
it('should preserve wildcard expansion for resources', () => {
const params = new URLSearchParams({
name: 'Wildcard App',
scopes: 'docker:*,vms:read_any',
});
const { formData } = useAuthorizationLink(params);
// Docker has all CRUD, VMs has just read - different action sets so separate groups
expect(formData.value.customPermissions!).toHaveLength(2);
const dockerGroup = formData.value.customPermissions!.find(
p => p.resources.includes(Resource.DOCKER)
);
expect(dockerGroup).toBeDefined();
// Should have all CRUD actions from wildcard
expect(dockerGroup?.actions).toContain(AuthAction.CREATE_ANY);
expect(dockerGroup?.actions).toContain(AuthAction.READ_ANY);
expect(dockerGroup?.actions).toContain(AuthAction.UPDATE_ANY);
expect(dockerGroup?.actions).toContain(AuthAction.DELETE_ANY);
const vmsGroup = formData.value.customPermissions!.find(
p => p.resources.includes(Resource.VMS)
);
expect(vmsGroup).toBeDefined();
expect(vmsGroup?.actions).toEqual([AuthAction.READ_ANY]);
});
it('should handle complex permission combinations', () => {
const params = new URLSearchParams({
name: 'Complex Permissions App',
scopes: 'connect:read_any,disk:read_any,docker:*,vms:update_any,vms:delete_any,dashboard:read_any',
});
const { formData } = useAuthorizationLink(params);
// Should group by action sets:
// - connect, disk, dashboard all have just read (group 1)
// - docker has all CRUD from wildcard (group 2)
// - vms has update+delete (group 3)
expect(formData.value.customPermissions!).toHaveLength(3);
// Find read-only group (connect, disk, dashboard)
const readOnlyGroup = formData.value.customPermissions!.find(
p => p.actions.length === 1 && p.actions[0] === AuthAction.READ_ANY
);
expect(readOnlyGroup).toBeDefined();
expect(readOnlyGroup?.resources).toContain(Resource.CONNECT);
expect(readOnlyGroup?.resources).toContain(Resource.DISK);
expect(readOnlyGroup?.resources).toContain(Resource.DASHBOARD);
// Find full CRUD group (docker with wildcard)
const fullCrudGroup = formData.value.customPermissions!.find(
p => p.actions.length === 4 && p.resources.includes(Resource.DOCKER)
);
expect(fullCrudGroup).toBeDefined();
expect(fullCrudGroup?.actions).toContain(AuthAction.CREATE_ANY);
expect(fullCrudGroup?.actions).toContain(AuthAction.READ_ANY);
expect(fullCrudGroup?.actions).toContain(AuthAction.UPDATE_ANY);
expect(fullCrudGroup?.actions).toContain(AuthAction.DELETE_ANY);
// Find update+delete group (vms)
const updateDeleteGroup = formData.value.customPermissions!.find(
p => p.resources.includes(Resource.VMS)
);
expect(updateDeleteGroup).toBeDefined();
expect(updateDeleteGroup?.actions).toContain(AuthAction.UPDATE_ANY);
expect(updateDeleteGroup?.actions).toContain(AuthAction.DELETE_ANY);
});
});
describe('efficient scope encoding', () => {
it('should decode grouped scopes correctly', () => {
const params = new URLSearchParams({
name: 'Grouped App',
scopes: 'docker+vms:read_any+update_any',
});
const { formData } = useAuthorizationLink(params);
// Should decode to a single group with both resources and both actions
expect(formData.value.customPermissions!).toHaveLength(1);
expect(formData.value.customPermissions![0].resources).toContain(Resource.DOCKER);
expect(formData.value.customPermissions![0].resources).toContain(Resource.VMS);
expect(formData.value.customPermissions![0].actions).toContain(AuthAction.READ_ANY);
expect(formData.value.customPermissions![0].actions).toContain(AuthAction.UPDATE_ANY);
});
it('should handle mixed grouped and individual scopes', () => {
const params = new URLSearchParams({
name: 'Mixed Grouped App',
scopes: 'docker+vms:read_any,dashboard:update_any',
});
const { formData } = useAuthorizationLink(params);
// Should have two groups: docker+vms with read, dashboard with update
expect(formData.value.customPermissions!).toHaveLength(2);
const readGroup = formData.value.customPermissions!.find(
p => p.actions.includes(AuthAction.READ_ANY)
);
expect(readGroup).toBeDefined();
expect(readGroup?.resources).toContain(Resource.DOCKER);
expect(readGroup?.resources).toContain(Resource.VMS);
const updateGroup = formData.value.customPermissions!.find(
p => p.actions.includes(AuthAction.UPDATE_ANY)
);
expect(updateGroup).toBeDefined();
expect(updateGroup?.resources).toEqual([Resource.DASHBOARD]);
});
});
});
+423 -212
View File
@@ -1,50 +1,99 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useMutation, useQuery } from '@vue/apollo-composable';
import { useClipboardWithToast } from '~/composables/useClipboardWithToast';
import {
import { ClipboardDocumentIcon } from '@heroicons/vue/24/solid';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
Dialog,
Input,
Label,
Select,
Button,
Dialog,
jsonFormsAjv,
jsonFormsRenderers
} from '@unraid/ui';
import { JsonForms } from '@jsonforms/vue';
import { extractGraphQLErrorMessage } from '~/helpers/functions';
import type { ApolloError } from '@apollo/client/errors';
import type { FragmentType } from '~/composables/gql/fragment-masking';
import type { Resource, Role } from '~/composables/gql/graphql';
import type {
ApiKeyFormSettings,
AuthAction,
CreateApiKeyInput,
Resource,
Role,
} from '~/composables/gql/graphql';
import type { ComposerTranslation } from 'vue-i18n';
import { useFragment } from '~/composables/gql/fragment-masking';
import { useApiKeyPermissionPresets } from '~/composables/useApiKeyPermissionPresets';
import { useApiKeyStore } from '~/store/apiKey';
import {
API_KEY_FRAGMENT,
API_KEY_FRAGMENT_WITH_KEY,
CREATE_API_KEY,
GET_API_KEY_META,
UPDATE_API_KEY,
} from './apikey.query';
import PermissionCounter from './PermissionCounter.vue';
import { GET_API_KEY_CREATION_FORM_SCHEMA } from './api-key-form.query';
import { API_KEY_FRAGMENT, CREATE_API_KEY, UPDATE_API_KEY } from './apikey.query';
import DeveloperAuthorizationLink from './DeveloperAuthorizationLink.vue';
import EffectivePermissions from './EffectivePermissions.vue';
defineProps<{ t: ComposerTranslation }>();
interface Props {
t?: ComposerTranslation;
}
const props = defineProps<Props>();
const { t } = props;
const apiKeyStore = useApiKeyStore();
const { modalVisible, editingKey } = storeToRefs(apiKeyStore);
const { modalVisible, editingKey, isAuthorizationMode, authorizationData, createdKey } =
storeToRefs(apiKeyStore);
const { result: apiKeyMetaResult } = useQuery(GET_API_KEY_META);
const possibleRoles = computed(() => apiKeyMetaResult.value?.apiKeyPossibleRoles || []);
const possiblePermissions = computed(() => apiKeyMetaResult.value?.apiKeyPossiblePermissions || []);
// Form data that matches what the backend expects
// This will be transformed into CreateApiKeyInput or UpdateApiKeyInput
interface FormData extends Partial<CreateApiKeyInput> {
keyName?: string; // Used in authorization mode
authorizationType?: 'roles' | 'groups' | 'custom';
permissionGroups?: string[];
permissionPresets?: string; // For the preset dropdown
customPermissions?: Array<{
resources: Resource[];
actions: AuthAction[];
}>;
requestedPermissions?: {
roles?: Role[];
permissionGroups?: string[];
customPermissions?: Array<{
resources: Resource[];
actions: AuthAction[];
}>;
};
consent?: boolean;
}
const formSchema = ref<ApiKeyFormSettings | null>(null);
const formData = ref<FormData>({
customPermissions: [],
roles: [],
authorizationType: 'roles',
} as FormData);
const formValid = ref(false);
// Use clipboard for copying
const { copyWithNotification, copied } = useClipboardWithToast();
// Computed property to transform formData permissions for the EffectivePermissions component
const formDataPermissions = computed(() => {
if (!formData.value.customPermissions) return [];
// Flatten the resources array into individual permission entries
return formData.value.customPermissions.flatMap((perm) =>
perm.resources.map((resource) => ({
resource,
actions: perm.actions, // Already string[] which can be AuthAction values
}))
);
});
const newKeyName = ref('');
const newKeyDescription = ref('');
const newKeyRoles = ref<Role[]>([]);
const newKeyPermissions = ref<{ resource: Resource; actions: string[] }[]>([]);
const { mutate: createApiKey, loading: createLoading, error: createError } = useMutation(CREATE_API_KEY);
const { mutate: updateApiKey, loading: updateLoading, error: updateError } = useMutation(UPDATE_API_KEY);
const postCreateLoading = ref(false);
@@ -52,154 +101,302 @@ const postCreateLoading = ref(false);
const loading = computed<boolean>(() => createLoading.value || updateLoading.value);
const error = computed<ApolloError | null>(() => createError.value || updateError.value);
// Computed property for button disabled state
const isButtonDisabled = computed<boolean>(() => {
// In authorization mode, only check loading states if we have a name
if (isAuthorizationMode.value && (formData.value.name || authorizationData.value?.formData?.name)) {
return loading.value || postCreateLoading.value;
}
// Regular validation for non-authorization mode
return loading.value || postCreateLoading.value || !formValid.value;
});
// Load form schema - always use creation form
const loadFormSchema = () => {
// Always load creation form schema
const { onResult, onError } = useQuery(GET_API_KEY_CREATION_FORM_SCHEMA);
onResult(async (result) => {
if (result.data?.getApiKeyCreationFormSchema) {
formSchema.value = result.data.getApiKeyCreationFormSchema;
if (isAuthorizationMode.value && authorizationData.value?.formData) {
// In authorization mode, use the form data from the authorization store
formData.value = { ...authorizationData.value.formData };
// Ensure the name field is set for validation
if (!formData.value.name && authorizationData.value.name) {
formData.value.name = authorizationData.value.name;
}
// In auth mode, if we have all required fields, consider it valid initially
// JsonForms will override this if there are actual errors
if (formData.value.name) {
formValid.value = true;
}
} else if (editingKey.value) {
// If editing, populate form data from existing key
populateFormFromExistingKey();
} else {
// For new keys, initialize with empty data
formData.value = {
customPermissions: [],
};
// Set formValid to true initially for new keys
// JsonForms will update this if there are validation errors
formValid.value = true;
}
}
});
onError((error) => {
console.error('Error loading creation form schema:', error);
});
};
// Initialize form on mount
onMounted(() => {
loadFormSchema();
});
// Watch for editing key changes
watch(
() => editingKey.value,
(key) => {
const fragmentKey = key
? useFragment(API_KEY_FRAGMENT, key as FragmentType<typeof API_KEY_FRAGMENT>)
: null;
if (fragmentKey) {
newKeyName.value = fragmentKey.name;
newKeyDescription.value = fragmentKey.description || '';
newKeyRoles.value = [...fragmentKey.roles];
newKeyPermissions.value = fragmentKey.permissions
? fragmentKey.permissions.map((p) => ({
resource: p.resource as Resource,
actions: [...p.actions],
}))
: [];
} else {
newKeyName.value = '';
newKeyDescription.value = '';
newKeyRoles.value = [];
newKeyPermissions.value = [];
() => {
if (!isAuthorizationMode.value) {
populateFormFromExistingKey();
}
},
{ immediate: true }
}
);
function togglePermission(resource: string, action: string, checked: boolean) {
const res = resource as Resource;
const perm = newKeyPermissions.value.find((p) => p.resource === res);
if (checked) {
if (perm) {
if (!perm.actions.includes(action)) perm.actions.push(action);
} else {
newKeyPermissions.value.push({ resource: res, actions: [action] });
}
} else {
if (perm) {
perm.actions = perm.actions.filter((a) => a !== action);
if (perm.actions.length === 0) {
newKeyPermissions.value = newKeyPermissions.value.filter((p) => p.resource !== res);
// Watch for authorization mode changes
watch(
() => isAuthorizationMode.value,
async (newValue) => {
if (newValue && authorizationData.value?.formData) {
formData.value = { ...authorizationData.value.formData };
// Ensure the name field is set for validation
if (!formData.value.name && authorizationData.value.name) {
formData.value.name = authorizationData.value.name;
}
// Set initial valid state if we have required fields
if (formData.value.name) {
formValid.value = true;
}
}
}
}
);
function areAllPermissionsSelected() {
return possiblePermissions.value.every((perm) => {
const selected = newKeyPermissions.value.find((p) => p.resource === perm.resource)?.actions || [];
return perm.actions.every((a) => selected.includes(a));
});
}
// Watch for authorization form data changes
watch(
() => authorizationData.value?.formData,
(newFormData) => {
if (isAuthorizationMode.value && newFormData) {
formData.value = { ...newFormData };
// Ensure the name field is set for validation
if (!formData.value.name && authorizationData.value?.name) {
formData.value.name = authorizationData.value.name;
}
}
},
{ deep: true }
);
function selectAllPermissions() {
newKeyPermissions.value = possiblePermissions.value.map((perm) => ({
resource: perm.resource as Resource,
actions: [...perm.actions],
}));
}
// Use the permission presets composable
const { applyPreset } = useApiKeyPermissionPresets();
function clearAllPermissions() {
newKeyPermissions.value = [];
}
// Watch for permission preset selection and expand into custom permissions
watch(
() => formData.value.permissionPresets,
(presetId) => {
if (!presetId || presetId === 'none') return;
function areAllActionsSelected(resource: string) {
const perm = possiblePermissions.value.find((p) => p.resource === resource);
if (!perm) return false;
const selected = newKeyPermissions.value.find((p) => p.resource === resource)?.actions || [];
return perm.actions.every((a) => selected.includes(a));
}
// Apply the preset to custom permissions
formData.value.customPermissions = applyPreset(presetId, formData.value.customPermissions);
function selectAllActions(resource: string) {
const res = resource as Resource;
const perm = possiblePermissions.value.find((p) => p.resource === res);
if (!perm) return;
const idx = newKeyPermissions.value.findIndex((p) => p.resource === res);
if (idx !== -1) {
newKeyPermissions.value[idx].actions = [...perm.actions];
} else {
newKeyPermissions.value.push({ resource: res, actions: [...perm.actions] });
// Reset the dropdown back to 'none'
formData.value.permissionPresets = 'none';
}
}
);
function clearAllActions(resource: string) {
newKeyPermissions.value = newKeyPermissions.value.filter((p) => p.resource !== resource);
}
// Populate form data from existing key
const populateFormFromExistingKey = async () => {
if (!editingKey.value || !formSchema.value) return;
const fragmentKey = useFragment(
API_KEY_FRAGMENT,
editingKey.value as FragmentType<typeof API_KEY_FRAGMENT>
);
if (fragmentKey) {
// Group permissions by actions for better UI
const permissionGroups = new Map<string, Resource[]>();
if (fragmentKey.permissions) {
for (const perm of fragmentKey.permissions) {
// Create a copy of the actions array to avoid modifying read-only data
const actionKey = [...perm.actions].sort().join(',');
if (!permissionGroups.has(actionKey)) {
permissionGroups.set(actionKey, []);
}
permissionGroups.get(actionKey)!.push(perm.resource);
}
}
const customPermissions = Array.from(permissionGroups.entries()).map(([actionKey, resources]) => ({
resources,
actions: actionKey.split(',') as AuthAction[], // Actions are now already in correct format
}));
formData.value = {
name: fragmentKey.name,
description: fragmentKey.description || '',
authorizationType: fragmentKey.roles.length > 0 ? 'roles' : 'custom',
roles: [...fragmentKey.roles],
customPermissions,
};
}
};
// Transform form data to API format
const transformFormDataForApi = (): CreateApiKeyInput => {
const apiData: CreateApiKeyInput = {
name: formData.value.name || formData.value.keyName || '',
description: formData.value.description,
roles: [],
permissions: undefined,
};
// Both authorization and regular mode now use the same form structure
if (formData.value.roles && formData.value.roles.length > 0) {
apiData.roles = formData.value.roles;
}
// Note: permissionGroups would need to be handled by backend
// The CreateApiKeyInput doesn't have permissionGroups field yet
// For now, we could expand them client-side by querying the permissions
// or add backend support to handle permission groups
// Always include permissions array, even if empty (for updates to clear permissions)
if (formData.value.customPermissions) {
// Expand resources array into individual AddPermissionInput entries
apiData.permissions = formData.value.customPermissions.flatMap((perm) =>
perm.resources.map((resource) => ({
resource,
actions: perm.actions,
}))
);
} else {
// If customPermissions is undefined or null, and we're editing,
// we should still send an empty array to clear permissions
if (editingKey.value) {
apiData.permissions = [];
}
}
// Note: expiresAt field would need to be added to CreateApiKeyInput type
// if (formData.value.expiresAt) {
// apiData.expiresAt = formData.value.expiresAt;
// }
return apiData;
};
const close = () => {
apiKeyStore.hideModal();
formData.value = {} as FormData; // Reset to empty object
};
// Handle form submission
async function upsertKey() {
// In authorization mode, skip validation if we have a name
if (!isAuthorizationMode.value && !formValid.value) {
return;
}
if (isAuthorizationMode.value && !formData.value.name) {
console.error('Cannot authorize without a name');
return;
}
// In authorization mode, validation is enough - no separate consent field
postCreateLoading.value = true;
try {
const apiData = transformFormDataForApi();
const isEdit = !!editingKey.value?.id;
let res;
if (isEdit && editingKey.value) {
res = await updateApiKey({
input: {
id: editingKey.value.id,
name: newKeyName.value,
description: newKeyDescription.value,
roles: newKeyRoles.value,
permissions: newKeyPermissions.value.length ? newKeyPermissions.value : undefined,
...apiData,
},
});
} else {
res = await createApiKey({
input: {
name: newKeyName.value,
description: newKeyDescription.value,
roles: newKeyRoles.value,
permissions: newKeyPermissions.value.length ? newKeyPermissions.value : undefined,
},
input: apiData,
});
}
const apiKeyResult = res?.data?.apiKey;
if (isEdit && apiKeyResult && 'update' in apiKeyResult) {
const fragmentData = useFragment(API_KEY_FRAGMENT_WITH_KEY, apiKeyResult.update);
const fragmentData = useFragment(API_KEY_FRAGMENT, apiKeyResult.update);
apiKeyStore.setCreatedKey(fragmentData);
} else if (!isEdit && apiKeyResult && 'create' in apiKeyResult) {
const fragmentData = useFragment(API_KEY_FRAGMENT_WITH_KEY, apiKeyResult.create);
const fragmentData = useFragment(API_KEY_FRAGMENT, apiKeyResult.create);
apiKeyStore.setCreatedKey(fragmentData);
// If in authorization mode, call the callback with the API key
if (isAuthorizationMode.value && authorizationData.value?.onAuthorize && 'key' in fragmentData) {
authorizationData.value.onAuthorize(fragmentData.key);
// Don't close the modal or reset form - let the callback handle it
return;
}
}
modalVisible.value = false;
editingKey.value = null;
newKeyName.value = '';
newKeyDescription.value = '';
newKeyRoles.value = [];
newKeyPermissions.value = [];
apiKeyStore.hideModal();
formData.value = {} as FormData; // Reset to empty object
} catch (error) {
console.error('Error in upsertKey:', error);
} finally {
postCreateLoading.value = false;
}
}
// Copy API key after creation
const copyApiKey = async () => {
if (createdKey.value && 'key' in createdKey.value) {
await copyWithNotification(createdKey.value.key, 'API key copied to clipboard');
}
};
</script>
<template>
<!-- Modal mode (handles both regular creation and authorization) -->
<Dialog
v-if="modalVisible"
v-model="modalVisible"
size="lg"
:title="editingKey ? t('Edit API Key') : t('Create API Key')"
size="xl"
:title="
isAuthorizationMode
? 'Authorize API Key Access'
: editingKey
? t
? t('Edit API Key')
: 'Edit API Key'
: t
? t('Create API Key')
: 'Create API Key'
"
:scrollable="true"
close-button-text="Cancel"
:primary-button-text="editingKey ? 'Save' : 'Create'"
:primary-button-text="isAuthorizationMode ? 'Authorize' : editingKey ? 'Save' : 'Create'"
:primary-button-loading="loading || postCreateLoading"
:primary-button-loading-text="editingKey ? 'Saving...' : 'Creating...'"
:primary-button-disabled="loading || postCreateLoading"
:primary-button-loading-text="
isAuthorizationMode ? 'Authorizing...' : editingKey ? 'Saving...' : 'Creating...'
"
:primary-button-disabled="isButtonDisabled"
@update:model-value="
(v) => {
if (!v) close();
@@ -207,103 +404,117 @@ async function upsertKey() {
"
@primary-click="upsertKey"
>
<div class="max-w-[800px]">
<form @submit.prevent="upsertKey">
<div class="mb-2">
<Label for="api-key-name">Name</Label>
<Input id="api-key-name" v-model="newKeyName" placeholder="Name" class="mt-1" />
<div class="w-full">
<!-- Show authorization description if in authorization mode -->
<div
v-if="isAuthorizationMode && formSchema?.dataSchema?.description"
class="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg"
>
<p class="text-sm">{{ formSchema.dataSchema.description }}</p>
</div>
<!-- Dynamic Form based on schema -->
<div
v-if="formSchema"
class="[&_.vertical-layout]:space-y-4"
@click.stop
@mousedown.stop
@focus.stop
>
<JsonForms
:schema="formSchema.dataSchema"
:uischema="formSchema.uiSchema"
:renderers="jsonFormsRenderers"
:data="formData"
:ajv="jsonFormsAjv"
@change="
({ data, errors }) => {
formData = data;
formValid = errors ? errors.length === 0 : true;
}
"
/>
</div>
<!-- Loading state -->
<div v-else class="flex items-center justify-center py-8">
<div class="text-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" />
<p class="text-sm text-muted-foreground">Loading form...</p>
</div>
<div class="mb-2">
<Label for="api-key-desc">Description</Label>
<Input id="api-key-desc" v-model="newKeyDescription" placeholder="Description" class="mt-1" />
</div>
<div class="mb-2">
<Label for="api-key-roles">Roles</Label>
<Select
v-model="newKeyRoles"
:items="possibleRoles"
:multiple="true"
:placeholder="'Select Roles'"
class="mt-1 w-full"
/>
</div>
<div class="mb-2">
<Label for="api-key-permissions">Permissions</Label>
<Accordion id="api-key-permissions" type="single" collapsible class="w-full mt-2">
<AccordionItem value="permissions">
<AccordionTrigger>
<PermissionCounter
:permissions="newKeyPermissions"
:possible-permissions="possiblePermissions"
/>
</AccordionTrigger>
<AccordionContent>
<div class="flex flex-row justify-end my-2">
<Button
size="sm"
variant="outline"
type="button"
@click="areAllPermissionsSelected() ? clearAllPermissions() : selectAllPermissions()"
>
{{ areAllPermissionsSelected() ? 'Select None' : 'Select All' }}
</Button>
</div>
<div class="flex flex-col gap-2 mt-1">
<div
v-for="perm in possiblePermissions"
:key="perm.resource"
class="rounded-sm p-2 border"
>
<div class="flex items-center justify-between mb-1">
<span class="font-semibold">{{ perm.resource }}</span>
<Button
size="sm"
variant="link"
type="button"
@click="
areAllActionsSelected(perm.resource)
? clearAllActions(perm.resource)
: selectAllActions(perm.resource)
"
>
{{ areAllActionsSelected(perm.resource) ? 'Select None' : 'Select All' }}
</Button>
</div>
<div class="flex gap-4 flex-wrap">
<label
v-for="action in perm.actions"
:key="action"
class="flex items-center gap-1"
>
<input
type="checkbox"
:checked="
!!newKeyPermissions.find(
(p) => p.resource === perm.resource && p.actions.includes(action)
)
"
@change="
(e: Event) =>
togglePermission(
perm.resource,
action,
(e.target as HTMLInputElement)?.checked
)
"
>
<span class="text-sm">{{ action }}</span>
</label>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
<div v-if="error" class="text-red-500 mt-2 text-sm">
</div>
<!-- Error display -->
<div v-if="error" class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg">
<p class="text-sm text-red-600 dark:text-red-400">
{{ extractGraphQLErrorMessage(error) }}
</p>
</div>
<!-- Permissions Preview -->
<div class="mt-6 p-4 bg-muted/50 rounded-lg border border-muted">
<EffectivePermissions
:roles="formData.roles || []"
:raw-permissions="formDataPermissions"
:show-header="true"
/>
<!-- Show selected roles for context -->
<div
v-if="formData.roles && formData.roles.length > 0"
class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"
>
<div class="text-xs text-gray-600 dark:text-gray-400 mb-1">Selected Roles:</div>
<div class="flex flex-wrap gap-1">
<span
v-for="role in formData.roles"
:key="role"
class="px-2 py-1 bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-300 rounded text-xs"
>
{{ role }}
</span>
</div>
</div>
</form>
</div>
<!-- Developer Tools Accordion (hide in authorization flow) -->
<div v-if="!isAuthorizationMode" class="mt-4">
<Accordion type="single" collapsible class="w-full">
<AccordionItem value="developer-tools">
<AccordionTrigger>
<span class="text-sm font-semibold">Developer Tools</span>
</AccordionTrigger>
<AccordionContent>
<div class="py-2">
<DeveloperAuthorizationLink
:roles="formData.roles || []"
:raw-permissions="formDataPermissions"
:app-name="formData.name || 'My Application'"
:app-description="formData.description || 'API key for my application'"
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
<!-- Success state for authorization mode -->
<div
v-if="isAuthorizationMode && createdKey && 'key' in createdKey"
class="mt-4 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg"
>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium">API Key created successfully!</span>
<Button type="button" variant="ghost" size="sm" @click="copyApiKey">
<ClipboardDocumentIcon class="w-4 h-4 mr-2" />
{{ copied ? 'Copied!' : 'Copy Key' }}
</Button>
</div>
<code class="block mt-2 p-2 bg-white dark:bg-gray-800 rounded text-xs break-all border">
{{ createdKey.key }}
</code>
<p class="text-xs text-muted-foreground mt-2">Save this key securely for your application.</p>
</div>
</div>
</Dialog>
</template>
+219 -88
View File
@@ -2,9 +2,10 @@
import { ref, watchEffect } from 'vue';
import { storeToRefs } from 'pinia';
import { useMutation, useQuery } from '@vue/apollo-composable';
import { useClipboard } from '@vueuse/core';
import { useClipboardWithToast } from '~/composables/useClipboardWithToast';
import type { AuthAction, ApiKeyFragment, Role } from '~/composables/gql/graphql';
import { ClipboardDocumentIcon, EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/solid';
import { ClipboardDocumentIcon, EyeIcon, EyeSlashIcon, ChevronDownIcon, LinkIcon } from '@heroicons/vue/24/solid';
import {
Accordion,
AccordionContent,
@@ -13,6 +14,10 @@ import {
Badge,
Button,
CardWrapper,
DropdownMenuRoot,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Input,
PageContainer,
Tooltip,
@@ -22,30 +27,33 @@ import {
} from '@unraid/ui';
import { extractGraphQLErrorMessage } from '~/helpers/functions';
import type { ApiKeyFragment, ApiKeyWithKeyFragment } from '~/composables/gql/graphql';
import { useFragment } from '~/composables/gql/fragment-masking';
import { useApiKeyStore } from '~/store/apiKey';
import { API_KEY_FRAGMENT, DELETE_API_KEY, GET_API_KEY_META, GET_API_KEYS } from './apikey.query';
import PermissionCounter from './PermissionCounter.vue';
import EffectivePermissions from '~/components/ApiKey/EffectivePermissions.vue';
import { generateScopes } from '~/utils/authorizationLink';
const { result, refetch } = useQuery(GET_API_KEYS);
const apiKeyStore = useApiKeyStore();
const { createdKey } = storeToRefs(apiKeyStore);
const apiKeys = ref<(ApiKeyFragment | ApiKeyWithKeyFragment)[]>([]);
const apiKeys = ref<ApiKeyFragment[]>([]);
watchEffect(() => {
const baseKeys: (ApiKeyFragment | ApiKeyWithKeyFragment)[] =
const baseKeys: ApiKeyFragment[] =
result.value?.apiKeys.map((key) => useFragment(API_KEY_FRAGMENT, key)) || [];
console.log(createdKey.value);
if (createdKey.value) {
const existingKeyIndex = baseKeys.findIndex((key) => key.id === createdKey.value?.id);
if (existingKeyIndex >= 0) {
baseKeys[existingKeyIndex] = createdKey.value as ApiKeyFragment | ApiKeyWithKeyFragment;
baseKeys[existingKeyIndex] = createdKey.value;
} else {
baseKeys.unshift(createdKey.value as ApiKeyFragment | ApiKeyWithKeyFragment);
baseKeys.unshift(createdKey.value);
}
// Don't automatically show keys - keep them hidden by default
}
apiKeys.value = baseKeys;
@@ -53,14 +61,23 @@ watchEffect(() => {
const metaQuery = useQuery(GET_API_KEY_META);
const possibleRoles = ref<string[]>([]);
const possiblePermissions = ref<{ resource: string; actions: string[] }[]>([]);
const possiblePermissions = ref<{ resource: string; actions: AuthAction[] }[]>([]);
watchEffect(() => {
possibleRoles.value = metaQuery.result.value?.apiKeyPossibleRoles || [];
possiblePermissions.value = metaQuery.result.value?.apiKeyPossiblePermissions || [];
// Cast actions to AuthAction[] since GraphQL returns string[] but we know they're AuthAction values
possiblePermissions.value = (metaQuery.result.value?.apiKeyPossiblePermissions || []).map(p => ({
resource: p.resource,
actions: p.actions as AuthAction[]
}));
});
const showKey = ref<Record<string, boolean>>({});
const { copy, copied } = useClipboard();
const { copyWithNotification, copied } = useClipboardWithToast();
// Template input state
const showTemplateInput = ref(false);
const templateUrl = ref('');
const templateError = ref('');
const { mutate: deleteKey } = useMutation(DELETE_API_KEY);
@@ -70,11 +87,57 @@ function toggleShowKey(keyId: string) {
showKey.value[keyId] = !showKey.value[keyId];
}
function openCreateModal(key: ApiKeyFragment | ApiKeyWithKeyFragment | null = null) {
function openCreateModal(key: ApiKeyFragment | ApiKeyFragment | null = null) {
apiKeyStore.clearCreatedKey();
apiKeyStore.showModal(key as ApiKeyFragment | null);
}
function openCreateFromTemplate() {
showTemplateInput.value = true;
templateUrl.value = '';
templateError.value = '';
}
function cancelTemplateInput() {
showTemplateInput.value = false;
templateUrl.value = '';
templateError.value = '';
}
function applyTemplate() {
templateError.value = '';
try {
// Parse the template URL or query string
let url: URL;
if (templateUrl.value.startsWith('http://') || templateUrl.value.startsWith('https://')) {
// Full URL provided
url = new URL(templateUrl.value);
} else if (templateUrl.value.startsWith('?')) {
// Query string only
url = new URL(window.location.origin + templateUrl.value);
} else {
// Try to parse as query string without ?
url = new URL(window.location.origin + '?' + templateUrl.value);
}
// Extract query parameters
const params = url.searchParams;
// Navigate to the authorization page with these params using window.location
const authUrl = new URL('/Tools/ApiKeyAuthorize', window.location.origin);
params.forEach((value, key) => {
authUrl.searchParams.append(key, value);
});
window.location.href = authUrl.toString();
cancelTemplateInput();
} catch (_err) {
templateError.value = 'Invalid template URL or query string. Please check the format and try again.';
}
}
async function _deleteKey(_id: string) {
if (!window.confirm('Are you sure you want to delete this API key? This action cannot be undone.'))
return;
@@ -87,13 +150,40 @@ async function _deleteKey(_id: string) {
}
}
function hasKey(key: ApiKeyFragment | ApiKeyWithKeyFragment): key is ApiKeyWithKeyFragment {
return 'key' in key && !!key.key;
async function copyKeyValue(keyValue: string) {
await copyWithNotification(keyValue, 'API key copied to clipboard');
}
async function copyKeyValue(keyValue: string) {
await copy(keyValue);
async function copyKeyTemplate(key: ApiKeyFragment) {
try {
// Generate scopes using the same logic as DeveloperAuthorizationLink
const scopes = generateScopes(
key.roles as Role[] || [],
key.permissions?.map(p => ({
resource: p.resource,
actions: p.actions as AuthAction[]
})) || []
);
// Build URL parameters for the template
const urlParams = new URLSearchParams({
name: key.name,
scopes: scopes.join(','),
});
if (key.description) {
urlParams.set('description', key.description);
}
// Don't include redirect_uri for templates
const templateQueryString = '?' + urlParams.toString();
await copyWithNotification(templateQueryString, 'Template copied to clipboard');
} catch (error) {
console.error('Failed to copy template:', error);
}
}
</script>
<template>
@@ -101,7 +191,22 @@ async function copyKeyValue(keyValue: string) {
<div>
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold tracking-tight">API Keys</h2>
<Button variant="primary" @click="openCreateModal(null)">Create API Key</Button>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<Button variant="primary">
Create API Key
<ChevronDownIcon class="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="openCreateModal(null)">
Create New
</DropdownMenuItem>
<DropdownMenuItem @click="openCreateFromTemplate">
Create from Template
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuRoot>
</div>
<div
v-if="deleteError"
@@ -109,98 +214,124 @@ async function copyKeyValue(keyValue: string) {
>
{{ deleteError }}
</div>
<ul v-if="apiKeys.length" class="flex flex-col gap-4 mb-6">
<CardWrapper v-for="key in apiKeys" :key="key.id">
<li class="flex flex-row items-start justify-between gap-4 p-4 list-none">
<div class="flex-1 min-w-0">
<header class="flex gap-2 justify-between items-start">
<div class="flex flex-col gap-2">
<span class="text-sm truncate"><b>ID:</b> {{ key.id.split(':')[1] }}</span>
<span class="text-sm truncate"><b>Name:</b> {{ key.name }}</span>
<span v-if="key.description" class="text-sm truncate"
><b>Description:</b> {{ key.description }}</span
>
<div v-if="key.roles.length" class="flex flex-wrap gap-2 items-center">
<span class="text-sm"><b>Roles:</b></span>
<Badge v-for="role in key.roles" :key="role" variant="blue" size="xs">{{
role
}}</Badge>
<div v-if="apiKeys.length" class="flex flex-col gap-4 mb-6">
<div v-for="key in apiKeys" :key="key.id" class="w-full">
<CardWrapper :padding="false">
<div class="p-4 overflow-hidden">
<div class="flex flex-col gap-2">
<div class="text-sm truncate max-w-[250px] md:max-w-md"><b>ID:</b> {{ key.id.split(':')[1] }}</div>
<div class="text-sm"><b>Name:</b> {{ key.name }}</div>
<div v-if="key.description" class="text-sm"
><b>Description:</b> {{ key.description }}</div>
<div v-if="key.roles.length" class="flex flex-wrap gap-2 items-center">
<span class="text-sm"><b>Roles:</b></span>
<Badge v-for="role in key.roles" :key="role" variant="blue" size="xs">{{
role
}}</Badge>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-green-700 font-medium"><b>API Key:</b></span>
<div class="relative flex-1 max-w-[300px]">
<Input
:model-value="showKey[key.id] ? key.key : '••••••••••••••••••••••••••••••••'"
class="w-full font-mono text-xs px-2 py-1 rounded pr-10"
readonly
/>
<button
type="button"
class="absolute inset-y-0 right-2 flex items-center px-1 text-gray-500 hover:text-gray-700"
tabindex="-1"
@click="toggleShowKey(key.id)"
>
<component :is="showKey[key.id] ? EyeSlashIcon : EyeIcon" class="w-5 h-5" />
</button>
</div>
<TooltipProvider>
<Tooltip :delay-duration="0">
<TooltipTrigger>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="copyKeyValue(key.key)">
<ClipboardDocumentIcon class="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ copied ? 'Copied!' : 'Copy to clipboard...' }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div class="flex gap-2 shrink-0">
<Button variant="secondary" size="sm" @click="openCreateModal(key)">Edit</Button>
<Button variant="destructive" size="sm" @click="_deleteKey(key.id)">Delete</Button>
</div>
</header>
<div v-if="key.permissions?.length" class="pt-2 w-full">
<span class="text-sm"><b>Permissions:</b></span>
<Accordion type="single" collapsible class="w-full">
</div>
<div v-if="key.permissions?.length || key.roles?.length" class="mt-4 pt-4 border-t">
<Accordion
type="single"
collapsible
class="w-full"
>
<AccordionItem :value="'permissions-' + key.id">
<AccordionTrigger>
<PermissionCounter
:permissions="key.permissions"
:possible-permissions="possiblePermissions"
/>
<span class="text-sm font-semibold">Effective Permissions</span>
</AccordionTrigger>
<AccordionContent>
<div v-if="key.permissions?.length" class="flex flex-col gap-2 my-2">
<div
v-for="perm in key.permissions ?? []"
:key="perm.resource"
class="border rounded-sm p-2"
>
<div class="flex items-center gap-2 justify-between">
<span class="font-semibold">{{ perm.resource }}</span>
<PermissionCounter
:permissions="[perm]"
:possible-permissions="possiblePermissions"
:hide-number="true"
/>
</div>
</div>
<div class="py-2 overflow-auto">
<EffectivePermissions
:roles="key.roles"
:raw-permissions="key.permissions?.map(p => ({
resource: p.resource,
actions: p.actions
})) || []"
:show-header="false"
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
<div v-if="hasKey(key)" class="mt-4 flex items-center gap-2">
<span class="text-green-700 font-medium">API Key:</span>
<div class="relative w-64">
<Input
:model-value="showKey[key.id] ? key.key : '••••••••••••••••••••••••••••••••'"
class="w-full font-mono text-base px-2 py-1 rounded pr-10"
readonly
/>
<button
type="button"
class="absolute inset-y-0 right-2 flex items-center px-1 text-gray-500 hover:text-gray-700"
tabindex="-1"
@click="toggleShowKey(key.id)"
>
<component :is="showKey[key.id] ? EyeSlashIcon : EyeIcon" class="w-5 h-5" />
</button>
</div>
<div class="mt-4 pt-4 border-t flex flex-wrap gap-2">
<Button variant="secondary" size="sm" @click="openCreateModal(key)">Edit</Button>
<TooltipProvider>
<Tooltip :delay-duration="0">
<TooltipTrigger>
<Button variant="ghost" size="icon" @click="copyKeyValue(key.key)">
<ClipboardDocumentIcon class="w-5 h-5" />
<Button variant="outline" size="sm" @click="copyKeyTemplate(key)">
<LinkIcon class="w-4 h-4 mr-1" />
Copy Template
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ copied ? 'Copied!' : 'Copy to clipboard...' }}</p>
<p>Copy a shareable template with these permissions</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button variant="destructive" size="sm" @click="_deleteKey(key.id)">Delete</Button>
</div>
</div>
</li>
</CardWrapper>
</ul>
<ul v-else class="flex flex-col gap-4 mb-6">
<li class="text-sm">No API keys found</li>
</ul>
</CardWrapper>
</div>
</div>
<div v-else class="flex flex-col gap-4 mb-6">
<p class="text-sm">No API keys found</p>
</div>
<!-- Template Input Dialog -->
<div v-if="showTemplateInput" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-background rounded-lg p-6 max-w-lg w-full mx-4">
<h3 class="text-lg font-semibold mb-4">Create from Template</h3>
<p class="text-sm text-muted-foreground mb-4">
Paste a template URL or query string to pre-fill the API key creation form with permissions.
</p>
<Input
v-model="templateUrl"
placeholder="Paste template URL or query string (e.g., ?name=MyApp&scopes=role:admin)"
class="mb-4"
@keydown.enter="applyTemplate"
/>
<div v-if="templateError" class="mb-4 p-3 rounded border border-destructive bg-destructive/10 text-destructive text-sm">
{{ templateError }}
</div>
<div class="flex gap-3 justify-end">
<Button variant="outline" @click="cancelTemplateInput">Cancel</Button>
<Button variant="primary" @click="applyTemplate">Apply Template</Button>
</div>
</div>
</div>
</div>
</PageContainer>
</template>
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Button, Input, Switch } from '@unraid/ui';
import { ClipboardDocumentIcon, LinkIcon } from '@heroicons/vue/24/outline';
import { generateAuthorizationUrl } from '~/utils/authorizationLink';
import { useClipboardWithToast } from '~/composables/useClipboardWithToast';
import type { Role, AuthAction } from '~/composables/gql/graphql';
interface RawPermission {
resource: string;
actions: AuthAction[];
}
interface Props {
roles?: Role[];
rawPermissions?: RawPermission[];
appName?: string;
appDescription?: string;
redirectUrl?: string;
show?: boolean;
isAuthorizationMode?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
roles: () => [],
rawPermissions: () => [],
appName: 'CliInternal',
appDescription: '',
redirectUrl: '',
show: true,
isAuthorizationMode: false,
});
// State for UI interactions
const copySuccess = ref(false);
const copyTemplateSuccess = ref(false);
const showUrl = ref(false);
const showTemplate = ref(false);
const useCustomCallback = ref(false);
const customCallbackUrl = ref('');
// Use clipboard composable
const { copyWithNotification } = useClipboardWithToast();
// Reset custom callback URL when checkbox is unchecked
watch(useCustomCallback, (newValue) => {
if (!newValue) {
customCallbackUrl.value = '';
}
});
// Computed property for the effective redirect URL
const effectiveRedirectUrl = computed(() => {
if (useCustomCallback.value && customCallbackUrl.value) {
return customCallbackUrl.value;
}
return props.redirectUrl;
});
// Computed property for authorization URL
const authorizationUrl = computed(() => {
if (!props.show) {
return '';
}
return generateAuthorizationUrl({
appName: props.appName,
appDescription: props.appDescription,
roles: props.roles,
rawPermissions: props.rawPermissions,
redirectUrl: effectiveRedirectUrl.value,
});
});
// Computed property for template query string (without redirect_uri)
const templateQueryString = computed(() => {
if (!props.show) {
return '';
}
// Generate URL without redirect_uri for template sharing
const url = generateAuthorizationUrl({
appName: props.appName,
appDescription: props.appDescription,
roles: props.roles,
rawPermissions: props.rawPermissions,
redirectUrl: '', // Empty redirect URL for templates
});
// Extract just the query string part
const urlObj = new URL(url, window.location.origin);
const params = new URLSearchParams(urlObj.search);
params.delete('redirect_uri'); // Remove redirect_uri from template
return '?' + params.toString();
});
// Check if there are any permissions to show
const hasPermissions = computed(() => {
return props.roles.length > 0 || props.rawPermissions.length > 0;
});
// Function to copy authorization URL
const handleCopy = async () => {
const success = await copyWithNotification(
authorizationUrl.value,
'Authorization URL copied to clipboard'
);
if (success) {
copySuccess.value = true;
setTimeout(() => {
copySuccess.value = false;
}, 2000);
}
};
// Function to toggle URL visibility
const toggleShowUrl = () => {
showUrl.value = !showUrl.value;
showTemplate.value = false; // Hide template when showing URL
};
// Function to toggle template visibility
const toggleShowTemplate = () => {
showTemplate.value = !showTemplate.value;
showUrl.value = false; // Hide URL when showing template
};
// Function to copy template query string
const copyTemplate = async () => {
const success = await copyWithNotification(
templateQueryString.value,
'Template copied to clipboard'
);
if (success) {
copyTemplateSuccess.value = true;
setTimeout(() => {
copyTemplateSuccess.value = false;
}, 2000);
}
};
</script>
<template>
<div v-if="show" class="space-y-3">
<div>
<h4 class="text-sm font-medium mb-2">Developer Authorization Link</h4>
<div v-if="!hasPermissions" class="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg mb-3">
<p class="text-sm text-amber-800 dark:text-amber-200">
No permissions selected. Add roles or permissions above to generate an authorization link.
</p>
</div>
<div v-else class="flex flex-wrap gap-2">
<Button variant="outline" size="sm" @click="toggleShowUrl">
<LinkIcon class="w-4 h-4 mr-1" />
{{ showUrl ? 'Hide' : 'Show' }} URL
</Button>
<Button variant="outline" size="sm" @click="handleCopy">
<ClipboardDocumentIcon class="w-4 h-4 mr-1" />
{{ copySuccess ? 'Copied!' : 'Copy URL' }}
</Button>
<Button variant="outline" size="sm" @click="toggleShowTemplate">
<LinkIcon class="w-4 h-4 mr-1" />
{{ showTemplate ? 'Hide' : 'Show' }} Template
</Button>
<Button variant="outline" size="sm" @click="copyTemplate">
<ClipboardDocumentIcon class="w-4 h-4 mr-1" />
{{ copyTemplateSuccess ? 'Copied!' : 'Copy Template' }}
</Button>
</div>
</div>
<p v-if="hasPermissions" class="text-sm text-muted-foreground">
Use this link to create an API key authorization for <strong>{{ appName }}</strong> with the selected permissions.
Perfect for testing your app's OAuth-style API key flow.
</p>
<div v-if="!isAuthorizationMode" class="flex items-center gap-2 mt-3">
<Switch
id="custom-callback"
v-model="useCustomCallback"
/>
<label for="custom-callback" class="text-sm font-medium cursor-pointer">
Use custom callback URL
</label>
</div>
<div v-if="!isAuthorizationMode && useCustomCallback" class="mt-2">
<Input
v-model="customCallbackUrl"
type="url"
placeholder="https://example.com/callback"
class="w-full"
/>
<p class="text-xs text-muted-foreground mt-1">
Enter the URL where users will be redirected after authorization
</p>
</div>
<div v-if="showUrl" class="p-3 bg-secondary rounded border border-muted mt-3">
<p class="text-xs text-muted-foreground mb-2">Full authorization URL with callback:</p>
<code class="text-xs break-all text-foreground">
{{ authorizationUrl }}
</code>
</div>
<div v-if="showTemplate" class="p-3 bg-secondary rounded border border-muted mt-3">
<p class="text-xs text-muted-foreground mb-2">Template query string (for sharing without callback):</p>
<code class="text-xs break-all text-foreground">
{{ templateQueryString }}
</code>
<p class="text-xs text-muted-foreground mt-2">
This template can be used with "Create from Template" to pre-fill permissions without a callback URL.
</p>
</div>
</div>
</template>
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { useLazyQuery } from '@vue/apollo-composable';
import { Badge } from '@unraid/ui';
import { PREVIEW_EFFECTIVE_PERMISSIONS } from './permissions-preview.query';
import type { AuthAction, Role, PreviewEffectivePermissionsQuery } from '~/composables/gql/graphql';
interface RawPermission {
resource: string;
actions: AuthAction[];
}
interface Props {
roles?: Role[];
rawPermissions?: RawPermission[];
showHeader?: boolean;
headerText?: string;
}
const props = withDefaults(defineProps<Props>(), {
roles: () => [],
rawPermissions: () => [],
showHeader: true,
headerText: 'Effective Permissions',
});
// Query for effective permissions
const { load: loadEffectivePermissions, loading, result } = useLazyQuery<PreviewEffectivePermissionsQuery>(PREVIEW_EFFECTIVE_PERMISSIONS);
// Computed property for effective permissions from the result
const effectivePermissions = computed(() => {
return result.value?.previewEffectivePermissions || [];
});
// Format action for display - show the actual enum value or formatted string
const formatAction = (action: string): string => {
if (action === '*') return 'ALL ACTIONS';
// If it's already an enum value like CREATE_ANY, READ_ANY, show as-is
if (action.includes('_')) {
return action; // Keep the original enum format
}
// If it's in scope format like 'create:any' or just 'create', format for display
if (action.includes(':')) {
return action.split(':')[0].toUpperCase() + ':' + action.split(':')[1].toUpperCase();
}
// For simple verbs, uppercase them
return action.toUpperCase();
};
// Watch for changes to roles and permissions and reload
watch(
() => ({
roles: props.roles,
rawPermissions: props.rawPermissions,
}),
async ({ roles, rawPermissions }) => {
// Skip if no roles or permissions
if ((!roles || roles.length === 0) && (!rawPermissions || rawPermissions.length === 0)) {
return;
}
try {
// Transform permissions to the format expected by the query
const permissions = rawPermissions?.map(perm => ({
resource: perm.resource,
actions: perm.actions
})) || [];
// Call load with the parameters
await loadEffectivePermissions(null, {
roles: roles || [],
permissions: permissions.length > 0 ? permissions : undefined,
});
} catch (error) {
console.error('Failed to load effective permissions:', error);
}
},
{ immediate: true, deep: true }
);
</script>
<template>
<div class="w-full">
<h3 v-if="showHeader" class="text-sm font-semibold mb-3 flex items-center gap-2">
{{ headerText }}
<span v-if="loading" class="text-xs text-muted-foreground">(loading...)</span>
</h3>
<!-- Show effective permissions -->
<div v-if="effectivePermissions.length > 0 && !loading" class="space-y-2">
<div class="text-xs text-muted-foreground mb-2">
These are the actual permissions that will be granted based on selected roles and custom permissions:
</div>
<div class="space-y-2 max-h-64 overflow-y-auto">
<div
v-for="perm in effectivePermissions"
:key="perm.resource"
class="text-xs bg-background p-2 rounded border border-muted"
>
<div class="flex items-center gap-2 mb-1">
<span class="font-medium">{{ perm.resource }}</span>
</div>
<div class="flex flex-wrap gap-1">
<Badge
v-for="action in perm.actions"
:key="action"
variant="green"
size="xs"
>
{{ formatAction(action) }}
</Badge>
</div>
</div>
</div>
</div>
<!-- Show loading state -->
<div v-else-if="loading" class="text-xs text-muted-foreground">
Loading permissions...
</div>
<!-- Show message when no permissions selected -->
<div v-else class="text-xs text-muted-foreground italic">
No permissions selected yet
</div>
</div>
</template>
+3 -2
View File
@@ -4,11 +4,12 @@ import { computed } from 'vue';
import { Badge } from '@unraid/ui';
import { actionVariant } from './actionVariant.js';
import type { AuthAction } from '~/composables/gql/graphql';
const props = withDefaults(
defineProps<{
permissions: { resource: string; actions: string[] }[];
possiblePermissions?: { resource: string; actions: string[] }[];
permissions: { resource: string; actions: AuthAction[] }[];
possiblePermissions?: { resource: string; actions: AuthAction[] }[];
hideNumber?: boolean;
label?: string;
}>(),
@@ -0,0 +1,13 @@
import { graphql } from '~/composables/gql';
export const GET_API_KEY_CREATION_FORM_SCHEMA = graphql(`
query GetApiKeyCreationFormSchema {
getApiKeyCreationFormSchema {
id
dataSchema
uiSchema
values
}
}
`);
+13 -16
View File
@@ -2,20 +2,6 @@ import { graphql } from '~/composables/gql/gql';
export const API_KEY_FRAGMENT = graphql(/* GraphQL */ `
fragment ApiKey on ApiKey {
id
name
description
createdAt
roles
permissions {
resource
actions
}
}
`);
export const API_KEY_FRAGMENT_WITH_KEY = graphql(/* GraphQL */ `
fragment ApiKeyWithKey on ApiKeyWithSecret {
id
key
name
@@ -29,6 +15,8 @@ export const API_KEY_FRAGMENT_WITH_KEY = graphql(/* GraphQL */ `
}
`);
export const API_KEY_FRAGMENT_WITH_KEY = API_KEY_FRAGMENT;
export const GET_API_KEYS = graphql(/* GraphQL */ `
query ApiKeys {
apiKeys {
@@ -41,7 +29,7 @@ export const CREATE_API_KEY = graphql(/* GraphQL */ `
mutation CreateApiKey($input: CreateApiKeyInput!) {
apiKey {
create(input: $input) {
...ApiKeyWithKey
...ApiKey
}
}
}
@@ -51,7 +39,7 @@ export const UPDATE_API_KEY = graphql(/* GraphQL */ `
mutation UpdateApiKey($input: UpdateApiKeyInput!) {
apiKey {
update(input: $input) {
...ApiKeyWithKey
...ApiKey
}
}
}
@@ -74,3 +62,12 @@ export const GET_API_KEY_META = graphql(/* GraphQL */ `
}
}
`);
export const PREVIEW_EFFECTIVE_PERMISSIONS = graphql(/* GraphQL */ `
query PreviewEffectivePermissions($roles: [Role!], $permissions: [AddPermissionInput!]) {
previewEffectivePermissions(roles: $roles, permissions: $permissions) {
resource
actions
}
}
`);
@@ -0,0 +1,19 @@
import gql from 'graphql-tag';
export const PREVIEW_EFFECTIVE_PERMISSIONS = gql`
query PreviewEffectivePermissions($roles: [Role!], $permissions: [AddPermissionInput!]) {
previewEffectivePermissions(roles: $roles, permissions: $permissions) {
resource
actions
}
}
`;
export const GET_PERMISSIONS_FOR_ROLES = gql`
query GetPermissionsForRoles($roles: [Role!]!) {
getPermissionsForRoles(roles: $roles) {
resource
actions
}
}
`;
+299
View File
@@ -0,0 +1,299 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Button, Input } from '@unraid/ui';
import { useClipboardWithToast } from '~/composables/useClipboardWithToast.js';
import { ClipboardDocumentIcon, EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia';
import { useAuthorizationLink } from '~/composables/useAuthorizationLink.js';
import { useApiKeyStore } from '~/store/apiKey.js';
// Use the composables for authorization logic
const {
authParams,
hasValidRedirectUri,
buildCallbackUrl,
formData: authorizationFormData,
displayAppName,
hasPermissions,
permissionsSummary,
} = useAuthorizationLink();
// Use the API key store to control the global modal
const apiKeyStore = useApiKeyStore();
const { createdKey, modalVisible } = storeToRefs(apiKeyStore);
// Component state
const showSuccess = ref(false);
const createdApiKey = ref('');
const error = ref('');
const showKey = ref(false);
// Use clipboard for copying
const { copyWithNotification, copied } = useClipboardWithToast();
// Watch for modal close to restore success view
watch(modalVisible, (isVisible) => {
if (!isVisible && createdKey.value && createdApiKey.value) {
// Modal was closed, restore success view after editing
showSuccess.value = true;
}
});
// Toggle key visibility
const toggleShowKey = () => {
showKey.value = !showKey.value;
};
// Copy API key
const copyApiKey = async () => {
if (createdApiKey.value) {
await copyWithNotification(createdApiKey.value, 'API key copied to clipboard');
}
};
// Open the authorization modal
const openAuthorizationModal = () => {
// Set up authorization parameters in the store
apiKeyStore.setAuthorizationMode(
authParams.value.name,
authParams.value.description || `API key for ${displayAppName.value}`,
authParams.value.scopes,
handleAuthorize,
authorizationFormData.value
);
// Show the modal
apiKeyStore.showModal();
};
// Handle authorization success
const handleAuthorize = (apiKey: string) => {
createdApiKey.value = apiKey;
showSuccess.value = true;
apiKeyStore.hideModal();
// No automatic redirect - user must click the button
};
// Open the edit modal for the created key
const modifyApiKey = () => {
if (createdKey.value) {
// Open the modal in edit mode with the created key
apiKeyStore.showModal(createdKey.value);
// Don't clear states - the watchers will handle the flow
}
};
// Handle denial
const deny = () => {
if (hasValidRedirectUri.value) {
try {
const url = buildCallbackUrl(undefined, 'access_denied');
window.location.href = url;
} catch {
window.location.href = '/';
}
} else {
window.location.href = '/';
}
};
// Return to app with API key
const returnToApp = () => {
if (!hasValidRedirectUri.value || !createdApiKey.value) return;
try {
const url = buildCallbackUrl(createdApiKey.value, undefined);
window.location.href = url;
} catch (_err) {
error.value = 'Failed to redirect back to application';
}
};
</script>
<template>
<div class="w-full max-w-4xl mx-auto p-6">
<!-- Success state -->
<div v-if="showSuccess && createdApiKey" class="w-full bg-background rounded-lg shadow-sm border border-muted">
<!-- Header -->
<div class="p-6 pb-4 border-b border-muted">
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center flex-shrink-0">
<svg class="h-5 w-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">API Key Created Successfully</h3>
<p class="text-sm text-muted-foreground">
Your API key for <strong>{{ displayAppName }}</strong> has been created
</p>
</div>
</div>
</div>
<!-- Content -->
<div class="p-6 space-y-4">
<!-- API Key section -->
<div>
<label class="text-sm font-medium text-muted-foreground mb-2 block">Generated API Key</label>
<div class="p-3 bg-secondary rounded-lg">
<div class="flex gap-2 mb-2">
<div class="relative flex-1">
<Input
:model-value="showKey ? createdApiKey : '••••••••••••••••••••••••••••••••'"
class="font-mono text-sm pr-10 bg-background"
readonly
/>
<button
type="button"
class="absolute inset-y-0 right-2 flex items-center px-1 text-muted-foreground hover:text-foreground"
@click="toggleShowKey"
>
<component :is="showKey ? EyeSlashIcon : EyeIcon" class="w-4 h-4" />
</button>
</div>
<Button
variant="outline"
size="icon"
@click="copyApiKey"
>
<ClipboardDocumentIcon class="w-4 h-4" />
</Button>
</div>
<p class="text-xs text-muted-foreground">
{{ copied ? '✓ Copied to clipboard' : hasValidRedirectUri ? 'Save this key securely for your application.' : 'Save this key securely. You can now use it in your application.' }}
</p>
</div>
</div>
<!-- Redirect info if available, or template info -->
<div v-if="hasValidRedirectUri">
<label class="text-sm font-medium text-muted-foreground mb-2 block">Next Step</label>
<div class="p-3 bg-secondary rounded-lg">
<p class="text-sm">
Send this API key to complete the authorization
</p>
<p class="text-xs text-muted-foreground mt-1">
Destination: <code class="bg-background px-1.5 py-0.5 rounded">{{ authParams.redirectUri }}</code>
</p>
</div>
</div>
<div v-else>
<label class="text-sm font-medium text-muted-foreground mb-2 block">Template Applied</label>
<div class="p-3 bg-secondary rounded-lg">
<p class="text-sm">
API key created from template with the configured permissions
</p>
<p class="text-xs text-muted-foreground mt-1">
You can manage this key from the API Keys settings page
</p>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="p-6 pt-2 flex gap-3">
<Button
variant="outline"
class="flex-1"
@click="modifyApiKey"
>
Modify API Key
</Button>
<Button
v-if="hasValidRedirectUri"
variant="primary"
class="flex-1"
@click="returnToApp"
>
Send Key to {{ authParams.name }}
</Button>
</div>
</div>
<!-- Authorization form using ApiKeyCreate component -->
<div v-else class="w-full bg-background rounded-lg shadow-sm border border-muted">
<!-- Header -->
<div class="p-6 pb-4 border-b border-muted">
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-full bg-blue-100 dark:bg-blue-900/20 flex items-center justify-center flex-shrink-0">
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">{{ hasValidRedirectUri ? 'API Key Authorization Request' : 'Create API Key from Template' }}</h3>
<p class="text-sm text-muted-foreground">
<span v-if="hasValidRedirectUri">
<strong>{{ displayAppName }}</strong> is requesting API access to your Unraid server
</span>
<span v-else>
Create an API key for <strong>{{ displayAppName }}</strong> with pre-configured permissions
</span>
</p>
</div>
</div>
</div>
<!-- Content -->
<div class="p-6 space-y-4">
<!-- Permissions section -->
<div>
<label class="text-sm font-medium text-muted-foreground mb-2 block">
{{ hasValidRedirectUri ? 'Requested Permissions' : 'Template Permissions' }}
</label>
<div v-if="hasPermissions" class="p-3 bg-secondary rounded-lg">
<p class="text-sm">{{ permissionsSummary }}</p>
</div>
<div v-else class="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<p class="text-sm text-amber-800 dark:text-amber-200">
<span v-if="hasValidRedirectUri">
No specific permissions requested. The application may be requesting basic access.
</span>
<span v-else>
No specific permissions defined in this template.
</span>
</p>
</div>
</div>
<!-- Redirect info if available -->
<div v-if="hasValidRedirectUri">
<label class="text-sm font-medium text-muted-foreground mb-2 block">After Authorization</label>
<div class="p-3 bg-secondary rounded-lg">
<p class="text-sm">
You will need to confirm and send the API key to the application
</p>
<p class="text-xs text-muted-foreground mt-1">
Destination: <code class="bg-background px-1.5 py-0.5 rounded">{{ authParams.redirectUri }}</code>
</p>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="p-6 pt-2 flex gap-3">
<Button
variant="outline"
class="flex-1"
@click="deny"
>
Cancel
</Button>
<Button
variant="primary"
class="flex-1"
@click="openAuthorizationModal"
>
{{ hasValidRedirectUri ? 'Review Permissions & Authorize' : 'Review Permissions' }}
</Button>
</div>
</div>
<!-- Error message -->
<div v-if="error" class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p class="text-red-800 dark:text-red-200">{{ error }}</p>
</div>
</div>
</template>
+25 -18
View File
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { reactive, watch } from 'vue';
import { Input, Label, Select, Switch } from '@unraid/ui';
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent, Input, Label, Select, Switch } from '@unraid/ui';
import { defaultColors } from '~/themes/default';
import type { Theme } from '~/themes/types';
@@ -49,28 +49,35 @@ const items = [
</script>
<template>
<div class="flex flex-col gap-2 border-solid border-2 p-2 border-r-2">
<h1 class="text-lg">Color Theme Customization</h1>
<Accordion>
<AccordionItem value="color-theme-customization">
<AccordionTrigger>Color Theme Customization</AccordionTrigger>
<AccordionContent>
<div class="flex flex-col gap-2 border-solid border-2 p-2 border-r-2">
<h1 class="text-lg">Color Theme Customization</h1>
<Label for="theme-select">Theme</Label>
<Select v-model="form.selectedTheme" :items="items" placeholder="Select a theme" />
<Label for="theme-select">Theme</Label>
<Select v-model="form.selectedTheme" :items="items" placeholder="Select a theme" />
<Label for="primary-text-color">Header Primary Text Color</Label>
<Input id="primary-text-color" v-model="form.textPrimary" />
<Label for="primary-text-color">Header Primary Text Color</Label>
<Input id="primary-text-color" v-model="form.textPrimary" />
<Label for="secondary-text-color">Header Secondary Text Color</Label>
<Input id="secondary-text-color" v-model="form.textSecondary" />
<Label for="secondary-text-color">Header Secondary Text Color</Label>
<Input id="secondary-text-color" v-model="form.textSecondary" />
<Label for="background-color">Header Background Color</Label>
<Input id="background-color" v-model="form.bgColor" />
<Label for="background-color">Header Background Color</Label>
<Input id="background-color" v-model="form.bgColor" />
<Label for="gradient">Gradient</Label>
<Switch id="gradient" v-model:checked="form.gradient" />
<Label for="gradient">Gradient</Label>
<Switch id="gradient" v-model:checked="form.gradient" />
<Label for="description">Description</Label>
<Switch id="description" v-model:checked="form.description" />
<Label for="description">Description</Label>
<Switch id="description" v-model:checked="form.description" />
<Label for="banner">Banner</Label>
<Switch id="banner" v-model:checked="form.banner" />
</div>
<Label for="banner">Banner</Label>
<Switch id="banner" v-model:checked="form.banner" />
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</template>

Some files were not shown because too many files have changed in this diff Show More