Skip to content

Commit 9323b14

Browse files
feat(onboarding): force reboot after internal boot setup and lock down wizard (#1966)
## Summary When internal boot configuration is applied during onboarding, the system must reboot (or shut down) to finalize setup — regardless of whether the apply succeeded or failed. Previously, users could escape the reboot path via the X button, back navigation, browser back, a keyboard shortcut (`Ctrl+Alt+Shift+O`), or a URL bypass (`?onboarding=bypass`). This left the system in a potentially broken state if internal boot was partially configured but the user never rebooted. ## Changes ### Lockdown mechanism - Added `internalBootApplyAttempted` flag to the onboarding draft store (persisted to localStorage) - Flag is set `true` when the Summary step begins applying internal boot, **before** the API call — this engages the lockdown immediately - All escape hatches are gated on this flag via a single `isInternalBootLocked` computed ### Wizard lockdown (OnboardingModal.vue) - X close button hidden when locked - Back button hidden on all steps when locked - `handleExitIntent`, `goToPreviousStep` — early return when locked - `goToStep` — blocks backward stepper clicks when locked - `handlePopstate` — calls `window.history.forward()` to neutralize browser back, with an `isProgrammaticHistoryExit` guard so the modal's own `closeModal()` history navigation isn't blocked - Removed keyboard shortcut `Ctrl+Alt+Shift+O` and URL parameter `?onboarding=bypass` entirely (feature hasn't shipped) ### Forced reboot/shutdown on success or failure (OnboardingNextStepsStep.vue) - `showRebootButton` now checks `internalBootSelection !== null` instead of `internalBootApplySucceeded` — reboot shows regardless of outcome - Added **shutdown button** alongside reboot as a secondary option (smaller, text-style) — mirrors the "Skip Setup" / "Get Started" CTA pattern - Shutdown calls the same server shutdown mutation and shows the same confirmation dialog pattern as reboot - Added failure alert: *"Internal boot timed out but is likely setup on your server. Please reboot your system to finalize setup."* - Added BIOS warning when `updateBios` was selected but apply failed — instructs user to manually update BIOS boot order - Made `completeOnboarding()` failure non-blocking for the reboot/shutdown path — wraps in try/catch so users are never stuck ### Standalone lockdown (OnboardingInternalBoot.standalone.vue) - Same lockdown: X hidden, popstate blocked, dialog dismiss blocked - "Edit Again" disabled when locked - "Close" button becomes **two buttons**: "Shutdown" (secondary) and "Reboot" (primary) when locked - BIOS warning shown on failure with `updateBios` selected - Failure message uses same wording as wizard flow ### Log message preservation (SummaryStep + composable) - `returnedError` and `failed` callbacks in `applyInternalBootSelection` preserve the original error output in the user-visible log stream - The generic "timed out / likely setup" message only appears on the Next Steps popup — the actual error details remain in the technical logs ### i18n - 7 new keys in `en.json` for failure messaging, BIOS warnings, shutdown button, and dialog descriptions ## Test plan - [x] All tests pass (64 test files, 628+ tests) - [x] Lint passes (ESLint + Prettier) - [x] Type-check passes (vue-tsc) - [ ] Manual: start onboarding with internal boot eligible → select storage boot → apply → verify lockdown engages - [ ] Manual: simulate API failure → verify reboot AND shutdown buttons still show + failure messaging - [ ] Manual: verify browser back, keyboard shortcut, URL bypass all do nothing during lockdown - [ ] Manual: click Shutdown → confirm it calls shutdown mutation, not reboot - [ ] Manual: test standalone internal boot wizard with same scenarios - [ ] Manual: verify log console still shows specific error details on failure ## Files changed (13) | File | Change | |------|--------| | `store/onboardingDraft.ts` | Added `internalBootApplyAttempted` flag + setter + persistence | | `store/onboardingModalVisibility.ts` | Removed bypass shortcut, URL action, and `bypassOnboarding()` | | `OnboardingModal.vue` | Added lockdown gates on X, back, popstate (with programmatic exit guard), stepper, exit intent | | `composables/internalBoot.ts` | Restored original error output in `returnedError`/`failed` callbacks | | `steps/OnboardingSummaryStep.vue` | Set `internalBootApplyAttempted` before apply | | `steps/OnboardingNextStepsStep.vue` | Forced reboot on failure, shutdown button, failure alerts, BIOS warning, resilient `finishOnboarding` | | `standalone/OnboardingInternalBoot.standalone.vue` | Same lockdown + shutdown/reboot buttons + failure messaging | | `locales/en.json` | 7 new i18n keys | | 5 test files | Updated/added tests for lockdown, shutdown, and failure behavior | <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added Shutdown alongside Reboot for internal-boot recovery and a confirmation dialog for power actions. * Modal/result UI now shows Reboot/Shutdown actions when internal-boot is locked. * **Improvements** * Locked failure state removes “Edit Again” and blocks close/back/browser-back while internal boot is in progress. * Internal-boot timeout and BIOS-missed messaging updated with new localization keys. * Onboarding flow tracks an “apply attempted” flag to drive UI state. * **Removed** * Keyboard shortcut bypass for onboarding. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent abe7283 commit 9323b14

18 files changed

+955
-260
lines changed

web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts

Lines changed: 190 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { reactive } from 'vue';
12
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils';
23

34
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -21,28 +22,48 @@ type InternalBootHistoryState = {
2122

2223
const {
2324
draftStore,
25+
reactiveStoreRef,
2426
applyInternalBootSelectionMock,
27+
submitInternalBootRebootMock,
28+
submitInternalBootShutdownMock,
2529
cleanupOnboardingStorageMock,
2630
dialogPropsRef,
2731
stepPropsRef,
2832
stepperPropsRef,
2933
} = vi.hoisted(() => {
34+
const reactiveRef: { value: Record<string, unknown> | null } = { value: null };
3035
const store = {
3136
internalBootSelection: null as {
3237
poolName: string;
3338
slotCount: number;
3439
devices: string[];
3540
bootSizeMiB: number;
3641
updateBios: boolean;
42+
poolMode: 'dedicated' | 'hybrid';
3743
} | null,
3844
internalBootApplySucceeded: false,
45+
internalBootApplyAttempted: false,
3946
setInternalBootApplySucceeded: vi.fn((value: boolean) => {
40-
store.internalBootApplySucceeded = value;
47+
if (reactiveRef.value) {
48+
reactiveRef.value.internalBootApplySucceeded = value;
49+
} else {
50+
store.internalBootApplySucceeded = value;
51+
}
52+
}),
53+
setInternalBootApplyAttempted: vi.fn((value: boolean) => {
54+
if (reactiveRef.value) {
55+
reactiveRef.value.internalBootApplyAttempted = value;
56+
} else {
57+
store.internalBootApplyAttempted = value;
58+
}
4159
}),
4260
};
4361

4462
return {
4563
draftStore: store,
64+
reactiveStoreRef: reactiveRef,
65+
submitInternalBootRebootMock: vi.fn(),
66+
submitInternalBootShutdownMock: vi.fn(),
4667
applyInternalBootSelectionMock:
4768
vi.fn<
4869
(
@@ -74,18 +95,37 @@ vi.mock('@unraid/ui', () => ({
7495
},
7596
}));
7697

98+
const reactiveDraftStore = reactive(draftStore);
99+
reactiveStoreRef.value = reactiveDraftStore;
100+
77101
vi.mock('@/components/Onboarding/store/onboardingDraft', () => ({
78-
useOnboardingDraftStore: () => draftStore,
102+
useOnboardingDraftStore: () => reactiveDraftStore,
79103
}));
80104

81105
vi.mock('@/components/Onboarding/composables/internalBoot', () => ({
82106
applyInternalBootSelection: applyInternalBootSelectionMock,
107+
submitInternalBootReboot: submitInternalBootRebootMock,
108+
submitInternalBootShutdown: submitInternalBootShutdownMock,
83109
}));
84110

85111
vi.mock('@/components/Onboarding/store/onboardingStorageCleanup', () => ({
86112
cleanupOnboardingStorage: cleanupOnboardingStorageMock,
87113
}));
88114

115+
vi.mock('@/components/Onboarding/components/InternalBootConfirmDialog.vue', () => ({
116+
default: {
117+
props: ['open', 'action', 'failed', 'disabled'],
118+
emits: ['confirm', 'cancel'],
119+
template: `
120+
<div v-if="open" data-testid="confirm-dialog-stub">
121+
<span data-testid="confirm-dialog-action">{{ action }}</span>
122+
<button data-testid="confirm-dialog-confirm" @click="$emit('confirm')">Confirm</button>
123+
<button data-testid="confirm-dialog-cancel" @click="$emit('cancel')">Cancel</button>
124+
</div>
125+
`,
126+
},
127+
}));
128+
89129
vi.mock('@/components/Onboarding/components/OnboardingConsole.vue', () => ({
90130
default: {
91131
props: ['logs'],
@@ -182,8 +222,9 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
182222
vi.clearAllMocks();
183223
window.history.replaceState(null, '', window.location.href);
184224

185-
draftStore.internalBootSelection = null;
186-
draftStore.internalBootApplySucceeded = false;
225+
reactiveDraftStore.internalBootSelection = null;
226+
reactiveDraftStore.internalBootApplySucceeded = false;
227+
reactiveDraftStore.internalBootApplyAttempted = false;
187228
dialogPropsRef.value = null;
188229
stepPropsRef.value = null;
189230
stepperPropsRef.value = null;
@@ -239,12 +280,13 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
239280
});
240281

241282
it('applies the selected internal boot configuration and records success', async () => {
242-
draftStore.internalBootSelection = {
283+
reactiveDraftStore.internalBootSelection = {
243284
poolName: 'cache',
244285
slotCount: 1,
245286
devices: ['DISK-A'],
246287
bootSizeMiB: 16384,
247288
updateBios: true,
289+
poolMode: 'hybrid',
248290
};
249291
applyInternalBootSelectionMock.mockResolvedValue({
250292
applySucceeded: true,
@@ -270,6 +312,7 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
270312
bootSizeMiB: 16384,
271313
updateBios: true,
272314
slotCount: 1,
315+
poolMode: 'hybrid',
273316
},
274317
{
275318
configured: 'Internal boot pool configured.',
@@ -289,13 +332,14 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
289332
});
290333
});
291334

292-
it('shows retry affordance when the apply helper returns a failure result', async () => {
293-
draftStore.internalBootSelection = {
335+
it('shows locked failure result with reboot button when apply fails', async () => {
336+
reactiveDraftStore.internalBootSelection = {
294337
poolName: 'cache',
295338
slotCount: 1,
296339
devices: ['DISK-A'],
297340
bootSizeMiB: 16384,
298341
updateBios: false,
342+
poolMode: 'hybrid',
299343
};
300344
applyInternalBootSelectionMock.mockResolvedValue({
301345
applySucceeded: false,
@@ -316,15 +360,8 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
316360

317361
expect(wrapper.text()).toContain('Setup Failed');
318362
expect(wrapper.text()).toContain('Internal boot setup returned an error: mkbootpool failed');
319-
expect(wrapper.find('[data-testid="internal-boot-standalone-edit-again"]').exists()).toBe(true);
320-
321-
await wrapper.get('[data-testid="internal-boot-standalone-edit-again"]').trigger('click');
322-
await flushPromises();
323-
324-
expect(stepperPropsRef.value).toMatchObject({
325-
activeStepIndex: 0,
326-
});
327-
expect(wrapper.find('[data-testid="internal-boot-step-stub"]').exists()).toBe(true);
363+
expect(wrapper.find('[data-testid="internal-boot-standalone-edit-again"]').exists()).toBe(false);
364+
expect(wrapper.find('[data-testid="internal-boot-standalone-reboot"]').exists()).toBe(true);
328365
});
329366

330367
it('restores the configure step when browser back leaves a reversible result', async () => {
@@ -355,13 +392,15 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
355392
expect(wrapper.find('[data-testid="internal-boot-step-stub"]').exists()).toBe(true);
356393
});
357394

358-
it('closes when browser back leaves a fully applied result', async () => {
359-
draftStore.internalBootSelection = {
395+
it('blocks browser back navigation when locked after a fully applied result', async () => {
396+
const forwardSpy = vi.spyOn(window.history, 'forward').mockImplementation(() => {});
397+
reactiveDraftStore.internalBootSelection = {
360398
poolName: 'cache',
361399
slotCount: 1,
362400
devices: ['DISK-A'],
363401
bootSizeMiB: 16384,
364402
updateBios: true,
403+
poolMode: 'hybrid',
365404
};
366405

367406
const wrapper = mountComponent();
@@ -384,8 +423,9 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
384423
});
385424
await flushPromises();
386425

387-
expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1);
388-
expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(false);
426+
expect(forwardSpy).toHaveBeenCalled();
427+
expect(cleanupOnboardingStorageMock).not.toHaveBeenCalled();
428+
expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(true);
389429
});
390430

391431
it('closes locally after showing a result', async () => {
@@ -435,12 +475,13 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
435475
});
436476

437477
it('shows warning result when apply succeeds with warnings', async () => {
438-
draftStore.internalBootSelection = {
478+
reactiveDraftStore.internalBootSelection = {
439479
poolName: 'boot-pool',
440480
slotCount: 1,
441481
devices: ['sda'],
442482
bootSizeMiB: 512,
443483
updateBios: true,
484+
poolMode: 'hybrid',
444485
};
445486
applyInternalBootSelectionMock.mockResolvedValue({
446487
applySucceeded: true,
@@ -478,4 +519,132 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
478519

479520
expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1);
480521
});
522+
523+
it('hides the X button when internalBootApplyAttempted is true', async () => {
524+
reactiveDraftStore.internalBootSelection = {
525+
poolName: 'cache',
526+
slotCount: 1,
527+
devices: ['DISK-A'],
528+
bootSizeMiB: 16384,
529+
updateBios: false,
530+
poolMode: 'hybrid',
531+
};
532+
533+
const wrapper = mountComponent();
534+
535+
await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
536+
await flushPromises();
537+
538+
expect(draftStore.internalBootApplyAttempted).toBe(true);
539+
expect(wrapper.find('[data-testid="internal-boot-standalone-close"]').exists()).toBe(false);
540+
});
541+
542+
it('hides "Edit Again" button when locked after apply', async () => {
543+
reactiveDraftStore.internalBootSelection = {
544+
poolName: 'cache',
545+
slotCount: 1,
546+
devices: ['DISK-A'],
547+
bootSizeMiB: 16384,
548+
updateBios: false,
549+
poolMode: 'hybrid',
550+
};
551+
applyInternalBootSelectionMock.mockResolvedValue({
552+
applySucceeded: false,
553+
hadWarnings: true,
554+
hadNonOptimisticFailures: true,
555+
logs: [{ message: 'Setup failed', type: 'error' }],
556+
});
557+
558+
const wrapper = mountComponent();
559+
560+
await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
561+
await flushPromises();
562+
563+
expect(draftStore.internalBootApplyAttempted).toBe(true);
564+
expect(wrapper.find('[data-testid="internal-boot-standalone-edit-again"]').exists()).toBe(false);
565+
});
566+
567+
it('shows "Reboot" button instead of "Close" when locked', async () => {
568+
reactiveDraftStore.internalBootSelection = {
569+
poolName: 'cache',
570+
slotCount: 1,
571+
devices: ['DISK-A'],
572+
bootSizeMiB: 16384,
573+
updateBios: true,
574+
poolMode: 'hybrid',
575+
};
576+
577+
const wrapper = mountComponent();
578+
579+
await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
580+
await flushPromises();
581+
582+
expect(draftStore.internalBootApplyAttempted).toBe(true);
583+
expect(wrapper.find('[data-testid="internal-boot-standalone-result-close"]').exists()).toBe(false);
584+
expect(wrapper.find('[data-testid="internal-boot-standalone-reboot"]').exists()).toBe(true);
585+
});
586+
587+
it('calls submitInternalBootReboot when reboot is confirmed through dialog', async () => {
588+
reactiveDraftStore.internalBootSelection = {
589+
poolName: 'cache',
590+
slotCount: 1,
591+
devices: ['DISK-A'],
592+
bootSizeMiB: 16384,
593+
updateBios: true,
594+
poolMode: 'hybrid',
595+
};
596+
597+
const wrapper = mountComponent();
598+
599+
await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
600+
await flushPromises();
601+
602+
await wrapper.get('[data-testid="internal-boot-standalone-reboot"]').trigger('click');
603+
await flushPromises();
604+
605+
expect(wrapper.find('[data-testid="confirm-dialog-stub"]').exists()).toBe(true);
606+
expect(wrapper.get('[data-testid="confirm-dialog-action"]').text()).toBe('reboot');
607+
608+
await wrapper.get('[data-testid="confirm-dialog-confirm"]').trigger('click');
609+
await flushPromises();
610+
611+
expect(submitInternalBootRebootMock).toHaveBeenCalledTimes(1);
612+
});
613+
614+
it('shows shutdown button when locked and calls submitInternalBootShutdown after confirmation', async () => {
615+
reactiveDraftStore.internalBootSelection = {
616+
poolName: 'cache',
617+
slotCount: 1,
618+
devices: ['DISK-A'],
619+
bootSizeMiB: 16384,
620+
updateBios: false,
621+
poolMode: 'hybrid',
622+
};
623+
624+
const wrapper = mountComponent();
625+
626+
await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
627+
await flushPromises();
628+
629+
const shutdownButton = wrapper.find('[data-testid="internal-boot-standalone-shutdown"]');
630+
expect(shutdownButton.exists()).toBe(true);
631+
632+
await shutdownButton.trigger('click');
633+
await flushPromises();
634+
635+
expect(wrapper.find('[data-testid="confirm-dialog-stub"]').exists()).toBe(true);
636+
expect(wrapper.get('[data-testid="confirm-dialog-action"]').text()).toBe('shutdown');
637+
638+
await wrapper.get('[data-testid="confirm-dialog-confirm"]').trigger('click');
639+
await flushPromises();
640+
641+
expect(submitInternalBootShutdownMock).toHaveBeenCalledTimes(1);
642+
expect(submitInternalBootRebootMock).not.toHaveBeenCalled();
643+
});
644+
645+
it('does not show shutdown button when not locked', () => {
646+
const wrapper = mountComponent();
647+
648+
expect(wrapper.find('[data-testid="internal-boot-standalone-shutdown"]').exists()).toBe(false);
649+
});
481650
});

web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type MockInternalBootSelection = {
1515
devices: string[];
1616
bootSizeMiB: number;
1717
updateBios: boolean;
18+
poolMode: 'dedicated' | 'hybrid';
1819
};
1920

2021
type InternalBootVm = {
@@ -308,8 +309,16 @@ describe('OnboardingInternalBootStep', () => {
308309
);
309310
});
310311

311-
it('defaults the storage pool name to cache', async () => {
312+
it('defaults the storage pool name to cache in hybrid mode', async () => {
312313
draftStore.bootMode = 'storage';
314+
draftStore.internalBootSelection = {
315+
poolName: '',
316+
slotCount: 1,
317+
devices: [],
318+
bootSizeMiB: 16384,
319+
updateBios: true,
320+
poolMode: 'hybrid',
321+
};
313322
contextResult.value = buildContext({
314323
assignableDisks: [
315324
{
@@ -332,8 +341,16 @@ describe('OnboardingInternalBootStep', () => {
332341
);
333342
});
334343

335-
it('leaves the pool name blank when cache already exists', async () => {
344+
it('leaves the pool name blank in hybrid mode when cache already exists', async () => {
336345
draftStore.bootMode = 'storage';
346+
draftStore.internalBootSelection = {
347+
poolName: '',
348+
slotCount: 1,
349+
devices: [],
350+
bootSizeMiB: 16384,
351+
updateBios: true,
352+
poolMode: 'hybrid',
353+
};
337354
contextResult.value = buildContext({
338355
poolNames: ['cache'],
339356
assignableDisks: [

0 commit comments

Comments
 (0)