diff --git a/api/src/unraid-api/graph/resolvers/sso/core/oidc.service.integration.test.ts b/api/src/unraid-api/graph/resolvers/sso/core/oidc.service.integration.test.ts index a867ddbd75..fba613641c 100644 --- a/api/src/unraid-api/graph/resolvers/sso/core/oidc.service.integration.test.ts +++ b/api/src/unraid-api/graph/resolvers/sso/core/oidc.service.integration.test.ts @@ -17,7 +17,10 @@ import { OidcProvider } from '@app/unraid-api/graph/resolvers/sso/models/oidc-pr import { OidcSessionService } from '@app/unraid-api/graph/resolvers/sso/session/oidc-session.service.js'; import { OidcStateService } from '@app/unraid-api/graph/resolvers/sso/session/oidc-state.service.js'; -describe('OidcService Integration Tests - Enhanced Logging', () => { +// These integration tests make real outbound network calls (discovery, SSL/DNS +// probes), which can exceed the 5s default under slow CI networking. Give the +// whole suite generous headroom. +describe('OidcService Integration Tests - Enhanced Logging', { timeout: 20000 }, () => { let service: OidcService; let configPersistence: OidcConfigPersistence; let loggerSpy: any; diff --git a/web/__test__/components/CallbackFeedback.test.ts b/web/__test__/components/CallbackFeedback.test.ts index abdb0e8a64..3ff0cb5447 100644 --- a/web/__test__/components/CallbackFeedback.test.ts +++ b/web/__test__/components/CallbackFeedback.test.ts @@ -310,6 +310,81 @@ describe('CallbackFeedback.vue', () => { expect(wrapper.find('.modal').attributes('data-success')).toBe('false'); }); + describe('OS update confirmation', () => { + it('renders the update confirmation when a standalone update lands on a server with a state error', () => { + updateOsStatus.value = 'confirming'; + callbackUpdateRelease.value = { name: 'Unraid 7.3.1' }; + osVersion.value = '7.3.0'; + // Server is in an error state (e.g. EGUID GUID mismatch) but no key was installed + stateDataError.value = true; + keyInstallStatus.value = 'ready'; + + const wrapper = mountComponent(); + + expect(wrapper.find('h1').text()).toBe('Update Unraid OS confirmation required'); + expect(wrapper.text()).toContain('Current Version: Unraid 7.3.0'); + expect(wrapper.text()).toContain('New Version: Unraid 7.3.1'); + expect(wrapper.text()).toContain('Confirm and start update'); + }); + + it('confirms the update when the confirm button is clicked', async () => { + updateOsStatus.value = 'confirming'; + callbackUpdateRelease.value = { name: 'Unraid 7.3.1' }; + stateDataError.value = true; + keyInstallStatus.value = 'ready'; + + const wrapper = mountComponent(); + + const confirmButton = wrapper + .findAll('button') + .find((button) => button.text() === 'Confirm and start update'); + expect(confirmButton).toBeDefined(); + await confirmButton!.trigger('click'); + + expect(mockInstallOsUpdate).toHaveBeenCalledTimes(1); + expect(mockSetCallbackStatus).toHaveBeenCalledWith('ready'); + }); + + it('suppresses the update confirmation when a key install left the server in an error state', () => { + updateOsStatus.value = 'confirming'; + callbackUpdateRelease.value = { name: 'Unraid 7.3.1' }; + osVersion.value = '7.3.0'; + // Combined key-install + update flow where the install errored + callbackStatus.value = 'success'; + keyActionType.value = 'purchase'; + keyInstallStatus.value = 'success'; + keyType.value = 'Pro'; + stateDataError.value = true; + callbackCallsCompleted.value = true; + + const wrapper = mountComponent(); + + expect(wrapper.text()).not.toContain('New Version: Unraid 7.3.1'); + expect(wrapper.text()).not.toContain('Confirm and start update'); + expect(wrapper.text()).toContain('Post Install License Key Error'); + }); + + it('suppresses the update confirmation while key-install reconciliation is still pending', () => { + updateOsStatus.value = 'confirming'; + callbackUpdateRelease.value = { name: 'Unraid 7.3.1' }; + osVersion.value = '7.3.0'; + // Combined key-install + update flow, but the delayed refreshServerState + // reconciliation has not finished yet, so the pre-refresh error persists. + callbackStatus.value = 'success'; + keyActionType.value = 'purchase'; + keyInstallStatus.value = 'success'; + keyType.value = 'Pro'; + stateDataError.value = true; + refreshServerStateStatus.value = 'refreshing'; + callbackCallsCompleted.value = false; + + const wrapper = mountComponent(); + + expect(wrapper.text()).not.toContain('New Version: Unraid 7.3.1'); + expect(wrapper.text()).not.toContain('Confirm and start update'); + }); + }); + it('reloads the page when the modal is dismissed after a callback action', async () => { const mockReload = vi.fn(); diff --git a/web/__test__/components/DropdownContent.test.ts b/web/__test__/components/DropdownContent.test.ts index e2be458c29..7bb6caa4e5 100644 --- a/web/__test__/components/DropdownContent.test.ts +++ b/web/__test__/components/DropdownContent.test.ts @@ -108,6 +108,7 @@ describe('DropdownContent', () => { }; beforeEach(() => { + vi.clearAllMocks(); setActivePinia(createPinia()); serverStoreRefs.keyActions!.value = []; @@ -129,6 +130,58 @@ describe('DropdownContent', () => { updateOsStoreRefs.availableWithRenewal!.value = null; }); + it('shows the OS update button even when the server has a state error', () => { + // e.g. EGUID key/GUID mismatch: updating is still allowed. + serverStoreRefs.stateDataError!.value = { message: 'Registration key mismatch' }; + + const wrapper = shallowMount(DropdownContent, { + global: { + plugins: [createTestI18n()], + }, + }); + + const items = wrapper + .findAllComponents({ name: 'DropdownItem' }) + .map((itemWrapper) => itemWrapper.props('item') as UserProfileLink); + + expect(items.some((item) => item?.text === 'Check for Update')).toBe(true); + }); + + it('hides the OS update button when update entitlement has expired', () => { + serverStoreRefs.regUpdatesExpired!.value = true; + + const wrapper = shallowMount(DropdownContent, { + global: { + plugins: [createTestI18n()], + }, + }); + + const items = wrapper + .findAllComponents({ name: 'DropdownItem' }) + .map((itemWrapper) => itemWrapper.props('item') as UserProfileLink); + + expect(items.some((item) => item?.text === 'Check for Update')).toBe(false); + // the renewal/eligibility link stands in for it + expect(items.some((item) => item?.text === 'OS Update Eligibility Expired')).toBe(true); + }); + + it('still shows the reboot button when entitlement has expired but a reboot is pending', () => { + serverStoreRefs.regUpdatesExpired!.value = true; + serverStoreRefs.rebootType!.value = 'update'; + + const wrapper = shallowMount(DropdownContent, { + global: { + plugins: [createTestI18n()], + }, + }); + + const items = wrapper + .findAllComponents({ name: 'DropdownItem' }) + .map((itemWrapper) => itemWrapper.props('item') as UserProfileLink); + + expect(items.some((item) => item?.text === 'Reboot Required for Update')).toBe(true); + }); + it('does not show manage-license helper text when sign-in is the only action', () => { const wrapper = shallowMount(DropdownContent, { global: { diff --git a/web/__test__/components/HeaderOsVersion.test.ts b/web/__test__/components/HeaderOsVersion.test.ts index e8051fb950..69156d19ea 100644 --- a/web/__test__/components/HeaderOsVersion.test.ts +++ b/web/__test__/components/HeaderOsVersion.test.ts @@ -11,7 +11,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { TestingPinia } from '@pinia/testing'; import type { VueWrapper } from '@vue/test-utils'; -import type { Error as CustomApiError } from '~/store/errors'; import type { ServerUpdateOsResponse } from '~/types/server'; import HeaderOsVersion from '~/components/HeaderOsVersion.standalone.vue'; @@ -77,11 +76,6 @@ describe('HeaderOsVersion', () => { let serverStore: ReturnType; let errorsStore: ReturnType; - const findUpdateStatusComponent = () => { - const statusElement = wrapper.find('a.group:not([title*="release notes"]), button.group'); - return statusElement.exists() ? statusElement : null; - }; - beforeEach(() => { testingPinia = createTestingPinia({ createSpy: vi.fn }); setActivePinia(testingPinia); @@ -124,14 +118,10 @@ describe('HeaderOsVersion', () => { expect(hasUpdateButton).toBe(false); }); - it('does not render update status when stateDataError is present', async () => { - const mockError: CustomApiError = { - message: 'State data fetch failed', - heading: 'Fetch Error', - level: 'error', - type: 'serverState', - }; - errorsStore.errors = [mockError]; + it('still renders the update-available badge when the server has a state error', async () => { + // A registration/key error (EGUID GUID mismatch → stateDataError) must not + // hide an available OS update — update eligibility is independent of it. + serverStore.state = 'EGUID'; serverStore.updateOsResponse = { version: '6.13.0', isNewer: true, @@ -141,7 +131,20 @@ describe('HeaderOsVersion', () => { await nextTick(); - expect(findUpdateStatusComponent()).toBeNull(); + expect(serverStore.stateDataError).toBeDefined(); + expect(wrapper.find('[title="Unraid OS 6.13.0 Update Available"]').exists()).toBe(true); + }); + + it('renders the pending-reboot badge even when stateDataError is present', async () => { + // EGUID (registration/key mismatch) produces a state error, but a pending + // reboot applies an already-installed update and must still be surfaced. + serverStore.state = 'EGUID'; + serverStore.rebootType = 'update'; + + await nextTick(); + + expect(wrapper.find('[title="Reboot Required for Update"]').exists()).toBe(true); + expect(wrapper.text()).toContain('Reboot Required for Update'); }); it('removes logo class from logo wrapper on mount', async () => { diff --git a/web/__test__/composables/useOsUpdateStatus.test.ts b/web/__test__/composables/useOsUpdateStatus.test.ts new file mode 100644 index 0000000000..bb1a5226b1 --- /dev/null +++ b/web/__test__/composables/useOsUpdateStatus.test.ts @@ -0,0 +1,126 @@ +import { createPinia, setActivePinia } from 'pinia'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Ref } from 'vue'; + +import { useOsUpdateStatus } from '~/composables/useOsUpdateStatus'; + +const { serverRefs, updateOsRefs, updateOsActionsRefs } = vi.hoisted(() => ({ + serverRefs: { + regUpdatesExpired: null as Ref | null, + stateDataError: null as Ref<{ message: string } | undefined> | null, + rebootType: null as Ref | null, + rebootVersion: null as Ref | null, + }, + updateOsRefs: { + available: null as Ref | null, + availableWithRenewal: null as Ref | null, + availableRequiresAuth: null as Ref | null, + }, + updateOsActionsRefs: { + rebootTypeText: null as Ref | null, + }, +})); + +vi.mock('~/store/server', async () => { + const { ref } = await import('vue'); + const { defineStore } = await import('pinia'); + serverRefs.regUpdatesExpired = ref(false); + serverRefs.stateDataError = ref(undefined); + serverRefs.rebootType = ref(''); + serverRefs.rebootVersion = ref(undefined); + const useServerStore = defineStore('serverMockForOsUpdateStatus', () => ({ + regUpdatesExpired: serverRefs.regUpdatesExpired!, + stateDataError: serverRefs.stateDataError!, + rebootType: serverRefs.rebootType!, + rebootVersion: serverRefs.rebootVersion!, + })); + return { useServerStore }; +}); + +vi.mock('~/store/updateOs', async () => { + const { ref } = await import('vue'); + const { defineStore } = await import('pinia'); + updateOsRefs.available = ref(undefined); + updateOsRefs.availableWithRenewal = ref(undefined); + updateOsRefs.availableRequiresAuth = ref(false); + const useUpdateOsStore = defineStore('updateOsMockForOsUpdateStatus', () => ({ + available: updateOsRefs.available!, + availableWithRenewal: updateOsRefs.availableWithRenewal!, + availableRequiresAuth: updateOsRefs.availableRequiresAuth!, + })); + return { useUpdateOsStore }; +}); + +vi.mock('~/store/updateOsActions', async () => { + const { ref } = await import('vue'); + const { defineStore } = await import('pinia'); + updateOsActionsRefs.rebootTypeText = ref(''); + const useUpdateOsActionsStore = defineStore('updateOsActionsMockForOsUpdateStatus', () => ({ + rebootTypeText: updateOsActionsRefs.rebootTypeText!, + })); + return { useUpdateOsActionsStore }; +}); + +describe('useOsUpdateStatus', () => { + beforeEach(() => { + setActivePinia(createPinia()); + serverRefs.regUpdatesExpired!.value = false; + serverRefs.stateDataError!.value = undefined; + serverRefs.rebootType!.value = ''; + serverRefs.rebootVersion!.value = undefined; + updateOsRefs.available!.value = undefined; + updateOsRefs.availableWithRenewal!.value = undefined; + updateOsRefs.availableRequiresAuth!.value = false; + updateOsActionsRefs.rebootTypeText!.value = ''; + }); + + it('keeps key-state errors separate from update eligibility', () => { + serverRefs.stateDataError!.value = { message: 'Registration key mismatch' }; + + const status = useOsUpdateStatus(); + + // A key error is informational and must not imply the entitlement expired. + expect(status.blockedByKeyState.value).toBe(true); + expect(status.entitlementExpired.value).toBe(false); + }); + + it('flags entitlement expiration independently of key state', () => { + serverRefs.regUpdatesExpired!.value = true; + + const status = useOsUpdateStatus(); + + expect(status.entitlementExpired.value).toBe(true); + expect(status.blockedByKeyState.value).toBe(false); + }); + + it('treats update and downgrade reboots as a required reboot', () => { + const status = useOsUpdateStatus(); + + serverRefs.rebootType!.value = 'update'; + expect(status.rebootRequired.value).toBe(true); + + serverRefs.rebootType!.value = 'downgrade'; + expect(status.rebootRequired.value).toBe(true); + + serverRefs.rebootType!.value = 'thirdPartyDriversDownloading'; + expect(status.rebootRequired.value).toBe(false); + + serverRefs.rebootType!.value = ''; + expect(status.rebootRequired.value).toBe(false); + }); + + it('reports an available update from either a direct or renewal release', () => { + const status = useOsUpdateStatus(); + + expect(status.updateAvailable.value).toBe(false); + + updateOsRefs.available!.value = '7.3.1'; + expect(status.updateAvailable.value).toBe(true); + + updateOsRefs.available!.value = undefined; + updateOsRefs.availableWithRenewal!.value = '7.3.1'; + expect(status.updateAvailable.value).toBe(true); + }); +}); diff --git a/web/auto-imports.d.ts b/web/auto-imports.d.ts index 81d84147ed..e7ac9983b5 100644 --- a/web/auto-imports.d.ts +++ b/web/auto-imports.d.ts @@ -7,10 +7,10 @@ export {} declare global { const avatarGroupInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useAvatarGroup.js')['avatarGroupInjectionKey'] - const defineLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineLocale.js')['defineLocale'] - const defineShortcuts: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.js')['defineShortcuts'] - const extendLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineLocale.js')['extendLocale'] - const extractShortcuts: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.js')['extractShortcuts'] + const defineLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.8.2_@internationalized+date@3.12.2_@internationalized+number@3.6.7_@netlify+_803f2ca0584a2439b30afe1f069cc63b/node_modules/@nuxt/ui/dist/runtime/composables/defineLocale').defineLocale + const defineShortcuts: typeof import('../node_modules/.pnpm/@nuxt+ui@4.8.2_@internationalized+date@3.12.2_@internationalized+number@3.6.7_@netlify+_803f2ca0584a2439b30afe1f069cc63b/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts').defineShortcuts + const extendLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.8.2_@internationalized+date@3.12.2_@internationalized+number@3.6.7_@netlify+_803f2ca0584a2439b30afe1f069cc63b/node_modules/@nuxt/ui/dist/runtime/composables/defineLocale').extendLocale + const extractShortcuts: typeof import('../node_modules/.pnpm/@nuxt+ui@4.8.2_@internationalized+date@3.12.2_@internationalized+number@3.6.7_@netlify+_803f2ca0584a2439b30afe1f069cc63b/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts').extractShortcuts const fieldGroupInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFieldGroup.js')['fieldGroupInjectionKey'] const formBusInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formBusInjectionKey'] const formFieldInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formFieldInjectionKey'] @@ -21,42 +21,19 @@ declare global { const kbdKeysMap: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.js')['kbdKeysMap'] const localeContextInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useLocale.js')['localeContextInjectionKey'] const portalTargetInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/usePortal.js')['portalTargetInjectionKey'] - const useAppConfig: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/vue/composables/useAppConfig.js')['useAppConfig'] + const useAppConfig: typeof import('../node_modules/.pnpm/@nuxt+ui@4.8.2_@internationalized+date@3.12.2_@internationalized+number@3.6.7_@netlify+_803f2ca0584a2439b30afe1f069cc63b/node_modules/@nuxt/ui/dist/runtime/vue/composables/useAppConfig.js').useAppConfig const useAvatarGroup: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useAvatarGroup.js')['useAvatarGroup'] const useComponentIcons: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.js')['useComponentIcons'] - const useContentSearch: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useContentSearch.js')['useContentSearch'] + const useContentSearch: typeof import('../node_modules/.pnpm/@nuxt+ui@4.8.2_@internationalized+date@3.12.2_@internationalized+number@3.6.7_@netlify+_803f2ca0584a2439b30afe1f069cc63b/node_modules/@nuxt/ui/dist/runtime/composables/useContentSearch').useContentSearch const useFieldGroup: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFieldGroup.js')['useFieldGroup'] - const useFileUpload: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.js')['useFileUpload'] - const useFormField: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['useFormField'] - const useKbd: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.js')['useKbd'] + const useFileUpload: typeof import('../node_modules/.pnpm/@nuxt+ui@4.8.2_@internationalized+date@3.12.2_@internationalized+number@3.6.7_@netlify+_803f2ca0584a2439b30afe1f069cc63b/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload').useFileUpload + const useFormField: typeof import('../node_modules/.pnpm/@nuxt+ui@4.8.2_@internationalized+date@3.12.2_@internationalized+number@3.6.7_@netlify+_803f2ca0584a2439b30afe1f069cc63b/node_modules/@nuxt/ui/dist/runtime/composables/useFormField').useFormField + const useKbd: typeof import('../node_modules/.pnpm/@nuxt+ui@4.8.2_@internationalized+date@3.12.2_@internationalized+number@3.6.7_@netlify+_803f2ca0584a2439b30afe1f069cc63b/node_modules/@nuxt/ui/dist/runtime/composables/useKbd').useKbd const useLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useLocale.js')['useLocale'] - const useOverlay: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.js')['useOverlay'] + const useOverlay: typeof import('../node_modules/.pnpm/@nuxt+ui@4.8.2_@internationalized+date@3.12.2_@internationalized+number@3.6.7_@netlify+_803f2ca0584a2439b30afe1f069cc63b/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay').useOverlay const usePortal: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/usePortal.js')['usePortal'] - const useResizable: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.js')['useResizable'] - const useScrollspy: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useScrollspy.js')['useScrollspy'] - const useToast: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useToast.js')['useToast'] -} -// for type re-export -declare global { - // @ts-ignore - export type { ShortcutConfig, ShortcutsConfig, ShortcutsOptions } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.d' - import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.d') - // @ts-ignore - export type { UseComponentIconsProps } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.d' - import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.d') - // @ts-ignore - export type { UseFileUploadOptions } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.d' - import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.d') - // @ts-ignore - export type { KbdKey, KbdKeySpecific } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.d' - import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.d') - // @ts-ignore - export type { OverlayOptions, Overlay } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.d' - import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.d') - // @ts-ignore - export type { UseResizableProps, UseResizableReturn } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.d' - import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.d') - // @ts-ignore - export type { Toast } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useToast.d' - import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useToast.d') + const useResizable: typeof import('../node_modules/.pnpm/@nuxt+ui@4.8.2_@internationalized+date@3.12.2_@internationalized+number@3.6.7_@netlify+_803f2ca0584a2439b30afe1f069cc63b/node_modules/@nuxt/ui/dist/runtime/composables/useResizable').useResizable + const useScrollShadow: typeof import('../node_modules/.pnpm/@nuxt+ui@4.8.2_@internationalized+date@3.12.2_@internationalized+number@3.6.7_@netlify+_803f2ca0584a2439b30afe1f069cc63b/node_modules/@nuxt/ui/dist/runtime/composables/useScrollShadow').useScrollShadow + const useScrollspy: typeof import('../node_modules/.pnpm/@nuxt+ui@4.8.2_@internationalized+date@3.12.2_@internationalized+number@3.6.7_@netlify+_803f2ca0584a2439b30afe1f069cc63b/node_modules/@nuxt/ui/dist/runtime/composables/useScrollspy').useScrollspy + const useToast: typeof import('../node_modules/.pnpm/@nuxt+ui@4.8.2_@internationalized+date@3.12.2_@internationalized+number@3.6.7_@netlify+_803f2ca0584a2439b30afe1f069cc63b/node_modules/@nuxt/ui/dist/runtime/composables/useToast').useToast } diff --git a/web/components.d.ts b/web/components.d.ts index 8ba941e38e..8baa6df9f6 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -1,8 +1,11 @@ /* eslint-disable */ // @ts-nocheck +// biome-ignore lint: disable +// oxlint-disable +// ------ // Generated by unplugin-vue-components // Read more: https://github.com/vuejs/core/pull/3399 -// biome-ignore lint: disable + export {} /* prettier-ignore */ diff --git a/web/src/components/HeaderOsVersion.standalone.vue b/web/src/components/HeaderOsVersion.standalone.vue index fa2bf7ad4c..c994060d02 100644 --- a/web/src/components/HeaderOsVersion.standalone.vue +++ b/web/src/components/HeaderOsVersion.standalone.vue @@ -26,9 +26,9 @@ import { getReleaseNotesUrl, WEBGUI_TOOLS_DOWNGRADE, WEBGUI_TOOLS_UPDATE } from import ChangelogModal from '~/components/UpdateOs/ChangelogModal.vue'; import { INFO_VERSIONS_QUERY } from '~/components/UserProfile/versions.query'; import { useClipboardWithToast } from '~/composables/useClipboardWithToast'; +import { useOsUpdateStatus } from '~/composables/useOsUpdateStatus'; import { useServerStore } from '~/store/server'; import { useUpdateOsStore } from '~/store/updateOs'; -import { useUpdateOsActionsStore } from '~/store/updateOsActions'; const { t } = useI18n(); const { copyWithNotification } = useClipboardWithToast(); @@ -44,11 +44,10 @@ onMounted(() => { // Initialize all stores - they're needed for the UI const serverStore = useServerStore(); const updateOsStore = useUpdateOsStore(); -const updateOsActionsStore = useUpdateOsActionsStore(); -const { osVersion, rebootType, stateDataError } = storeToRefs(serverStore); -const { available, availableWithRenewal } = storeToRefs(updateOsStore); -const { rebootTypeText } = storeToRefs(updateOsActionsStore); +const { osVersion } = storeToRefs(serverStore); +const { available, availableWithRenewal, updateAvailable, rebootType, rebootTypeText } = + useOsUpdateStatus(); // Use lazy query and only load when dropdown is opened const { load: loadVersions, result: versionsResult } = useLazyQuery(INFO_VERSIONS_QUERY); @@ -120,11 +119,8 @@ const handleUpdateStatusClick = () => { }; const updateOsStatus = computed(() => { - if (stateDataError.value) { - // only allowed to update when server is does not have a state error - return null; - } - + // A pending reboot applies an already-installed update/downgrade — surface it + // regardless of registration/key state, which does not gate applying it. if (rebootTypeText.value) { return { badge: { @@ -136,7 +132,7 @@ const updateOsStatus = computed(() => { }; } - if (availableWithRenewal.value || available.value) { + if (updateAvailable.value) { return { badge: { color: 'orange', diff --git a/web/src/components/UpdateOs/Status.vue b/web/src/components/UpdateOs/Status.vue index f148099524..c8eb4d3d1d 100644 --- a/web/src/components/UpdateOs/Status.vue +++ b/web/src/components/UpdateOs/Status.vue @@ -16,6 +16,7 @@ import { Badge, BrandLoading, Button } from '@unraid/ui'; import { WEBGUI_TOOLS_REGISTRATION } from '~/helpers/urls'; import useDateTimeHelper from '~/composables/dateTime'; +import { useOsUpdateStatus } from '~/composables/useOsUpdateStatus'; import { useAccountStore } from '~/store/account'; import { useServerStore } from '~/store/server'; import { useUpdateOsStore } from '~/store/updateOs'; @@ -42,12 +43,18 @@ const updateOsActionsStore = useUpdateOsActionsStore(); const LoadingIcon = () => h(BrandLoading, { variant: 'white', style: 'width: 16px; height: 16px;' }); -const { dateTimeFormat, osVersion, rebootType, rebootVersion, regExp, regUpdatesExpired } = - storeToRefs(serverStore); -const { available, availableWithRenewal } = storeToRefs(updateOsStore); -const { ineligibleText, rebootTypeText, status } = storeToRefs(updateOsActionsStore); - -const updateAvailable = computed(() => available.value || availableWithRenewal.value); +const { dateTimeFormat, osVersion, regExp } = storeToRefs(serverStore); +const { ineligibleText, status } = storeToRefs(updateOsActionsStore); +const { + available, + availableWithRenewal, + updateAvailable, + entitlementExpired, + rebootType, + rebootTypeText, + rebootVersion, + rebootRequired, +} = useOsUpdateStatus(); const { outputDateTimeReadableDiff: readableDiffRegExp, outputDateTimeFormatted: formattedRegExp } = useDateTimeHelper(dateTimeFormat.value, t, true, regExp.value); @@ -57,12 +64,12 @@ const regExpOutput = computed(() => { return undefined; } return { - text: regUpdatesExpired.value + text: entitlementExpired.value ? `${t('registration.updateExpirationAction.eligibleForUpdatesReleasedOnOr', [formattedRegExp.value])} ${t('registration.updateExpirationAction.extendYourLicenseToAccessThe')}` : t('registration.updateExpirationAction.eligibleForFreeFeatureUpdatesUntil', [ formattedRegExp.value, ]), - title: regUpdatesExpired.value + title: entitlementExpired.value ? t('registration.updateExpirationAction.ineligibleAsOf', [readableDiffRegExp.value]) : t('registration.updateExpirationAction.eligibleForFreeFeatureUpdatesFor', [ readableDiffRegExp.value, @@ -70,9 +77,7 @@ const regExpOutput = computed(() => { }; }); -const showRebootButton = computed( - () => rebootType.value === 'downgrade' || rebootType.value === 'update' -); +const showRebootButton = rebootRequired; const checkButton = computed(() => { if (showRebootButton.value || props.showExternalDowngrade) { diff --git a/web/src/components/UserProfile/CallbackFeedback.vue b/web/src/components/UserProfile/CallbackFeedback.vue index 47d3430866..b58acdd1df 100644 --- a/web/src/components/UserProfile/CallbackFeedback.vue +++ b/web/src/components/UserProfile/CallbackFeedback.vue @@ -240,13 +240,23 @@ const showUpdateEligibility = computed(() => { return !['Basic', 'Plus', 'Pro', 'Lifetime', 'Trial'].includes(keyType.value); }); +const keyInstallResolved = computed( + () => keyInstallStatus.value === 'success' || keyInstallStatus.value === 'failed' +); + const showPostInstallKeyError = computed(() => - Boolean( - stateDataError.value && - callbackCallsCompleted.value && - (keyInstallStatus.value === 'success' || keyInstallStatus.value === 'failed') - ) + Boolean(stateDataError.value && callbackCallsCompleted.value && keyInstallResolved.value) ); + +/** + * A key install in this callback left the server with a license error — or the + * post-callback reconciliation that would clear it hasn't finished yet. Until we + * know the final key state, keep suppressing the OS update confirmation so the + * user can't start an update against a possibly-still-broken license. Broader + * than `showPostInstallKeyError` (which waits for `callbackCallsCompleted`) + * because the confirm button must stay hidden during the pending window too. + */ +const keyErrorBlocksUpdate = computed(() => Boolean(stateDataError.value && keyInstallResolved.value));