Compare commits

...

12 Commits

Author SHA1 Message Date
github-actions[bot]
d3459ecbc6 chore(main): release 4.19.0 (#1650)
🤖 I have created a release *beep* *boop*
---


## [4.19.0](https://github.com/unraid/api/compare/v4.18.2...v4.19.0)
(2025-09-04)


### Features

* mount vue apps, not web components
([#1639](https://github.com/unraid/api/issues/1639))
([88087d5](88087d5201))


### Bug Fixes

* api version json response
([#1653](https://github.com/unraid/api/issues/1653))
([292bc0f](292bc0fc81))
* enhance DOM validation and cleanup in vue-mount-app
([6cf7c88](6cf7c88242))
* enhance getKeyFile function to handle missing key file gracefully
([#1659](https://github.com/unraid/api/issues/1659))
([728b38a](728b38ac11))
* info alert docker icon
([#1661](https://github.com/unraid/api/issues/1661))
([239cdd6](239cdd6133))
* oidc cache busting issues fixed
([#1656](https://github.com/unraid/api/issues/1656))
([e204eb8](e204eb80a0))
* **plugin:** restore cleanup behavior for unsupported unraid versions
([#1658](https://github.com/unraid/api/issues/1658))
([534a077](534a07788b))
* UnraidToaster component and update dialog close button
([#1657](https://github.com/unraid/api/issues/1657))
([44774d0](44774d0acd))
* vue mounting logic with tests
([#1651](https://github.com/unraid/api/issues/1651))
([33774aa](33774aa596))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-04 15:42:35 -04:00
Pujit Mehrotra
534a07788b fix(plugin): restore cleanup behavior for unsupported unraid versions (#1658)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Adds guided warnings and an explicit cleanup/uninstall workflow for
unsupported Unraid versions, with safer removal paths by OS release.

* **Bug Fixes**
* Detects and removes both new and legacy Connect configurations,
ensuring proper sign-out and web-server reload.
* Strengthens version gating to avoid problematic pre-release builds and
advises uninstall/upgrade when needed.

* **Chores**
  * Lowers declared minimum Unraid version to broaden compatibility.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-04 15:28:20 -04:00
Eli Bosley
239cdd6133 fix: info alert docker icon (#1661) 2025-09-04 15:18:07 -04:00
Eli Bosley
77cfc07dda refactor: enhance CSS structure with @layer for component styles (#1660)
- Introduced @layer directive to ensure base styles have lower priority
than Tailwind utilities.
- Organized CSS resets for box-sizing, figures, headings, paragraphs,
and unordered lists under a single @layer base block for improved
maintainability.

These changes streamline the CSS structure and enhance compatibility
with Tailwind CSS utilities.

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

## Summary by CodeRabbit

- Style
- Wrapped core resets in a base style layer, adjusting cascade with
utility classes.
  - Applied global box-sizing within the base layer.
  - Consolidated heading and paragraph resets into the layer.
- Added a reset for unordered lists to remove default bullets and
padding.
  - Retained the logo figure reset within the layer.
- Updated formatting and header comments to reflect the layering
approach.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-04 14:36:25 -04:00
Eli Bosley
728b38ac11 fix: enhance getKeyFile function to handle missing key file gracefully (#1659)
- Updated the getKeyFile function to catch ENOENT errors when the
specified key file does not exist, returning an empty string instead of
throwing an error.
- Added new tests to verify the behavior of getKeyFile when the key file
is missing and when it exists, ensuring robust error handling and
correct functionality.

These changes improve the reliability of the key file retrieval process
in the application.

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

## Summary by CodeRabbit

- New Features
  - None

- Bug Fixes
- Prevents errors when the key file is missing by returning an empty
value instead of failing, while preserving existing behaviors in other
states.

- Tests
  - Refactored tests to use a mocked filesystem with better isolation.
  - Added scenarios for missing key files and correctly decoded keys.
  - Improved assertions for clearer, deterministic outcomes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-09-04 14:23:42 -04:00
Eli Bosley
44774d0acd fix: UnraidToaster component and update dialog close button (#1657)
- Introduced a new UnraidToaster component for displaying notifications
with customizable positions.
- Updated the DialogClose component to use a span element for better
semantic structure.
- Enhanced CSS for the sonner component to ensure proper layout and
styling.

These changes improve user feedback through notifications and refine the
dialog close button's implementation.

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

* **New Features**
* Added a toaster notifications component with configurable screen
position, rich colors, and a close button; programmatic and legacy
mounting helpers exposed.

* **Style**
  * Updated toast close-button spacing and min-width behavior.
* Simplified dialog close-button rendering and removed redundant style
resets.
* Reduced SSO provider icon size and added SSO button font-size tokens.

* **Tests**
  * Added unit tests covering component mounting and global exports.

* **Chores**
  * Deployment now performs broader remote cleanup before syncing.
* **Chores**
* Type declarations and tsconfig updated for global mount/utility
typings.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-04 12:34:16 -04:00
Eli Bosley
e204eb80a0 fix: oidc cache busting issues fixed (#1656)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- Bug Fixes
- SSO/OIDC provider changes now take effect immediately by clearing
caches on updates, deletes, and settings changes.
  - Updating a provider’s issuer no longer requires an API restart.

- Tests
- Added extensive test coverage for OIDC config caching, including
per‑provider and global invalidation and manual/automatic configuration
paths.

- Chores
- Updated internal module wiring to resolve circular dependencies; no
user-facing changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-04 12:29:13 -04:00
Eli Bosley
0c727c37f4 refactor: update UserProfile positioning and clean up unraid components
- Adjusted the positioning of the UserProfile component to be absolute, ensuring it aligns correctly within its parent container.
- Modified the clean-unraid.sh script to remove the entire components directory instead of just the standalone apps directory, enhancing cleanup efficiency.
- Added a cleanup step in deploy-dev.sh to clear the remote standalone directory before deployment, ensuring a fresh setup.

These changes improve the layout of the UserProfile component and streamline the deployment process by ensuring no residual files remain.
2025-09-04 07:24:46 -04:00
Eli Bosley
292bc0fc81 fix: api version json response (#1653)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Version command now supports JSON output via a -j/--json flag,
returning version, build (when available), and a combined value. Default
human-readable output remains unchanged.
* **Tests**
* Added comprehensive tests for version command behavior across
human-readable and JSON modes, including scenarios with and without
build metadata.
* **Chores**
  * Bumped API configuration version from 4.18.1 to 4.18.2.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-03 21:09:37 -04:00
Eli Bosley
53f501e1a7 refactor: update test for hidden element mounting in vue-mount-app
- Renamed test case to clarify that the component should mount even when the element is hidden.
- Adjusted assertions to ensure that hidden elements can still mount successfully without triggering warnings.

This change enhances the clarity and reliability of the test suite for the vue-mount-app component.
2025-09-03 17:31:52 -04:00
Eli Bosley
6cf7c88242 fix: enhance DOM validation and cleanup in vue-mount-app
- Improved validation logic for mounted elements to ensure stable DOM connections and prevent manipulation issues.
- Added cleanup step to clear existing unraid-components directory before installation, ensuring a clean setup.

This update aims to enhance the reliability of component mounting and reduce potential UI issues.
2025-09-03 17:29:51 -04:00
Eli Bosley
33774aa596 fix: vue mounting logic with tests (#1651)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* Bug Fixes
* Prevents duplicate modal instances and remounts, improving stability
across pages.
* Improves auto-mount reliability with better DOM validation and
recovery from mount errors.
* Enhances cleanup during unmounts to avoid residual artifacts and
intermittent UI issues.
* More robust handling of shadow DOM and problematic DOM structures,
reducing crashes.

* Style
* Adds extra top margin to the OS version controls for improved spacing.

* Tests
* Introduces a comprehensive test suite covering mounting, unmounting,
error recovery, i18n, and global state behaviors.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-03 17:10:21 -04:00
39 changed files with 2616 additions and 274 deletions

View File

@@ -1 +1 @@
{".":"4.18.2"}
{".":"4.19.0"}

View File

@@ -76,4 +76,21 @@ body {
button:not(:disabled),
[role='button']:not(:disabled) {
cursor: pointer;
}
/* Font size overrides for SSO button component */
unraid-sso-button {
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
--text-5xl: 3rem;
--text-6xl: 3.75rem;
--text-7xl: 4.5rem;
--text-8xl: 6rem;
--text-9xl: 8rem;
}

View File

@@ -229,6 +229,8 @@
top: 0;
height: 20px;
width: 20px;
min-width: inherit !important;
margin: 0 !important;
display: flex;
justify-content: center;
align-items: center;
@@ -696,4 +698,11 @@
.sonner-loader[data-visible='false'] {
opacity: 0;
transform: scale(0.8) translate(-50%, -50%);
}
/* Override Unraid webgui docker icon styles on sonner containers */
[data-sonner-toast] [data-icon]:before,
[data-sonner-toast] .fa-docker:before {
font-family: inherit !important;
content: '' !important;
}

View File

@@ -167,6 +167,27 @@
--max-width-800px: 800px;
--max-width-1024px: 1024px;
/* Container sizes adjusted for 10px base font size (1.6x scale) */
--container-xs: 32rem;
--container-sm: 38.4rem;
--container-md: 44.8rem;
--container-lg: 51.2rem;
--container-xl: 57.6rem;
--container-2xl: 67.2rem;
--container-3xl: 76.8rem;
--container-4xl: 89.6rem;
--container-5xl: 102.4rem;
--container-6xl: 115.2rem;
--container-7xl: 128rem;
/* Extended width scale for max-w-* utilities */
--width-5xl: 102.4rem;
--width-6xl: 115.2rem;
--width-7xl: 128rem;
--width-8xl: 140.8rem;
--width-9xl: 153.6rem;
--width-10xl: 166.4rem;
/* Animations */
--animate-mark-2: mark-2 1.5s ease infinite;
--animate-mark-3: mark-3 1.5s ease infinite;

View File

@@ -1,5 +1,24 @@
# Changelog
## [4.19.0](https://github.com/unraid/api/compare/v4.18.2...v4.19.0) (2025-09-04)
### Features
* mount vue apps, not web components ([#1639](https://github.com/unraid/api/issues/1639)) ([88087d5](https://github.com/unraid/api/commit/88087d5201992298cdafa791d5d1b5bb23dcd72b))
### Bug Fixes
* api version json response ([#1653](https://github.com/unraid/api/issues/1653)) ([292bc0f](https://github.com/unraid/api/commit/292bc0fc810a0d0f0cce6813b0631ff25099cc05))
* enhance DOM validation and cleanup in vue-mount-app ([6cf7c88](https://github.com/unraid/api/commit/6cf7c88242f2f4fe9f83871560039767b5b90273))
* enhance getKeyFile function to handle missing key file gracefully ([#1659](https://github.com/unraid/api/issues/1659)) ([728b38a](https://github.com/unraid/api/commit/728b38ac11faeacd39ce9d0157024ad140e29b36))
* info alert docker icon ([#1661](https://github.com/unraid/api/issues/1661)) ([239cdd6](https://github.com/unraid/api/commit/239cdd6133690699348e61f68e485d2b54fdcbdb))
* oidc cache busting issues fixed ([#1656](https://github.com/unraid/api/issues/1656)) ([e204eb8](https://github.com/unraid/api/commit/e204eb80a00ab9242e3dca4ccfc3e1b55a7694b7))
* **plugin:** restore cleanup behavior for unsupported unraid versions ([#1658](https://github.com/unraid/api/issues/1658)) ([534a077](https://github.com/unraid/api/commit/534a07788b76de49e9ba14059a9aed0bf16e02ca))
* UnraidToaster component and update dialog close button ([#1657](https://github.com/unraid/api/issues/1657)) ([44774d0](https://github.com/unraid/api/commit/44774d0acdd25aa33cb60a5d0b4f80777f4068e5))
* vue mounting logic with tests ([#1651](https://github.com/unraid/api/issues/1651)) ([33774aa](https://github.com/unraid/api/commit/33774aa596124a031a7452b62ca4c43743a09951))
## [4.18.2](https://github.com/unraid/api/compare/v4.18.1...v4.18.2) (2025-09-03)

View File

@@ -1,5 +1,5 @@
{
"version": "4.18.1",
"version": "4.18.2",
"extraOrigins": [],
"sandbox": true,
"ssoSubIds": [],

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/api",
"version": "4.18.2",
"version": "4.19.0",
"main": "src/cli/index.ts",
"type": "module",
"corepack": {

View File

@@ -1,11 +1,12 @@
import { expect, test } from 'vitest';
import { expect, test, vi } from 'vitest';
import { store } from '@app/store/index.js';
import { FileLoadStatus, StateFileKey } from '@app/store/types.js';
import '@app/core/utils/misc/get-key-file.js';
import '@app/store/modules/emhttp.js';
vi.mock('fs/promises');
test('Before loading key returns null', async () => {
const { getKeyFile } = await import('@app/core/utils/misc/get-key-file.js');
const { status } = store.getState().registration;
@@ -48,21 +49,70 @@ test('Returns empty key if key location is empty', async () => {
await expect(getKeyFile()).resolves.toBe('');
});
test(
'Returns decoded key file if key location exists',
async () => {
const { getKeyFile } = await import('@app/core/utils/misc/get-key-file.js');
const { loadStateFiles } = await import('@app/store/modules/emhttp.js');
const { loadRegistrationKey } = await import('@app/store/modules/registration.js');
// Load state files into store
await store.dispatch(loadStateFiles());
await store.dispatch(loadRegistrationKey());
// Check if store has state files loaded
const { status } = store.getState().registration;
expect(status).toBe(FileLoadStatus.LOADED);
await expect(getKeyFile()).resolves.toMatchInlineSnapshot(
'"hVs1tLjvC9FiiQsIwIQ7G1KszAcexf0IneThhnmf22SB0dGs5WzRkqMiSMmt2DtR5HOXFUD32YyxuzGeUXmky3zKpSu6xhZNKVg5atGM1OfvkzHBMldI3SeBLuUFSgejLbpNUMdTrbk64JJdbzle4O8wiQgkIpAMIGxeYLwLBD4zHBcfyzq40QnxG--HcX6j25eE0xqa2zWj-j0b0rCAXahJV2a3ySCbPzr1MvfPRTVb0rr7KJ-25R592hYrz4H7Sc1B3p0lr6QUxHE6o7bcYrWKDRtIVoZ8SMPpd1_0gzYIcl5GsDFzFumTXUh8NEnl0Q8hwW1YE-tRc6Y_rrvd7w"'
);
},
{ timeout: 10000 }
);
test('Returns empty string when key file does not exist (ENOENT)', async () => {
const { readFile } = await import('fs/promises');
// Mock readFile to throw ENOENT error
const readFileMock = vi.mocked(readFile);
readFileMock.mockRejectedValueOnce(
Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' })
);
// Clear the module cache and re-import to get fresh module with mock
vi.resetModules();
const { getKeyFile } = await import('@app/core/utils/misc/get-key-file.js');
const { updateEmhttpState } = await import('@app/store/modules/emhttp.js');
const { store: freshStore } = await import('@app/store/index.js');
// Set key file location to a non-existent file
freshStore.dispatch(
updateEmhttpState({
field: StateFileKey.var,
state: {
regFile: '/boot/config/Pro.key',
},
})
);
// Should return empty string when file doesn't exist
await expect(getKeyFile()).resolves.toBe('');
// Clear mock
readFileMock.mockReset();
vi.resetModules();
});
test('Returns decoded key file if key location exists', async () => {
const { readFile } = await import('fs/promises');
// Mock a valid key file content
const mockKeyContent =
'hVs1tLjvC9FiiQsIwIQ7G1KszAcexf0IneThhnmf22SB0dGs5WzRkqMiSMmt2DtR5HOXFUD32YyxuzGeUXmky3zKpSu6xhZNKVg5atGM1OfvkzHBMldI3SeBLuUFSgejLbpNUMdTrbk64JJdbzle4O8wiQgkIpAMIGxeYLwLBD4zHBcfyzq40QnxG--HcX6j25eE0xqa2zWj-j0b0rCAXahJV2a3ySCbPzr1MvfPRTVb0rr7KJ-25R592hYrz4H7Sc1B3p0lr6QUxHE6o7bcYrWKDRtIVoZ8SMPpd1_0gzYIcl5GsDFzFumTXUh8NEnl0Q8hwW1YE-tRc6Y_rrvd7w==';
const binaryContent = Buffer.from(mockKeyContent, 'base64').toString('binary');
const readFileMock = vi.mocked(readFile);
readFileMock.mockResolvedValue(binaryContent);
// Clear the module cache and re-import to get fresh module with mock
vi.resetModules();
const { getKeyFile } = await import('@app/core/utils/misc/get-key-file.js');
const { loadStateFiles } = await import('@app/store/modules/emhttp.js');
const { loadRegistrationKey } = await import('@app/store/modules/registration.js');
const { store: freshStore } = await import('@app/store/index.js');
// Load state files into store
await freshStore.dispatch(loadStateFiles());
await freshStore.dispatch(loadRegistrationKey());
// Check if store has state files loaded
const { status } = freshStore.getState().registration;
expect(status).toBe(FileLoadStatus.LOADED);
const result = await getKeyFile();
expect(result).toBe(
'hVs1tLjvC9FiiQsIwIQ7G1KszAcexf0IneThhnmf22SB0dGs5WzRkqMiSMmt2DtR5HOXFUD32YyxuzGeUXmky3zKpSu6xhZNKVg5atGM1OfvkzHBMldI3SeBLuUFSgejLbpNUMdTrbk64JJdbzle4O8wiQgkIpAMIGxeYLwLBD4zHBcfyzq40QnxG--HcX6j25eE0xqa2zWj-j0b0rCAXahJV2a3ySCbPzr1MvfPRTVb0rr7KJ-25R592hYrz4H7Sc1B3p0lr6QUxHE6o7bcYrWKDRtIVoZ8SMPpd1_0gzYIcl5GsDFzFumTXUh8NEnl0Q8hwW1YE-tRc6Y_rrvd7w'
);
// Clear mock
readFileMock.mockReset();
vi.resetModules();
}, 10000);

View File

@@ -16,11 +16,22 @@ export const getKeyFile = async function (appStore: RootState = store.getState()
const keyFileName = basename(emhttp.var?.regFile);
const registrationKeyFilePath = join(paths['keyfile-base'], keyFileName);
const keyFile = await readFile(registrationKeyFilePath, 'binary');
return Buffer.from(keyFile, 'binary')
.toString('base64')
.trim()
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
try {
const keyFile = await readFile(registrationKeyFilePath, 'binary');
return Buffer.from(keyFile, 'binary')
.toString('base64')
.trim()
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
} catch (error) {
// Handle ENOENT error when Pro.key file doesn't exist
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
// Return empty string when key file is missing (ENOKEYFILE state)
return '';
}
// Re-throw other errors
throw error;
}
};

View File

@@ -0,0 +1,111 @@
import { Test, TestingModule } from '@nestjs/testing';
import { afterEach, beforeEach, describe, expect, it, MockInstance, vi } from 'vitest';
import { LogService } from '@app/unraid-api/cli/log.service.js';
import { VersionCommand } from '@app/unraid-api/cli/version.command.js';
let API_VERSION_MOCK = '4.18.2+build123';
vi.mock('@app/environment.js', async (importOriginal) => {
const actual = (await importOriginal()) as any;
return {
...actual,
get API_VERSION() {
return API_VERSION_MOCK;
},
};
});
describe('VersionCommand', () => {
let command: VersionCommand;
let logService: LogService;
let consoleLogSpy: MockInstance<typeof console.log>;
beforeEach(async () => {
API_VERSION_MOCK = '4.18.2+build123'; // Reset to default before each test
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const module: TestingModule = await Test.createTestingModule({
providers: [
VersionCommand,
{
provide: LogService,
useValue: {
info: vi.fn(),
},
},
],
}).compile();
command = module.get<VersionCommand>(VersionCommand);
logService = module.get<LogService>(LogService);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('run', () => {
it('should output version with logger when no options provided', async () => {
await command.run([]);
expect(logService.info).toHaveBeenCalledWith('Unraid API v4.18.2+build123');
expect(consoleLogSpy).not.toHaveBeenCalled();
});
it('should output version with logger when json option is false', async () => {
await command.run([], { json: false });
expect(logService.info).toHaveBeenCalledWith('Unraid API v4.18.2+build123');
expect(consoleLogSpy).not.toHaveBeenCalled();
});
it('should output JSON when json option is true', async () => {
await command.run([], { json: true });
expect(logService.info).not.toHaveBeenCalled();
expect(consoleLogSpy).toHaveBeenCalledWith(
JSON.stringify({
version: '4.18.2',
build: 'build123',
combined: '4.18.2+build123',
})
);
});
it('should handle version without build info', async () => {
API_VERSION_MOCK = '4.18.2'; // Set version without build info
const module: TestingModule = await Test.createTestingModule({
providers: [
VersionCommand,
{
provide: LogService,
useValue: {
info: vi.fn(),
},
},
],
}).compile();
const commandWithoutBuild = module.get<VersionCommand>(VersionCommand);
await commandWithoutBuild.run([], { json: true });
expect(consoleLogSpy).toHaveBeenCalledWith(
JSON.stringify({
version: '4.18.2',
build: undefined,
combined: '4.18.2',
})
);
});
});
describe('parseJson', () => {
it('should return true', () => {
expect(command.parseJson()).toBe(true);
});
});
});

View File

@@ -1,14 +1,37 @@
import { Command, CommandRunner } from 'nest-commander';
import { Command, CommandRunner, Option } from 'nest-commander';
import { API_VERSION } from '@app/environment.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
@Command({ name: 'version' })
interface VersionOptions {
json?: boolean;
}
@Command({ name: 'version', description: 'Display API version information' })
export class VersionCommand extends CommandRunner {
constructor(private readonly logger: LogService) {
super();
}
async run(): Promise<void> {
this.logger.info(`Unraid API v${API_VERSION}`);
@Option({
flags: '-j, --json',
description: 'Output version information as JSON',
})
parseJson(): boolean {
return true;
}
async run(passedParam: string[], options?: VersionOptions): Promise<void> {
if (options?.json) {
const [baseVersion, buildInfo] = API_VERSION.split('+');
const versionInfo = {
version: baseVersion || API_VERSION,
build: buildInfo || undefined,
combined: API_VERSION,
};
console.log(JSON.stringify(versionInfo));
} else {
this.logger.info(`Unraid API v${API_VERSION}`);
}
}
}

View File

@@ -0,0 +1,216 @@
import { ConfigService } from '@nestjs/config';
import { Test } from '@nestjs/testing';
import * as client from 'openid-client';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { OidcClientConfigService } from '@app/unraid-api/graph/resolvers/sso/client/oidc-client-config.service.js';
import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/core/oidc-validation.service.js';
import { OidcProvider } from '@app/unraid-api/graph/resolvers/sso/models/oidc-provider.model.js';
vi.mock('openid-client');
describe('OidcClientConfigService - Cache Behavior', () => {
let service: OidcClientConfigService;
let validationService: OidcValidationService;
const createMockProvider = (port: number): OidcProvider => ({
id: 'test-provider',
name: 'Test Provider',
clientId: 'test-client-id',
clientSecret: 'test-secret',
issuer: `http://localhost:${port}`,
scopes: ['openid', 'profile', 'email'],
authorizationRules: [],
});
const createMockConfiguration = (port: number) => {
const mockConfig = {
serverMetadata: vi.fn(() => ({
issuer: `http://localhost:${port}`,
authorization_endpoint: `http://localhost:${port}/auth`,
token_endpoint: `http://localhost:${port}/token`,
jwks_uri: `http://localhost:${port}/jwks`,
userinfo_endpoint: `http://localhost:${port}/userinfo`,
})),
};
return mockConfig as unknown as client.Configuration;
};
beforeEach(async () => {
vi.clearAllMocks();
const mockConfigService = {
get: vi.fn(),
set: vi.fn(),
};
const module = await Test.createTestingModule({
providers: [
OidcClientConfigService,
OidcValidationService,
{
provide: ConfigService,
useValue: mockConfigService,
},
],
}).compile();
service = module.get<OidcClientConfigService>(OidcClientConfigService);
validationService = module.get<OidcValidationService>(OidcValidationService);
});
describe('Configuration Caching', () => {
it('should cache configuration on first call', async () => {
const provider = createMockProvider(1029);
const mockConfig = createMockConfiguration(1029);
vi.spyOn(validationService, 'performDiscovery').mockResolvedValueOnce(mockConfig);
// First call
const config1 = await service.getOrCreateConfig(provider);
expect(validationService.performDiscovery).toHaveBeenCalledTimes(1);
expect(config1.serverMetadata().issuer).toBe('http://localhost:1029');
// Second call with same provider ID should use cache
const config2 = await service.getOrCreateConfig(provider);
expect(validationService.performDiscovery).toHaveBeenCalledTimes(1);
expect(config2).toBe(config1);
});
it('should return stale cached configuration when issuer changes without cache clear', async () => {
const provider1029 = createMockProvider(1029);
const provider1030 = createMockProvider(1030);
const mockConfig1029 = createMockConfiguration(1029);
const mockConfig1030 = createMockConfiguration(1030);
vi.spyOn(validationService, 'performDiscovery')
.mockResolvedValueOnce(mockConfig1029)
.mockResolvedValueOnce(mockConfig1030);
// Initial configuration on port 1029
const config1 = await service.getOrCreateConfig(provider1029);
expect(config1.serverMetadata().issuer).toBe('http://localhost:1029');
expect(config1.serverMetadata().authorization_endpoint).toBe('http://localhost:1029/auth');
// Update provider to port 1030 (simulating UI change)
// Without clearing cache, it should still return the old cached config
const config2 = await service.getOrCreateConfig(provider1030);
// THIS IS THE BUG: The service returns cached config for port 1029
// even though the provider now has issuer on port 1030
expect(config2.serverMetadata().issuer).toBe('http://localhost:1029');
expect(config2.serverMetadata().authorization_endpoint).toBe('http://localhost:1029/auth');
// performDiscovery should only be called once because cache is used
expect(validationService.performDiscovery).toHaveBeenCalledTimes(1);
});
it('should return fresh configuration after cache is cleared', async () => {
const provider1029 = createMockProvider(1029);
const provider1030 = createMockProvider(1030);
const mockConfig1029 = createMockConfiguration(1029);
const mockConfig1030 = createMockConfiguration(1030);
vi.spyOn(validationService, 'performDiscovery')
.mockResolvedValueOnce(mockConfig1029)
.mockResolvedValueOnce(mockConfig1030);
// Initial configuration on port 1029
const config1 = await service.getOrCreateConfig(provider1029);
expect(config1.serverMetadata().issuer).toBe('http://localhost:1029');
// Clear cache for the provider
service.clearCache(provider1030.id);
// Now it should fetch fresh config for port 1030
const config2 = await service.getOrCreateConfig(provider1030);
expect(config2.serverMetadata().issuer).toBe('http://localhost:1030');
expect(config2.serverMetadata().authorization_endpoint).toBe('http://localhost:1030/auth');
// performDiscovery should be called twice (once for each port)
expect(validationService.performDiscovery).toHaveBeenCalledTimes(2);
});
it('should clear all provider caches when clearCache is called without providerId', async () => {
const provider1 = { ...createMockProvider(1029), id: 'provider1' };
const provider2 = { ...createMockProvider(1030), id: 'provider2' };
const mockConfig1 = createMockConfiguration(1029);
const mockConfig2 = createMockConfiguration(1030);
vi.spyOn(validationService, 'performDiscovery')
.mockResolvedValueOnce(mockConfig1)
.mockResolvedValueOnce(mockConfig2)
.mockResolvedValueOnce(mockConfig1)
.mockResolvedValueOnce(mockConfig2);
// Cache both providers
await service.getOrCreateConfig(provider1);
await service.getOrCreateConfig(provider2);
expect(service.getCacheSize()).toBe(2);
// Clear all caches
service.clearCache();
expect(service.getCacheSize()).toBe(0);
// Both should fetch fresh configs
await service.getOrCreateConfig(provider1);
await service.getOrCreateConfig(provider2);
// performDiscovery should be called 4 times total
expect(validationService.performDiscovery).toHaveBeenCalledTimes(4);
});
});
describe('Manual Configuration Caching', () => {
it('should cache manual configuration and exhibit same stale cache issue', async () => {
const provider1029: OidcProvider = {
id: 'manual-provider',
name: 'Manual Provider',
clientId: 'client-id',
clientSecret: 'secret',
issuer: '',
authorizationEndpoint: 'http://localhost:1029/auth',
tokenEndpoint: 'http://localhost:1029/token',
scopes: ['openid'],
authorizationRules: [],
};
const provider1030: OidcProvider = {
...provider1029,
authorizationEndpoint: 'http://localhost:1030/auth',
tokenEndpoint: 'http://localhost:1030/token',
};
// Mock the client.Configuration constructor for manual configs
const mockManualConfig1029 = createMockConfiguration(1029);
const mockManualConfig1030 = createMockConfiguration(1030);
let configCallCount = 0;
vi.mocked(client.Configuration).mockImplementation(() => {
configCallCount++;
return configCallCount === 1 ? mockManualConfig1029 : mockManualConfig1030;
});
vi.mocked(client.ClientSecretPost).mockReturnValue({} as any);
vi.mocked(client.allowInsecureRequests).mockImplementation(() => {});
// First call with port 1029
const config1 = await service.getOrCreateConfig(provider1029);
expect(config1.serverMetadata().authorization_endpoint).toBe('http://localhost:1029/auth');
// Update to port 1030 without clearing cache
const config2 = await service.getOrCreateConfig(provider1030);
// BUG: Still returns cached config with port 1029
expect(config2.serverMetadata().authorization_endpoint).toBe('http://localhost:1029/auth');
// Clear cache and try again
service.clearCache(provider1030.id);
const config3 = await service.getOrCreateConfig(provider1030);
// Now it should return the updated config
expect(config3.serverMetadata().authorization_endpoint).toBe('http://localhost:1030/auth');
});
});
});

View File

@@ -1,11 +1,11 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { OidcClientConfigService } from '@app/unraid-api/graph/resolvers/sso/client/oidc-client-config.service.js';
import { OidcRedirectUriService } from '@app/unraid-api/graph/resolvers/sso/client/oidc-redirect-uri.service.js';
import { OidcBaseModule } from '@app/unraid-api/graph/resolvers/sso/core/oidc-base.module.js';
@Module({
imports: [OidcBaseModule],
imports: [forwardRef(() => OidcBaseModule)],
providers: [OidcClientConfigService, OidcRedirectUriService],
exports: [OidcClientConfigService, OidcRedirectUriService],
})

View File

@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { UserSettingsModule } from '@unraid/shared/services/user-settings.js';
import { OidcClientModule } from '@app/unraid-api/graph/resolvers/sso/client/oidc-client.module.js';
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';
@Module({
imports: [UserSettingsModule],
imports: [UserSettingsModule, forwardRef(() => OidcClientModule)],
providers: [OidcConfigPersistence, OidcValidationService],
exports: [OidcConfigPersistence, OidcValidationService],
})

View File

@@ -0,0 +1,276 @@
import { ConfigService } from '@nestjs/config';
import { Test } from '@nestjs/testing';
import * as fs from 'fs/promises';
import { UserSettingsService } from '@unraid/shared/services/user-settings.js';
import * as client from 'openid-client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { OidcClientConfigService } from '@app/unraid-api/graph/resolvers/sso/client/oidc-client-config.service.js';
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 { OidcProvider } from '@app/unraid-api/graph/resolvers/sso/models/oidc-provider.model.js';
vi.mock('openid-client');
vi.mock('fs/promises', () => ({
writeFile: vi.fn().mockResolvedValue(undefined),
mkdir: vi.fn().mockResolvedValue(undefined),
stat: vi.fn().mockRejectedValue(new Error('File not found')),
}));
describe('OIDC Config Cache Fix - Integration Test', () => {
let configPersistence: OidcConfigPersistence;
let clientConfigService: OidcClientConfigService;
let mockConfigService: any;
afterEach(() => {
delete process.env.PATHS_CONFIG;
});
const createMockProvider = (port: number): OidcProvider => ({
id: 'test-provider',
name: 'Test Provider',
clientId: 'test-client-id',
clientSecret: 'test-secret',
issuer: `http://localhost:${port}`,
scopes: ['openid', 'profile', 'email'],
authorizationRules: [
{
claim: 'email',
operator: 'endsWith' as any,
value: ['@example.com'],
},
],
});
const createMockConfiguration = (port: number) => {
const mockConfig = {
serverMetadata: vi.fn(() => ({
issuer: `http://localhost:${port}`,
authorization_endpoint: `http://localhost:${port}/auth`,
token_endpoint: `http://localhost:${port}/token`,
jwks_uri: `http://localhost:${port}/jwks`,
userinfo_endpoint: `http://localhost:${port}/userinfo`,
})),
};
return mockConfig as unknown as client.Configuration;
};
beforeEach(async () => {
vi.clearAllMocks();
// Set environment variable for config path
process.env.PATHS_CONFIG = '/tmp/test-config';
mockConfigService = {
get: vi.fn((key: string) => {
if (key === 'oidc') {
return {
providers: [createMockProvider(1029)],
defaultAllowedOrigins: [],
};
}
if (key === 'paths.config') {
return '/tmp/test-config';
}
return undefined;
}),
set: vi.fn(),
getOrThrow: vi.fn((key: string) => {
if (key === 'paths.config' || key === 'paths') {
return '/tmp/test-config';
}
return '/tmp/test-config';
}),
};
const mockUserSettingsService = {
register: vi.fn(),
getAllSettings: vi.fn(),
getAllValues: vi.fn(),
updateNamespacedValues: vi.fn(),
};
const module = await Test.createTestingModule({
providers: [
OidcConfigPersistence,
OidcClientConfigService,
OidcValidationService,
{
provide: ConfigService,
useValue: mockConfigService,
},
{
provide: UserSettingsService,
useValue: mockUserSettingsService,
},
],
}).compile();
configPersistence = module.get<OidcConfigPersistence>(OidcConfigPersistence);
clientConfigService = module.get<OidcClientConfigService>(OidcClientConfigService);
// Mock the persist method since we don't want to write to disk in tests
vi.spyOn(configPersistence as any, 'persist').mockResolvedValue(undefined);
});
describe('Cache clearing on provider update', () => {
it('should clear cache when provider is updated via upsertProvider', async () => {
const provider1029 = createMockProvider(1029);
const provider1030 = createMockProvider(1030);
const mockConfig1029 = createMockConfiguration(1029);
const mockConfig1030 = createMockConfiguration(1030);
// Mock validation service to return configs
const validationService = (configPersistence as any).validationService;
vi.spyOn(validationService, 'performDiscovery')
.mockResolvedValueOnce(mockConfig1029)
.mockResolvedValueOnce(mockConfig1030);
// First, get config for port 1029 - this caches it
const config1 = await clientConfigService.getOrCreateConfig(provider1029);
expect(config1.serverMetadata().issuer).toBe('http://localhost:1029');
// Spy on clearCache method
const clearCacheSpy = vi.spyOn(clientConfigService, 'clearCache');
// Update the provider to port 1030 via upsertProvider
await configPersistence.upsertProvider(provider1030);
// Verify cache was cleared for this specific provider
expect(clearCacheSpy).toHaveBeenCalledWith(provider1030.id);
// Now get config again - should fetch fresh config for port 1030
const config2 = await clientConfigService.getOrCreateConfig(provider1030);
expect(config2.serverMetadata().issuer).toBe('http://localhost:1030');
expect(config2.serverMetadata().authorization_endpoint).toBe('http://localhost:1030/auth');
// Verify discovery was called twice (not using cache)
expect(validationService.performDiscovery).toHaveBeenCalledTimes(2);
});
it('should clear cache when provider is deleted', async () => {
const provider = createMockProvider(1029);
const mockConfig = createMockConfiguration(1029);
// Setup initial provider in config
mockConfigService.get.mockReturnValue({
providers: [provider, { ...provider, id: 'other-provider' }],
defaultAllowedOrigins: [],
});
// Mock validation service
const validationService = (configPersistence as any).validationService;
vi.spyOn(validationService, 'performDiscovery').mockResolvedValue(mockConfig);
// First, cache the provider config
await clientConfigService.getOrCreateConfig(provider);
// Spy on clearCache
const clearCacheSpy = vi.spyOn(clientConfigService, 'clearCache');
// Delete the provider
const deleted = await configPersistence.deleteProvider(provider.id);
expect(deleted).toBe(true);
// Verify cache was cleared for the deleted provider
expect(clearCacheSpy).toHaveBeenCalledWith(provider.id);
});
it('should clear all provider caches when updated via settings updateValues', async () => {
// This simulates what happens when settings are saved through the UI
const settingsCallback = (configPersistence as any).userSettings.register.mock.calls[0][1];
const newConfig = {
providers: [
{
...createMockProvider(1030),
authorizationMode: 'simple',
simpleAuthorization: {
allowedDomains: ['example.com'],
allowedEmails: [],
allowedUserIds: [],
},
},
],
defaultAllowedOrigins: [],
};
// Spy on clearCache
const clearCacheSpy = vi.spyOn(clientConfigService, 'clearCache');
// Mock validation
const validationService = (configPersistence as any).validationService;
vi.spyOn(validationService, 'validateProvider').mockResolvedValue({
isValid: true,
});
// Call the updateValues function (simulating saving settings from UI)
await settingsCallback.updateValues(newConfig);
// Verify cache was cleared (called without arguments to clear all)
expect(clearCacheSpy).toHaveBeenCalledWith();
});
it('should NOT require API restart after updating provider issuer', async () => {
// This test confirms that the fix eliminates the need for API restart
const settingsCallback = (configPersistence as any).userSettings.register.mock.calls[0][1];
const newConfig = {
providers: [createMockProvider(1030)],
defaultAllowedOrigins: [],
};
// Mock validation
const validationService = (configPersistence as any).validationService;
vi.spyOn(validationService, 'validateProvider').mockResolvedValue({
isValid: true,
});
// Update settings
const result = await settingsCallback.updateValues(newConfig);
// Verify that restartRequired is false
expect(result.restartRequired).toBe(false);
});
});
describe('Provider validation on save', () => {
it('should validate providers and include warnings but still save', async () => {
const settingsCallback = (configPersistence as any).userSettings.register.mock.calls[0][1];
const newConfig = {
providers: [
createMockProvider(1030),
{ ...createMockProvider(1031), id: 'invalid-provider', name: 'Invalid Provider' },
],
defaultAllowedOrigins: [],
};
// Mock validation - first provider valid, second invalid
const validationService = (configPersistence as any).validationService;
vi.spyOn(validationService, 'validateProvider')
.mockResolvedValueOnce({ isValid: true })
.mockResolvedValueOnce({
isValid: false,
error: 'Discovery failed: Unable to reach issuer',
});
// Update settings
const result = await settingsCallback.updateValues(newConfig);
// Should save successfully but include warnings
expect(result.restartRequired).toBe(false);
expect(result.warnings).toBeDefined();
expect(result.warnings).toContain(
'❌ Invalid Provider: Discovery failed: Unable to reach issuer'
);
expect(result.values.providers).toHaveLength(2);
// Cache should still be cleared even with validation warnings
const clearCacheSpy = vi.spyOn(clientConfigService, 'clearCache');
await settingsCallback.updateValues(newConfig);
expect(clearCacheSpy).toHaveBeenCalled();
});
});
});

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { forwardRef, Inject, Injectable, Optional } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { RuleEffect } from '@jsonforms/core';
@@ -6,6 +6,7 @@ import { mergeSettingSlices } from '@unraid/shared/jsonforms/settings.js';
import { ConfigFilePersister } from '@unraid/shared/services/config-file.js';
import { UserSettingsService } from '@unraid/shared/services/user-settings.js';
import { OidcClientConfigService } from '@app/unraid-api/graph/resolvers/sso/client/oidc-client-config.service.js';
import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/core/oidc-validation.service.js';
import {
AuthorizationOperator,
@@ -30,7 +31,10 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
constructor(
configService: ConfigService,
private readonly userSettings: UserSettingsService,
private readonly validationService: OidcValidationService
private readonly validationService: OidcValidationService,
@Optional()
@Inject(forwardRef(() => OidcClientConfigService))
private readonly clientConfigService?: OidcClientConfigService
) {
super(configService);
this.registerSettings();
@@ -252,6 +256,15 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
this.configService.set(this.configKey(), newConfig);
await this.persist(newConfig);
// Clear the OIDC client configuration cache when a provider is updated
// This ensures the new issuer/endpoints are used immediately
if (this.clientConfigService) {
this.clientConfigService.clearCache(cleanedProvider.id);
this.logger.debug(
`Cleared OIDC client configuration cache for provider ${cleanedProvider.id}`
);
}
return cleanedProvider;
}
@@ -328,6 +341,12 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
this.configService.set(this.configKey(), newConfig);
await this.persist(newConfig);
// Clear the cache for the deleted provider
if (this.clientConfigService) {
this.clientConfigService.clearCache(id);
this.logger.debug(`Cleared OIDC client configuration cache for deleted provider ${id}`);
}
return true;
}
@@ -440,6 +459,13 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
this.configService.set(this.configKey(), processedConfig);
await this.persist(processedConfig);
// Clear the OIDC client configuration cache to ensure fresh discovery
// This fixes the issue where changing issuer URLs requires API restart
if (this.clientConfigService) {
this.clientConfigService.clearCache();
this.logger.debug('Cleared OIDC client configuration cache after provider update');
}
// Include validation results in response
const response: { restartRequired: boolean; values: OidcConfig; warnings?: string[] } = {
restartRequired: false,

View File

@@ -1,7 +1,7 @@
{
"name": "unraid-monorepo",
"private": true,
"version": "4.18.2",
"version": "4.19.0",
"scripts": {
"build": "pnpm -r build",
"build:watch": " pnpm -r --parallel build:watch",

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/connect-plugin",
"version": "4.18.2",
"version": "4.19.0",
"private": true,
"dependencies": {
"commander": "14.0.0",

View File

@@ -14,9 +14,9 @@
<!ENTITY tag "">
<!ENTITY api_version "">
]>
<!-- Plugin min version is lower than the actual min version of Connect (6.12.0) to facilitate cleanup on newly unsupported versions. -->
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&plugin_url;"
launch="&launch;" min="6.12.15" icon="globe">
launch="&launch;" min="6.9.0-rc1" icon="globe">
<CHANGES>
##a long time ago in a galaxy far far away
@@ -60,10 +60,10 @@ exit 0
// Check Unraid version
$version = @parse_ini_file('/etc/unraid-version', true)['version'];
// Check if this is a supported 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);
// Check if this is a recommended version
// - Should be 6.12.15 or higher
// - Should 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\\.15-/', $version);
if ($is_stable_6_12_or_higher) {
echo "Running on supported version {$version}\n";
@@ -71,9 +71,103 @@ if ($is_stable_6_12_or_higher) {
}
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";
echo "The plugin may not function correctly on this system. It may stop working entirely in the future.\n";
echo "⚠️ Please uninstall this plugin or upgrade to a newer version of Unraid to enjoy Unraid Connect\n";
exit(1);
// early escape handled via unraid_connect_cleanup_for_unsupported_os_versions step
exit(0);
]]>
</INLINE>
</FILE>
<!-- cleanup script for Unraid 6.12.0 and earlier. when run, it will exit the plugin install with an error status. -->
<!-- See the version of dynamix.unraid.net.plg in commit a240a031a for the original implementation of this code. -->
<FILE Name="/tmp/unraid_connect_cleanup_for_unsupported_os_versions.sh" Run="/bin/bash" Method="install" Max="6.12.0">
<INLINE>
<![CDATA[
# Inlined copy of perform_connect_cleanup from
# /usr/local/share/dynamix.unraid.net/install/scripts/cleanup.sh
# to avoid the need to decompress the plugin tarball just to run the cleanup script
# Handle flash backup deactivation and Connect signout
perform_connect_cleanup() {
printf "\n**********************************\n"
printf "🧹 CLEANING UP - may take a minute\n"
printf "**********************************\n"
# Handle git-based flash backups
if [ -f "/boot/.git" ]; then
if [ -f "/etc/rc.d/rc.flash_backup" ]; then
printf "\nStopping flash backup service. Please wait...\n"
/etc/rc.d/rc.flash_backup stop >/dev/null 2>&1
fi
if [ -f "/usr/local/emhttp/plugins/dynamix.my.servers/include/UpdateFlashBackup.php" ]; then
printf "\nDeactivating flash backup. Please wait...\n"
/usr/bin/php /usr/local/emhttp/plugins/dynamix.my.servers/include/UpdateFlashBackup.php deactivate
fi
fi
# Check if connect.json or myservers.cfg exists
if [ -f "/boot/config/plugins/dynamix.my.servers/configs/connect.json" ] || [ -f "/boot/config/plugins/dynamix.my.servers/myservers.cfg" ]; then
# Stop unraid-api
printf "\nStopping unraid-api. Please wait...\n"
output=$(/etc/rc.d/rc.unraid-api stop --delete 2>&1)
if [ -z "$output" ]; then
echo "Waiting for unraid-api to stop..."
sleep 5 # Give it time to stop
fi
echo "Stopped unraid-api: $output"
# Sign out of Unraid Connect (we'll use curl directly from shell)
# We need to extract the username from connect.json or myservers.cfg and the registration key
has_username=false
# Check connect.json first (newer format)
if [ -f "/boot/config/plugins/dynamix.my.servers/configs/connect.json" ] && command -v jq >/dev/null 2>&1; then
username=$(jq -r '.username' "/boot/config/plugins/dynamix.my.servers/configs/connect.json" 2>/dev/null)
if [ -n "$username" ] && [ "$username" != "null" ]; then
has_username=true
fi
fi
# Fallback to myservers.cfg (legacy format)
if [ "$has_username" = false ] && [ -f "/boot/config/plugins/dynamix.my.servers/myservers.cfg" ]; then
if grep -q 'username' "/boot/config/plugins/dynamix.my.servers/myservers.cfg"; then
has_username=true
fi
fi
if [ "$has_username" = true ]; then
printf "\nSigning out of Unraid Connect\n"
# Check if regFILE exists in var.ini
if [ -f "/var/local/emhttp/var.ini" ]; then
regfile=$(grep "regFILE" "/var/local/emhttp/var.ini" | cut -d= -f2)
if [ -n "$regfile" ] && [ -f "$regfile" ]; then
# Base64 encode the key file and send to server
encoded_key=$(base64 "$regfile" | tr -d '\n')
if [ -n "$encoded_key" ]; then
curl -s -X POST "https://keys.lime-technology.com/account/server/unregister" \
-d "keyfile=$encoded_key" >/dev/null 2>&1
fi
fi
fi
fi
# Remove config files
rm -f /boot/config/plugins/dynamix.my.servers/myservers.cfg
rm -f /boot/config/plugins/dynamix.my.servers/configs/connect.json
# Reload nginx to disable Remote Access
printf "\n⚠ Reloading Web Server. If this window stops updating for two minutes please close it.\n"
/etc/rc.d/rc.nginx reload >/dev/null 2>&1
fi
}
perform_connect_cleanup
echo "Done. Please uninstall the Connect plugin to complete the cleanup."
# Exit with error to clarify that further user action--either uninstalling the plugin or upgrading to a newer version of Unraid--is required.
exit 1;
]]>
</INLINE>
</FILE>
@@ -332,6 +426,14 @@ if [ -d "/usr/local/unraid-api/node_modules" ]; then
rm -rf "/usr/local/unraid-api/node_modules"
fi
# Clear existing unraid-components directory contents to ensure clean installation
echo "Cleaning up existing unraid-components directory..."
DIR="/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components"
if [ -d "$DIR" ]; then
echo "Clearing contents of: $DIR"
rm -rf "$DIR"/*
fi
# Install the package using the explicit file path
upgradepkg --install-new --reinstall "${PKG_FILE}"
if [ $? -ne 0 ]; then

View File

@@ -1,5 +1,7 @@
#!/bin/sh
# Script to handle cleanup operations during removal
# NOTE: an inline copy of this script exists in dynamix.unraid.net.plg for Unraid 6.12.14 and earlier
# When updating this script, be sure to update the inline copy as well.
# Get the operation mode
MODE="${1:-cleanup}"
@@ -23,8 +25,8 @@ perform_connect_cleanup() {
fi
fi
# Check if myservers.cfg exists
if [ -f "/boot/config/plugins/dynamix.my.servers/myservers.cfg" ]; then
# Check if connect.json or myservers.cfg exists
if [ -f "/boot/config/plugins/dynamix.my.servers/configs/connect.json" ] || [ -f "/boot/config/plugins/dynamix.my.servers/myservers.cfg" ]; then
# Stop unraid-api
printf "\nStopping unraid-api. Please wait...\n"
output=$(/etc/rc.d/rc.unraid-api stop --delete 2>&1)
@@ -35,8 +37,25 @@ perform_connect_cleanup() {
echo "Stopped unraid-api: $output"
# Sign out of Unraid Connect (we'll use curl directly from shell)
# We need to extract the username from myservers.cfg and the registration key
if grep -q 'username' "/boot/config/plugins/dynamix.my.servers/myservers.cfg"; then
# We need to extract the username from connect.json or myservers.cfg and the registration key
has_username=false
# Check connect.json first (newer format)
if [ -f "/boot/config/plugins/dynamix.my.servers/configs/connect.json" ] && command -v jq >/dev/null 2>&1; then
username=$(jq -r '.username' "/boot/config/plugins/dynamix.my.servers/configs/connect.json" 2>/dev/null)
if [ -n "$username" ] && [ "$username" != "null" ]; then
has_username=true
fi
fi
# Fallback to myservers.cfg (legacy format)
if [ "$has_username" = false ] && [ -f "/boot/config/plugins/dynamix.my.servers/myservers.cfg" ]; then
if grep -q 'username' "/boot/config/plugins/dynamix.my.servers/myservers.cfg"; then
has_username=true
fi
fi
if [ "$has_username" = true ]; then
printf "\nSigning out of Unraid Connect\n"
# Check if regFILE exists in var.ini
if [ -f "/var/local/emhttp/var.ini" ]; then
@@ -52,8 +71,9 @@ perform_connect_cleanup() {
fi
fi
# Remove myservers.cfg
# Remove config files
rm -f /boot/config/plugins/dynamix.my.servers/myservers.cfg
rm -f /boot/config/plugins/dynamix.my.servers/configs/connect.json
# Reload nginx to disable Remote Access
printf "\n⚠ Reloading Web Server. If this window stops updating for two minutes please close it.\n"
@@ -131,4 +151,4 @@ case "$MODE" in
echo "Usage: $0 [cleanup]"
exit 1
;;
esac
esac

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/ui",
"version": "4.18.2",
"version": "4.19.0",
"private": true,
"license": "GPL-2.0-or-later",
"type": "module",

View File

@@ -5,32 +5,7 @@ const props = defineProps<DialogCloseProps>();
</script>
<template>
<DialogClose v-bind="props">
<DialogClose v-bind="props" as="span">
<slot />
</DialogClose>
</template>
<style>
/* Reset webgui button styles for dialog close buttons */
[role='dialog'] button[type='button'],
button[aria-label*='close' i],
button[aria-label*='dismiss' i] {
/* Reset ALL webgui button styles using !important where needed */
all: unset !important;
/* Re-apply necessary styles after reset */
display: inline-flex !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
cursor: pointer !important;
box-sizing: border-box !important;
/* Reset any webgui CSS variables */
--button-border: none !important;
--button-text-color: inherit !important;
--button-background: transparent !important;
--button-background-size: auto !important;
}
</style>

View File

@@ -241,7 +241,9 @@ const updateItem = (index: number, newValue: unknown) => {
</Tabs>
<div v-else class="border-muted rounded-lg border-2 border-dashed py-8 text-center">
<p class="text-muted-foreground mb-4">No {{ itemTypeName.toLowerCase() }}s configured</p>
<p class="text-muted-foreground mb-4 text-center">
No {{ itemTypeName.toLowerCase() }}s configured
</p>
<Button variant="outline" size="md" :disabled="!control.enabled" @click="addItem">
<Plus class="mr-2 h-4 w-4" />
Add First {{ itemTypeName }}

View File

@@ -0,0 +1,866 @@
import { defineComponent, h } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MockInstance } from 'vitest';
import type { App as VueApp } from 'vue';
// Extend HTMLElement to include Vue's internal properties (matching the source file)
interface HTMLElementWithVue extends HTMLElement {
__vueParentComponent?: {
appContext?: {
app?: VueApp;
};
};
}
// We'll manually mock createApp only in specific tests that need it
vi.mock('vue', async () => {
const actual = await vi.importActual<typeof import('vue')>('vue');
return {
...actual,
};
});
const mockEnsureTeleportContainer = vi.fn();
vi.mock('@unraid/ui', () => ({
ensureTeleportContainer: mockEnsureTeleportContainer,
}));
const mockI18n = {
global: {},
install: vi.fn(),
};
vi.mock('vue-i18n', () => ({
createI18n: vi.fn(() => mockI18n),
}));
const mockApolloClient = { query: vi.fn(), mutate: vi.fn() };
vi.mock('~/helpers/create-apollo-client', () => ({
client: mockApolloClient,
}));
const mockGlobalPinia = {
install: vi.fn(),
use: vi.fn(),
_a: null,
_e: null,
_s: new Map(),
state: {},
};
vi.mock('~/store/globalPinia', () => ({
globalPinia: mockGlobalPinia,
}));
vi.mock('~/locales/en_US.json', () => ({
default: { test: 'Test Message' },
}));
vi.mock('~/helpers/i18n-utils', () => ({
createHtmlEntityDecoder: vi.fn(() => (str: string) => str),
}));
vi.mock('~/assets/main.css?inline', () => ({
default: '.test { color: red; }',
}));
describe('vue-mount-app', () => {
let mountVueApp: typeof import('~/components/Wrapper/vue-mount-app').mountVueApp;
let unmountVueApp: typeof import('~/components/Wrapper/vue-mount-app').unmountVueApp;
let getMountedApp: typeof import('~/components/Wrapper/vue-mount-app').getMountedApp;
let autoMountComponent: typeof import('~/components/Wrapper/vue-mount-app').autoMountComponent;
let TestComponent: ReturnType<typeof defineComponent>;
let consoleWarnSpy: MockInstance;
let consoleErrorSpy: MockInstance;
let consoleInfoSpy: MockInstance;
let consoleDebugSpy: MockInstance;
let testContainer: HTMLDivElement;
beforeEach(async () => {
const module = await import('~/components/Wrapper/vue-mount-app');
mountVueApp = module.mountVueApp;
unmountVueApp = module.unmountVueApp;
getMountedApp = module.getMountedApp;
autoMountComponent = module.autoMountComponent;
TestComponent = defineComponent({
name: 'TestComponent',
props: {
message: {
type: String,
default: 'Hello',
},
},
setup(props) {
return () => h('div', { class: 'test-component' }, props.message);
},
});
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
consoleDebugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
testContainer = document.createElement('div');
testContainer.id = 'test-container';
document.body.appendChild(testContainer);
vi.clearAllMocks();
// Clear mounted apps from previous tests
if (window.mountedApps) {
window.mountedApps.clear();
}
});
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
if (window.mountedApps) {
window.mountedApps.clear();
}
});
describe('mountVueApp', () => {
it('should mount a Vue app to a single element', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
});
expect(app).toBeTruthy();
expect(element.querySelector('.test-component')).toBeTruthy();
expect(element.textContent).toBe('Hello');
expect(mockEnsureTeleportContainer).toHaveBeenCalled();
});
it('should mount with custom props', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
props: { message: 'Custom Message' },
});
expect(app).toBeTruthy();
expect(element.textContent).toBe('Custom Message');
});
it('should parse props from element attributes', () => {
const element = document.createElement('div');
element.id = 'app';
element.setAttribute('message', 'Attribute Message');
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
});
expect(app).toBeTruthy();
expect(element.textContent).toBe('Attribute Message');
});
it('should parse JSON props from attributes', () => {
const element = document.createElement('div');
element.id = 'app';
element.setAttribute('message', '{"text": "JSON Message"}');
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
});
expect(app).toBeTruthy();
expect(element.getAttribute('message')).toBe('{"text": "JSON Message"}');
});
it('should handle HTML-encoded JSON in attributes', () => {
const element = document.createElement('div');
element.id = 'app';
element.setAttribute('message', '{&quot;text&quot;: &quot;Encoded&quot;}');
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
});
expect(app).toBeTruthy();
expect(element.getAttribute('message')).toBe('{&quot;text&quot;: &quot;Encoded&quot;}');
});
it('should mount to multiple elements', () => {
const element1 = document.createElement('div');
element1.className = 'multi-mount';
document.body.appendChild(element1);
const element2 = document.createElement('div');
element2.className = 'multi-mount';
document.body.appendChild(element2);
const app = mountVueApp({
component: TestComponent,
selector: '.multi-mount',
});
expect(app).toBeTruthy();
expect(element1.querySelector('.test-component')).toBeTruthy();
expect(element2.querySelector('.test-component')).toBeTruthy();
});
it('should use shadow root when specified', () => {
const element = document.createElement('div');
element.id = 'shadow-app';
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#shadow-app',
useShadowRoot: true,
});
expect(app).toBeTruthy();
expect(element.shadowRoot).toBeTruthy();
expect(element.shadowRoot?.querySelector('#app')).toBeTruthy();
expect(element.shadowRoot?.querySelector('.test-component')).toBeTruthy();
});
it('should inject styles into shadow root', () => {
const element = document.createElement('div');
element.id = 'shadow-app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#shadow-app',
useShadowRoot: true,
});
const styleElement = element.shadowRoot?.querySelector('style[data-tailwind]');
expect(styleElement).toBeTruthy();
expect(styleElement?.textContent).toBe('.test { color: red; }');
});
it('should inject global styles to document', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#app',
});
const globalStyle = document.querySelector('style[data-tailwind-global]');
expect(globalStyle).toBeTruthy();
expect(globalStyle?.textContent).toBe('.test { color: red; }');
});
it('should warn when app is already mounted', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
const app1 = mountVueApp({
component: TestComponent,
selector: '#app',
appId: 'test-app',
});
const app2 = mountVueApp({
component: TestComponent,
selector: '#app',
appId: 'test-app',
});
expect(app1).toBeTruthy();
expect(app2).toBe(app1);
expect(consoleWarnSpy).toHaveBeenCalledWith('[VueMountApp] App test-app is already mounted');
});
it('should handle modal singleton behavior', () => {
const element1 = document.createElement('div');
element1.id = 'modals';
document.body.appendChild(element1);
const element2 = document.createElement('div');
element2.id = 'unraid-modals';
document.body.appendChild(element2);
const app1 = mountVueApp({
component: TestComponent,
selector: '#modals',
appId: 'modals',
});
const app2 = mountVueApp({
component: TestComponent,
selector: '#unraid-modals',
appId: 'unraid-modals',
});
expect(app1).toBeTruthy();
expect(app2).toBe(app1);
expect(consoleDebugSpy).toHaveBeenCalledWith(
'[VueMountApp] Modals component already mounted as modals, skipping unraid-modals'
);
});
it('should clean up existing Vue attributes', () => {
const element = document.createElement('div');
element.id = 'app';
element.setAttribute('data-vue-mounted', 'true');
element.setAttribute('data-v-app', '');
element.setAttribute('data-server-rendered', 'true');
element.setAttribute('data-v-123', '');
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#app',
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] Element #app has Vue attributes but no content, cleaning up'
);
});
it('should handle elements with problematic child nodes', () => {
const element = document.createElement('div');
element.id = 'app';
element.appendChild(document.createTextNode(' '));
element.appendChild(document.createComment('test comment'));
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
});
expect(app).toBeTruthy();
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] Cleaning up problematic nodes in #app before mounting'
);
});
it('should return null when no elements found', () => {
const app = mountVueApp({
component: TestComponent,
selector: '#non-existent',
});
expect(app).toBeNull();
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] No elements found for selector: #non-existent'
);
});
it('should handle mount errors gracefully', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
const ErrorComponent = defineComponent({
setup() {
throw new Error('Component error');
},
});
expect(() => {
mountVueApp({
component: ErrorComponent,
selector: '#app',
});
}).toThrow('Component error');
expect(consoleErrorSpy).toHaveBeenCalled();
});
it('should add unapi class to mounted elements', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#app',
});
expect(element.classList.contains('unapi')).toBe(true);
expect(element.getAttribute('data-vue-mounted')).toBe('true');
});
it('should skip disconnected elements during multi-mount', () => {
const element1 = document.createElement('div');
element1.className = 'multi';
document.body.appendChild(element1);
const element2 = document.createElement('div');
element2.className = 'multi';
// This element is NOT added to the document
// Create a third element and manually add it to element1 to simulate DOM issues
const orphanedChild = document.createElement('span');
element1.appendChild(orphanedChild);
// Now remove element1 from DOM temporarily to trigger the warning
element1.remove();
// Add element1 back
document.body.appendChild(element1);
// Create elements matching the selector
document.body.innerHTML = '';
const validElement = document.createElement('div');
validElement.className = 'multi';
document.body.appendChild(validElement);
const disconnectedElement = document.createElement('div');
disconnectedElement.className = 'multi';
const container = document.createElement('div');
container.appendChild(disconnectedElement);
// Now disconnectedElement has a parent but that parent is not in the document
const app = mountVueApp({
component: TestComponent,
selector: '.multi',
});
expect(app).toBeTruthy();
// The app should mount only to the connected element
expect(validElement.querySelector('.test-component')).toBeTruthy();
});
});
describe('unmountVueApp', () => {
it('should unmount a mounted app', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
appId: 'test-app',
});
expect(app).toBeTruthy();
expect(getMountedApp('test-app')).toBe(app);
const result = unmountVueApp('test-app');
expect(result).toBe(true);
expect(getMountedApp('test-app')).toBeUndefined();
});
it('should clean up data attributes on unmount', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#app',
appId: 'test-app',
});
expect(element.getAttribute('data-vue-mounted')).toBe('true');
expect(element.classList.contains('unapi')).toBe(true);
unmountVueApp('test-app');
expect(element.getAttribute('data-vue-mounted')).toBeNull();
});
it('should unmount cloned apps', () => {
const element1 = document.createElement('div');
element1.className = 'multi';
document.body.appendChild(element1);
const element2 = document.createElement('div');
element2.className = 'multi';
document.body.appendChild(element2);
mountVueApp({
component: TestComponent,
selector: '.multi',
appId: 'multi-app',
});
const result = unmountVueApp('multi-app');
expect(result).toBe(true);
});
it('should remove shadow root containers', () => {
const element = document.createElement('div');
element.id = 'shadow-app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#shadow-app',
appId: 'shadow-app',
useShadowRoot: true,
});
expect(element.shadowRoot?.querySelector('#app')).toBeTruthy();
unmountVueApp('shadow-app');
expect(element.shadowRoot?.querySelector('#app')).toBeFalsy();
});
it('should warn when unmounting non-existent app', () => {
const result = unmountVueApp('non-existent');
expect(result).toBe(false);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] No app found with id: non-existent'
);
});
it('should handle unmount errors gracefully', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
appId: 'test-app',
});
// Force an error by corrupting the app
if (app) {
(app as { unmount: () => void }).unmount = () => {
throw new Error('Unmount error');
};
}
const result = unmountVueApp('test-app');
expect(result).toBe(true);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] Error unmounting app test-app:',
expect.any(Error)
);
});
});
describe('getMountedApp', () => {
it('should return mounted app by id', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
appId: 'test-app',
});
expect(getMountedApp('test-app')).toBe(app);
});
it('should return undefined for non-existent app', () => {
expect(getMountedApp('non-existent')).toBeUndefined();
});
});
describe('autoMountComponent', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should auto-mount when DOM is ready', async () => {
const element = document.createElement('div');
element.id = 'auto-app';
document.body.appendChild(element);
autoMountComponent(TestComponent, '#auto-app');
await vi.runAllTimersAsync();
expect(element.querySelector('.test-component')).toBeTruthy();
});
it('should wait for DOMContentLoaded if document is loading', async () => {
Object.defineProperty(document, 'readyState', {
value: 'loading',
writable: true,
});
const element = document.createElement('div');
element.id = 'auto-app';
document.body.appendChild(element);
autoMountComponent(TestComponent, '#auto-app');
expect(element.querySelector('.test-component')).toBeFalsy();
Object.defineProperty(document, 'readyState', {
value: 'complete',
writable: true,
});
document.dispatchEvent(new Event('DOMContentLoaded'));
await vi.runAllTimersAsync();
expect(element.querySelector('.test-component')).toBeTruthy();
});
it('should skip auto-mount for already mounted modals', async () => {
const element1 = document.createElement('div');
element1.id = 'modals';
document.body.appendChild(element1);
mountVueApp({
component: TestComponent,
selector: '#modals',
appId: 'modals',
});
autoMountComponent(TestComponent, '#modals');
await vi.runAllTimersAsync();
expect(consoleDebugSpy).toHaveBeenCalledWith(
'[VueMountApp] Modals component already mounted, skipping #modals'
);
});
it('should add delay for problematic selectors', async () => {
const element = document.createElement('div');
element.id = 'unraid-connect-settings';
document.body.appendChild(element);
autoMountComponent(TestComponent, '#unraid-connect-settings');
await vi.advanceTimersByTimeAsync(100);
expect(element.querySelector('.test-component')).toBeFalsy();
await vi.advanceTimersByTimeAsync(200);
expect(element.querySelector('.test-component')).toBeTruthy();
});
it('should mount even when element is hidden', async () => {
const element = document.createElement('div');
element.id = 'hidden-app';
element.style.display = 'none';
document.body.appendChild(element);
autoMountComponent(TestComponent, '#hidden-app');
await vi.runAllTimersAsync();
// Hidden elements should still mount successfully
expect(element.querySelector('.test-component')).toBeTruthy();
expect(consoleWarnSpy).not.toHaveBeenCalledWith(
expect.stringContaining('No valid DOM elements found')
);
});
it('should handle nextSibling errors with retry', async () => {
const element = document.createElement('div');
element.id = 'error-app';
element.setAttribute('data-vue-mounted', 'true');
element.setAttribute('data-v-app', '');
document.body.appendChild(element);
// Simulate the element having Vue instance references which cause nextSibling errors
const mockVueInstance = { appContext: { app: {} as VueApp } };
(element as HTMLElementWithVue).__vueParentComponent = mockVueInstance;
// Add an invalid child that will trigger cleanup
const textNode = document.createTextNode(' ');
element.appendChild(textNode);
autoMountComponent(TestComponent, '#error-app');
await vi.runAllTimersAsync();
// Should detect and clean up existing Vue state
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('[VueMountApp] Element #error-app has Vue attributes but no content, cleaning up')
);
// Should successfully mount after cleanup
expect(element.querySelector('.test-component')).toBeTruthy();
});
it('should skip mounting if no elements found', async () => {
autoMountComponent(TestComponent, '#non-existent');
await vi.runAllTimersAsync();
expect(consoleWarnSpy).not.toHaveBeenCalled();
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should pass options to mountVueApp', async () => {
const element = document.createElement('div');
element.id = 'options-app';
document.body.appendChild(element);
autoMountComponent(TestComponent, '#options-app', {
props: { message: 'Auto Mount Message' },
useShadowRoot: true,
});
await vi.runAllTimersAsync();
expect(element.shadowRoot).toBeTruthy();
expect(element.shadowRoot?.textContent).toContain('Auto Mount Message');
});
});
describe('i18n setup', () => {
it('should setup i18n with default locale', () => {
const element = document.createElement('div');
element.id = 'i18n-app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#i18n-app',
});
expect(mockI18n.install).toHaveBeenCalled();
});
it('should parse window locale data', () => {
const localeData = {
fr_FR: { test: 'Message de test' },
};
(window as unknown as Record<string, unknown>).LOCALE_DATA = encodeURIComponent(JSON.stringify(localeData));
const element = document.createElement('div');
element.id = 'i18n-app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#i18n-app',
});
delete (window as unknown as Record<string, unknown>).LOCALE_DATA;
});
it('should handle locale data parsing errors', () => {
(window as unknown as Record<string, unknown>).LOCALE_DATA = 'invalid json';
const element = document.createElement('div');
element.id = 'i18n-app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#i18n-app',
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[VueMountApp] error parsing messages',
expect.any(Error)
);
delete (window as unknown as Record<string, unknown>).LOCALE_DATA;
});
});
describe('error recovery', () => {
it('should attempt recovery from nextSibling error', async () => {
vi.useFakeTimers();
const element = document.createElement('div');
element.id = 'recovery-app';
document.body.appendChild(element);
// Create a mock Vue app that throws on first mount attempt
let mountAttempt = 0;
const mockApp = {
use: vi.fn().mockReturnThis(),
provide: vi.fn().mockReturnThis(),
mount: vi.fn().mockImplementation(() => {
mountAttempt++;
if (mountAttempt === 1) {
const error = new TypeError('Cannot read property nextSibling of null');
throw error;
}
return mockApp;
}),
unmount: vi.fn(),
version: '3.0.0',
config: { globalProperties: {} },
};
// Mock createApp using module mock
const vueModule = await import('vue');
vi.spyOn(vueModule, 'createApp').mockReturnValue(mockApp as never);
mountVueApp({
component: TestComponent,
selector: '#recovery-app',
appId: 'recovery-app',
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] Attempting recovery from nextSibling error for #recovery-app'
);
await vi.advanceTimersByTimeAsync(10);
expect(consoleInfoSpy).toHaveBeenCalledWith(
'[VueMountApp] Successfully recovered from nextSibling error for #recovery-app'
);
vi.useRealTimers();
});
it('should not attempt recovery with skipRecovery flag', async () => {
const element = document.createElement('div');
element.id = 'no-recovery-app';
document.body.appendChild(element);
const mockApp = {
use: vi.fn().mockReturnThis(),
provide: vi.fn().mockReturnThis(),
mount: vi.fn().mockImplementation(() => {
throw new TypeError('Cannot read property nextSibling of null');
}),
unmount: vi.fn(),
version: '3.0.0',
config: { globalProperties: {} },
};
const vueModule = await import('vue');
vi.spyOn(vueModule, 'createApp').mockReturnValue(mockApp as never);
expect(() => {
mountVueApp({
component: TestComponent,
selector: '#no-recovery-app',
skipRecovery: true,
});
}).toThrow('Cannot read property nextSibling of null');
expect(consoleWarnSpy).not.toHaveBeenCalledWith(
expect.stringContaining('Attempting recovery')
);
});
});
describe('global exposure', () => {
it('should expose mountedApps globally', () => {
expect(window.mountedApps).toBeDefined();
expect(window.mountedApps).toBeInstanceOf(Map);
});
it('should expose globalPinia globally', () => {
expect(window.globalPinia).toBeDefined();
expect(window.globalPinia).toBe(mockGlobalPinia);
});
});
});

View File

@@ -0,0 +1,278 @@
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
// Mock all the component imports
vi.mock('~/components/Auth.ce.vue', () => ({
default: { name: 'MockAuth', template: '<div>Auth</div>' }
}));
vi.mock('~/components/ConnectSettings/ConnectSettings.ce.vue', () => ({
default: { name: 'MockConnectSettings', template: '<div>ConnectSettings</div>' }
}));
vi.mock('~/components/DownloadApiLogs.ce.vue', () => ({
default: { name: 'MockDownloadApiLogs', template: '<div>DownloadApiLogs</div>' }
}));
vi.mock('~/components/HeaderOsVersion.ce.vue', () => ({
default: { name: 'MockHeaderOsVersion', template: '<div>HeaderOsVersion</div>' }
}));
vi.mock('~/components/Modals.ce.vue', () => ({
default: { name: 'MockModals', template: '<div>Modals</div>' }
}));
vi.mock('~/components/UserProfile.ce.vue', () => ({
default: { name: 'MockUserProfile', template: '<div>UserProfile</div>' }
}));
vi.mock('~/components/UpdateOs.ce.vue', () => ({
default: { name: 'MockUpdateOs', template: '<div>UpdateOs</div>' }
}));
vi.mock('~/components/DowngradeOs.ce.vue', () => ({
default: { name: 'MockDowngradeOs', template: '<div>DowngradeOs</div>' }
}));
vi.mock('~/components/Registration.ce.vue', () => ({
default: { name: 'MockRegistration', template: '<div>Registration</div>' }
}));
vi.mock('~/components/WanIpCheck.ce.vue', () => ({
default: { name: 'MockWanIpCheck', template: '<div>WanIpCheck</div>' }
}));
vi.mock('~/components/Activation/WelcomeModal.ce.vue', () => ({
default: { name: 'MockWelcomeModal', template: '<div>WelcomeModal</div>' }
}));
vi.mock('~/components/SsoButton.ce.vue', () => ({
default: { name: 'MockSsoButton', template: '<div>SsoButton</div>' }
}));
vi.mock('~/components/Logs/LogViewer.ce.vue', () => ({
default: { name: 'MockLogViewer', template: '<div>LogViewer</div>' }
}));
vi.mock('~/components/ThemeSwitcher.ce.vue', () => ({
default: { name: 'MockThemeSwitcher', template: '<div>ThemeSwitcher</div>' }
}));
vi.mock('~/components/ApiKeyPage.ce.vue', () => ({
default: { name: 'MockApiKeyPage', template: '<div>ApiKeyPage</div>' }
}));
vi.mock('~/components/DevModalTest.ce.vue', () => ({
default: { name: 'MockDevModalTest', template: '<div>DevModalTest</div>' }
}));
vi.mock('~/components/ApiKeyAuthorize.ce.vue', () => ({
default: { name: 'MockApiKeyAuthorize', template: '<div>ApiKeyAuthorize</div>' }
}));
vi.mock('~/components/UnraidToaster.vue', () => ({
default: { name: 'MockUnraidToaster', template: '<div>UnraidToaster</div>' }
}));
// Mock vue-mount-app module
const mockAutoMountComponent = vi.fn();
const mockMountVueApp = vi.fn();
const mockGetMountedApp = vi.fn();
vi.mock('~/components/Wrapper/vue-mount-app', () => ({
autoMountComponent: mockAutoMountComponent,
mountVueApp: mockMountVueApp,
getMountedApp: mockGetMountedApp,
}));
// Mock theme store
const mockSetTheme = vi.fn();
const mockSetCssVars = vi.fn();
const mockUseThemeStore = vi.fn(() => ({
setTheme: mockSetTheme,
setCssVars: mockSetCssVars,
}));
vi.mock('~/store/theme', () => ({
useThemeStore: mockUseThemeStore,
}));
// Mock globalPinia
vi.mock('~/store/globalPinia', () => ({
globalPinia: { state: {} },
}));
// Mock apollo client
const mockApolloClient = {
query: vi.fn(),
mutate: vi.fn(),
};
vi.mock('~/helpers/create-apollo-client', () => ({
client: mockApolloClient,
}));
// Mock @vue/apollo-composable
const mockProvideApolloClient = vi.fn();
vi.mock('@vue/apollo-composable', () => ({
provideApolloClient: mockProvideApolloClient,
}));
// Mock graphql
const mockParse = vi.fn();
vi.mock('graphql', () => ({
parse: mockParse,
}));
// Mock @unraid/ui
const mockEnsureTeleportContainer = vi.fn();
vi.mock('@unraid/ui', () => ({
ensureTeleportContainer: mockEnsureTeleportContainer,
}));
describe('standalone-mount', () => {
beforeEach(() => {
// Reset module cache to ensure fresh imports
vi.resetModules();
// Reset all mocks
vi.clearAllMocks();
// Use Vitest's unstubAllGlobals to clean up any global stubs from previous tests
vi.unstubAllGlobals();
// Mock document methods
vi.spyOn(document.head, 'appendChild').mockImplementation(() => document.createElement('style'));
vi.spyOn(document, 'addEventListener').mockImplementation(() => {});
// Clear DOM
document.head.innerHTML = '';
document.body.innerHTML = '';
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
vi.unstubAllGlobals();
});
describe('initialization', () => {
it('should set up Apollo client globally', async () => {
await import('~/components/standalone-mount');
expect(window.apolloClient).toBe(mockApolloClient);
expect(window.graphqlParse).toBe(mockParse);
expect(window.gql).toBe(mockParse);
expect(mockProvideApolloClient).toHaveBeenCalledWith(mockApolloClient);
});
it('should initialize theme store', async () => {
await import('~/components/standalone-mount');
expect(mockUseThemeStore).toHaveBeenCalled();
expect(mockSetTheme).toHaveBeenCalled();
expect(mockSetCssVars).toHaveBeenCalled();
});
it('should ensure teleport container exists', async () => {
await import('~/components/standalone-mount');
expect(mockEnsureTeleportContainer).toHaveBeenCalled();
});
});
describe('component auto-mounting', () => {
it('should auto-mount all defined components', async () => {
await import('~/components/standalone-mount');
// Verify that autoMountComponent was called multiple times
expect(mockAutoMountComponent.mock.calls.length).toBeGreaterThan(0);
// Verify all calls have the correct structure
mockAutoMountComponent.mock.calls.forEach(call => {
expect(call[0]).toBeDefined(); // Component
expect(call[1]).toBeDefined(); // Selector
expect(call[2]).toMatchObject({
appId: expect.any(String),
useShadowRoot: false,
});
});
// Extract all selectors that were mounted
const mountedSelectors = mockAutoMountComponent.mock.calls.map(call => call[1]);
// Verify critical components are mounted
expect(mountedSelectors).toContain('unraid-auth');
expect(mountedSelectors).toContain('unraid-modals');
expect(mountedSelectors).toContain('unraid-user-profile');
expect(mountedSelectors).toContain('uui-toaster');
expect(mountedSelectors).toContain('#modals'); // Legacy modal selector
// Verify no shadow DOM is used
const allUseShadowRoot = mockAutoMountComponent.mock.calls.every(
call => call[2].useShadowRoot === false
);
expect(allUseShadowRoot).toBe(true);
});
});
describe('global exports', () => {
it('should expose UnraidComponents globally', async () => {
await import('~/components/standalone-mount');
expect(window.UnraidComponents).toBeDefined();
expect(window.UnraidComponents).toHaveProperty('Auth');
expect(window.UnraidComponents).toHaveProperty('ConnectSettings');
expect(window.UnraidComponents).toHaveProperty('DownloadApiLogs');
expect(window.UnraidComponents).toHaveProperty('HeaderOsVersion');
expect(window.UnraidComponents).toHaveProperty('Modals');
expect(window.UnraidComponents).toHaveProperty('UserProfile');
expect(window.UnraidComponents).toHaveProperty('UpdateOs');
expect(window.UnraidComponents).toHaveProperty('DowngradeOs');
expect(window.UnraidComponents).toHaveProperty('Registration');
expect(window.UnraidComponents).toHaveProperty('WanIpCheck');
expect(window.UnraidComponents).toHaveProperty('WelcomeModal');
expect(window.UnraidComponents).toHaveProperty('SsoButton');
expect(window.UnraidComponents).toHaveProperty('LogViewer');
expect(window.UnraidComponents).toHaveProperty('ThemeSwitcher');
expect(window.UnraidComponents).toHaveProperty('ApiKeyPage');
expect(window.UnraidComponents).toHaveProperty('DevModalTest');
expect(window.UnraidComponents).toHaveProperty('ApiKeyAuthorize');
expect(window.UnraidComponents).toHaveProperty('UnraidToaster');
});
it('should expose utility functions globally', async () => {
await import('~/components/standalone-mount');
expect(window.mountVueApp).toBe(mockMountVueApp);
expect(window.getMountedApp).toBe(mockGetMountedApp);
});
it('should create dynamic mount functions for each component', async () => {
await import('~/components/standalone-mount');
// Check for some dynamic mount functions
expect(typeof window.mountAuth).toBe('function');
expect(typeof window.mountConnectSettings).toBe('function');
expect(typeof window.mountUserProfile).toBe('function');
expect(typeof window.mountModals).toBe('function');
expect(typeof window.mountThemeSwitcher).toBe('function');
// Test calling a dynamic mount function
const customSelector = '#custom-auth';
window.mountAuth?.(customSelector);
expect(mockMountVueApp).toHaveBeenCalledWith(
expect.objectContaining({
selector: customSelector,
useShadowRoot: false,
})
);
});
it('should use default selector when no custom selector provided', async () => {
await import('~/components/standalone-mount');
// Call mount function without custom selector
window.mountAuth?.();
expect(mockMountVueApp).toHaveBeenCalledWith(
expect.objectContaining({
selector: 'unraid-auth',
useShadowRoot: false,
})
);
});
});
// Skip SSR safety test as it's complex to test with module isolation
describe.skip('SSR safety', () => {
it('should not initialize when window is undefined', async () => {
// This test is skipped because the module initialization happens at import time
// and it's difficult to properly isolate the window object manipulation
// The functionality is simple enough - just checking if window exists before running code
});
});
});

View File

@@ -17,65 +17,74 @@
/*
* Minimal styles for our components
* Only essential styles to ensure components work properly
* Using @layer to ensure these have lower priority than Tailwind utilities
*/
/* Box-sizing for proper layout */
.unapi *,
.unapi *::before,
.unapi *::after {
box-sizing: border-box;
}
/* Reset figure element for logo */
.unapi figure {
margin: 0;
padding: 0;
}
/* Reset heading elements - only margin/padding */
.unapi h1,
.unapi h2,
.unapi h3,
.unapi h4,
.unapi h5 {
margin: 0;
padding: 0;
}
/* Reset paragraph element */
.unapi p {
margin: 0;
padding: 0;
}
/* Reset toggle/switch button backgrounds */
button[role="switch"],
button[role="switch"][data-state="checked"],
button[role="switch"][data-state="unchecked"] {
background-color: transparent !important;
background: transparent !important;
border: 1px solid #ccc !important;
}
/* Style for checked state */
button[role="switch"][data-state="checked"] {
background-color: #ff8c2f !important; /* Unraid orange */
}
/* Style for unchecked state */
button[role="switch"][data-state="unchecked"] {
background-color: #e5e5e5 !important;
}
/* Dark mode toggle styles */
.dark button[role="switch"][data-state="unchecked"] {
background-color: #333 !important;
border-color: #555 !important;
}
/* Toggle thumb/handle */
button[role="switch"] span {
background-color: white !important;
@layer base {
/* Box-sizing for proper layout */
.unapi *,
.unapi *::before,
.unapi *::after {
box-sizing: border-box;
}
/* Reset figure element for logo */
.unapi figure {
margin: 0;
padding: 0;
}
/* Reset heading elements - only margin/padding */
.unapi h1,
.unapi h2,
.unapi h3,
.unapi h4,
.unapi h5 {
margin: 0;
padding: 0;
}
/* Reset paragraph element */
.unapi p {
margin: 0;
padding: 0;
}
/* Reset UL styles to prevent default browser styling */
.unapi ul {
padding-inline-start: 0;
list-style-type: none;
}
/* Reset toggle/switch button backgrounds */
.unapi button[role="switch"],
.unapi button[role="switch"][data-state="checked"],
.unapi button[role="switch"][data-state="unchecked"] {
background-color: transparent;
background: transparent;
border: 1px solid #ccc;
}
/* Style for checked state */
.unapi button[role="switch"][data-state="checked"] {
background-color: #ff8c2f; /* Unraid orange */
}
/* Style for unchecked state */
.unapi button[role="switch"][data-state="unchecked"] {
background-color: #e5e5e5;
}
/* Dark mode toggle styles */
.unapi.dark button[role="switch"][data-state="unchecked"],
.dark .unapi button[role="switch"][data-state="unchecked"] {
background-color: #333;
border-color: #555;
}
/* Toggle thumb/handle */
.unapi button[role="switch"] span {
background-color: white;
}
}

View File

@@ -417,7 +417,7 @@ const copyApiKey = async () => {
:open="props.open"
sheet-side="bottom"
:sheet-class="'h-[100vh] flex flex-col'"
:dialog-class="'max-w-4xl max-h-[90vh] overflow-hidden'"
:dialog-class="'max-w-3xl max-h-[90vh] overflow-hidden'"
:show-close-button="true"
@update:open="
(v) => {

View File

@@ -113,7 +113,7 @@ const returnToApp = () => {
</script>
<template>
<div class="w-full max-w-4xl mx-auto p-6">
<div class="w-full max-w-3xl mx-auto p-6">
<!-- Success state -->
<div v-if="showSuccess && createdApiKey" class="w-full bg-background rounded-lg shadow-sm border border-muted">
<!-- Header -->

View File

@@ -141,7 +141,7 @@ const updateOsStatus = computed(() => {
</script>
<template>
<div class="flex flex-col gap-y-2 mt-2 ml-2">
<div class="flex flex-col gap-y-2 mt-4 ml-4">
<a
:href="unraidLogoHeaderLink.href"
:title="unraidLogoHeaderLink.title"
@@ -156,7 +156,7 @@ const updateOsStatus = computed(() => {
>
</a>
<div class="flex flex-wrap justify-start gap-2">
<div class="flex flex-wrap justify-start gap-2 mt-2">
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<Button
@@ -165,7 +165,7 @@ const updateOsStatus = computed(() => {
:title="t('Version Information')"
>
<InformationCircleIcon
class="fill-current w-3 h-3 xs:w-4 xs:h-4 shrink-0"
:style="{ width: '12px', height: '12px', flexShrink: 0 }"
/>
{{ displayOsVersion }}
</Button>

View File

@@ -0,0 +1,11 @@
<script lang="ts" setup>
import { Toaster } from '@unraid/ui';
defineProps<{
position: 'top-center' | 'top-right' | 'top-left' | 'bottom-center' | 'bottom-right' | 'bottom-left';
}>();
</script>
<template>
<Toaster rich-colors close-button :position="position" />
</template>

View File

@@ -85,7 +85,7 @@ onMounted(() => {
</script>
<template>
<div id="UserProfile" class="text-foreground relative z-20 flex flex-col h-full gap-y-1 pt-2 pr-2">
<div id="UserProfile" class="text-foreground z-20 flex flex-col h-full gap-y-1 pt-2 pr-2 absolute top-0 right-0">
<div
v-if="bannerGradient"
class="absolute z-0 w-full top-0 bottom-0 right-0"

View File

@@ -23,6 +23,15 @@ const mountedAppContainers = new Map<string, HTMLElement[]>(); // shadow-root co
// Shared style injection tracking
const styleInjected = new WeakSet<Document | ShadowRoot>();
// Extend HTMLElement to include Vue's internal properties
interface HTMLElementWithVue extends HTMLElement {
__vueParentComponent?: {
appContext?: {
app?: VueApp;
};
};
}
// Expose globally for debugging
declare global {
interface Window {
@@ -94,6 +103,7 @@ export interface MountOptions {
appId?: string;
useShadowRoot?: boolean;
props?: Record<string, unknown>;
skipRecovery?: boolean; // Internal flag to prevent recursive recovery attempts
}
// Helper function to parse props from HTML attributes
@@ -133,7 +143,7 @@ function parsePropsFromElement(element: Element): Record<string, unknown> {
}
export function mountVueApp(options: MountOptions): VueApp | null {
const { component, selector, appId = selector, useShadowRoot = false, props = {} } = options;
const { component, selector, appId = selector, useShadowRoot = false, props = {}, skipRecovery = false } = options;
// Check if app is already mounted
if (mountedApps.has(appId)) {
@@ -141,6 +151,85 @@ export function mountVueApp(options: MountOptions): VueApp | null {
return mountedApps.get(appId)!;
}
// Special handling for modals - enforce singleton behavior
if (selector.includes('unraid-modals') || selector === '#modals') {
const existingModalApps = ['modals', 'modals-direct', 'unraid-modals'];
for (const modalId of existingModalApps) {
if (mountedApps.has(modalId)) {
console.debug(`[VueMountApp] Modals component already mounted as ${modalId}, skipping ${appId}`);
return mountedApps.get(modalId)!;
}
}
}
// Check if any elements matching the selector already have Vue apps mounted
const potentialTargets = document.querySelectorAll(selector);
for (const target of potentialTargets) {
const element = target as HTMLElementWithVue;
const hasVueAttributes = element.hasAttribute('data-vue-mounted') ||
element.hasAttribute('data-v-app') ||
element.hasAttribute('data-server-rendered');
if (hasVueAttributes || element.__vueParentComponent) {
// Check if the existing Vue component is actually working (has content)
const hasContent = element.innerHTML.trim().length > 0 ||
element.children.length > 0;
if (hasContent) {
console.info(`[VueMountApp] Element ${selector} already has working Vue component, skipping remount`);
// Return the existing app if we can find it
const existingApp = mountedApps.get(appId);
if (existingApp) {
return existingApp;
}
// If we can't find the app reference but component is working, return null (success)
return null;
}
console.warn(`[VueMountApp] Element ${selector} has Vue attributes but no content, cleaning up`);
try {
// DO NOT attempt to unmount existing Vue instances - this causes the nextSibling error
// Instead, just clear the DOM state and let Vue handle the cleanup naturally
// Remove all Vue-related attributes
element.removeAttribute('data-vue-mounted');
element.removeAttribute('data-v-app');
element.removeAttribute('data-server-rendered');
// Remove any Vue-injected attributes
Array.from(element.attributes).forEach(attr => {
if (attr.name.startsWith('data-v-')) {
element.removeAttribute(attr.name);
}
});
// Clear the element content to ensure fresh state
element.innerHTML = '';
// Remove the __vueParentComponent reference without calling unmount
delete element.__vueParentComponent;
console.info(`[VueMountApp] Cleared Vue state from ${selector} without unmounting (prevents nextSibling errors)`);
} catch (error) {
console.warn(`[VueMountApp] Error cleaning up existing Vue instance:`, error);
// Force clear everything if normal cleanup fails
element.innerHTML = '';
element.removeAttribute('data-vue-mounted');
element.removeAttribute('data-v-app');
element.removeAttribute('data-server-rendered');
// Remove all data-v-* attributes
Array.from(element.attributes).forEach(attr => {
if (attr.name.startsWith('data-v-')) {
element.removeAttribute(attr.name);
}
});
}
}
}
// Find all mount targets
const targets = document.querySelectorAll(selector);
if (targets.length === 0) {
@@ -174,8 +263,64 @@ export function mountVueApp(options: MountOptions): VueApp | null {
targets.forEach((target, index) => {
const mountTarget = target as HTMLElement;
// Add unapi class for minimal styling
// Comprehensive DOM validation
if (!mountTarget.isConnected || !mountTarget.parentNode || !document.contains(mountTarget)) {
console.warn(`[VueMountApp] Mount target not properly connected to DOM for ${appId}, skipping`);
return;
}
// Special handling for PHP-generated pages that might have whitespace/comment nodes
if (mountTarget.childNodes.length > 0) {
let hasProblematicNodes = false;
const nodesToRemove: Node[] = [];
Array.from(mountTarget.childNodes).forEach(node => {
// Check for orphaned nodes
if (node.parentNode !== mountTarget) {
hasProblematicNodes = true;
return;
}
// Check for empty text nodes or comments that could cause fragment issues
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim() === '') {
nodesToRemove.push(node);
hasProblematicNodes = true;
} else if (node.nodeType === Node.COMMENT_NODE) {
nodesToRemove.push(node);
hasProblematicNodes = true;
}
});
if (hasProblematicNodes) {
console.warn(`[VueMountApp] Cleaning up problematic nodes in ${selector} before mounting`);
// Remove problematic nodes
nodesToRemove.forEach(node => {
try {
if (node.parentNode) {
node.parentNode.removeChild(node);
}
} catch (_e) {
// If removal fails, clear the entire content
mountTarget.innerHTML = '';
}
});
// If we still have orphaned nodes after cleanup, clear everything
const remainingInvalidChildren = Array.from(mountTarget.childNodes).filter(node => {
return node.parentNode !== mountTarget;
});
if (remainingInvalidChildren.length > 0) {
console.warn(`[VueMountApp] Clearing all content due to remaining orphaned nodes in ${selector}`);
mountTarget.innerHTML = '';
}
}
}
// Add unapi class for minimal styling and mark as mounted
mountTarget.classList.add('unapi');
mountTarget.setAttribute('data-vue-mounted', 'true');
if (useShadowRoot) {
// Create shadow root if needed
@@ -195,15 +340,26 @@ export function mountVueApp(options: MountOptions): VueApp | null {
// For the first target, use the main app, otherwise create clones
if (index === 0) {
app.mount(container);
try {
app.mount(container);
} catch (error) {
console.error(`[VueMountApp] Error mounting main app to shadow root ${selector}:`, error);
throw error;
}
} else {
const targetProps = { ...parsePropsFromElement(mountTarget), ...props };
const clonedApp = createApp(component, targetProps);
clonedApp.use(i18n);
clonedApp.use(globalPinia);
clonedApp.provide(DefaultApolloClient, apolloClient);
clonedApp.mount(container);
clones.push(clonedApp);
try {
clonedApp.mount(container);
clones.push(clonedApp);
} catch (error) {
console.error(`[VueMountApp] Error mounting cloned app to shadow root ${selector}:`, error);
// Don't call unmount since mount failed - just let the app be garbage collected
}
}
} else {
// Direct mount without shadow root
@@ -213,7 +369,45 @@ export function mountVueApp(options: MountOptions): VueApp | null {
// but they'll share the same Pinia store
if (index === 0) {
// First target, use the main app
app.mount(mountTarget);
try {
// Final validation before mounting
if (!mountTarget.isConnected || !document.contains(mountTarget)) {
throw new Error(`Mount target disconnected before mounting: ${selector}`);
}
app.mount(mountTarget);
} catch (error) {
console.error(`[VueMountApp] Error mounting main app to ${selector}:`, error);
// Special handling for nextSibling error - attempt recovery (only if not already retrying)
if (!skipRecovery && error instanceof TypeError && error.message.includes('nextSibling')) {
console.warn(`[VueMountApp] Attempting recovery from nextSibling error for ${selector}`);
// Remove the problematic data attribute that might be causing issues
mountTarget.removeAttribute('data-vue-mounted');
// Try mounting after a brief delay to let DOM settle
setTimeout(() => {
try {
// Ensure element is still valid
if (mountTarget.isConnected && document.contains(mountTarget)) {
app.mount(mountTarget);
mountTarget.setAttribute('data-vue-mounted', 'true');
console.info(`[VueMountApp] Successfully recovered from nextSibling error for ${selector}`);
} else {
console.error(`[VueMountApp] Recovery failed - element no longer in DOM: ${selector}`);
}
} catch (retryError) {
console.error(`[VueMountApp] Recovery attempt failed for ${selector}:`, retryError);
}
}, 10);
// Return without throwing to allow other elements to mount
return;
}
throw error;
}
} else {
// Additional targets, create cloned apps with their own props
const targetProps = { ...parsePropsFromElement(mountTarget), ...props };
@@ -221,8 +415,14 @@ export function mountVueApp(options: MountOptions): VueApp | null {
clonedApp.use(i18n);
clonedApp.use(globalPinia); // Shared Pinia instance
clonedApp.provide(DefaultApolloClient, apolloClient);
clonedApp.mount(mountTarget);
clones.push(clonedApp);
try {
clonedApp.mount(mountTarget);
clones.push(clonedApp);
} catch (error) {
console.error(`[VueMountApp] Error mounting cloned app to ${selector}:`, error);
// Don't call unmount since mount failed - just let the app be garbage collected
}
}
}
});
@@ -242,17 +442,43 @@ export function unmountVueApp(appId: string): boolean {
return false;
}
// Unmount clones first
// Unmount clones first with error handling
const clones = mountedAppClones.get(appId) ?? [];
for (const c of clones) c.unmount();
for (const c of clones) {
try {
c.unmount();
} catch (error) {
console.warn(`[VueMountApp] Error unmounting clone for ${appId}:`, error);
}
}
mountedAppClones.delete(appId);
// Remove shadow containers
// Remove shadow containers with error handling
const containers = mountedAppContainers.get(appId) ?? [];
for (const el of containers) el.remove();
for (const el of containers) {
try {
el.remove();
} catch (error) {
console.warn(`[VueMountApp] Error removing container for ${appId}:`, error);
}
}
mountedAppContainers.delete(appId);
app.unmount();
// Unmount main app with error handling
try {
app.unmount();
// Clean up data attributes from mounted elements
const elements = document.querySelectorAll(`[data-vue-mounted="true"]`);
elements.forEach(el => {
if (el.classList.contains('unapi')) {
el.removeAttribute('data-vue-mounted');
}
});
} catch (error) {
console.warn(`[VueMountApp] Error unmounting app ${appId}:`, error);
}
mountedApps.delete(appId);
return true;
}
@@ -264,16 +490,123 @@ export function getMountedApp(appId: string): VueApp | undefined {
// Auto-mount function for script tags
export function autoMountComponent(component: Component, selector: string, options?: Partial<MountOptions>) {
const tryMount = () => {
// Check if elements exist before attempting to mount
if (document.querySelector(selector)) {
try {
mountVueApp({ component, selector, ...options });
} catch (error) {
console.error(`[VueMountApp] Failed to mount component for selector ${selector}:`, error);
// Special handling for modals - should only mount once, ignore subsequent attempts
if (selector.includes('unraid-modals') || selector === '#modals') {
const modalAppId = options?.appId || 'modals';
if (mountedApps.has(modalAppId) || mountedApps.has('modals-direct')) {
console.debug(`[VueMountApp] Modals component already mounted, skipping ${selector}`);
return;
}
}
// Check if elements exist before attempting to mount
const elements = document.querySelectorAll(selector);
if (elements.length > 0) {
// For specific problematic selectors, add extra delay to let page scripts settle
const isProblematicSelector = selector.includes('unraid-connect-settings') ||
selector.includes('unraid-modals') ||
selector.includes('unraid-theme-switcher');
if (isProblematicSelector) {
// Wait longer for PHP-generated pages with dynamic content
setTimeout(() => {
performMount();
}, 200);
return;
}
performMount();
}
// Silently skip if no elements found - this is expected for most components
};
const performMount = () => {
const elements = document.querySelectorAll(selector);
if (elements.length === 0) return;
// Validate all elements are properly connected to the DOM
const validElements = Array.from(elements).filter(el => {
const element = el as HTMLElement;
// Basic connectivity check - element must be in DOM
if (!element.isConnected || !element.parentNode || !document.contains(element)) {
return false;
}
// Additional check: ensure the element's parentNode relationship is stable
// This catches cases where elements appear connected but have DOM manipulation issues
try {
// Try to access nextSibling - this will throw if DOM is in inconsistent state
void element.nextSibling;
// Verify parent-child relationship is intact
if (element.parentNode && !Array.from(element.parentNode.childNodes).includes(element)) {
console.warn(`[VueMountApp] Element ${selector} has broken parent-child relationship`);
return false;
}
} catch (error) {
console.warn(`[VueMountApp] Element ${selector} has unstable DOM state:`, error);
return false;
}
return true;
});
if (validElements.length > 0) {
try {
mountVueApp({ component, selector, ...options });
} catch (error) {
console.error(`[VueMountApp] Failed to mount component for selector ${selector}:`, error);
// Additional debugging for this specific error
if (error instanceof TypeError && error.message.includes('nextSibling')) {
console.warn(`[VueMountApp] DOM state issue detected for ${selector}, attempting cleanup and retry`);
// Perform more aggressive cleanup for nextSibling errors
validElements.forEach(el => {
const element = el as HTMLElement;
// Remove all Vue-related attributes that might be causing issues
element.removeAttribute('data-vue-mounted');
element.removeAttribute('data-v-app');
Array.from(element.attributes).forEach(attr => {
if (attr.name.startsWith('data-v-')) {
element.removeAttribute(attr.name);
}
});
// Completely reset the element's content and state
element.innerHTML = '';
element.className = element.className.replace(/\bunapi\b/g, '').trim();
// Remove any Vue instance references
delete (element as unknown as HTMLElementWithVue).__vueParentComponent;
});
// Wait for DOM to stabilize and try again
setTimeout(() => {
try {
console.info(`[VueMountApp] Retrying mount for ${selector} after cleanup`);
mountVueApp({ component, selector, ...options, skipRecovery: true });
} catch (retryError) {
console.error(`[VueMountApp] Retry failed for ${selector}:`, retryError);
// If retry also fails, try one more time with even more delay
setTimeout(() => {
try {
console.info(`[VueMountApp] Final retry attempt for ${selector}`);
mountVueApp({ component, selector, ...options, skipRecovery: true });
} catch (finalError) {
console.error(`[VueMountApp] All retry attempts failed for ${selector}:`, finalError);
}
}, 100);
}
}, 50);
}
}
} else {
console.warn(`[VueMountApp] No valid DOM elements found for ${selector} (${elements.length} elements exist but not properly connected)`);
}
};
// Wait for DOM to be ready
if (document.readyState === 'loading') {

View File

@@ -27,7 +27,7 @@ const handleClick = () => {
<img
v-if="props.provider.buttonIcon"
:src="props.provider.buttonIcon"
class="w-6 h-6 sso-button-icon flex-shrink-0"
class="w-4 h-4 sso-button-icon flex-shrink-0"
alt=""
aria-hidden="true"
>

View File

@@ -1,5 +1,4 @@
// Import all components
import type { Component } from 'vue';
import Auth from './Auth.ce.vue';
import ConnectSettings from './ConnectSettings/ConnectSettings.ce.vue';
import DownloadApiLogs from './DownloadApiLogs.ce.vue';
@@ -17,7 +16,7 @@ import ThemeSwitcher from './ThemeSwitcher.ce.vue';
import ApiKeyPage from './ApiKeyPage.ce.vue';
import DevModalTest from './DevModalTest.ce.vue';
import ApiKeyAuthorize from './ApiKeyAuthorize.ce.vue';
import UnraidToaster from './UnraidToaster.vue';
// Import utilities
import { autoMountComponent, mountVueApp, getMountedApp } from './Wrapper/vue-mount-app';
import { useThemeStore } from '~/store/theme';
@@ -27,92 +26,11 @@ import { provideApolloClient } from '@vue/apollo-composable';
import { parse } from 'graphql';
import { ensureTeleportContainer } from '@unraid/ui';
// Extend window interface for Apollo client
declare global {
interface Window {
apolloClient: typeof apolloClient;
gql: typeof parse;
graphqlParse: typeof parse;
}
}
// Window type definitions are automatically included via tsconfig.json
// Add pre-render CSS to hide components until they're mounted
function injectPreRenderCSS() {
const style = document.createElement('style');
style.id = 'unraid-prerender-css';
style.textContent = `
/* Hide unraid components during initial load to prevent FOUC */
unraid-auth,
unraid-connect-settings,
unraid-download-api-logs,
unraid-header-os-version,
unraid-modals,
unraid-user-profile,
unraid-update-os,
unraid-downgrade-os,
unraid-registration,
unraid-wan-ip-check,
unraid-welcome-modal,
unraid-sso-button,
unraid-log-viewer,
unraid-theme-switcher,
unraid-api-key-manager,
unraid-dev-modal-test,
unraid-api-key-authorize {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
/* Show components once they have the unapi class (mounted) */
unraid-auth.unapi,
unraid-connect-settings.unapi,
unraid-download-api-logs.unapi,
unraid-header-os-version.unapi,
unraid-modals.unapi,
unraid-user-profile.unapi,
unraid-update-os.unapi,
unraid-downgrade-os.unapi,
unraid-registration.unapi,
unraid-wan-ip-check.unapi,
unraid-welcome-modal.unapi,
unraid-sso-button.unapi,
unraid-log-viewer.unapi,
unraid-theme-switcher.unapi,
unraid-api-key-manager.unapi,
unraid-dev-modal-test.unapi,
unraid-api-key-authorize.unapi {
opacity: 1;
}
/* Font size overrides for SSO button component */
unraid-sso-button {
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
--text-5xl: 3rem;
--text-6xl: 3.75rem;
--text-7xl: 4.5rem;
--text-8xl: 6rem;
--text-9xl: 8rem;
}
`;
document.head.appendChild(style);
}
// Initialize global Apollo client context
if (typeof window !== 'undefined') {
// Inject pre-render CSS as early as possible
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectPreRenderCSS);
} else {
injectPreRenderCSS();
}
// Make Apollo client globally available
window.apolloClient = apolloClient;
@@ -140,6 +58,7 @@ const componentMappings = [
{ component: DownloadApiLogs, selector: 'unraid-download-api-logs', appId: 'download-api-logs' },
{ component: HeaderOsVersion, selector: 'unraid-header-os-version', appId: 'header-os-version' },
{ component: Modals, selector: 'unraid-modals', appId: 'modals' },
{ component: Modals, selector: '#modals', appId: 'modals-legacy' }, // Legacy ID selector
{ component: UserProfile, selector: 'unraid-user-profile', appId: 'user-profile' },
{ component: UpdateOs, selector: 'unraid-update-os', appId: 'update-os' },
{ component: DowngradeOs, selector: 'unraid-downgrade-os', appId: 'downgrade-os' },
@@ -152,6 +71,8 @@ const componentMappings = [
{ component: ApiKeyPage, selector: 'unraid-api-key-manager', appId: 'api-key-manager' },
{ component: DevModalTest, selector: 'unraid-dev-modal-test', appId: 'dev-modal-test' },
{ component: ApiKeyAuthorize, selector: 'unraid-api-key-authorize', appId: 'api-key-authorize' },
{ component: UnraidToaster, selector: 'uui-toaster', appId: 'toaster' },
{ component: UnraidToaster, selector: 'unraid-toaster', appId: 'toaster-legacy' }, // Legacy alias
];
// Auto-mount all components
@@ -162,20 +83,7 @@ componentMappings.forEach(({ component, selector, appId }) => {
});
});
// Special handling for Modals - also mount to #modals
autoMountComponent(Modals, '#modals', {
appId: 'modals-direct',
useShadowRoot: false,
});
// Expose functions globally for testing and dynamic mounting
declare global {
interface Window {
UnraidComponents: Record<string, Component>;
mountVueApp: typeof mountVueApp;
getMountedApp: typeof getMountedApp;
}
}
// Window interface extensions are defined in ~/types/window.d.ts
if (typeof window !== 'undefined') {
// Expose all components
@@ -197,6 +105,7 @@ if (typeof window !== 'undefined') {
ApiKeyPage,
DevModalTest,
ApiKeyAuthorize,
UnraidToaster,
};
// Expose utility functions

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/web",
"version": "4.18.2",
"version": "4.19.0",
"private": true,
"license": "GPL-2.0-or-later",
"scripts": {

View File

@@ -15,12 +15,12 @@ echo "Cleaning deployed files from Unraid server: $server_name"
exit_code=0
# Remove standalone apps directory
echo "Removing standalone apps directory..."
ssh root@"${server_name}" "rm -rf /usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone/"
standalone_exit_code=$?
if [ $standalone_exit_code -ne 0 ]; then
echo "Warning: Failed to remove standalone apps directory"
exit_code=$standalone_exit_code
echo "Removing components directory..."
ssh root@"${server_name}" "rm -rf /usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/"
components_exit_code=$?
if [ $components_exit_code -ne 0 ]; then
echo "Warning: Failed to remove components directory"
exit_code=$components_exit_code
fi
# Clean up auth-request.php file

View File

@@ -34,6 +34,8 @@ if [ "$has_standalone" = true ]; then
echo "Deploying standalone apps..."
# Ensure remote directory exists
ssh root@"${server_name}" "mkdir -p /usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone/"
# Clear the remote standalone directory before rsyncing
ssh root@"${server_name}" "rm -rf /usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/*"
# Run rsync with proper quoting
rsync -avz --delete -e "ssh" "$standalone_directory" "root@${server_name}:/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone/"
standalone_exit_code=$?

View File

@@ -1,4 +1,7 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"types": ["./types/window"]
}
}

52
web/types/window.d.ts vendored Normal file
View File

@@ -0,0 +1,52 @@
import type { Component } from 'vue';
import type { parse } from 'graphql';
import type { client as apolloClient } from '~/helpers/create-apollo-client';
import type { mountVueApp, getMountedApp } from '~/components/Wrapper/vue-mount-app';
/**
* Global Window interface extensions for Unraid components
* This file provides type definitions for properties added to the window object
* by the standalone-mount.ts module
*/
declare global {
interface Window {
// Apollo GraphQL client and utilities
apolloClient: typeof apolloClient;
gql: typeof parse;
graphqlParse: typeof parse;
// Vue component registry and utilities
UnraidComponents: Record<string, Component>;
mountVueApp: typeof mountVueApp;
getMountedApp: typeof getMountedApp;
// Dynamic mount functions created at runtime
// These are generated for each component in componentMappings
mountAuth?: (selector?: string) => unknown;
mountConnectSettings?: (selector?: string) => unknown;
mountDownloadApiLogs?: (selector?: string) => unknown;
mountHeaderOsVersion?: (selector?: string) => unknown;
mountModals?: (selector?: string) => unknown;
mountModalsLegacy?: (selector?: string) => unknown;
mountUserProfile?: (selector?: string) => unknown;
mountUpdateOs?: (selector?: string) => unknown;
mountDowngradeOs?: (selector?: string) => unknown;
mountRegistration?: (selector?: string) => unknown;
mountWanIpCheck?: (selector?: string) => unknown;
mountWelcomeModal?: (selector?: string) => unknown;
mountSsoButton?: (selector?: string) => unknown;
mountLogViewer?: (selector?: string) => unknown;
mountThemeSwitcher?: (selector?: string) => unknown;
mountApiKeyManager?: (selector?: string) => unknown;
mountDevModalTest?: (selector?: string) => unknown;
mountApiKeyAuthorize?: (selector?: string) => unknown;
mountToaster?: (selector?: string) => unknown;
mountToasterLegacy?: (selector?: string) => unknown;
// Index signature for any other dynamic mount functions
[key: `mount${string}`]: ((selector?: string) => unknown) | undefined;
}
}
// Export empty object to make this a module and enable augmentation
export {};