mirror of
https://github.com/unraid/api.git
synced 2026-01-02 14:40:01 -06:00
Compare commits
9 Commits
4.18.0-bui
...
v4.18.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d89682a3f | ||
|
|
bc15bd3d70 | ||
|
|
7c3aee8f3f | ||
|
|
c7c3bb57ea | ||
|
|
99dbad57d5 | ||
|
|
c42f79d406 | ||
|
|
4d8588b173 | ||
|
|
0d1d27064e | ||
|
|
0fe2c2c1c8 |
2
.github/workflows/build-plugin.yml
vendored
2
.github/workflows/build-plugin.yml
vendored
@@ -152,7 +152,7 @@ jobs:
|
||||
with:
|
||||
workflow: release-production.yml
|
||||
inputs: '{ "version": "${{ steps.vars.outputs.API_VERSION }}" }'
|
||||
token: ${{ secrets.WORKFLOW_TRIGGER_PAT }}
|
||||
token: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN }}
|
||||
|
||||
- name: Upload to Cloudflare
|
||||
if: inputs.RELEASE_CREATED == 'false'
|
||||
|
||||
34
.github/workflows/main.yml
vendored
34
.github/workflows/main.yml
vendored
@@ -117,42 +117,62 @@ jobs:
|
||||
# Verify libvirt is running using sudo to bypass group membership delays
|
||||
sudo virsh list --all || true
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Build UI Package First
|
||||
run: |
|
||||
echo "🔧 Building UI package for web tests dependency..."
|
||||
cd ../unraid-ui && pnpm run build
|
||||
|
||||
- name: Run Tests Concurrently
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Run all tests in parallel with labeled output
|
||||
# Run all tests in parallel with labeled output and coverage generation
|
||||
echo "🚀 Starting API coverage tests..."
|
||||
pnpm run coverage > api-test.log 2>&1 &
|
||||
API_PID=$!
|
||||
|
||||
echo "🚀 Starting Connect plugin tests..."
|
||||
(cd ../packages/unraid-api-plugin-connect && pnpm test) > connect-test.log 2>&1 &
|
||||
(cd ../packages/unraid-api-plugin-connect && pnpm test --coverage 2>/dev/null || pnpm test) > connect-test.log 2>&1 &
|
||||
CONNECT_PID=$!
|
||||
|
||||
echo "🚀 Starting Shared package tests..."
|
||||
(cd ../packages/unraid-shared && pnpm test) > shared-test.log 2>&1 &
|
||||
(cd ../packages/unraid-shared && pnpm test --coverage 2>/dev/null || pnpm test) > shared-test.log 2>&1 &
|
||||
SHARED_PID=$!
|
||||
|
||||
echo "🚀 Starting Web package coverage tests..."
|
||||
(cd ../web && (pnpm test --coverage || pnpm test)) > web-test.log 2>&1 &
|
||||
WEB_PID=$!
|
||||
|
||||
echo "🚀 Starting UI package coverage tests..."
|
||||
(cd ../unraid-ui && pnpm test --coverage 2>/dev/null || pnpm test) > ui-test.log 2>&1 &
|
||||
UI_PID=$!
|
||||
|
||||
# Wait for all processes and capture exit codes
|
||||
wait $API_PID && echo "✅ API tests completed" || { echo "❌ API tests failed"; API_EXIT=1; }
|
||||
wait $CONNECT_PID && echo "✅ Connect tests completed" || { echo "❌ Connect tests failed"; CONNECT_EXIT=1; }
|
||||
wait $SHARED_PID && echo "✅ Shared tests completed" || { echo "❌ Shared tests failed"; SHARED_EXIT=1; }
|
||||
wait $WEB_PID && echo "✅ Web tests completed" || { echo "❌ Web tests failed"; WEB_EXIT=1; }
|
||||
wait $UI_PID && echo "✅ UI tests completed" || { echo "❌ UI tests failed"; UI_EXIT=1; }
|
||||
|
||||
# Display all outputs
|
||||
echo "📋 API Test Results:" && cat api-test.log
|
||||
echo "📋 Connect Plugin Test Results:" && cat connect-test.log
|
||||
echo "📋 Shared Package Test Results:" && cat shared-test.log
|
||||
echo "📋 Web Package Test Results:" && cat web-test.log
|
||||
echo "📋 UI Package Test Results:" && cat ui-test.log
|
||||
|
||||
# Exit with error if any test failed
|
||||
if [[ ${API_EXIT:-0} -eq 1 || ${CONNECT_EXIT:-0} -eq 1 || ${SHARED_EXIT:-0} -eq 1 ]]; then
|
||||
if [[ ${API_EXIT:-0} -eq 1 || ${CONNECT_EXIT:-0} -eq 1 || ${SHARED_EXIT:-0} -eq 1 || ${WEB_EXIT:-0} -eq 1 || ${UI_EXIT:-0} -eq 1 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload all coverage reports to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./coverage/coverage-final.json,../web/coverage/coverage-final.json,../unraid-ui/coverage/coverage-final.json,../packages/unraid-api-plugin-connect/coverage/coverage-final.json,../packages/unraid-shared/coverage/coverage-final.json
|
||||
fail_ci_if_error: false
|
||||
|
||||
build-api:
|
||||
name: Build API
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"4.18.0"}
|
||||
{".":"4.18.2"}
|
||||
|
||||
@@ -418,6 +418,23 @@
|
||||
--normal-border: hsl(var(--border));
|
||||
--normal-text: hsl(var(--foreground));
|
||||
|
||||
--success-bg: hsl(var(--background));
|
||||
--success-border: hsl(var(--border));
|
||||
--success-text: hsl(140, 100%, 27%);
|
||||
|
||||
--info-bg: hsl(var(--background));
|
||||
--info-border: hsl(var(--border));
|
||||
--info-text: hsl(210, 92%, 45%);
|
||||
|
||||
--warning-bg: hsl(var(--background));
|
||||
--warning-border: hsl(var(--border));
|
||||
--warning-text: hsl(31, 92%, 45%);
|
||||
|
||||
--error-bg: hsl(var(--background));
|
||||
--error-border: hsl(var(--border));
|
||||
--error-text: hsl(360, 100%, 45%);
|
||||
|
||||
/* Old colors, preserved for reference
|
||||
--success-bg: hsl(143, 85%, 96%);
|
||||
--success-border: hsl(145, 92%, 91%);
|
||||
--success-text: hsl(140, 100%, 27%);
|
||||
@@ -432,7 +449,7 @@
|
||||
|
||||
--error-bg: hsl(359, 100%, 97%);
|
||||
--error-border: hsl(359, 100%, 94%);
|
||||
--error-text: hsl(360, 100%, 45%);
|
||||
--error-text: hsl(360, 100%, 45%); */
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-theme='light'] [data-sonner-toast][data-invert='true'] {
|
||||
@@ -452,6 +469,23 @@
|
||||
--normal-border: hsl(var(--border));
|
||||
--normal-text: hsl(var(--foreground));
|
||||
|
||||
--success-bg: hsl(var(--background));
|
||||
--success-border: hsl(var(--border));
|
||||
--success-text: hsl(150, 86%, 65%);
|
||||
|
||||
--info-bg: hsl(var(--background));
|
||||
--info-border: hsl(var(--border));
|
||||
--info-text: hsl(216, 87%, 65%);
|
||||
|
||||
--warning-bg: hsl(var(--background));
|
||||
--warning-border: hsl(var(--border));
|
||||
--warning-text: hsl(46, 87%, 65%);
|
||||
|
||||
--error-bg: hsl(var(--background));
|
||||
--error-border: hsl(var(--border));
|
||||
--error-text: hsl(358, 100%, 81%);
|
||||
|
||||
/* Old colors, preserved for reference
|
||||
--success-bg: hsl(150, 100%, 6%);
|
||||
--success-border: hsl(147, 100%, 12%);
|
||||
--success-text: hsl(150, 86%, 65%);
|
||||
@@ -466,7 +500,7 @@
|
||||
|
||||
--error-bg: hsl(358, 76%, 10%);
|
||||
--error-border: hsl(357, 89%, 16%);
|
||||
--error-text: hsl(358, 100%, 81%);
|
||||
--error-text: hsl(358, 100%, 81%); */
|
||||
}
|
||||
|
||||
[data-rich-colors='true'][data-sonner-toast][data-type='success'] {
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
## [4.18.2](https://github.com/unraid/api/compare/v4.18.1...v4.18.2) (2025-09-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing CPU guest metrics to CPU responses ([#1644](https://github.com/unraid/api/issues/1644)) ([99dbad5](https://github.com/unraid/api/commit/99dbad57d55a256f5f3f850f9a47a6eaa6348065))
|
||||
* **plugin:** raise minimum unraid os version to 6.12.15 ([#1649](https://github.com/unraid/api/issues/1649)) ([bc15bd3](https://github.com/unraid/api/commit/bc15bd3d7008acb416ac3c6fb1f4724c685ec7e7))
|
||||
* update GitHub Actions token for workflow trigger ([4d8588b](https://github.com/unraid/api/commit/4d8588b17331afa45ba8caf84fcec8c0ea03591f))
|
||||
* update OIDC URL validation and add tests ([#1646](https://github.com/unraid/api/issues/1646)) ([c7c3bb5](https://github.com/unraid/api/commit/c7c3bb57ea482633a7acff064b39fbc8d4e07213))
|
||||
* use shared bg & border color for styled toasts ([#1647](https://github.com/unraid/api/issues/1647)) ([7c3aee8](https://github.com/unraid/api/commit/7c3aee8f3f9ba82ae8c8ed3840c20ab47f3cb00f))
|
||||
|
||||
## [4.18.1](https://github.com/unraid/api/compare/v4.18.0...v4.18.1) (2025-09-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* OIDC and API Key management issues ([#1642](https://github.com/unraid/api/issues/1642)) ([0fe2c2c](https://github.com/unraid/api/commit/0fe2c2c1c85dcc547e4b1217a3b5636d7dd6d4b4))
|
||||
* rm redundant emission to `$HOME/.pm2/logs` ([#1640](https://github.com/unraid/api/issues/1640)) ([a8e4119](https://github.com/unraid/api/commit/a8e4119270868a1dabccd405853a7340f8dcd8a5))
|
||||
|
||||
## [4.18.0](https://github.com/unraid/api/compare/v4.17.0...v4.18.0) (2025-09-02)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.17.0",
|
||||
"version": "4.18.1",
|
||||
"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
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/api",
|
||||
"version": "4.18.0",
|
||||
"version": "4.18.2",
|
||||
"main": "src/cli/index.ts",
|
||||
"type": "module",
|
||||
"corepack": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -27,6 +27,16 @@ export class CpuLoad {
|
||||
description: 'The percentage of time the CPU spent servicing hardware interrupts.',
|
||||
})
|
||||
percentIrq!: number;
|
||||
|
||||
@Field(() => Float, {
|
||||
description: 'The percentage of time the CPU spent running virtual machines (guest).',
|
||||
})
|
||||
percentGuest!: number;
|
||||
|
||||
@Field(() => Float, {
|
||||
description: 'The percentage of CPU time stolen by the hypervisor.',
|
||||
})
|
||||
percentSteal!: number;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
|
||||
246
api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.spec.ts
Normal file
246
api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.spec.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
|
||||
|
||||
vi.mock('systeminformation', () => ({
|
||||
cpu: vi.fn().mockResolvedValue({
|
||||
manufacturer: 'Intel',
|
||||
brand: 'Core i7-9700K',
|
||||
vendor: 'Intel',
|
||||
family: '6',
|
||||
model: '158',
|
||||
stepping: '12',
|
||||
revision: '',
|
||||
voltage: '1.2V',
|
||||
speed: 3.6,
|
||||
speedMin: 800,
|
||||
speedMax: 4900,
|
||||
cores: 16,
|
||||
physicalCores: 8,
|
||||
processors: 1,
|
||||
socket: 'LGA1151',
|
||||
cache: {
|
||||
l1d: 32768,
|
||||
l1i: 32768,
|
||||
l2: 262144,
|
||||
l3: 12582912,
|
||||
},
|
||||
}),
|
||||
cpuFlags: vi.fn().mockResolvedValue('fpu vme de pse tsc msr pae mce cx8'),
|
||||
currentLoad: vi.fn().mockResolvedValue({
|
||||
avgLoad: 2.5,
|
||||
currentLoad: 25.5,
|
||||
currentLoadUser: 15.0,
|
||||
currentLoadSystem: 8.0,
|
||||
currentLoadNice: 0.5,
|
||||
currentLoadIdle: 74.5,
|
||||
currentLoadIrq: 1.0,
|
||||
currentLoadSteal: 0.2,
|
||||
currentLoadGuest: 0.3,
|
||||
rawCurrentLoad: 25500,
|
||||
rawCurrentLoadUser: 15000,
|
||||
rawCurrentLoadSystem: 8000,
|
||||
rawCurrentLoadNice: 500,
|
||||
rawCurrentLoadIdle: 74500,
|
||||
rawCurrentLoadIrq: 1000,
|
||||
rawCurrentLoadSteal: 200,
|
||||
rawCurrentLoadGuest: 300,
|
||||
cpus: [
|
||||
{
|
||||
load: 30.0,
|
||||
loadUser: 20.0,
|
||||
loadSystem: 10.0,
|
||||
loadNice: 0,
|
||||
loadIdle: 70.0,
|
||||
loadIrq: 0,
|
||||
loadSteal: 0,
|
||||
loadGuest: 0,
|
||||
rawLoad: 30000,
|
||||
rawLoadUser: 20000,
|
||||
rawLoadSystem: 10000,
|
||||
rawLoadNice: 0,
|
||||
rawLoadIdle: 70000,
|
||||
rawLoadIrq: 0,
|
||||
rawLoadSteal: 0,
|
||||
rawLoadGuest: 0,
|
||||
},
|
||||
{
|
||||
load: 21.0,
|
||||
loadUser: 15.0,
|
||||
loadSystem: 6.0,
|
||||
loadNice: 0,
|
||||
loadIdle: 79.0,
|
||||
loadIrq: 0,
|
||||
loadSteal: 0,
|
||||
loadGuest: 0,
|
||||
rawLoad: 21000,
|
||||
rawLoadUser: 15000,
|
||||
rawLoadSystem: 6000,
|
||||
rawLoadNice: 0,
|
||||
rawLoadIdle: 79000,
|
||||
rawLoadIrq: 0,
|
||||
rawLoadSteal: 0,
|
||||
rawLoadGuest: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('CpuService', () => {
|
||||
let service: CpuService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new CpuService();
|
||||
});
|
||||
|
||||
describe('generateCpu', () => {
|
||||
it('should return CPU information with correct structure', async () => {
|
||||
const result = await service.generateCpu();
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'info/cpu',
|
||||
manufacturer: 'Intel',
|
||||
brand: 'Core i7-9700K',
|
||||
vendor: 'Intel',
|
||||
family: '6',
|
||||
model: '158',
|
||||
stepping: 12,
|
||||
revision: '',
|
||||
voltage: '1.2V',
|
||||
speed: 3.6,
|
||||
speedmin: 800,
|
||||
speedmax: 4900,
|
||||
cores: 8,
|
||||
threads: 16,
|
||||
processors: 1,
|
||||
socket: 'LGA1151',
|
||||
cache: {
|
||||
l1d: 32768,
|
||||
l1i: 32768,
|
||||
l2: 262144,
|
||||
l3: 12582912,
|
||||
},
|
||||
flags: ['fpu', 'vme', 'de', 'pse', 'tsc', 'msr', 'pae', 'mce', 'cx8'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing speed values', async () => {
|
||||
const { cpu } = await import('systeminformation');
|
||||
vi.mocked(cpu).mockResolvedValueOnce({
|
||||
manufacturer: 'Intel',
|
||||
brand: 'Core i7-9700K',
|
||||
vendor: 'Intel',
|
||||
family: '6',
|
||||
model: '158',
|
||||
stepping: '12',
|
||||
revision: '',
|
||||
voltage: '1.2V',
|
||||
speed: 3.6,
|
||||
cores: 16,
|
||||
physicalCores: 8,
|
||||
processors: 1,
|
||||
socket: 'LGA1151',
|
||||
cache: { l1d: 32768, l1i: 32768, l2: 262144, l3: 12582912 },
|
||||
} as any);
|
||||
|
||||
const result = await service.generateCpu();
|
||||
|
||||
expect(result.speedmin).toBe(-1);
|
||||
expect(result.speedmax).toBe(-1);
|
||||
});
|
||||
|
||||
it('should handle cpuFlags error gracefully', async () => {
|
||||
const { cpuFlags } = await import('systeminformation');
|
||||
vi.mocked(cpuFlags).mockRejectedValueOnce(new Error('flags error'));
|
||||
|
||||
const result = await service.generateCpu();
|
||||
|
||||
expect(result.flags).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCpuLoad', () => {
|
||||
it('should return CPU utilization with all load metrics', async () => {
|
||||
const result = await service.generateCpuLoad();
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'info/cpu-load',
|
||||
percentTotal: 25.5,
|
||||
cpus: [
|
||||
{
|
||||
percentTotal: 30.0,
|
||||
percentUser: 20.0,
|
||||
percentSystem: 10.0,
|
||||
percentNice: 0,
|
||||
percentIdle: 70.0,
|
||||
percentIrq: 0,
|
||||
percentGuest: 0,
|
||||
percentSteal: 0,
|
||||
},
|
||||
{
|
||||
percentTotal: 21.0,
|
||||
percentUser: 15.0,
|
||||
percentSystem: 6.0,
|
||||
percentNice: 0,
|
||||
percentIdle: 79.0,
|
||||
percentIrq: 0,
|
||||
percentGuest: 0,
|
||||
percentSteal: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should include guest and steal metrics when present', async () => {
|
||||
const { currentLoad } = await import('systeminformation');
|
||||
vi.mocked(currentLoad).mockResolvedValueOnce({
|
||||
avgLoad: 2.5,
|
||||
currentLoad: 25.5,
|
||||
currentLoadUser: 15.0,
|
||||
currentLoadSystem: 8.0,
|
||||
currentLoadNice: 0.5,
|
||||
currentLoadIdle: 74.5,
|
||||
currentLoadIrq: 1.0,
|
||||
currentLoadSteal: 0.2,
|
||||
currentLoadGuest: 0.3,
|
||||
rawCurrentLoad: 25500,
|
||||
rawCurrentLoadUser: 15000,
|
||||
rawCurrentLoadSystem: 8000,
|
||||
rawCurrentLoadNice: 500,
|
||||
rawCurrentLoadIdle: 74500,
|
||||
rawCurrentLoadIrq: 1000,
|
||||
rawCurrentLoadSteal: 200,
|
||||
rawCurrentLoadGuest: 300,
|
||||
cpus: [
|
||||
{
|
||||
load: 30.0,
|
||||
loadUser: 20.0,
|
||||
loadSystem: 10.0,
|
||||
loadNice: 0,
|
||||
loadIdle: 70.0,
|
||||
loadIrq: 0,
|
||||
loadGuest: 2.5,
|
||||
loadSteal: 1.2,
|
||||
rawLoad: 30000,
|
||||
rawLoadUser: 20000,
|
||||
rawLoadSystem: 10000,
|
||||
rawLoadNice: 0,
|
||||
rawLoadIdle: 70000,
|
||||
rawLoadIrq: 0,
|
||||
rawLoadGuest: 2500,
|
||||
rawLoadSteal: 1200,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.generateCpuLoad();
|
||||
|
||||
expect(result.cpus[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
percentGuest: 2.5,
|
||||
percentSteal: 1.2,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,8 @@ export class CpuService {
|
||||
percentNice: cpu.loadNice,
|
||||
percentIdle: cpu.loadIdle,
|
||||
percentIrq: cpu.loadIrq,
|
||||
percentGuest: cpu.loadGuest || 0,
|
||||
percentSteal: cpu.loadSteal || 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ describe('MetricsResolver', () => {
|
||||
loadNice: 0,
|
||||
loadIdle: 70.0,
|
||||
loadIrq: 0,
|
||||
loadGuest: 0,
|
||||
loadSteal: 0,
|
||||
},
|
||||
{
|
||||
load: 21.0,
|
||||
@@ -40,6 +42,8 @@ describe('MetricsResolver', () => {
|
||||
loadNice: 0,
|
||||
loadIdle: 79.0,
|
||||
loadIrq: 0,
|
||||
loadGuest: 0,
|
||||
loadSteal: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { UserSettingsService } from '@unraid/shared/services/user-settings.js';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/core/oidc-config.service.js';
|
||||
import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/core/oidc-validation.service.js';
|
||||
import { OidcUrlPatterns } from '@app/unraid-api/graph/resolvers/sso/utils/oidc-url-patterns.util.js';
|
||||
|
||||
describe('OidcConfigPersistence', () => {
|
||||
let service: OidcConfigPersistence;
|
||||
let mockConfigService: ConfigService;
|
||||
let mockUserSettingsService: UserSettingsService;
|
||||
let mockValidationService: OidcValidationService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
OidcConfigPersistence,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: UserSettingsService,
|
||||
useValue: {
|
||||
register: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: OidcValidationService,
|
||||
useValue: {
|
||||
validateProvider: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<OidcConfigPersistence>(OidcConfigPersistence);
|
||||
mockConfigService = module.get<ConfigService>(ConfigService);
|
||||
mockUserSettingsService = module.get<UserSettingsService>(UserSettingsService);
|
||||
mockValidationService = module.get<OidcValidationService>(OidcValidationService);
|
||||
|
||||
// Mock persist method to avoid file system operations
|
||||
vi.spyOn(service, 'persist').mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe('URL validation integration', () => {
|
||||
it('should validate issuer URLs using the shared utility', () => {
|
||||
// Test that our shared utility correctly validates URLs
|
||||
// This ensures the pattern we use in the form schema works correctly
|
||||
const examples = OidcUrlPatterns.getExamples();
|
||||
|
||||
// Test valid URLs
|
||||
examples.valid.forEach((url) => {
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl(url)).toBe(true);
|
||||
});
|
||||
|
||||
// Test invalid URLs
|
||||
examples.invalid.forEach((url) => {
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl(url)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate the pattern constant matches the regex', () => {
|
||||
// Ensure the pattern string can be compiled into a valid regex
|
||||
expect(() => new RegExp(OidcUrlPatterns.ISSUER_URL_PATTERN)).not.toThrow();
|
||||
|
||||
// Ensure the static regex matches the pattern
|
||||
const manualRegex = new RegExp(OidcUrlPatterns.ISSUER_URL_PATTERN);
|
||||
expect(OidcUrlPatterns.ISSUER_URL_REGEX.source).toBe(manualRegex.source);
|
||||
});
|
||||
|
||||
it('should reject the specific URL from the bug report', () => {
|
||||
// Test the exact scenario that caused the original bug
|
||||
const problematicUrl = 'https://accounts.google.com/';
|
||||
const correctUrl = 'https://accounts.google.com';
|
||||
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl(problematicUrl)).toBe(false);
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl(correctUrl)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
OidcAuthorizationRule,
|
||||
OidcProvider,
|
||||
} from '@app/unraid-api/graph/resolvers/sso/models/oidc-provider.model.js';
|
||||
import { OidcUrlPatterns } from '@app/unraid-api/graph/resolvers/sso/utils/oidc-url-patterns.util.js';
|
||||
import {
|
||||
createAccordionLayout,
|
||||
createLabeledControl,
|
||||
@@ -194,25 +195,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 +377,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 +573,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',
|
||||
@@ -613,7 +621,22 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
type: 'string',
|
||||
title: 'Issuer URL',
|
||||
format: 'uri',
|
||||
description: 'OIDC issuer URL (e.g., https://accounts.google.com)',
|
||||
allOf: [
|
||||
{
|
||||
pattern: OidcUrlPatterns.ISSUER_URL_PATTERN,
|
||||
errorMessage:
|
||||
'Must be a valid HTTP or HTTPS URL without trailing slashes or whitespace',
|
||||
},
|
||||
{
|
||||
not: {
|
||||
pattern: '\\.well-known',
|
||||
},
|
||||
errorMessage:
|
||||
'Cannot contain /.well-known/ paths. Use the base issuer URL instead (e.g., https://accounts.google.com instead of https://accounts.google.com/.well-known/openid-configuration)',
|
||||
},
|
||||
],
|
||||
description:
|
||||
'OIDC issuer URL (e.g., https://accounts.google.com). Cannot contain /.well-known/ paths - use the base issuer URL instead of the full discovery endpoint. Must not end with a trailing slash.',
|
||||
},
|
||||
authorizationEndpoint: {
|
||||
anyOf: [
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { OidcUrlPatterns } from '@app/unraid-api/graph/resolvers/sso/utils/oidc-url-patterns.util.js';
|
||||
|
||||
describe('OidcUrlPatterns', () => {
|
||||
describe('ISSUER_URL_PATTERN', () => {
|
||||
it('should be defined as a string', () => {
|
||||
expect(typeof OidcUrlPatterns.ISSUER_URL_PATTERN).toBe('string');
|
||||
expect(OidcUrlPatterns.ISSUER_URL_PATTERN).toBe('^https?://[^/\\s]+(?:/[^/\\s]*)*[^/\\s]$');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ISSUER_URL_REGEX', () => {
|
||||
it('should be a RegExp instance', () => {
|
||||
expect(OidcUrlPatterns.ISSUER_URL_REGEX).toBeInstanceOf(RegExp);
|
||||
});
|
||||
|
||||
it('should match the pattern string', () => {
|
||||
const regex = new RegExp(OidcUrlPatterns.ISSUER_URL_PATTERN);
|
||||
expect(OidcUrlPatterns.ISSUER_URL_REGEX.source).toBe(regex.source);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidIssuerUrl', () => {
|
||||
it('should accept valid URLs without trailing slash', () => {
|
||||
const validUrls = [
|
||||
'https://accounts.google.com',
|
||||
'https://auth.example.com/oidc',
|
||||
'https://auth.example.com/realms/master',
|
||||
'http://localhost:8080',
|
||||
'http://localhost:8080/auth',
|
||||
'https://login.microsoftonline.com/common/v2.0',
|
||||
];
|
||||
|
||||
validUrls.forEach((url) => {
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl(url)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject URLs with trailing slashes', () => {
|
||||
const invalidUrls = [
|
||||
'https://accounts.google.com/',
|
||||
'https://auth.example.com/oidc/',
|
||||
'https://auth.example.com/realms/master/',
|
||||
'http://localhost:8080/',
|
||||
'http://localhost:8080/auth/',
|
||||
'https://login.microsoftonline.com/common/v2.0/',
|
||||
];
|
||||
|
||||
invalidUrls.forEach((url) => {
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl(url)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject URLs with whitespace', () => {
|
||||
const invalidUrls = [
|
||||
'https://accounts.google.com ',
|
||||
' https://accounts.google.com',
|
||||
'https://accounts. google.com',
|
||||
'https://accounts.google.com\t',
|
||||
'https://accounts.google.com\n',
|
||||
];
|
||||
|
||||
invalidUrls.forEach((url) => {
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl(url)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept both HTTP and HTTPS protocols', () => {
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl('https://example.com')).toBe(true);
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl('http://example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject other protocols', () => {
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl('ftp://example.com')).toBe(false);
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl('ws://example.com')).toBe(false);
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl('file://example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept .well-known URLs without trailing slashes', () => {
|
||||
const wellKnownUrls = [
|
||||
'https://example.com/.well-known/openid-configuration',
|
||||
'https://auth.example.com/path/.well-known/openid-configuration',
|
||||
'https://example.com/.well-known/jwks.json',
|
||||
'https://keycloak.example.com/realms/master/.well-known/openid-configuration',
|
||||
];
|
||||
|
||||
wellKnownUrls.forEach((url) => {
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl(url)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject .well-known URLs with trailing slashes', () => {
|
||||
const invalidWellKnownUrls = [
|
||||
'https://example.com/.well-known/openid-configuration/',
|
||||
'https://auth.example.com/path/.well-known/openid-configuration/',
|
||||
'https://example.com/.well-known/jwks.json/',
|
||||
'https://keycloak.example.com/realms/master/.well-known/openid-configuration/',
|
||||
];
|
||||
|
||||
invalidWellKnownUrls.forEach((url) => {
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl(url)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex real-world scenarios', () => {
|
||||
// Google
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl('https://accounts.google.com')).toBe(true);
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl('https://accounts.google.com/')).toBe(false);
|
||||
|
||||
// Microsoft
|
||||
expect(
|
||||
OidcUrlPatterns.isValidIssuerUrl('https://login.microsoftonline.com/tenant-id/v2.0')
|
||||
).toBe(true);
|
||||
expect(
|
||||
OidcUrlPatterns.isValidIssuerUrl('https://login.microsoftonline.com/tenant-id/v2.0/')
|
||||
).toBe(false);
|
||||
|
||||
// Auth0
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl('https://tenant.auth0.com')).toBe(true);
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl('https://tenant.auth0.com/')).toBe(false);
|
||||
|
||||
// Keycloak
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl('https://keycloak.example.com/realms/master')).toBe(
|
||||
true
|
||||
);
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl('https://keycloak.example.com/realms/master/')).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
// AWS Cognito
|
||||
expect(
|
||||
OidcUrlPatterns.isValidIssuerUrl(
|
||||
'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_example'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
OidcUrlPatterns.isValidIssuerUrl(
|
||||
'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_example/'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExamples', () => {
|
||||
it('should return valid and invalid URL examples', () => {
|
||||
const examples = OidcUrlPatterns.getExamples();
|
||||
|
||||
expect(examples).toHaveProperty('valid');
|
||||
expect(examples).toHaveProperty('invalid');
|
||||
expect(Array.isArray(examples.valid)).toBe(true);
|
||||
expect(Array.isArray(examples.invalid)).toBe(true);
|
||||
expect(examples.valid.length).toBeGreaterThan(0);
|
||||
expect(examples.invalid.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have all valid examples pass validation', () => {
|
||||
const examples = OidcUrlPatterns.getExamples();
|
||||
|
||||
examples.valid.forEach((url) => {
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl(url)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have all invalid examples fail validation', () => {
|
||||
const examples = OidcUrlPatterns.getExamples();
|
||||
|
||||
examples.invalid.forEach((url) => {
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl(url)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with the bug report scenario', () => {
|
||||
it('should specifically catch the Google trailing slash issue from the bug report', () => {
|
||||
// The exact scenario from the bug report
|
||||
const problematicUrl = 'https://accounts.google.com/';
|
||||
const correctUrl = 'https://accounts.google.com';
|
||||
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl(problematicUrl)).toBe(false);
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl(correctUrl)).toBe(true);
|
||||
});
|
||||
|
||||
it('should prevent the double slash in discovery URL construction', () => {
|
||||
// Simulate what would happen in discovery URL construction
|
||||
const issuerWithSlash = 'https://accounts.google.com/';
|
||||
const issuerWithoutSlash = 'https://accounts.google.com';
|
||||
|
||||
// This is what would happen in the discovery process
|
||||
const discoveryWithSlash = `${issuerWithSlash}/.well-known/openid-configuration`;
|
||||
const discoveryWithoutSlash = `${issuerWithoutSlash}/.well-known/openid-configuration`;
|
||||
|
||||
expect(discoveryWithSlash).toBe(
|
||||
'https://accounts.google.com//.well-known/openid-configuration'
|
||||
); // Double slash - bad
|
||||
expect(discoveryWithoutSlash).toBe(
|
||||
'https://accounts.google.com/.well-known/openid-configuration'
|
||||
); // Single slash - good
|
||||
|
||||
// Our validation should prevent the first scenario
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl(issuerWithSlash)).toBe(false);
|
||||
expect(OidcUrlPatterns.isValidIssuerUrl(issuerWithoutSlash)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Utility for OIDC URL validation patterns
|
||||
*/
|
||||
export class OidcUrlPatterns {
|
||||
/**
|
||||
* Regex pattern for validating OIDC issuer URLs
|
||||
* - Allows HTTP and HTTPS protocols
|
||||
* - Prevents trailing slashes
|
||||
* - Prevents whitespace
|
||||
* - Allows paths but not ending with slash
|
||||
*/
|
||||
static readonly ISSUER_URL_PATTERN = '^https?://[^/\\s]+(?:/[^/\\s]*)*[^/\\s]$';
|
||||
|
||||
/**
|
||||
* Compiled regex for issuer URL validation
|
||||
*/
|
||||
static readonly ISSUER_URL_REGEX = new RegExp(OidcUrlPatterns.ISSUER_URL_PATTERN);
|
||||
|
||||
/**
|
||||
* Validate an issuer URL against the pattern
|
||||
* @param url The URL to validate
|
||||
* @returns True if the URL is valid, false otherwise
|
||||
*/
|
||||
static isValidIssuerUrl(url: string): boolean {
|
||||
return this.ISSUER_URL_REGEX.test(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get examples of valid and invalid issuer URLs for documentation/testing
|
||||
*/
|
||||
static getExamples() {
|
||||
return {
|
||||
valid: [
|
||||
// Standard issuer URLs (most common)
|
||||
'https://accounts.google.com',
|
||||
'https://auth.example.com/oidc',
|
||||
'https://auth.example.com/realms/master',
|
||||
'http://localhost:8080',
|
||||
'http://localhost:8080/auth',
|
||||
'https://login.microsoftonline.com/common/v2.0',
|
||||
'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_example',
|
||||
// Well-known URLs are valid at the URL pattern level (schema-level validation handles rejection)
|
||||
'https://example.com/.well-known/openid-configuration',
|
||||
'https://auth.example.com/path/.well-known/openid-configuration',
|
||||
'https://example.com/.well-known/jwks.json',
|
||||
],
|
||||
invalid: [
|
||||
'https://accounts.google.com/', // Trailing slash
|
||||
'https://auth.example.com/oidc/', // Trailing slash
|
||||
'https://auth.example.com/realms/master/', // Trailing slash
|
||||
'http://localhost:8080/', // Trailing slash
|
||||
'https://accounts.google.com ', // Trailing whitespace
|
||||
' https://accounts.google.com', // Leading whitespace
|
||||
'https://accounts. google.com', // Internal whitespace
|
||||
'ftp://example.com', // Invalid protocol
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "unraid-monorepo",
|
||||
"private": true,
|
||||
"version": "4.18.0",
|
||||
"version": "4.18.2",
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"build:watch": " pnpm -r --parallel build:watch",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/connect-plugin",
|
||||
"version": "4.18.0",
|
||||
"version": "4.18.2",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"commander": "14.0.0",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
]>
|
||||
|
||||
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&plugin_url;"
|
||||
launch="&launch;" min="6.9.0-rc1" icon="globe">
|
||||
launch="&launch;" min="6.12.15" icon="globe">
|
||||
|
||||
<CHANGES>
|
||||
##a long time ago in a galaxy far far away
|
||||
@@ -61,19 +61,19 @@ exit 0
|
||||
$version = @parse_ini_file('/etc/unraid-version', true)['version'];
|
||||
|
||||
// Check if this is a supported version
|
||||
// - Must be 6.12.0 or higher
|
||||
// - Must not be a 6.12.0 beta/rc version
|
||||
$is_stable_6_12_or_higher = version_compare($version, '6.12.0', '>=') && !preg_match('/^6\\.12\\.0-/', $version);
|
||||
// - Must be 6.12.15 or higher
|
||||
// - Must not be a 6.12.15 beta/rc version
|
||||
$is_stable_6_12_or_higher = version_compare($version, '6.12.15', '>=') && !preg_match('/^6\\.12\\.0-/', $version);
|
||||
|
||||
if ($is_stable_6_12_or_higher) {
|
||||
echo "Running on supported version {$version}\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
echo "Warning: Unsupported Unraid version {$version}. This plugin requires Unraid 6.12.0 or higher.\n";
|
||||
echo "The plugin may not function correctly on this system.\n";
|
||||
echo "Warning: Unsupported Unraid version {$version}. This plugin requires Unraid 6.12.15 or higher.\n";
|
||||
echo "The plugin will not function correctly on this system.\n";
|
||||
|
||||
exit(0);
|
||||
exit(1);
|
||||
]]>
|
||||
</INLINE>
|
||||
</FILE>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
Menu="ManagementAccess:160"
|
||||
Title="API Config Download"
|
||||
Icon="icon-download"
|
||||
Tag="download"
|
||||
---
|
||||
<unraid-config-download />
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -864,6 +864,9 @@ importers:
|
||||
'@vueuse/core':
|
||||
specifier: 13.8.0
|
||||
version: 13.8.0(vue@3.5.20(typescript@5.9.2))
|
||||
ajv-errors:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0(ajv@8.17.1)
|
||||
class-variance-authority:
|
||||
specifier: 0.7.1
|
||||
version: 0.7.1
|
||||
@@ -6062,6 +6065,11 @@ packages:
|
||||
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ajv-errors@3.0.0:
|
||||
resolution: {integrity: sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==}
|
||||
peerDependencies:
|
||||
ajv: ^8.0.1
|
||||
|
||||
ajv-formats@2.1.1:
|
||||
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
|
||||
peerDependencies:
|
||||
@@ -19100,6 +19108,10 @@ snapshots:
|
||||
clean-stack: 2.2.0
|
||||
indent-string: 4.0.0
|
||||
|
||||
ajv-errors@3.0.0(ajv@8.17.1):
|
||||
dependencies:
|
||||
ajv: 8.17.1
|
||||
|
||||
ajv-formats@2.1.1(ajv@8.17.1):
|
||||
optionalDependencies:
|
||||
ajv: 8.17.1
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
[![Issues][issues-shield]][issues-url]
|
||||
[![MIT License][license-shield]][license-url]
|
||||
[![LinkedIn][linkedin-shield]][linkedin-url]
|
||||
[![Codecov][codecov-shield]][codecov-url]
|
||||
|
||||
</div>
|
||||
<!-- PROJECT LOGO -->
|
||||
@@ -283,6 +284,8 @@ Project Link: [https://github.com/unraid/api](https://github.com/unraid/api)
|
||||
[license-url]: https://github.com/unraid/api/blob/main/LICENSE.txt
|
||||
[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=for-the-badge&logo=linkedin&colorB=555
|
||||
[linkedin-url]: https://www.linkedin.com/company/unraid
|
||||
[codecov-shield]: https://img.shields.io/codecov/c/github/unraid/api?style=for-the-badge
|
||||
[codecov-url]: https://codecov.io/gh/unraid/api
|
||||
[Nuxt.js]: https://img.shields.io/badge/Nuxt-002E3B?style=for-the-badge&logo=nuxtdotjs&logoColor=#00DC82
|
||||
[Node.js]: https://img.shields.io/badge/node.js-6DA55F?style=for-the-badge&logo=node.js&logoColor=white
|
||||
[PHP]: https://img.shields.io/badge/php-%23777BB4.svg?style=for-the-badge&logo=php&logoColor=white
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/ui",
|
||||
"version": "4.18.0",
|
||||
"version": "4.18.2",
|
||||
"private": true,
|
||||
"license": "GPL-2.0-or-later",
|
||||
"type": "module",
|
||||
@@ -42,9 +42,9 @@
|
||||
"deploy:storybook:staging": "pnpm build-storybook && wrangler deploy --env staging"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "8.17.1",
|
||||
"tailwindcss": "4.1.12",
|
||||
"vue": "3.5.20",
|
||||
"ajv": "8.17.1"
|
||||
"vue": "3.5.20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/vue": "1.7.23",
|
||||
@@ -55,6 +55,7 @@
|
||||
"@jsonforms/vue-vanilla": "3.6.0",
|
||||
"@tailwindcss/cli": "4.1.12",
|
||||
"@vueuse/core": "13.8.0",
|
||||
"ajv-errors": "^3.0.0",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"dompurify": "3.2.6",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createAjv } from '@jsonforms/core';
|
||||
import type Ajv from 'ajv';
|
||||
import addErrors from 'ajv-errors';
|
||||
|
||||
export interface JsonFormsConfig {
|
||||
/**
|
||||
@@ -20,10 +21,15 @@ export interface JsonFormsConfig {
|
||||
* This ensures all JSONForms instances have proper validation and visibility rule support
|
||||
*/
|
||||
export function createJsonFormsAjv(): Ajv {
|
||||
return createAjv({
|
||||
const ajv = createAjv({
|
||||
allErrors: true,
|
||||
strict: false,
|
||||
});
|
||||
|
||||
// Add support for custom error messages
|
||||
addErrors(ajv);
|
||||
|
||||
return ajv;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/web",
|
||||
"version": "4.18.0",
|
||||
"version": "4.18.2",
|
||||
"private": true,
|
||||
"license": "GPL-2.0-or-later",
|
||||
"scripts": {
|
||||
|
||||
@@ -22,6 +22,9 @@ export default defineConfig({
|
||||
include: ['__test__/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts}'],
|
||||
testTimeout: 5000,
|
||||
hookTimeout: 5000,
|
||||
coverage: {
|
||||
reporter: ['text', 'json', 'html'],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user