mirror of
https://github.com/unraid/api.git
synced 2025-12-30 21:19:49 -06:00
fix: OIDC and API Key management issues (#1642)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.17.0",
|
||||
"version": "4.18.0",
|
||||
"extraOrigins": [],
|
||||
"sandbox": true,
|
||||
"ssoSubIds": [],
|
||||
|
||||
252
api/docs/public/programmatic-api-key-management.md
Normal file
252
api/docs/public/programmatic-api-key-management.md
Normal file
@@ -0,0 +1,252 @@
|
||||
---
|
||||
title: Programmatic API Key Management
|
||||
description: Create, use, and delete API keys programmatically for automated workflows
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
# Programmatic API Key Management
|
||||
|
||||
This guide explains how to create, use, and delete API keys programmatically using the Unraid API CLI, enabling automated workflows and scripts.
|
||||
|
||||
## Overview
|
||||
|
||||
The `unraid-api apikey` command supports both interactive and non-interactive modes, making it suitable for:
|
||||
|
||||
- Automated deployment scripts
|
||||
- CI/CD pipelines
|
||||
- Temporary access provisioning
|
||||
- Infrastructure as code workflows
|
||||
|
||||
:::tip[Quick Start]
|
||||
Jump to the [Complete Workflow Example](#complete-workflow-example) to see everything in action.
|
||||
:::
|
||||
|
||||
## Creating API Keys Programmatically
|
||||
|
||||
### Basic Creation with JSON Output
|
||||
|
||||
Use the `--json` flag to get machine-readable output:
|
||||
|
||||
```bash
|
||||
unraid-api apikey --create --name "workflow key" --roles ADMIN --json
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "your-generated-api-key-here",
|
||||
"name": "workflow key",
|
||||
"id": "generated-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Creation with Permissions
|
||||
|
||||
```bash
|
||||
unraid-api apikey --create \
|
||||
--name "limited access key" \
|
||||
--permissions "DOCKER:READ_ANY,ARRAY:READ_ANY" \
|
||||
--description "Read-only access for monitoring" \
|
||||
--json
|
||||
```
|
||||
|
||||
### Handling Existing Keys
|
||||
|
||||
If a key with the same name exists, use `--overwrite`:
|
||||
|
||||
```bash
|
||||
unraid-api apikey --create --name "existing key" --roles ADMIN --overwrite --json
|
||||
```
|
||||
|
||||
:::warning[Key Replacement]
|
||||
The `--overwrite` flag will permanently replace the existing key. The old key will be immediately invalidated.
|
||||
:::
|
||||
|
||||
## Deleting API Keys Programmatically
|
||||
|
||||
### Non-Interactive Deletion
|
||||
|
||||
Delete a key by name without prompts:
|
||||
|
||||
```bash
|
||||
unraid-api apikey --delete --name "workflow key"
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```
|
||||
Successfully deleted 1 API key
|
||||
```
|
||||
|
||||
### JSON Output for Deletion
|
||||
|
||||
Use `--json` flag for machine-readable delete confirmation:
|
||||
|
||||
```bash
|
||||
unraid-api apikey --delete --name "workflow key" --json
|
||||
```
|
||||
|
||||
**Success Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"deleted": 1,
|
||||
"keys": [
|
||||
{
|
||||
"id": "generated-uuid",
|
||||
"name": "workflow key"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Error Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"deleted": 0,
|
||||
"error": "No API key found with name: nonexistent key"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
When the specified key doesn't exist:
|
||||
|
||||
```bash
|
||||
unraid-api apikey --delete --name "nonexistent key"
|
||||
# Output: No API keys found to delete
|
||||
```
|
||||
|
||||
**JSON Error Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"deleted": 0,
|
||||
"message": "No API keys found to delete"
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Workflow Example
|
||||
|
||||
Here's a complete example for temporary access provisioning:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# 1. Create temporary API key
|
||||
echo "Creating temporary API key..."
|
||||
KEY_DATA=$(unraid-api apikey --create \
|
||||
--name "temp deployment key" \
|
||||
--roles ADMIN \
|
||||
--description "Temporary key for deployment $(date)" \
|
||||
--json)
|
||||
|
||||
# 2. Extract the API key
|
||||
API_KEY=$(echo "$KEY_DATA" | jq -r '.key')
|
||||
echo "API key created successfully"
|
||||
|
||||
# 3. Use the key for operations
|
||||
echo "Configuring services..."
|
||||
curl -H "Authorization: Bearer $API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"provider": "azure", "clientId": "your-client-id"}' \
|
||||
http://localhost:3001/graphql
|
||||
|
||||
# 4. Clean up (always runs, even on error)
|
||||
trap 'echo "Cleaning up..."; unraid-api apikey --delete --name "temp deployment key"' EXIT
|
||||
|
||||
echo "Deployment completed successfully"
|
||||
```
|
||||
|
||||
## Command Reference
|
||||
|
||||
### Create Command Options
|
||||
|
||||
| Flag | Description | Example |
|
||||
| ----------------------- | ----------------------- | --------------------------------- |
|
||||
| `--name <name>` | Key name (required) | `--name "my key"` |
|
||||
| `--roles <roles>` | Comma-separated roles | `--roles ADMIN,VIEWER` |
|
||||
| `--permissions <perms>` | Resource:action pairs | `--permissions "DOCKER:READ_ANY"` |
|
||||
| `--description <desc>` | Key description | `--description "CI/CD key"` |
|
||||
| `--overwrite` | Replace existing key | `--overwrite` |
|
||||
| `--json` | Machine-readable output | `--json` |
|
||||
|
||||
### Available Roles
|
||||
|
||||
- `ADMIN` - Full system access
|
||||
- `CONNECT` - Unraid Connect features
|
||||
- `VIEWER` - Read-only access
|
||||
- `GUEST` - Limited access
|
||||
|
||||
### Available Resources and Actions
|
||||
|
||||
**Resources:** `ACTIVATION_CODE`, `API_KEY`, `ARRAY`, `CLOUD`, `CONFIG`, `CONNECT`, `CONNECT__REMOTE_ACCESS`, `CUSTOMIZATIONS`, `DASHBOARD`, `DISK`, `DISPLAY`, `DOCKER`, `FLASH`, `INFO`, `LOGS`, `ME`, `NETWORK`, `NOTIFICATIONS`, `ONLINE`, `OS`, `OWNER`, `PERMISSION`, `REGISTRATION`, `SERVERS`, `SERVICES`, `SHARE`, `VARS`, `VMS`, `WELCOME`
|
||||
|
||||
**Actions:** `CREATE_ANY`, `CREATE_OWN`, `READ_ANY`, `READ_OWN`, `UPDATE_ANY`, `UPDATE_OWN`, `DELETE_ANY`, `DELETE_OWN`
|
||||
|
||||
### Delete Command Options
|
||||
|
||||
| Flag | Description | Example |
|
||||
| --------------- | ------------------------ | ----------------- |
|
||||
| `--delete` | Enable delete mode | `--delete` |
|
||||
| `--name <name>` | Key to delete (optional) | `--name "my key"` |
|
||||
|
||||
**Note:** If `--name` is omitted, the command runs interactively.
|
||||
|
||||
## Best Practices
|
||||
|
||||
:::info[Security Best Practices]
|
||||
**Minimal Permissions**
|
||||
|
||||
- Use specific permissions instead of ADMIN role when possible
|
||||
- Example: `--permissions "DOCKER:READ_ANY"` instead of `--roles ADMIN`
|
||||
|
||||
**Key Lifecycle Management**
|
||||
|
||||
- Always clean up temporary keys after use
|
||||
- Store API keys securely (environment variables, secrets management)
|
||||
- Use descriptive names and descriptions for audit trails
|
||||
:::
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Check exit codes (`$?`) after each command
|
||||
- Use `set -e` in bash scripts to fail fast
|
||||
- Implement proper cleanup with `trap`
|
||||
|
||||
### Key Naming
|
||||
|
||||
- Use descriptive names that include purpose and date
|
||||
- Names must contain only letters, numbers, and spaces
|
||||
- Unicode letters are supported
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
:::note[Common Error Messages]
|
||||
|
||||
**"API key name must contain only letters, numbers, and spaces"**
|
||||
|
||||
- **Solution:** Remove special characters like hyphens, underscores, or symbols
|
||||
|
||||
**"API key with name 'x' already exists"**
|
||||
|
||||
- **Solution:** Use `--overwrite` flag or choose a different name
|
||||
|
||||
**"Please add at least one role or permission to the key"**
|
||||
|
||||
- **Solution:** Specify either `--roles` or `--permissions` (or both)
|
||||
|
||||
:::
|
||||
|
||||
### Debug Mode
|
||||
|
||||
For troubleshooting, run with debug logging:
|
||||
|
||||
```bash
|
||||
LOG_LEVEL=debug unraid-api apikey --create --name "debug key" --roles ADMIN
|
||||
```
|
||||
@@ -282,4 +282,153 @@ describe('ApiKeyCommand', () => {
|
||||
expect(result).toEqual([Role.ADMIN, Role.CONNECT]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON output functionality', () => {
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('should output JSON when creating key with --json flag', async () => {
|
||||
const mockKey = {
|
||||
id: 'test-id-123',
|
||||
key: 'test-key-456',
|
||||
name: 'JSON_TEST',
|
||||
roles: [Role.ADMIN],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey);
|
||||
|
||||
await command.run([], {
|
||||
name: 'JSON_TEST',
|
||||
create: true,
|
||||
roles: [Role.ADMIN],
|
||||
json: true,
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify({ key: 'test-key-456', name: 'JSON_TEST', id: 'test-id-123' })
|
||||
);
|
||||
expect(logService.log).not.toHaveBeenCalledWith('test-key-456');
|
||||
});
|
||||
|
||||
it('should output JSON when fetching existing key with --json flag', async () => {
|
||||
const existingKey = {
|
||||
id: 'existing-id-456',
|
||||
key: 'existing-key-789',
|
||||
name: 'EXISTING_JSON',
|
||||
roles: [Role.VIEWER],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(existingKey);
|
||||
|
||||
await command.run([], {
|
||||
name: 'EXISTING_JSON',
|
||||
create: false,
|
||||
json: true,
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify({ key: 'existing-key-789', name: 'EXISTING_JSON', id: 'existing-id-456' })
|
||||
);
|
||||
expect(logService.log).not.toHaveBeenCalledWith('existing-key-789');
|
||||
});
|
||||
|
||||
it('should output JSON when deleting key with --json flag', async () => {
|
||||
const existingKeys = [
|
||||
{
|
||||
id: 'delete-id-123',
|
||||
name: 'DELETE_JSON',
|
||||
key: 'delete-key-456',
|
||||
roles: [Role.GUEST],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
},
|
||||
];
|
||||
vi.spyOn(apiKeyService, 'findAll').mockResolvedValue(existingKeys);
|
||||
vi.spyOn(apiKeyService, 'deleteApiKeys').mockResolvedValue();
|
||||
|
||||
await command.run([], {
|
||||
name: 'DELETE_JSON',
|
||||
delete: true,
|
||||
json: true,
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
deleted: 1,
|
||||
keys: [{ id: 'delete-id-123', name: 'DELETE_JSON' }],
|
||||
})
|
||||
);
|
||||
expect(logService.log).not.toHaveBeenCalledWith('Successfully deleted 1 API key');
|
||||
});
|
||||
|
||||
it('should output JSON error when deleting non-existent key with --json flag', async () => {
|
||||
vi.spyOn(apiKeyService, 'findAll').mockResolvedValue([]);
|
||||
|
||||
await command.run([], {
|
||||
name: 'NONEXISTENT',
|
||||
delete: true,
|
||||
json: true,
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify({ deleted: 0, message: 'No API keys found to delete' })
|
||||
);
|
||||
expect(logService.log).not.toHaveBeenCalledWith('No API keys found to delete');
|
||||
});
|
||||
|
||||
it('should not suppress creation message when not using JSON', async () => {
|
||||
const mockKey = {
|
||||
id: 'test-id',
|
||||
key: 'test-key',
|
||||
name: 'NO_JSON_TEST',
|
||||
roles: [Role.ADMIN],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey);
|
||||
|
||||
await command.run([], {
|
||||
name: 'NO_JSON_TEST',
|
||||
create: true,
|
||||
roles: [Role.ADMIN],
|
||||
json: false,
|
||||
});
|
||||
|
||||
expect(logService.log).toHaveBeenCalledWith('Creating API Key...');
|
||||
expect(logService.log).toHaveBeenCalledWith('test-key');
|
||||
expect(consoleSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should suppress creation message when using JSON', async () => {
|
||||
const mockKey = {
|
||||
id: 'test-id',
|
||||
key: 'test-key',
|
||||
name: 'JSON_SUPPRESS_TEST',
|
||||
roles: [Role.ADMIN],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey);
|
||||
|
||||
await command.run([], {
|
||||
name: 'JSON_SUPPRESS_TEST',
|
||||
create: true,
|
||||
roles: [Role.ADMIN],
|
||||
json: true,
|
||||
});
|
||||
|
||||
expect(logService.log).not.toHaveBeenCalledWith('Creating API Key...');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify({ key: 'test-key', name: 'JSON_SUPPRESS_TEST', id: 'test-id' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,12 +10,13 @@ import { Permission } from '@app/unraid-api/graph/resolvers/api-key/api-key.mode
|
||||
|
||||
interface KeyOptions {
|
||||
name: string;
|
||||
create: boolean;
|
||||
create?: boolean;
|
||||
delete?: boolean;
|
||||
description?: string;
|
||||
roles?: Role[];
|
||||
permissions?: Permission[];
|
||||
overwrite?: boolean;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
@Command({
|
||||
@@ -100,46 +101,102 @@ ACTIONS: ${Object.values(AuthAction).join(', ')}`,
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Prompt the user to select API keys to delete. Then, delete the selected keys. */
|
||||
private async deleteKeys() {
|
||||
@Option({
|
||||
flags: '--json',
|
||||
description: 'Output machine-readable JSON format',
|
||||
})
|
||||
parseJson(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Helper to output either JSON or regular log message */
|
||||
private output(message: string, jsonData?: object, jsonOutput?: boolean): void {
|
||||
if (jsonOutput && jsonData) {
|
||||
console.log(JSON.stringify(jsonData));
|
||||
} else {
|
||||
this.logger.log(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Helper to output either JSON or regular error message */
|
||||
private outputError(message: string, jsonData?: object, jsonOutput?: boolean): void {
|
||||
if (jsonOutput && jsonData) {
|
||||
console.log(JSON.stringify(jsonData));
|
||||
} else {
|
||||
this.logger.error(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete API keys either by name (non-interactive) or by prompting user selection (interactive). */
|
||||
private async deleteKeys(name?: string, jsonOutput?: boolean) {
|
||||
const allKeys = await this.apiKeyService.findAll();
|
||||
if (allKeys.length === 0) {
|
||||
this.logger.log('No API keys found to delete');
|
||||
this.output(
|
||||
'No API keys found to delete',
|
||||
{ deleted: 0, message: 'No API keys found to delete' },
|
||||
jsonOutput
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const answers = await this.inquirerService.prompt<DeleteApiKeyAnswers>(
|
||||
DeleteApiKeyQuestionSet.name,
|
||||
{}
|
||||
);
|
||||
if (!answers.selectedKeys || answers.selectedKeys.length === 0) {
|
||||
this.logger.log('No keys selected for deletion');
|
||||
return;
|
||||
let selectedKeyIds: string[];
|
||||
let deletedKeys: { id: string; name: string }[] = [];
|
||||
|
||||
if (name) {
|
||||
// Non-interactive mode: delete by name
|
||||
const keyToDelete = allKeys.find((key) => key.name === name);
|
||||
if (!keyToDelete) {
|
||||
this.outputError(
|
||||
`No API key found with name: ${name}`,
|
||||
{ deleted: 0, error: `No API key found with name: ${name}` },
|
||||
jsonOutput
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
selectedKeyIds = [keyToDelete.id];
|
||||
deletedKeys = [{ id: keyToDelete.id, name: keyToDelete.name }];
|
||||
} else {
|
||||
// Interactive mode: prompt user to select keys
|
||||
const answers = await this.inquirerService.prompt<DeleteApiKeyAnswers>(
|
||||
DeleteApiKeyQuestionSet.name,
|
||||
{}
|
||||
);
|
||||
if (!answers.selectedKeys || answers.selectedKeys.length === 0) {
|
||||
this.output(
|
||||
'No keys selected for deletion',
|
||||
{ deleted: 0, message: 'No keys selected for deletion' },
|
||||
jsonOutput
|
||||
);
|
||||
return;
|
||||
}
|
||||
selectedKeyIds = answers.selectedKeys;
|
||||
deletedKeys = allKeys
|
||||
.filter((key) => selectedKeyIds.includes(key.id))
|
||||
.map((key) => ({ id: key.id, name: key.name }));
|
||||
}
|
||||
|
||||
try {
|
||||
await this.apiKeyService.deleteApiKeys(answers.selectedKeys);
|
||||
this.logger.log(`Successfully deleted ${answers.selectedKeys.length} API keys`);
|
||||
await this.apiKeyService.deleteApiKeys(selectedKeyIds);
|
||||
const message = `Successfully deleted ${selectedKeyIds.length} API key${selectedKeyIds.length === 1 ? '' : 's'}`;
|
||||
this.output(message, { deleted: selectedKeyIds.length, keys: deletedKeys }, jsonOutput);
|
||||
} catch (error) {
|
||||
this.logger.error(error as any);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.outputError(errorMessage, { deleted: 0, error: errorMessage }, jsonOutput);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async run(
|
||||
_: string[],
|
||||
options: KeyOptions = { create: false, name: '', delete: false }
|
||||
): Promise<void> {
|
||||
async run(_: string[], options: KeyOptions = { name: '', delete: false }): Promise<void> {
|
||||
try {
|
||||
if (options.delete) {
|
||||
await this.deleteKeys();
|
||||
await this.deleteKeys(options.name, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
const key = this.apiKeyService.findByField('name', options.name);
|
||||
if (key) {
|
||||
this.logger.log(key.key);
|
||||
} else if (options.create) {
|
||||
this.output(key.key, { key: key.key, name: key.name, id: key.id }, options.json);
|
||||
} else if (options.create === true) {
|
||||
// Check if we have minimum required info from flags (name + at least one role or permission)
|
||||
const hasMinimumInfo =
|
||||
options.name &&
|
||||
@@ -153,14 +210,20 @@ ACTIONS: ${Object.values(AuthAction).join(', ')}`,
|
||||
// Non-interactive mode - check if key exists and handle overwrite
|
||||
const existingKey = this.apiKeyService.findByField('name', options.name);
|
||||
if (existingKey && !options.overwrite) {
|
||||
this.logger.error(
|
||||
`API key with name '${options.name}' already exists. Use --overwrite to replace it.`
|
||||
this.outputError(
|
||||
`API key with name '${options.name}' already exists. Use --overwrite to replace it.`,
|
||||
{
|
||||
error: `API key with name '${options.name}' already exists. Use --overwrite to replace it.`,
|
||||
},
|
||||
options.json
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log('Creating API Key...');
|
||||
if (!options.json) {
|
||||
this.logger.log('Creating API Key...');
|
||||
}
|
||||
|
||||
if (!options.roles && !options.permissions) {
|
||||
this.logger.error('Please add at least one role or permission to the key.');
|
||||
@@ -178,7 +241,7 @@ ACTIONS: ${Object.values(AuthAction).join(', ')}`,
|
||||
overwrite: options.overwrite ?? false,
|
||||
});
|
||||
|
||||
this.logger.log(key.key);
|
||||
this.output(key.key, { key: key.key, name: key.name, id: key.id }, options.json);
|
||||
} else {
|
||||
this.logger.log('No Key Found');
|
||||
process.exit(1);
|
||||
|
||||
@@ -194,25 +194,31 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
provider.authorizationRules = rules;
|
||||
}
|
||||
|
||||
// Validate that authorization rules are present and valid for ALL providers
|
||||
// Skip providers without authorization rules (they will be ignored)
|
||||
if (!provider.authorizationRules || provider.authorizationRules.length === 0) {
|
||||
throw new Error(
|
||||
`Provider "${provider.name}" requires authorization rules. Please configure who can access your server.`
|
||||
this.logger.warn(
|
||||
`Provider "${provider.name}" has no authorization rules and will be ignored. Configure authorization rules to enable this provider.`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate each rule has valid values
|
||||
for (const rule of provider.authorizationRules) {
|
||||
if (!rule.claim || !rule.claim.trim()) {
|
||||
throw new Error(`Provider "${provider.name}": Authorization rule claim cannot be empty`);
|
||||
}
|
||||
if (!rule.operator) {
|
||||
throw new Error(`Provider "${provider.name}": Authorization rule operator is required`);
|
||||
}
|
||||
if (!rule.value || rule.value.length === 0 || rule.value.every((v) => !v || !v.trim())) {
|
||||
throw new Error(
|
||||
`Provider "${provider.name}": Authorization rule for claim "${rule.claim}" must have at least one non-empty value`
|
||||
);
|
||||
// Validate each rule has valid values (only if rules exist)
|
||||
if (provider.authorizationRules && provider.authorizationRules.length > 0) {
|
||||
for (const rule of provider.authorizationRules) {
|
||||
if (!rule.claim || !rule.claim.trim()) {
|
||||
throw new Error(
|
||||
`Provider "${provider.name}": Authorization rule claim cannot be empty`
|
||||
);
|
||||
}
|
||||
if (!rule.operator) {
|
||||
throw new Error(
|
||||
`Provider "${provider.name}": Authorization rule operator is required`
|
||||
);
|
||||
}
|
||||
if (!rule.value || rule.value.length === 0 || rule.value.every((v) => !v || !v.trim())) {
|
||||
throw new Error(
|
||||
`Provider "${provider.name}": Authorization rule for claim "${rule.claim}" must have at least one non-empty value`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,12 +376,13 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
}),
|
||||
};
|
||||
|
||||
// Validate authorization rules for ALL providers including unraid.net
|
||||
// Validate authorization rules for providers that have them
|
||||
for (const provider of processedConfig.providers) {
|
||||
if (!provider.authorizationRules || provider.authorizationRules.length === 0) {
|
||||
throw new Error(
|
||||
`Provider "${provider.name}" requires authorization rules. Please configure who can access your server.`
|
||||
this.logger.warn(
|
||||
`Provider "${provider.name}" has no authorization rules and will be ignored. Configure authorization rules to enable this provider.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate each rule has valid values
|
||||
@@ -565,9 +572,9 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
providersSlice.elements[0].elements.unshift(
|
||||
createLabeledControl({
|
||||
scope: '#/properties/sso/properties/defaultAllowedOrigins',
|
||||
label: 'Allowed Redirect Origins',
|
||||
label: 'Allowed OIDC Redirect Origins',
|
||||
description:
|
||||
'Add trusted origins here when accessing Unraid through custom ports, reverse proxies, or Tailscale. Each origin should include the protocol and optionally a port (e.g., https://unraid.local:8443)',
|
||||
'Add trusted origins for OIDC redirection. These are URLs that the OIDC provider can redirect to after authentication when accessing Unraid through custom ports, reverse proxies, or Tailscale. Each origin should include the protocol and optionally a port (e.g., https://unraid.local:8443)',
|
||||
controlOptions: {
|
||||
format: 'array',
|
||||
inputType: 'text',
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
Menu="ManagementAccess:160"
|
||||
Title="API Config Download"
|
||||
Icon="icon-download"
|
||||
Tag="download"
|
||||
---
|
||||
<unraid-config-download />
|
||||
@@ -153,8 +153,8 @@ const addItem = () => {
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
const newItems = [...items.value];
|
||||
newItems.splice(index, 1);
|
||||
// Create a completely new array by filtering out the item
|
||||
const newItems = items.value.filter((_, i) => i !== index);
|
||||
items.value = newItems;
|
||||
};
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ const addItem = () => {
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
const newItems = [...items.value];
|
||||
newItems.splice(index, 1);
|
||||
// Create a completely new array by filtering out the item
|
||||
const newItems = items.value.filter((_, i) => i !== index);
|
||||
items.value = newItems;
|
||||
};
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ const { modalVisible, editingKey, isAuthorizationMode, authorizationData, create
|
||||
// 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<{
|
||||
@@ -74,7 +73,6 @@ const formSchema = ref<ApiKeyFormSettings | null>(null);
|
||||
const formData = ref<FormData>({
|
||||
customPermissions: [],
|
||||
roles: [],
|
||||
authorizationType: 'roles',
|
||||
} as FormData);
|
||||
const formValid = ref(false);
|
||||
|
||||
@@ -83,10 +81,14 @@ const { copyWithNotification, copied } = useClipboardWithToast();
|
||||
|
||||
// Computed property to transform formData permissions for the EffectivePermissions component
|
||||
const formDataPermissions = computed(() => {
|
||||
if (!formData.value.customPermissions) return [];
|
||||
// Explicitly depend on the array length to ensure reactivity when going to/from empty
|
||||
const permissions = formData.value.customPermissions;
|
||||
const permissionCount = permissions?.length ?? 0;
|
||||
|
||||
if (!permissions || permissionCount === 0) return [];
|
||||
|
||||
// Flatten the resources array into individual permission entries
|
||||
return formData.value.customPermissions.flatMap((perm) =>
|
||||
return permissions.flatMap((perm) =>
|
||||
perm.resources.map((resource) => ({
|
||||
resource,
|
||||
actions: perm.actions, // Already string[] which can be AuthAction values
|
||||
@@ -141,6 +143,7 @@ const loadFormSchema = () => {
|
||||
// For new keys, initialize with empty data
|
||||
formData.value = {
|
||||
customPermissions: [],
|
||||
roles: [],
|
||||
};
|
||||
// Set formValid to true initially for new keys
|
||||
// JsonForms will update this if there are validation errors
|
||||
@@ -250,7 +253,6 @@ const populateFormFromExistingKey = async () => {
|
||||
formData.value = {
|
||||
name: fragmentKey.name,
|
||||
description: fragmentKey.description || '',
|
||||
authorizationType: fragmentKey.roles.length > 0 ? 'roles' : 'custom',
|
||||
roles: [...fragmentKey.roles],
|
||||
customPermissions,
|
||||
};
|
||||
@@ -303,7 +305,10 @@ const transformFormDataForApi = (): CreateApiKeyInput => {
|
||||
|
||||
const close = () => {
|
||||
apiKeyStore.hideModal();
|
||||
formData.value = {} as FormData; // Reset to empty object
|
||||
formData.value = {
|
||||
customPermissions: [],
|
||||
roles: [],
|
||||
} as FormData;
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
@@ -356,7 +361,10 @@ async function upsertKey() {
|
||||
}
|
||||
|
||||
apiKeyStore.hideModal();
|
||||
formData.value = {} as FormData; // Reset to empty object
|
||||
formData.value = {
|
||||
customPermissions: [],
|
||||
roles: [],
|
||||
} as FormData;
|
||||
} catch (error) {
|
||||
console.error('Error in upsertKey:', error);
|
||||
} finally {
|
||||
|
||||
@@ -34,6 +34,7 @@ const state = reactive({
|
||||
loadedContentChunks: [] as { content: string; startLine: number }[],
|
||||
currentStartLine: undefined as number | undefined,
|
||||
isLoadingMore: false,
|
||||
isRefreshing: false,
|
||||
isAtTop: false,
|
||||
canLoadMore: false,
|
||||
initialLoadComplete: false,
|
||||
@@ -104,11 +105,25 @@ watch(
|
||||
const effectiveStartLine = startLine || 1;
|
||||
|
||||
if (state.isLoadingMore) {
|
||||
// Loading more historical content - prepend to existing chunks
|
||||
state.loadedContentChunks.unshift({ content, startLine: effectiveStartLine });
|
||||
state.isLoadingMore = false;
|
||||
|
||||
nextTick(() => (state.canLoadMore = true));
|
||||
} else if (state.isRefreshing) {
|
||||
// Refreshing - replace all content and reset state
|
||||
state.loadedContentChunks = [{ content, startLine: effectiveStartLine }];
|
||||
state.isRefreshing = false;
|
||||
state.currentStartLine = undefined;
|
||||
state.isAtTop = false;
|
||||
state.initialLoadComplete = true;
|
||||
|
||||
nextTick(() => {
|
||||
forceScrollToBottom();
|
||||
setTimeout(() => (state.canLoadMore = true), 300);
|
||||
});
|
||||
} else {
|
||||
// Initial load - replace all content
|
||||
state.loadedContentChunks = [{ content, startLine: effectiveStartLine }];
|
||||
|
||||
nextTick(() => {
|
||||
@@ -235,16 +250,6 @@ const downloadLogFile = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Clear all state to initial values
|
||||
const clearState = () => {
|
||||
state.loadedContentChunks = [];
|
||||
state.currentStartLine = undefined;
|
||||
state.isAtTop = false;
|
||||
state.canLoadMore = false;
|
||||
state.initialLoadComplete = false;
|
||||
state.isLoadingMore = false;
|
||||
};
|
||||
|
||||
// Helper function to start log subscription
|
||||
const startLogSubscription = () => {
|
||||
if (!props.logFilePath) return;
|
||||
@@ -297,10 +302,12 @@ const startLogSubscription = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh logs
|
||||
// Refresh logs with full reset
|
||||
const refreshLogContent = async () => {
|
||||
// Clear the state
|
||||
clearState();
|
||||
// Set refresh flag to indicate we're refreshing
|
||||
state.isRefreshing = true;
|
||||
state.isLoadingMore = false;
|
||||
state.canLoadMore = false;
|
||||
|
||||
// Refetch with explicit variables to ensure we get the latest logs
|
||||
await refetchLogContent({
|
||||
@@ -310,13 +317,7 @@ const refreshLogContent = async () => {
|
||||
});
|
||||
|
||||
// Restart the subscription with the same variables used for refetch
|
||||
// Note: subscribeToMore in Vue Apollo doesn't return an unsubscribe function
|
||||
// The previous subscription is automatically replaced when calling subscribeToMore again
|
||||
startLogSubscription();
|
||||
|
||||
nextTick(() => {
|
||||
forceScrollToBottom();
|
||||
});
|
||||
};
|
||||
|
||||
watch(() => props.logFilePath, refreshLogContent);
|
||||
|
||||
Reference in New Issue
Block a user