Files
api/web/src/components/Onboarding/steps/OnboardingNextStepsStep.vue
T
Ajit Mehrotra 9323b14879 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 -->
2026-03-26 20:47:58 -04:00

487 lines
20 KiB
Vue

<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMutation } from '@vue/apollo-composable';
import {
BookOpenIcon,
ChatBubbleBottomCenterTextIcon,
ChevronLeftIcon,
ClipboardDocumentListIcon,
LinkIcon,
RocketLaunchIcon,
ServerIcon,
WrenchScrewdriverIcon,
} from '@heroicons/vue/24/outline';
import { CheckCircleIcon, EnvelopeIcon } from '@heroicons/vue/24/solid';
import { BrandButton } from '@unraid/ui';
// Use ?raw to import SVG content string
import UnraidIconSvg from '@/assets/partners/simple-icons-unraid.svg?raw';
import InternalBootConfirmDialog from '@/components/Onboarding/components/InternalBootConfirmDialog.vue';
import {
submitInternalBootReboot,
submitInternalBootShutdown,
} from '@/components/Onboarding/composables/internalBoot';
import { COMPLETE_ONBOARDING_MUTATION } from '@/components/Onboarding/graphql/completeUpgradeStep.mutation';
import { useActivationCodeDataStore } from '@/components/Onboarding/store/activationCodeData';
import { useOnboardingDraftStore } from '@/components/Onboarding/store/onboardingDraft';
import { useOnboardingStore } from '@/components/Onboarding/store/onboardingStatus';
import { cleanupOnboardingStorage } from '@/components/Onboarding/store/onboardingStorageCleanup';
export interface Props {
onComplete: () => void;
onBack?: () => void;
showBack?: boolean;
}
const props = defineProps<Props>();
const { t } = useI18n();
const store = useActivationCodeDataStore();
const draftStore = useOnboardingDraftStore();
const { mutate: completeOnboarding } = useMutation(COMPLETE_ONBOARDING_MUTATION);
const { refetchOnboarding } = useOnboardingStore();
const partnerInfo = computed(() => store.partnerInfo);
const activationCode = computed(() => store.activationCode);
// Check if we have any core documentation links
const hasCoreDocsLinks = computed(
() =>
!!partnerInfo.value?.partner?.manualUrl ||
!!partnerInfo.value?.partner?.hardwareSpecsUrl ||
!!partnerInfo.value?.partner?.supportUrl
);
// Check if we have any extra links
const hasExtraLinks = computed(() => (partnerInfo.value?.partner?.extraLinks?.length ?? 0) > 0);
// Check if we have any content to show in the "Learn about your server" section
// Only show if there are LINKS (docs or extra links) - system specs alone isn't enough
const hasAnyPartnerContent = computed(() => hasCoreDocsLinks.value || hasExtraLinks.value);
const showRebootButton = computed(() => draftStore.internalBootSelection !== null);
const internalBootFailed = computed(
() =>
draftStore.internalBootSelection !== null &&
draftStore.internalBootApplyAttempted &&
!draftStore.internalBootApplySucceeded
);
const biosUpdateMissed = computed(
() => internalBootFailed.value && (draftStore.internalBootSelection?.updateBios ?? false)
);
const primaryButtonText = computed(() =>
showRebootButton.value
? t('onboarding.nextSteps.reboot')
: t('onboarding.nextSteps.continueToDashboard')
);
const pendingPowerAction = ref<'reboot' | 'shutdown' | null>(null);
const isCompleting = ref(false);
const completionError = ref<string | null>(null);
const basicsItems = [
{ label: t('onboarding.nextSteps.basics.shares'), url: 'https://docs.unraid.net/go/shares/' },
{ label: t('onboarding.nextSteps.basics.arrays'), url: 'https://docs.unraid.net/go/parity/' },
{
label: t('onboarding.nextSteps.basics.cachePools'),
url: 'https://docs.unraid.net/go/adding-pools/',
},
{
label: t('onboarding.nextSteps.basics.installingApps'),
url: 'https://docs.unraid.net/unraid-os/manual/applications/',
},
{ label: t('onboarding.nextSteps.basics.remoteAccess'), url: 'https://docs.unraid.net/go/tailscale/' },
{
label: t('onboarding.nextSteps.basics.commonIssues'),
url: 'https://docs.unraid.net/category/common-issues/',
},
];
const handleMouseMove = (e: MouseEvent) => {
const el = e.currentTarget as HTMLElement;
const rect = el.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
el.style.setProperty('--x', `${x}px`);
el.style.setProperty('--y', `${y}px`);
};
const finishOnboarding = async ({ action }: { action?: 'reboot' | 'shutdown' } = {}) => {
if (isCompleting.value) {
return;
}
isCompleting.value = true;
completionError.value = null;
try {
await completeOnboarding();
try {
await refetchOnboarding();
} catch (error: unknown) {
console.error('Failed to refresh onboarding state:', error);
}
} catch (error: unknown) {
console.error('Failed to complete onboarding:', error);
if (!action) {
completionError.value = t('onboarding.nextSteps.completionFailed');
isCompleting.value = false;
return;
}
}
cleanupOnboardingStorage();
if (action === 'shutdown') {
submitInternalBootShutdown();
return;
}
if (action === 'reboot') {
submitInternalBootReboot();
return;
}
props.onComplete();
isCompleting.value = false;
};
const handlePrimaryAction = async () => {
if (showRebootButton.value) {
pendingPowerAction.value = 'reboot';
return;
}
await finishOnboarding();
};
const handleShutdownAction = () => {
pendingPowerAction.value = 'shutdown';
};
const handleConfirmPowerAction = async () => {
const action = pendingPowerAction.value;
pendingPowerAction.value = null;
if (action) {
await finishOnboarding({ action });
}
};
const handleCancelPowerAction = () => {
pendingPowerAction.value = null;
};
</script>
<template>
<div class="mx-auto w-full max-w-4xl px-4 pb-4 md:px-8">
<div class="bg-elevated border-muted rounded-xl border p-6 text-left shadow-sm md:p-10">
<!-- Header -->
<div class="mb-8 flex flex-col justify-between gap-4 md:flex-row md:items-start">
<div class="space-y-2">
<div class="flex items-center gap-3">
<RocketLaunchIcon class="text-primary h-8 w-8" />
<h2 class="text-highlighted text-3xl font-extrabold tracking-tight uppercase">
{{ t('onboarding.nextSteps.title') }}
</h2>
</div>
<p class="text-muted text-lg">
{{ t('onboarding.nextSteps.description') }}
</p>
</div>
</div>
<!-- Main Grid -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Unraid Basics -->
<div class="border-muted bg-bg/50 flex h-full flex-col rounded-lg border p-5">
<div class="border-muted mb-4 flex items-center gap-2 border-b pb-2">
<!-- Unraid Icon -->
<div
class="text-primary h-5 w-5 [&>svg]:h-full [&>svg]:w-full [&>svg]:fill-current"
v-html="UnraidIconSvg"
/>
<h3 class="text-highlighted text-sm font-bold tracking-wider uppercase">
{{ t('onboarding.nextSteps.basics') }}
</h3>
</div>
<ul class="flex-grow space-y-3">
<li v-for="item in basicsItems" :key="item.label">
<a
:href="item.url"
target="_blank"
rel="noopener noreferrer"
@click.stop
class="text-primary hover:text-primary/60 group flex items-center gap-2 text-sm transition-colors hover:underline"
>
<span
class="bg-primary group-hover:bg-primary/60 h-1.5 w-1.5 flex-shrink-0 rounded-full transition-colors"
/>
{{ item.label }}
</a>
</li>
</ul>
</div>
<!-- Stay in Touch -->
<div class="border-muted bg-bg/50 flex h-full flex-col rounded-lg border p-5">
<div class="border-muted mb-4 flex items-center gap-2 border-b pb-2">
<ChatBubbleBottomCenterTextIcon class="text-primary h-5 w-5" />
<h3 class="text-highlighted text-sm font-bold tracking-wider uppercase">
{{ t('onboarding.nextSteps.stayInTouch') }}
</h3>
</div>
<div class="grid flex-grow grid-cols-1 content-start gap-3">
<!-- Newsletter (Unraid Orange) -->
<a
href="https://newsletter.unraid.net/"
target="_blank"
rel="noopener noreferrer"
@click.stop
class="group border-accented text-primary relative w-full overflow-hidden rounded-md border px-4 py-3 shadow-sm transition-colors duration-300 hover:border-transparent"
style="--brand-bg: #f15a24; --x: 50%; --y: 50%"
@mousemove="handleMouseMove"
>
<!-- Circle Fill Animation -->
<div
class="absolute z-0 -translate-x-1/2 -translate-y-1/2 scale-0 rounded-full bg-[var(--brand-bg)] transition-transform duration-500 ease-in-out group-hover:scale-100"
style="width: 300%; padding-bottom: 300%; left: var(--x); top: var(--y)"
/>
<div
class="relative z-10 flex items-center gap-3 transition-colors duration-300 group-hover:text-white"
>
<EnvelopeIcon class="h-5 w-5 flex-shrink-0 transition-transform group-hover:scale-110" />
<span>{{ t('onboarding.nextSteps.stayInTouch.newsletter') }}</span>
</div>
</a>
<!-- YouTube (Red) -->
<a
href="https://www.youtube.com/@uncastshow"
target="_blank"
rel="noopener noreferrer"
@click.stop
class="group border-accented text-primary relative w-full overflow-hidden rounded-md border px-4 py-3 shadow-sm transition-colors duration-300 hover:border-transparent"
style="--brand-bg: #ff0000; --x: 50%; --y: 50%"
@mousemove="handleMouseMove"
>
<div
class="absolute z-0 -translate-x-1/2 -translate-y-1/2 scale-0 rounded-full bg-[var(--brand-bg)] transition-transform duration-500 ease-in-out group-hover:scale-100"
style="width: 300%; padding-bottom: 300%; left: var(--x); top: var(--y)"
/>
<div
class="relative z-10 flex items-center gap-3 transition-colors duration-300 group-hover:text-white"
>
<!-- YouTube SVG -->
<svg
role="img"
viewBox="0 0 24 24"
class="h-5 w-5 fill-current transition-transform group-hover:scale-110"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
/>
</svg>
<span>{{ t('onboarding.nextSteps.stayInTouch.youtube') }}</span>
</div>
</a>
<!-- Discord (Blurple) -->
<a
href="https://discord.unraid.net/"
target="_blank"
rel="noopener noreferrer"
@click.stop
class="group border-accented text-primary relative w-full overflow-hidden rounded-md border px-4 py-3 shadow-sm transition-colors duration-300 hover:border-transparent"
style="--brand-bg: #5865f2; --x: 50%; --y: 50%"
@mousemove="handleMouseMove"
>
<div
class="absolute z-0 -translate-x-1/2 -translate-y-1/2 scale-0 rounded-full bg-[var(--brand-bg)] transition-transform duration-500 ease-in-out group-hover:scale-100"
style="width: 300%; padding-bottom: 300%; left: var(--x); top: var(--y)"
/>
<div
class="relative z-10 flex items-center gap-3 transition-colors duration-300 group-hover:text-white"
>
<!-- Discord SVG -->
<svg
role="img"
viewBox="0 0 24 24"
class="h-5 w-5 fill-current transition-transform group-hover:scale-110"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
/>
</svg>
<span>{{ t('onboarding.nextSteps.stayInTouch.discord') }}</span>
</div>
</a>
</div>
</div>
</div>
<!-- Partner Box (Optional Row) - Only show if there's any content -->
<div
v-if="hasAnyPartnerContent"
class="border-primary/20 bg-primary/5 relative mt-6 overflow-hidden rounded-lg border p-6"
>
<!-- Decorative background hint -->
<div class="pointer-events-none absolute top-0 right-0 p-4 opacity-10">
<ServerIcon class="text-primary h-24 w-24" />
</div>
<div class="text-primary mb-5 flex items-center gap-2">
<ServerIcon class="h-5 w-5" />
<h3 class="text-sm font-bold tracking-wider uppercase">
{{ t('onboarding.nextSteps.learnServer') }}
</h3>
</div>
<div class="relative z-10 flex flex-col gap-6 text-left">
<!-- Top Row: Specs and Core Docs -->
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
<!-- Specs -->
<div class="space-y-4">
<div
v-if="activationCode?.system?.serverName || activationCode?.system?.model"
class="space-y-1"
>
<p class="text-muted text-xs font-bold tracking-wide uppercase opacity-70">
{{ t('onboarding.nextSteps.specs') }}
</p>
<div class="flex flex-col gap-0.5">
<span
v-if="activationCode?.system?.serverName"
class="text-highlighted text-lg font-bold"
>{{ activationCode.system.serverName }}</span
>
<span v-if="activationCode?.system?.model" class="text-muted font-medium">{{
activationCode.system.model
}}</span>
</div>
</div>
</div>
<!-- Core Docs - Only show if there are any core doc links -->
<div v-if="hasCoreDocsLinks" class="space-y-2">
<a
v-if="partnerInfo?.partner?.manualUrl"
:href="partnerInfo.partner.manualUrl"
target="_blank"
@click.stop
class="text-primary hover:text-primary/80 flex items-center gap-2 font-medium hover:underline"
>
<BookOpenIcon class="h-5 w-5 flex-shrink-0" />
<span>{{ t('onboarding.nextSteps.manual') }}</span>
</a>
<a
v-if="partnerInfo?.partner?.hardwareSpecsUrl"
:href="partnerInfo.partner.hardwareSpecsUrl"
target="_blank"
@click.stop
class="text-primary hover:text-primary/80 flex items-center gap-2 font-medium hover:underline"
>
<ClipboardDocumentListIcon class="h-5 w-5 flex-shrink-0" />
<span>{{ t('onboarding.nextSteps.hardwareSpecs') }}</span>
</a>
<a
v-if="partnerInfo?.partner?.supportUrl"
:href="partnerInfo.partner.supportUrl"
target="_blank"
@click.stop
class="text-primary hover:text-primary/80 flex items-center gap-2 font-medium hover:underline"
>
<WrenchScrewdriverIcon class="h-5 w-5 flex-shrink-0" />
<span>{{ t('onboarding.nextSteps.support') }}</span>
</a>
</div>
</div>
<!-- Additional Links -->
<div v-if="hasExtraLinks" class="border-primary/10 border-t pt-4">
<p class="text-muted mb-2 text-xs font-bold tracking-wide uppercase opacity-70">
{{ t('onboarding.nextSteps.additionalLinks') }}
</p>
<ul class="space-y-1.5">
<li v-for="link in partnerInfo?.partner?.extraLinks" :key="link.title">
<a
:href="link.url"
target="_blank"
@click.stop
class="text-primary hover:text-primary/80 flex items-center gap-2 text-sm hover:underline"
>
<LinkIcon class="h-4 w-4 flex-shrink-0 opacity-70" />
{{ link.title }}
</a>
</li>
</ul>
</div>
</div>
</div>
<InternalBootConfirmDialog
:open="pendingPowerAction !== null"
:action="pendingPowerAction ?? 'reboot'"
:disabled="isCompleting"
@confirm="handleConfirmPowerAction"
@cancel="handleCancelPowerAction"
/>
<div v-if="internalBootFailed" class="mt-6 space-y-3">
<UAlert
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:description="t('onboarding.nextSteps.internalBootFailed')"
/>
<UAlert
v-if="biosUpdateMissed"
color="warning"
variant="subtle"
icon="i-lucide-info"
:description="t('onboarding.nextSteps.internalBootBiosMissed')"
/>
</div>
<p v-if="completionError" role="alert" aria-live="polite" class="mt-6 text-sm text-red-600">
{{ completionError }}
</p>
<!-- Footer -->
<div
class="border-muted mt-8 flex flex-col-reverse items-center justify-between gap-6 border-t pt-8 sm:flex-row"
>
<button
v-if="showBack"
@click="props.onBack"
class="text-muted hover:text-toned group flex items-center justify-center gap-2 font-medium transition-colors sm:w-auto sm:justify-start"
>
<ChevronLeftIcon class="h-5 w-5 transition-transform group-hover:-translate-x-0.5" />
{{ t('common.back') }}
</button>
<div v-else class="hidden w-1 sm:block" />
<div class="flex w-full items-center justify-end gap-4 sm:w-auto">
<button
v-if="showRebootButton"
:disabled="isCompleting"
class="text-muted hover:text-highlighted text-sm font-medium transition-colors disabled:opacity-50"
@click="handleShutdownAction"
>
{{ t('onboarding.nextSteps.shutdown') }}
</button>
<BrandButton
:text="primaryButtonText"
:disabled="isCompleting"
class="!bg-primary hover:!bg-primary/90 w-full min-w-[200px] !text-white shadow-md transition-all hover:shadow-lg sm:w-auto"
@click="handlePrimaryAction"
:icon-right="CheckCircleIcon"
/>
</div>
</div>
</div>
</div>
</template>