From 78c4b927b49552fe4227b10742697ac711e5128b Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 13 Mar 2026 09:32:32 -0400 Subject: [PATCH 1/9] Add TPM licensing availability to registration --- api/dev/states/var.ini | 3 +- api/generated-schema.graphql | 1 + api/src/__test__/store/modules/emhttp.test.ts | 1 + .../__test__/store/state-parsers/var.test.ts | 1 + api/src/core/types/states/var.ts | 1 + .../__test__/state-file-loader.test.ts | 11 +++++-- api/src/store/state-parsers/var.ts | 1 + api/src/unraid-api/cli/generated/graphql.ts | 5 +++- .../graph/resolvers/vars/vars.model.ts | 3 ++ web/__test__/components/Registration.test.ts | 20 +++++++++++++ web/_webGui/testWebComponents.page | 2 ++ web/src/_data/serverState.ts | 3 ++ .../components/Registration.standalone.vue | 29 +++++++++++++++++++ web/src/composables/gql/gql.ts | 6 ++-- web/src/composables/gql/graphql.ts | 7 +++-- web/src/locales/en.json | 6 ++++ web/src/store/server.fragment.ts | 2 ++ web/src/store/server.ts | 15 ++++++++++ web/types/server.ts | 2 ++ 19 files changed, 109 insertions(+), 10 deletions(-) diff --git a/api/dev/states/var.ini b/api/dev/states/var.ini index 662903a4c0..c8df905cfb 100644 --- a/api/dev/states/var.ini +++ b/api/dev/states/var.ini @@ -91,6 +91,7 @@ configValid="ineligible" joinStatus="Not joined" deviceCount="4" flashGUID="0000-0000-0000-000000000000" +tpmGUID="03-V35H8S0L1QHK1SBG1XHXJNH7" flashProduct="DataTraveler_3.0" flashVendor="KINGSTON" regCheck="" @@ -141,4 +142,4 @@ shareSMBCount="1" shareNFSCount="0" shareMoverActive="no" reservedNames="parity,parity2,parity3,diskP,diskQ,diskR,disk,disks,flash,boot,user,user0,disk0,disk1,disk2,disk3,disk4,disk5,disk6,disk7,disk8,disk9,disk10,disk11,disk12,disk13,disk14,disk15,disk16,disk17,disk18,disk19,disk20,disk21,disk22,disk23,disk24,disk25,disk26,disk27,disk28,disk29,disk30,disk31" -csrf_token="0000000000000000" \ No newline at end of file +csrf_token="0000000000000000" diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 2f51f85b5b..6f35a56d8e 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -552,6 +552,7 @@ type Vars implements Node { flashGuid: String flashProduct: String flashVendor: String + tpmGuid: String regCheck: String regFile: String regGuid: String diff --git a/api/src/__test__/store/modules/emhttp.test.ts b/api/src/__test__/store/modules/emhttp.test.ts index e9e987e859..696f8d35c6 100644 --- a/api/src/__test__/store/modules/emhttp.test.ts +++ b/api/src/__test__/store/modules/emhttp.test.ts @@ -1144,6 +1144,7 @@ test('After init returns values from cfg file for all fields', { timeout: 30000 "sysFlashSlots": 1, "sysModel": "Dell R710", "timeZone": "Australia/Adelaide", + "tpmGuid": "03-V35H8S0L1QHK1SBG1XHXJNH7", "useNetbios": "yes", "useNtp": true, "useSsh": true, diff --git a/api/src/__test__/store/state-parsers/var.test.ts b/api/src/__test__/store/state-parsers/var.test.ts index 9b70869387..26042cc934 100644 --- a/api/src/__test__/store/state-parsers/var.test.ts +++ b/api/src/__test__/store/state-parsers/var.test.ts @@ -161,6 +161,7 @@ test('Returns parsed state file', async () => { "sysFlashSlots": 1, "sysModel": "Dell R710", "timeZone": "Australia/Adelaide", + "tpmGuid": "03-V35H8S0L1QHK1SBG1XHXJNH7", "useNetbios": "yes", "useNtp": true, "useSsh": true, diff --git a/api/src/core/types/states/var.ts b/api/src/core/types/states/var.ts index 2245f78c34..7b7dc7eaaf 100644 --- a/api/src/core/types/states/var.ts +++ b/api/src/core/types/states/var.ts @@ -32,6 +32,7 @@ export type Var = { flashGuid: string; flashProduct: string; flashVendor: string; + tpmGuid?: string; /** Current progress of the {@link ?content=mover | mover}. */ fsCopyPrcnt: number; fsNumMounted: number; diff --git a/api/src/store/services/__test__/state-file-loader.test.ts b/api/src/store/services/__test__/state-file-loader.test.ts index d699cdcab3..6738014c33 100644 --- a/api/src/store/services/__test__/state-file-loader.test.ts +++ b/api/src/store/services/__test__/state-file-loader.test.ts @@ -11,7 +11,10 @@ import { StateFileKey } from '@app/store/types.js'; const VAR_FIXTURE = readFileSync(new URL('../../../../dev/states/var.ini', import.meta.url), 'utf-8'); const writeVarFixture = (dir: string, safeMode: 'yes' | 'no') => { - const content = VAR_FIXTURE.replace(/safeMode="(yes|no)"/, `safeMode="${safeMode}"`); + const content = VAR_FIXTURE.replace(/safeMode="(yes|no)"/, `safeMode="${safeMode}"`).replace( + /flashGUID="([^"]+)"/, + 'flashGUID="$1"\ntpmGUID="03-V35H8S0L1QHK1SBG1XHXJNH7"' + ); writeFileSync(join(dir, `${StateFileKey.var}.ini`), content); }; @@ -43,12 +46,16 @@ describe('loadStateFileSync', () => { const result = loadStateFileSync(StateFileKey.var); expect(result?.safeMode).toBe(true); + expect(result?.tpmGuid).toBe('03-V35H8S0L1QHK1SBG1XHXJNH7'); expect(dispatchSpy).toHaveBeenCalledWith( expect.objectContaining({ type: 'emhttp/updateEmhttpState', payload: { field: StateFileKey.var, - state: expect.objectContaining({ safeMode: true }), + state: expect.objectContaining({ + safeMode: true, + tpmGuid: '03-V35H8S0L1QHK1SBG1XHXJNH7', + }), }, }) ); diff --git a/api/src/store/state-parsers/var.ts b/api/src/store/state-parsers/var.ts index 140569d71e..e0446a1220 100644 --- a/api/src/store/state-parsers/var.ts +++ b/api/src/store/state-parsers/var.ts @@ -39,6 +39,7 @@ export type VarIni = { flashGuid: string; flashProduct: string; flashVendor: string; + tpmGuid?: string; fsCopyPrcnt: string; fsNumMounted: string; fsNumUnmountable: string; diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index e95c361499..1f03d6b58f 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -414,6 +414,8 @@ export type BrandingConfig = { background?: Maybe; /** Banner image source. Supports local path, remote URL, or data URI/base64. */ bannerImage?: Maybe; + /** Built-in case model value written to case-model.cfg when no custom override is supplied. */ + caseModel?: Maybe; /** Case model image source. Supports local path, remote URL, or data URI/base64. */ caseModelImage?: Maybe; /** Indicates if a partner logo exists */ @@ -451,6 +453,7 @@ export type BrandingConfig = { export type BrandingConfigInput = { background?: InputMaybe; bannerImage?: InputMaybe; + caseModel?: InputMaybe; caseModelImage?: InputMaybe; hasPartnerLogo?: InputMaybe; header?: InputMaybe; @@ -3548,4 +3551,4 @@ export const GetSsoUsersDocument = {"kind":"Document","definitions":[{"kind":"Op export const SystemReportDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SystemReport"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"machineId"}},{"kind":"Field","name":{"kind":"Name","value":"system"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"manufacturer"}},{"kind":"Field","name":{"kind":"Name","value":"model"}},{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"sku"}},{"kind":"Field","name":{"kind":"Name","value":"serial"}},{"kind":"Field","name":{"kind":"Name","value":"uuid"}}]}},{"kind":"Field","name":{"kind":"Name","value":"versions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"core"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unraid"}},{"kind":"Field","name":{"kind":"Name","value":"kernel"}}]}},{"kind":"Field","name":{"kind":"Name","value":"packages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"openssl"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"server"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const ConnectStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ConnectStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connect"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dynamicRemoteAccess"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enabledType"}},{"kind":"Field","name":{"kind":"Name","value":"runningType"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]}}]} as unknown as DocumentNode; export const ServicesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"online"}},{"kind":"Field","name":{"kind":"Name","value":"uptime"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"timestamp"}}]}},{"kind":"Field","name":{"kind":"Name","value":"version"}}]}}]}}]} as unknown as DocumentNode; -export const ValidateOidcSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidateOidcSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"validateOidcSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]}}]} as unknown as DocumentNode; +export const ValidateOidcSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidateOidcSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"validateOidcSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/api/src/unraid-api/graph/resolvers/vars/vars.model.ts b/api/src/unraid-api/graph/resolvers/vars/vars.model.ts index 1097db4f15..15ec2b727d 100644 --- a/api/src/unraid-api/graph/resolvers/vars/vars.model.ts +++ b/api/src/unraid-api/graph/resolvers/vars/vars.model.ts @@ -311,6 +311,9 @@ export class Vars extends Node { @Field({ nullable: true }) flashVendor?: string; + @Field({ nullable: true }) + tpmGuid?: string; + @Field({ nullable: true }) regCheck?: string; diff --git a/web/__test__/components/Registration.test.ts b/web/__test__/components/Registration.test.ts index d6d363d925..a08e0770ce 100644 --- a/web/__test__/components/Registration.test.ts +++ b/web/__test__/components/Registration.test.ts @@ -121,6 +121,7 @@ vi.mock('~/components/UserProfile/UptimeExpire.vue', () => ({ const initialServerState = { dateTimeFormat: { date: 'MMM D, YYYY', time: 'h:mm A' }, deviceCount: 0, + flashGuid: '', guid: '', keyfile: '', regGuid: '', @@ -133,6 +134,7 @@ const initialServerState = { state: 'ENOKEYFILE', stateData: { heading: 'Default Heading', message: 'Default Message' }, stateDataError: false, + tpmGuid: '', tooManyDevices: false, }; @@ -288,6 +290,24 @@ describe('Registration.standalone.vue', () => { expect(attachedStorageDevicesItem?.props('text')).toBe('8 out of unlimited devices'); }); + it('shows TPM transfer guidance when TPM licensing is available', async () => { + serverStore.state = 'PRO'; + serverStore.guid = '058F-6387-0000-0000F1F1E1C6'; + serverStore.flashGuid = '058F-6387-0000-0000F1F1E1C6'; + serverStore.tpmGuid = '03-V35H8S0L1QHK1SBG1XHXJNH7'; + serverStore.keyfile = 'keyfile-present'; + + await wrapper.vm.$nextTick(); + + const transferNotice = wrapper.find('[data-testid="tpm-transfer-available"]'); + + expect(transferNotice.exists()).toBe(true); + expect(transferNotice.text()).toContain('TPM licensing is available on this server.'); + expect(transferNotice.text()).toContain('Stop the array.'); + expect(transferNotice.text()).toContain('Remove the USB flash boot device.'); + expect(transferNotice.text()).toContain('Start the array.'); + }); + it('adds Activate Trial fallback for ENOKEYFILE partner activation', async () => { activationCodeStateHolder.current!.value = { code: 'PARTNER-CODE-123', diff --git a/web/_webGui/testWebComponents.page b/web/_webGui/testWebComponents.page index 58b07af40c..3aad585c30 100644 --- a/web/_webGui/testWebComponents.page +++ b/web/_webGui/testWebComponents.page @@ -90,6 +90,7 @@ $serverData = [ "flashProduct" => $var['flashProduct'], "flashVendor" => $var['flashVendor'], "flashBackupActivated" => empty($flashbackup_status['activated']) ? '' : 'true', + "flashGuid" => $var['flashGUID'], "guid" => $var['flashGUID'], "hasRemoteApikey" => !empty($myservers['remote']['apikey']), "lanIp" => ipaddr(), @@ -113,6 +114,7 @@ $serverData = [ "registeredTime" => $myservers['remote']['regWizTime'] ?? '', "site" => $_SERVER['REQUEST_SCHEME']."://".$_SERVER['HTTP_HOST'], "state" => strtoupper(empty($var['regCheck']) ? $var['regTy'] : $var['regCheck']), + "tpmGuid" => $var['tpmGUID'] ?? '', "textColor" => ($header) ? '#'.$header : '', "theme" => $display['theme'], "ts" => time(), diff --git a/web/src/_data/serverState.ts b/web/src/_data/serverState.ts index 92f2b20adc..2a9293ace3 100644 --- a/web/src/_data/serverState.ts +++ b/web/src/_data/serverState.ts @@ -49,6 +49,7 @@ import type { const state: ServerState = 'BASIC' as ServerState; const currentFlashGuid = '1111-1111-YIJD-ZACK1234TEST'; // this is the flash drive that's been booted from +const currentTpmGuid = '03-V35H8S0L1QHK1SBG1XHXJNH7'; const regGuid = '1111-1111-YIJD-ZACK1234TEST'; // this guid is registered in key server const keyfileBase64 = ''; @@ -147,6 +148,7 @@ const baseServerState: Server = { deviceCount: 3, expireTime, flashBackupActivated: !!connectPluginInstalled, + flashGuid: currentFlashGuid, flashProduct: 'SanDisk_3.2Gen1', flashVendor: 'USB', guid: currentFlashGuid, @@ -181,6 +183,7 @@ const baseServerState: Server = { name: 'white', textColor: '', }, + tpmGuid: currentTpmGuid, // updateOsResponse: { // version: '6.12.6', // name: 'Unraid 6.12.6', diff --git a/web/src/components/Registration.standalone.vue b/web/src/components/Registration.standalone.vue index 5c54af1156..5674511f7e 100644 --- a/web/src/components/Registration.standalone.vue +++ b/web/src/components/Registration.standalone.vue @@ -47,6 +47,7 @@ const { bootDeviceType, dateTimeFormat, deviceCount, + flashGuid, flashProduct, flashVendor, guid, @@ -63,6 +64,7 @@ const { state, stateData, stateDataError, + tpmGuid, tooManyDevices, } = storeToRefs(serverStore); @@ -132,6 +134,15 @@ const showPartnerActivationCode = computed(() => { (currentState === 'ENOKEYFILE' || currentState === 'TRIAL' || currentState === 'EEXPIRED') ); }); +const showTpmTransferInfo = computed((): boolean => + Boolean( + keyInstalled.value && + bootDeviceType.value === 'flash' && + flashGuid.value && + tpmGuid.value && + flashGuid.value !== tpmGuid.value + ) +); // Organize items into three sections const bootDeviceItems = computed((): RegistrationItemProps[] => { @@ -384,6 +395,24 @@ const actionItems = computed((): RegistrationItemProps[] => { class="rounded-lg border border-gray-200 p-4 dark:border-gray-700" >

{{ t('registration.actions') }}

+
+

+ {{ t('registration.tpmTransferAvailable') }} +

+

+ {{ t('registration.tpmTransferAvailableDescription') }} +

+
    +
  1. {{ t('registration.tpmTransferAvailableSteps.stopArray') }}
  2. +
  3. {{ t('registration.tpmTransferAvailableSteps.removeFlash') }}
  4. +
  5. {{ t('registration.tpmTransferAvailableSteps.transferOnRegistrationPage') }}
  6. +
  7. {{ t('registration.tpmTransferAvailableSteps.startArray') }}
  8. +
+
; sysModel?: Maybe; timeZone?: Maybe; + tpmGuid?: Maybe; /** Should a NTP server be used for time sync? */ useNtp?: Maybe; useSsh?: Maybe; @@ -4026,7 +4027,7 @@ export type CloudStateQuery = { __typename?: 'Query', cloud: ( export type ServerStateQueryVariables = Exact<{ [key: string]: never; }>; -export type ServerStateQuery = { __typename?: 'Query', config: { __typename?: 'Config', error?: string | null, valid?: boolean | null }, info: { __typename?: 'Info', os: { __typename?: 'InfoOs', hostname?: string | null } }, owner: { __typename?: 'Owner', avatar: string, username: string }, registration?: { __typename?: 'Registration', type?: RegistrationType | null, state?: RegistrationState | null, expiration?: string | null, updateExpiration?: string | null, keyFile?: { __typename?: 'KeyFile', contents?: string | null } | null } | null, vars: { __typename?: 'Vars', regGen?: string | null, regState?: RegistrationState | null, configError?: ConfigErrorState | null, configValid?: boolean | null } }; +export type ServerStateQuery = { __typename?: 'Query', config: { __typename?: 'Config', error?: string | null, valid?: boolean | null }, info: { __typename?: 'Info', os: { __typename?: 'InfoOs', hostname?: string | null } }, owner: { __typename?: 'Owner', avatar: string, username: string }, registration?: { __typename?: 'Registration', type?: RegistrationType | null, state?: RegistrationState | null, expiration?: string | null, updateExpiration?: string | null, keyFile?: { __typename?: 'KeyFile', contents?: string | null } | null } | null, vars: { __typename?: 'Vars', flashGuid?: string | null, tpmGuid?: string | null, regGen?: string | null, regState?: RegistrationState | null, configError?: ConfigErrorState | null, configValid?: boolean | null } }; export type GetThemeQueryVariables = Exact<{ [key: string]: never; }>; @@ -4114,5 +4115,5 @@ export const ConnectSignInDocument = {"kind":"Document","definitions":[{"kind":" export const SignOutDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SignOut"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectSignOut"}}]}}]} as unknown as DocumentNode; export const IsSsoEnabledDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"IsSSOEnabled"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"isSSOEnabled"}}]}}]} as unknown as DocumentNode; export const CloudStateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"cloudState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cloud"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PartialCloud"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PartialCloud"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Cloud"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cloud"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"minigraphql"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"relay"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]} as unknown as DocumentNode; -export const ServerStateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"serverState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}}]}},{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"os"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hostname"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"owner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"registration"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"expiration"}},{"kind":"Field","name":{"kind":"Name","value":"keyFile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contents"}}]}},{"kind":"Field","name":{"kind":"Name","value":"updateExpiration"}}]}},{"kind":"Field","name":{"kind":"Name","value":"vars"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"regGen"}},{"kind":"Field","name":{"kind":"Name","value":"regState"}},{"kind":"Field","name":{"kind":"Name","value":"configError"}},{"kind":"Field","name":{"kind":"Name","value":"configValid"}}]}}]}}]} as unknown as DocumentNode; -export const GetThemeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"getTheme"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publicTheme"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerImage"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerGradient"}},{"kind":"Field","name":{"kind":"Name","value":"headerBackgroundColor"}},{"kind":"Field","name":{"kind":"Name","value":"showHeaderDescription"}},{"kind":"Field","name":{"kind":"Name","value":"headerPrimaryTextColor"}},{"kind":"Field","name":{"kind":"Name","value":"headerSecondaryTextColor"}}]}}]}}]} as unknown as DocumentNode; +export const ServerStateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"serverState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}}]}},{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"os"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hostname"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"owner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"registration"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"expiration"}},{"kind":"Field","name":{"kind":"Name","value":"keyFile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contents"}}]}},{"kind":"Field","name":{"kind":"Name","value":"updateExpiration"}}]}},{"kind":"Field","name":{"kind":"Name","value":"vars"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"flashGuid"}},{"kind":"Field","name":{"kind":"Name","value":"tpmGuid"}},{"kind":"Field","name":{"kind":"Name","value":"regGen"}},{"kind":"Field","name":{"kind":"Name","value":"regState"}},{"kind":"Field","name":{"kind":"Name","value":"configError"}},{"kind":"Field","name":{"kind":"Name","value":"configValid"}}]}}]}}]} as unknown as DocumentNode; +export const GetThemeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"getTheme"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publicTheme"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerImage"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerGradient"}},{"kind":"Field","name":{"kind":"Name","value":"headerBackgroundColor"}},{"kind":"Field","name":{"kind":"Name","value":"showHeaderDescription"}},{"kind":"Field","name":{"kind":"Name","value":"headerPrimaryTextColor"}},{"kind":"Field","name":{"kind":"Name","value":"headerSecondaryTextColor"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 1c6973b4fe..2c1533ec6d 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -672,6 +672,12 @@ "registration.registeredTo": "Registered to", "registration.activationCode": "Activation Code", "registration.partnerActivationDetected": "It appears you already have a license associated with this server. You can activate it now for free to unlock all features.", + "registration.tpmTransferAvailable": "TPM licensing is available on this server.", + "registration.tpmTransferAvailableDescription": "To move this license from your USB flash device to TPM, follow these steps from Tools > Registration.", + "registration.tpmTransferAvailableSteps.stopArray": "Stop the array.", + "registration.tpmTransferAvailableSteps.removeFlash": "Remove the USB flash boot device.", + "registration.tpmTransferAvailableSteps.transferOnRegistrationPage": "On Tools > Registration, initiate a license transfer.", + "registration.tpmTransferAvailableSteps.startArray": "Start the array.", "registration.replaceCheck.checkEligibility": "Check Eligibility", "registration.transferLicenseToNewDevice": "Transfer License to New Device", "registration.trialExpiration": "Trial expiration", diff --git a/web/src/store/server.fragment.ts b/web/src/store/server.fragment.ts index a384604b82..3613f3d296 100644 --- a/web/src/store/server.fragment.ts +++ b/web/src/store/server.fragment.ts @@ -88,6 +88,8 @@ export const SERVER_STATE_QUERY = graphql(/* GraphQL */ ` updateExpiration } vars { + flashGuid + tpmGuid regGen regState configError diff --git a/web/src/store/server.ts b/web/src/store/server.ts index e4c74399b2..d39aee868a 100644 --- a/web/src/store/server.ts +++ b/web/src/store/server.ts @@ -88,6 +88,7 @@ export const useServerStore = defineStore('server', () => { const email = ref(''); const expireTime = ref(0); const flashBackupActivated = ref(false); + const flashGuid = ref(''); const flashProduct = ref(''); const flashVendor = ref(''); const guid = ref(''); @@ -136,6 +137,7 @@ export const useServerStore = defineStore('server', () => { const ssoEnabled = ref(false); const state = ref(); const theme = ref(); + const tpmGuid = ref(''); watch(theme, (newVal) => { if (newVal) { themeStore.setTheme(newVal); @@ -185,6 +187,7 @@ export const useServerStore = defineStore('server', () => { deviceCount: deviceCount.value, email: email.value, expireTime: expireTime.value, + flashGuid: flashGuid.value, flashProduct: flashProduct.value, flashVendor: flashVendor.value, guid: guid.value, @@ -207,6 +210,7 @@ export const useServerStore = defineStore('server', () => { site: site.value, state: state.value, theme: theme.value, + tpmGuid: tpmGuid.value, uptime: uptime.value, username: username.value, wanFQDN: wanFQDN.value, @@ -288,6 +292,7 @@ export const useServerStore = defineStore('server', () => { regTy: regTy.value, site: site.value, state: state.value, + tpmGuid: tpmGuid.value, uptime: uptime.value, username: username.value, wanFQDN: wanFQDN.value, @@ -1027,6 +1032,9 @@ export const useServerStore = defineStore('server', () => { if (typeof data?.flashBackupActivated !== 'undefined') { flashBackupActivated.value = data.flashBackupActivated; } + if (typeof data?.flashGuid !== 'undefined') { + flashGuid.value = data.flashGuid; + } if (typeof data?.flashProduct !== 'undefined') { flashProduct.value = data.flashProduct; } @@ -1087,6 +1095,9 @@ export const useServerStore = defineStore('server', () => { if (typeof data?.theme !== 'undefined') { theme.value = data.theme; } + if (typeof data?.tpmGuid !== 'undefined') { + tpmGuid.value = data.tpmGuid; + } if (typeof data?.updateOsIgnoredReleases !== 'undefined') { updateOsIgnoredReleases.value = data.updateOsIgnoredReleases; } @@ -1141,9 +1152,11 @@ export const useServerStore = defineStore('server', () => { data.registration && data.registration.keyFile && data.registration.keyFile.contents ? data.registration.keyFile.contents : undefined, + flashGuid: data.vars?.flashGuid ?? undefined, regGen: data.vars && data.vars.regGen ? parseInt(data.vars.regGen) : undefined, regTy: data.registration?.type ?? undefined, state: data.registration?.state ?? data.vars?.regState ?? undefined, + tpmGuid: data.vars?.tpmGuid ?? undefined, config: data.config ? { id: 'config', ...data.config } : { @@ -1368,6 +1381,7 @@ export const useServerStore = defineStore('server', () => { deviceCount, expireTime, flashBackupActivated, + flashGuid, flashProduct, flashVendor, guid, @@ -1395,6 +1409,7 @@ export const useServerStore = defineStore('server', () => { ssoEnabled, state, theme, + tpmGuid, updateOsIgnoredReleases, updateOsNotificationsEnabled, updateOsResponse, diff --git a/web/types/server.ts b/web/types/server.ts index 8045936f79..5c80e48f9e 100644 --- a/web/types/server.ts +++ b/web/types/server.ts @@ -91,6 +91,7 @@ export interface Server { email?: string; expireTime?: number; flashBackupActivated?: boolean; + flashGuid?: string; flashProduct?: string; flashVendor?: string; guid?: string; @@ -117,6 +118,7 @@ export interface Server { ssoEnabled?: boolean; state?: ServerState; theme?: Theme | undefined; + tpmGuid?: string; updateOsIgnoredReleases?: string[]; updateOsNotificationsEnabled?: boolean; updateOsResponse?: ServerUpdateOsResponse; From b7e26280c93ee95d82dbd96d55aa58a263371140 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 13 Mar 2026 09:45:38 -0400 Subject: [PATCH 2/9] Hide TPM transfer notice for trial licenses --- web/__test__/components/Registration.test.ts | 12 ++++++++++++ web/src/components/Registration.standalone.vue | 3 +++ 2 files changed, 15 insertions(+) diff --git a/web/__test__/components/Registration.test.ts b/web/__test__/components/Registration.test.ts index a08e0770ce..2860ad3ba2 100644 --- a/web/__test__/components/Registration.test.ts +++ b/web/__test__/components/Registration.test.ts @@ -308,6 +308,18 @@ describe('Registration.standalone.vue', () => { expect(transferNotice.text()).toContain('Start the array.'); }); + it('does not show TPM transfer guidance for trial states', async () => { + serverStore.state = 'TRIAL'; + serverStore.guid = '058F-6387-0000-0000F1F1E1C6'; + serverStore.flashGuid = '058F-6387-0000-0000F1F1E1C6'; + serverStore.tpmGuid = '03-V35H8S0L1QHK1SBG1XHXJNH7'; + serverStore.keyfile = 'keyfile-present'; + + await wrapper.vm.$nextTick(); + + expect(wrapper.find('[data-testid="tpm-transfer-available"]').exists()).toBe(false); + }); + it('adds Activate Trial fallback for ENOKEYFILE partner activation', async () => { activationCodeStateHolder.current!.value = { code: 'PARTNER-CODE-123', diff --git a/web/src/components/Registration.standalone.vue b/web/src/components/Registration.standalone.vue index 5674511f7e..4a80ceaec8 100644 --- a/web/src/components/Registration.standalone.vue +++ b/web/src/components/Registration.standalone.vue @@ -137,7 +137,10 @@ const showPartnerActivationCode = computed(() => { const showTpmTransferInfo = computed((): boolean => Boolean( keyInstalled.value && + !showTrialExpiration.value && bootDeviceType.value === 'flash' && + // On TPM systems, flashGuid may fall back to the TPM GUID, so only show + // this while we're still booted from flash and the GUIDs differ. flashGuid.value && tpmGuid.value && flashGuid.value !== tpmGuid.value From 7abf943f2ba806d6445077eb671d1b950dcfc014 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 13 Mar 2026 12:47:56 -0400 Subject: [PATCH 3/9] Refine TPM transfer registration guidance --- web/__test__/components/Registration.test.ts | 23 +++++ .../components/Registration.standalone.vue | 90 +++++++++++++++++-- web/src/locales/en.json | 7 +- 3 files changed, 112 insertions(+), 8 deletions(-) diff --git a/web/__test__/components/Registration.test.ts b/web/__test__/components/Registration.test.ts index 2860ad3ba2..dc4839cba2 100644 --- a/web/__test__/components/Registration.test.ts +++ b/web/__test__/components/Registration.test.ts @@ -305,7 +305,10 @@ describe('Registration.standalone.vue', () => { expect(transferNotice.text()).toContain('TPM licensing is available on this server.'); expect(transferNotice.text()).toContain('Stop the array.'); expect(transferNotice.text()).toContain('Remove the USB flash boot device.'); + expect(transferNotice.text()).toContain('Refresh this page.'); + expect(transferNotice.text()).toContain('Press Replace Key.'); expect(transferNotice.text()).toContain('Start the array.'); + expect(transferNotice.text()).not.toContain('Tools > Registration'); }); it('does not show TPM transfer guidance for trial states', async () => { @@ -320,6 +323,26 @@ describe('Registration.standalone.vue', () => { expect(wrapper.find('[data-testid="tpm-transfer-available"]').exists()).toBe(false); }); + it('shows checked TPM transfer steps after switching to TPM boot', async () => { + serverStore.state = 'EGUID'; + serverStore.guid = '03-V35H8S0L1QHK1SBG1XHXJNH7'; + serverStore.tpmGuid = '03-V35H8S0L1QHK1SBG1XHXJNH7'; + serverStore.regGuid = '058F-6387-0000-0000F1F1E1C6'; + + await wrapper.vm.$nextTick(); + + const transferNotice = wrapper.find('[data-testid="tpm-transfer-ready"]'); + + expect(transferNotice.exists()).toBe(true); + expect(transferNotice.text()).toContain('Continue your TPM license transfer.'); + expect(transferNotice.text()).toContain('The first two steps are already complete.'); + expect(transferNotice.text()).toContain('[x]'); + expect(transferNotice.text()).toContain('Stop the array.'); + expect(transferNotice.text()).toContain('Remove the USB flash boot device.'); + expect(transferNotice.text()).toContain('Press Replace Key.'); + expect(transferNotice.text()).toContain('Start the array.'); + }); + it('adds Activate Trial fallback for ENOKEYFILE partner activation', async () => { activationCodeStateHolder.current!.value = { code: 'PARTNER-CODE-123', diff --git a/web/src/components/Registration.standalone.vue b/web/src/components/Registration.standalone.vue index 4a80ceaec8..aa3d2dbceb 100644 --- a/web/src/components/Registration.standalone.vue +++ b/web/src/components/Registration.standalone.vue @@ -146,6 +146,56 @@ const showTpmTransferInfo = computed((): boolean => flashGuid.value !== tpmGuid.value ) ); +const showTpmTransferReadyInfo = computed((): boolean => + Boolean( + !showTrialExpiration.value && + state.value === 'EGUID' && + bootDeviceType.value === 'tpm' && + guid.value && + tpmGuid.value && + guid.value === tpmGuid.value + ) +); +const tpmTransferPreparationSteps = computed(() => [ + { + completed: false, + label: t('registration.tpmTransferAvailableSteps.stopArray'), + }, + { + completed: false, + label: t('registration.tpmTransferAvailableSteps.removeFlash'), + }, + { + completed: false, + label: t('registration.tpmTransferAvailableSteps.refreshPage'), + }, + { + completed: false, + label: t('registration.tpmTransferAvailableSteps.replaceKey'), + }, + { + completed: false, + label: t('registration.tpmTransferAvailableSteps.startArray'), + }, +]); +const tpmTransferReadySteps = computed(() => [ + { + completed: true, + label: t('registration.tpmTransferAvailableSteps.stopArray'), + }, + { + completed: true, + label: t('registration.tpmTransferAvailableSteps.removeFlash'), + }, + { + completed: false, + label: t('registration.tpmTransferAvailableSteps.replaceKey'), + }, + { + completed: false, + label: t('registration.tpmTransferAvailableSteps.startArray'), + }, +]); // Organize items into three sections const bootDeviceItems = computed((): RegistrationItemProps[] => { @@ -409,12 +459,40 @@ const actionItems = computed((): RegistrationItemProps[] => {

{{ t('registration.tpmTransferAvailableDescription') }}

-
    -
  1. {{ t('registration.tpmTransferAvailableSteps.stopArray') }}
  2. -
  3. {{ t('registration.tpmTransferAvailableSteps.removeFlash') }}
  4. -
  5. {{ t('registration.tpmTransferAvailableSteps.transferOnRegistrationPage') }}
  6. -
  7. {{ t('registration.tpmTransferAvailableSteps.startArray') }}
  8. -
+
    +
  • + {{ step.completed ? '[x]' : '[ ]' }} + {{ step.label }} +
  • +
+
+
+

+ {{ t('registration.tpmTransferReady') }} +

+

+ {{ t('registration.tpmTransferReadyDescription') }} +

+
    +
  • + + {{ step.completed ? '[x]' : '[ ]' }} + + {{ step.label }} +
  • +
Registration.", + "registration.tpmTransferAvailableDescription": "To move this license from your USB flash device to TPM, complete these steps on this page.", "registration.tpmTransferAvailableSteps.stopArray": "Stop the array.", "registration.tpmTransferAvailableSteps.removeFlash": "Remove the USB flash boot device.", - "registration.tpmTransferAvailableSteps.transferOnRegistrationPage": "On Tools > Registration, initiate a license transfer.", + "registration.tpmTransferAvailableSteps.refreshPage": "Refresh this page.", + "registration.tpmTransferAvailableSteps.replaceKey": "Press Replace Key.", "registration.tpmTransferAvailableSteps.startArray": "Start the array.", + "registration.tpmTransferReady": "Continue your TPM license transfer.", + "registration.tpmTransferReadyDescription": "The first two steps are already complete. Press Replace Key to transfer this license to TPM, then start the array.", "registration.replaceCheck.checkEligibility": "Check Eligibility", "registration.transferLicenseToNewDevice": "Transfer License to New Device", "registration.trialExpiration": "Trial expiration", From c31be46b218b2c4a9817c17f4c4a87b666122b76 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 13 Mar 2026 13:46:54 -0400 Subject: [PATCH 4/9] Rename registration boot device copy --- web/__test__/components/Registration.test.ts | 3 +++ web/src/locales/en.json | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/web/__test__/components/Registration.test.ts b/web/__test__/components/Registration.test.ts index dc4839cba2..b3c724ff3f 100644 --- a/web/__test__/components/Registration.test.ts +++ b/web/__test__/components/Registration.test.ts @@ -260,6 +260,9 @@ describe('Registration.standalone.vue', () => { await wrapper.vm.$nextTick(); + expect(wrapper.text()).toContain('License Device'); + expect(wrapper.text()).toContain('License device type'); + const keyTypeItem = findItemByLabel(t('License key type')); expect(keyTypeItem).toBeDefined(); diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 0ffa226c10..f7a9351bce 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -647,8 +647,8 @@ "notifications.sidebar.unreadTab": "Unread", "registration.actions": "Actions", "registration.attachedStorageDevices": "Attached Storage Devices", - "registration.bootDevice": "Boot Device", - "registration.bootDeviceType": "Boot device type", + "registration.bootDevice": "License Device", + "registration.bootDeviceType": "License device type", "registration.bootDeviceType.flash": "USB Flash", "registration.bootDeviceType.internalBoot": "Internal Boot", "registration.bootDeviceType.internalBootMulti": "Internal Boot (Multi-device)", From 1f243d7667208b426c20e32469ae1205bed1f278 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 13 Mar 2026 14:13:35 -0400 Subject: [PATCH 5/9] Drive TPM transfer step from array state --- web/__test__/components/Registration.test.ts | 39 +++++++++++++++++++ .../components/Registration.standalone.vue | 13 +++++-- web/src/composables/gql/gql.ts | 6 +-- web/src/composables/gql/graphql.ts | 4 +- web/src/locales/en.json | 1 + web/src/store/server.fragment.ts | 1 + web/src/store/server.ts | 7 ++++ web/types/server.ts | 1 + 8 files changed, 64 insertions(+), 8 deletions(-) diff --git a/web/__test__/components/Registration.test.ts b/web/__test__/components/Registration.test.ts index b3c724ff3f..5e6bdbdf4f 100644 --- a/web/__test__/components/Registration.test.ts +++ b/web/__test__/components/Registration.test.ts @@ -124,6 +124,7 @@ const initialServerState = { flashGuid: '', guid: '', keyfile: '', + mdState: '', regGuid: '', regTm: '', regTo: '', @@ -297,6 +298,7 @@ describe('Registration.standalone.vue', () => { serverStore.state = 'PRO'; serverStore.guid = '058F-6387-0000-0000F1F1E1C6'; serverStore.flashGuid = '058F-6387-0000-0000F1F1E1C6'; + serverStore.mdState = 'STOPPED'; serverStore.tpmGuid = '03-V35H8S0L1QHK1SBG1XHXJNH7'; serverStore.keyfile = 'keyfile-present'; @@ -314,6 +316,23 @@ describe('Registration.standalone.vue', () => { expect(transferNotice.text()).not.toContain('Tools > Registration'); }); + it('only checks the stop-array step when the array is stopped', async () => { + serverStore.state = 'PRO'; + serverStore.guid = '058F-6387-0000-0000F1F1E1C6'; + serverStore.flashGuid = '058F-6387-0000-0000F1F1E1C6'; + serverStore.mdState = 'STARTED'; + serverStore.tpmGuid = '03-V35H8S0L1QHK1SBG1XHXJNH7'; + serverStore.keyfile = 'keyfile-present'; + + await wrapper.vm.$nextTick(); + + const transferNotice = wrapper.find('[data-testid="tpm-transfer-available"]'); + + expect(transferNotice.exists()).toBe(true); + expect(transferNotice.text()).not.toContain('[x] Stop the array.'); + expect(transferNotice.text()).toMatch(/\[\s\]Stop the array\./); + }); + it('does not show TPM transfer guidance for trial states', async () => { serverStore.state = 'TRIAL'; serverStore.guid = '058F-6387-0000-0000F1F1E1C6'; @@ -329,6 +348,7 @@ describe('Registration.standalone.vue', () => { it('shows checked TPM transfer steps after switching to TPM boot', async () => { serverStore.state = 'EGUID'; serverStore.guid = '03-V35H8S0L1QHK1SBG1XHXJNH7'; + serverStore.mdState = 'STOPPED'; serverStore.tpmGuid = '03-V35H8S0L1QHK1SBG1XHXJNH7'; serverStore.regGuid = '058F-6387-0000-0000F1F1E1C6'; @@ -346,6 +366,25 @@ describe('Registration.standalone.vue', () => { expect(transferNotice.text()).toContain('Start the array.'); }); + it('shows the stop-array step as incomplete in TPM-ready state while the array is running', async () => { + serverStore.state = 'EGUID'; + serverStore.guid = '03-V35H8S0L1QHK1SBG1XHXJNH7'; + serverStore.mdState = 'STARTED'; + serverStore.tpmGuid = '03-V35H8S0L1QHK1SBG1XHXJNH7'; + serverStore.regGuid = '058F-6387-0000-0000F1F1E1C6'; + + await wrapper.vm.$nextTick(); + + const transferNotice = wrapper.find('[data-testid="tpm-transfer-ready"]'); + + expect(transferNotice.exists()).toBe(true); + expect(transferNotice.text()).toContain( + 'The USB flash boot device is already removed. Stop the array, then press Replace Key to transfer this license to TPM.' + ); + expect(transferNotice.text()).toMatch(/\[\s\]Stop the array\./); + expect(transferNotice.text()).toMatch(/\[x\]Remove the USB flash boot device\./); + }); + it('adds Activate Trial fallback for ENOKEYFILE partner activation', async () => { activationCodeStateHolder.current!.value = { code: 'PARTNER-CODE-123', diff --git a/web/src/components/Registration.standalone.vue b/web/src/components/Registration.standalone.vue index aa3d2dbceb..8aa7c6abce 100644 --- a/web/src/components/Registration.standalone.vue +++ b/web/src/components/Registration.standalone.vue @@ -54,6 +54,7 @@ const { keyActions, keyfile, computedRegDevs, + mdState, regGuid, regTm, regTo, @@ -156,9 +157,15 @@ const showTpmTransferReadyInfo = computed((): boolean => guid.value === tpmGuid.value ) ); +const isArrayStopped = computed((): boolean => mdState.value === 'STOPPED'); +const tpmTransferReadyDescription = computed(() => + isArrayStopped.value + ? t('registration.tpmTransferReadyDescription') + : t('registration.tpmTransferReadyDescriptionArrayRunning') +); const tpmTransferPreparationSteps = computed(() => [ { - completed: false, + completed: isArrayStopped.value, label: t('registration.tpmTransferAvailableSteps.stopArray'), }, { @@ -180,7 +187,7 @@ const tpmTransferPreparationSteps = computed(() => [ ]); const tpmTransferReadySteps = computed(() => [ { - completed: true, + completed: isArrayStopped.value, label: t('registration.tpmTransferAvailableSteps.stopArray'), }, { @@ -479,7 +486,7 @@ const actionItems = computed((): RegistrationItemProps[] => { {{ t('registration.tpmTransferReady') }}

- {{ t('registration.tpmTransferReadyDescription') }} + {{ tpmTransferReadyDescription }}

  • ; -export type ServerStateQuery = { __typename?: 'Query', config: { __typename?: 'Config', error?: string | null, valid?: boolean | null }, info: { __typename?: 'Info', os: { __typename?: 'InfoOs', hostname?: string | null } }, owner: { __typename?: 'Owner', avatar: string, username: string }, registration?: { __typename?: 'Registration', type?: RegistrationType | null, state?: RegistrationState | null, expiration?: string | null, updateExpiration?: string | null, keyFile?: { __typename?: 'KeyFile', contents?: string | null } | null } | null, vars: { __typename?: 'Vars', flashGuid?: string | null, tpmGuid?: string | null, regGen?: string | null, regState?: RegistrationState | null, configError?: ConfigErrorState | null, configValid?: boolean | null } }; +export type ServerStateQuery = { __typename?: 'Query', config: { __typename?: 'Config', error?: string | null, valid?: boolean | null }, info: { __typename?: 'Info', os: { __typename?: 'InfoOs', hostname?: string | null } }, owner: { __typename?: 'Owner', avatar: string, username: string }, registration?: { __typename?: 'Registration', type?: RegistrationType | null, state?: RegistrationState | null, expiration?: string | null, updateExpiration?: string | null, keyFile?: { __typename?: 'KeyFile', contents?: string | null } | null } | null, vars: { __typename?: 'Vars', flashGuid?: string | null, tpmGuid?: string | null, mdState?: string | null, regGen?: string | null, regState?: RegistrationState | null, configError?: ConfigErrorState | null, configValid?: boolean | null } }; export type GetThemeQueryVariables = Exact<{ [key: string]: never; }>; @@ -4115,5 +4115,5 @@ export const ConnectSignInDocument = {"kind":"Document","definitions":[{"kind":" export const SignOutDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SignOut"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectSignOut"}}]}}]} as unknown as DocumentNode; export const IsSsoEnabledDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"IsSSOEnabled"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"isSSOEnabled"}}]}}]} as unknown as DocumentNode; export const CloudStateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"cloudState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cloud"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PartialCloud"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PartialCloud"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Cloud"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cloud"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"minigraphql"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"relay"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]} as unknown as DocumentNode; -export const ServerStateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"serverState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}}]}},{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"os"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hostname"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"owner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"registration"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"expiration"}},{"kind":"Field","name":{"kind":"Name","value":"keyFile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contents"}}]}},{"kind":"Field","name":{"kind":"Name","value":"updateExpiration"}}]}},{"kind":"Field","name":{"kind":"Name","value":"vars"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"flashGuid"}},{"kind":"Field","name":{"kind":"Name","value":"tpmGuid"}},{"kind":"Field","name":{"kind":"Name","value":"regGen"}},{"kind":"Field","name":{"kind":"Name","value":"regState"}},{"kind":"Field","name":{"kind":"Name","value":"configError"}},{"kind":"Field","name":{"kind":"Name","value":"configValid"}}]}}]}}]} as unknown as DocumentNode; +export const ServerStateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"serverState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}}]}},{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"os"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hostname"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"owner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"registration"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"expiration"}},{"kind":"Field","name":{"kind":"Name","value":"keyFile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contents"}}]}},{"kind":"Field","name":{"kind":"Name","value":"updateExpiration"}}]}},{"kind":"Field","name":{"kind":"Name","value":"vars"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"flashGuid"}},{"kind":"Field","name":{"kind":"Name","value":"tpmGuid"}},{"kind":"Field","name":{"kind":"Name","value":"mdState"}},{"kind":"Field","name":{"kind":"Name","value":"regGen"}},{"kind":"Field","name":{"kind":"Name","value":"regState"}},{"kind":"Field","name":{"kind":"Name","value":"configError"}},{"kind":"Field","name":{"kind":"Name","value":"configValid"}}]}}]}}]} as unknown as DocumentNode; export const GetThemeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"getTheme"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publicTheme"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerImage"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerGradient"}},{"kind":"Field","name":{"kind":"Name","value":"headerBackgroundColor"}},{"kind":"Field","name":{"kind":"Name","value":"showHeaderDescription"}},{"kind":"Field","name":{"kind":"Name","value":"headerPrimaryTextColor"}},{"kind":"Field","name":{"kind":"Name","value":"headerSecondaryTextColor"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/web/src/locales/en.json b/web/src/locales/en.json index f7a9351bce..367d446da2 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -681,6 +681,7 @@ "registration.tpmTransferAvailableSteps.startArray": "Start the array.", "registration.tpmTransferReady": "Continue your TPM license transfer.", "registration.tpmTransferReadyDescription": "The first two steps are already complete. Press Replace Key to transfer this license to TPM, then start the array.", + "registration.tpmTransferReadyDescriptionArrayRunning": "The USB flash boot device is already removed. Stop the array, then press Replace Key to transfer this license to TPM.", "registration.replaceCheck.checkEligibility": "Check Eligibility", "registration.transferLicenseToNewDevice": "Transfer License to New Device", "registration.trialExpiration": "Trial expiration", diff --git a/web/src/store/server.fragment.ts b/web/src/store/server.fragment.ts index 3613f3d296..dd8933f3f0 100644 --- a/web/src/store/server.fragment.ts +++ b/web/src/store/server.fragment.ts @@ -90,6 +90,7 @@ export const SERVER_STATE_QUERY = graphql(/* GraphQL */ ` vars { flashGuid tpmGuid + mdState regGen regState configError diff --git a/web/src/store/server.ts b/web/src/store/server.ts index d39aee868a..42208defa4 100644 --- a/web/src/store/server.ts +++ b/web/src/store/server.ts @@ -109,6 +109,7 @@ export const useServerStore = defineStore('server', () => { const lanIp = ref(''); const license = ref(''); const locale = ref(''); + const mdState = ref(''); const name = ref(''); const osVersion = ref(''); const osVersionBranch = ref('stable'); @@ -196,6 +197,7 @@ export const useServerStore = defineStore('server', () => { lanIp: lanIp.value, license: license.value, locale: locale.value, + mdState: mdState.value, name: name.value, osVersion: osVersion.value, osVersionBranch: osVersionBranch.value, @@ -1056,6 +1058,9 @@ export const useServerStore = defineStore('server', () => { if (typeof data?.locale !== 'undefined') { locale.value = data.locale; } + if (typeof data?.mdState !== 'undefined') { + mdState.value = data.mdState; + } if (typeof data?.name !== 'undefined') { name.value = data.name; } @@ -1153,6 +1158,7 @@ export const useServerStore = defineStore('server', () => { ? data.registration.keyFile.contents : undefined, flashGuid: data.vars?.flashGuid ?? undefined, + mdState: data.vars?.mdState ?? undefined, regGen: data.vars && data.vars.regGen ? parseInt(data.vars.regGen) : undefined, regTy: data.registration?.type ?? undefined, state: data.registration?.state ?? data.vars?.regState ?? undefined, @@ -1389,6 +1395,7 @@ export const useServerStore = defineStore('server', () => { keyfile, inIframe, locale, + mdState, lanIp, name, osVersion, diff --git a/web/types/server.ts b/web/types/server.ts index 5c80e48f9e..99b5403d2b 100644 --- a/web/types/server.ts +++ b/web/types/server.ts @@ -100,6 +100,7 @@ export interface Server { lanIp?: string; license?: string; locale?: string; + mdState?: string; name?: string; osVersion?: string; osVersionBranch?: ServerOsVersionBranch; From 50a58e847b872a178bcf93ba04391f3d0dd5ac3d Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 13 Mar 2026 14:14:10 -0400 Subject: [PATCH 6/9] Reload after callback feedback close --- .../components/CallbackFeedback.test.ts | 32 +++++++++++++++++++ .../UserProfile/CallbackFeedback.vue | 17 +++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/web/__test__/components/CallbackFeedback.test.ts b/web/__test__/components/CallbackFeedback.test.ts index 79d2028b0e..abdb0e8a64 100644 --- a/web/__test__/components/CallbackFeedback.test.ts +++ b/web/__test__/components/CallbackFeedback.test.ts @@ -190,6 +190,8 @@ vi.mock('~/store/updateOsActions', () => ({ })); describe('CallbackFeedback.vue', () => { + const originalLocation = window.location; + beforeEach(() => { accountAction.value = undefined; accountActionHide.value = false; @@ -220,6 +222,11 @@ describe('CallbackFeedback.vue', () => { mockSetCallbackStatus.mockClear(); mockInstallOsUpdate.mockClear(); mockSetUpdateOsStatus.mockClear(); + + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); }); afterEach(() => { @@ -302,4 +309,29 @@ describe('CallbackFeedback.vue', () => { expect(wrapper.find('.modal').attributes('data-error')).toBe('true'); expect(wrapper.find('.modal').attributes('data-success')).toBe('false'); }); + + it('reloads the page when the modal is dismissed after a callback action', async () => { + const mockReload = vi.fn(); + + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...originalLocation, + reload: mockReload, + }, + }); + + callbackStatus.value = 'success'; + keyActionType.value = 'purchase'; + keyInstallStatus.value = 'success'; + keyType.value = 'Pro'; + + const wrapper = mountComponent(); + + wrapper.findComponent({ name: 'Modal' }).vm.$emit('close'); + await wrapper.vm.$nextTick(); + + expect(mockSetCallbackStatus).toHaveBeenCalledWith('ready'); + expect(mockReload).toHaveBeenCalledTimes(1); + }); }); diff --git a/web/src/components/UserProfile/CallbackFeedback.vue b/web/src/components/UserProfile/CallbackFeedback.vue index ace442e8ac..47d3430866 100644 --- a/web/src/components/UserProfile/CallbackFeedback.vue +++ b/web/src/components/UserProfile/CallbackFeedback.vue @@ -110,11 +110,15 @@ const subheading = computed(() => { }); const closeText = computed(() => t('common.close')); // !connectPluginInstalled.value ? t('userProfile.callbackFeedback.noThanks') : -const close = () => { +const close = (options?: { reload?: boolean }) => { if (callbackStatus.value === 'loading') { return; } - return callbackActionsStore.setCallbackStatus('ready'); + callbackActionsStore.setCallbackStatus('ready'); + + if (options?.reload) { + window.location.reload(); + } }; const confirmUpdateOs = () => { @@ -254,7 +258,7 @@ const showPostInstallKeyError = computed(() => :error="callbackStatus === 'error'" :success="callbackStatus === 'success' && updateOsStatus !== 'confirming'" :show-close-x="callbackStatus !== 'loading'" - @close="close" + @close="close({ reload: true })" >