Files
dbx/apps/desktop/src/components/settings/McpAuthorizationStepper.vue
T

101 lines
6.1 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, ref } from "vue";
import { useI18n } from "vue-i18n";
type StepId = "capabilities" | "connections" | "databases" | "overrides";
const { t } = useI18n();
const steps = computed<Array<{ id: StepId; title: string; description: string }>>(() => [
{ id: "overrides", title: t("settings.mcpAuthStepOverridesTitle"), description: t("settings.mcpAuthStepOverridesDescription") },
{ id: "connections", title: t("settings.mcpAuthStepConnectionsTitle"), description: t("settings.mcpAuthStepConnectionsDescription") },
{ id: "databases", title: t("settings.mcpAuthStepDatabasesTitle"), description: t("settings.mcpAuthStepDatabasesDescription") },
{ id: "capabilities", title: t("settings.mcpAuthStepCapabilitiesTitle"), description: t("settings.mcpAuthStepCapabilitiesDescription") },
]);
const currentStep = ref<StepId>("overrides");
const currentIndex = computed(() => steps.value.findIndex((step) => step.id === currentStep.value));
// Every step is directly reachable from the nav above and all policy edits save as they are made,
// so the last step has nothing to advance to and nothing to confirm.
const isLastStep = computed(() => currentIndex.value === steps.value.length - 1);
function scrollableAncestor(target: EventTarget | null): HTMLElement | null {
let element = target instanceof HTMLElement ? target.parentElement : null;
while (element) {
const overflowY = window.getComputedStyle(element).overflowY;
if (overflowY === "auto" || overflowY === "scroll") return element;
element = element.parentElement;
}
return null;
}
async function selectStep(step: StepId, event?: Event) {
if (step === currentStep.value) return;
const scroller = scrollableAncestor(event?.currentTarget ?? null);
const scrollTop = scroller?.scrollTop;
currentStep.value = step;
if (!scroller || scrollTop === undefined) return;
await nextTick();
requestAnimationFrame(() => {
if (scroller.isConnected) scroller.scrollTop = scrollTop;
});
}
function previousStep(event: Event) {
const step = steps.value[currentIndex.value - 1];
if (step) void selectStep(step.id, event);
}
function nextStep(event: Event) {
const step = steps.value[currentIndex.value + 1];
if (step) void selectStep(step.id, event);
}
</script>
<template>
<section class="space-y-4 rounded-md border bg-background p-3 sm:p-4">
<header class="space-y-1">
<p class="text-sm font-semibold">{{ t("settings.mcpAuthTitle") }}</p>
<p class="text-xs text-muted-foreground">{{ t("settings.mcpAuthDescription") }}</p>
</header>
<nav class="flex items-stretch gap-1 overflow-x-auto rounded-md border bg-muted/50 p-1 sm:grid sm:grid-cols-4" :aria-label="t('settings.mcpAuthStepsLabel')">
<button
v-for="(step, index) in steps"
:key="step.id"
type="button"
class="group flex min-h-16 min-w-48 flex-none items-start gap-2 rounded px-2.5 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 sm:min-w-0"
:class="currentStep === step.id ? 'bg-background shadow-sm' : 'text-muted-foreground hover:bg-background/70'"
:aria-current="currentStep === step.id ? 'step' : undefined"
@click="selectStep(step.id, $event)"
>
<span
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border text-[11px] font-semibold"
:class="currentStep === step.id ? 'border-primary bg-primary text-primary-foreground' : index < currentIndex ? 'border-primary/40 bg-primary/10 text-primary' : 'bg-background text-muted-foreground'"
>{{ index + 1 }}</span
>
<span class="min-w-0 flex-1">
<span class="block whitespace-normal text-xs font-medium leading-5">{{ step.title }}</span>
<span class="mt-0.5 block whitespace-normal text-[10px] leading-4 text-muted-foreground">{{ step.description }}</span>
</span>
</button>
</nav>
<div :class="currentStep === 'overrides' ? 'block' : 'hidden'" :aria-hidden="currentStep !== 'overrides'" role="region" :aria-label="t('settings.mcpAuthStepRegionLabel', { step: 1, title: t('settings.mcpAuthStepOverridesTitle') })"><slot name="overrides" /></div>
<div :class="currentStep === 'connections' ? 'block' : 'hidden'" :aria-hidden="currentStep !== 'connections'" role="region" :aria-label="t('settings.mcpAuthStepRegionLabel', { step: 2, title: t('settings.mcpAuthStepConnectionsTitle') })"><slot name="connections" /></div>
<div :class="currentStep === 'databases' ? 'block' : 'hidden'" :aria-hidden="currentStep !== 'databases'" role="region" :aria-label="t('settings.mcpAuthStepRegionLabel', { step: 3, title: t('settings.mcpAuthStepDatabasesTitle') })"><slot name="databases" /></div>
<div :class="currentStep === 'capabilities' ? 'block' : 'hidden'" :aria-hidden="currentStep !== 'capabilities'" role="region" :aria-label="t('settings.mcpAuthStepRegionLabel', { step: 4, title: t('settings.mcpAuthStepCapabilitiesTitle') })"><slot name="capabilities" /></div>
<footer class="flex items-center justify-between gap-3 border-t pt-3">
<button type="button" class="rounded-md border bg-background px-3 py-1.5 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-50" :disabled="currentIndex === 0" @click="previousStep($event)">{{ t("settings.mcpAuthPreviousStep") }}</button>
<p class="text-center text-xs text-muted-foreground">{{ t("settings.mcpAuthStepProgress", { current: currentIndex + 1, total: steps.length }) }}</p>
<!-- Kept in the layout (visibility: hidden) so the progress label holds the same offset in
every step: a fixed-width spacer could not match a label whose width varies per locale.
`invisible` also drops the button from the tab order and the accessibility tree. -->
<button type="button" class="rounded-md border bg-background px-3 py-1.5 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-50" :class="{ invisible: isLastStep }" :disabled="isLastStep" @click="nextStep($event)">
{{ t("settings.mcpAuthNextStep") }}
</button>
</footer>
</section>
</template>