Files
dezky/apps/portal/components/partner/CustomerCreateWizard.vue
T
Ronni Baslund 3288fde693 feat(portal): customer-admin surface on real data + Stripe billing + session resilience
Access & navigation
- Gate partner-mode strictly to partner staff so admins/end-users never inherit
  leftover partner-view state; purge stale session entry on hydrate.
- Role-driven admin entry: useMe.isTenantAdmin, Admin/Personal tiles in the app
  launcher, and an /admin route guard in the global middleware (fail closed).
- Drop the duplicate user identity block from the sidebar footer.

Admin pages on real data
- New tenant-scoped, membership-gated endpoints: GET /tenants/:slug/{audit,users,
  invoices}; useTenant composable resolves the active workspace + subscription.
- Dashboard: real seats, spend (cycle-normalized + minor-units), plan, renewal,
  and recent audit; unbacked sections removed.
- Users & groups: real members; Groups/Invitations/Service accounts shown as
  honest "coming soon".
- Subscription & invoices: real plan hero, invoice history, and billing details.

Stripe payment method (Elements + SetupIntent)
- StripeClient: publishable key + getDefaultCard/createSetupIntent/setDefaultCard.
- CustomerBillingController + BillingService methods (ensure-customer on demand).
- Portal: PaymentMethodModal, useStripeJs (CDN load), proxies; hidePostalCode.

Editable billing details & whitelabel branding
- PATCH /tenants/:slug/billing-info (narrow: company/VAT/country/email).
- TenantBranding schema/service + GET/PUT /tenants/:slug/branding: real product
  name, accent colour, and per-tenant email-template overrides.
- Branding preview + sidebar workspace mark wired to real name/plan/seats/colour
  with YIQ auto-contrast (readableOn util).

Session resilience
- Request offline_access so Authentik issues a refresh token (automaticRefresh).
- Silent refresh + single retry on 401 for writes (useApiFetch, incl. partner
  pages) and reads (useMe.fetchMe) — no redirect, no lost input.
- Modal backdrop closes only on press+release on the backdrop (no more
  drag-select-to-close).
2026-05-31 00:19:34 +02:00

740 lines
24 KiB
Vue

<script setup lang="ts">
// 6-step wizard that walks the partner through provisioning a new customer org.
// The actual orchestration (Authentik tenant, Stalwart mailbox, OCIS space …)
// happens behind a single "Provision" action. Here we just collect input and
// give them a clear review summary.
defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: []; done: [] }>()
const { request } = useApiFetch()
// 5 steps. Branding was dropped because nothing on the backend persists it
// yet (no Tenant.branding field, no logo upload pipeline) — partners
// configure branding post-provisioning via /partner/branding once that
// surface gets wired to a real backend.
const STEPS = [
{ n: 1, label: 'Organization' },
{ n: 2, label: 'Domain' },
{ n: 3, label: 'First admin' },
{ n: 4, label: 'Plan' },
{ n: 5, label: 'Review' },
] as const
const LAST_STEP = STEPS.length
const step = ref(1)
// Default form state. Fields start empty so the partner fills in their
// real customer's details rather than editing pre-filled fixture data.
// `plan` defaults to Business + `cycle` to Monthly because those are the
// usual choice and the radio/dropdown render expects a value; both stay
// editable.
const form = reactive({
legalName: '',
displayName: '',
cvr: '',
country: '',
address: '',
domain: '',
preconfigureDns: true,
adminFirst: '',
adminLast: '',
adminEmail: '',
adminPhone: '',
sendWelcome: true,
plan: 'Business' as PlanLabel,
seats: 0,
cycle: 'Monthly' as 'Monthly' | 'Quarterly' | 'Yearly',
currency: 'DKK' as 'DKK' | 'EUR' | 'USD',
})
// Static plan metadata. Features stay hard-coded (they're product copy, not
// catalog data). Prices come from the live /api/prices catalog at render
// time — see `visiblePlans` below.
const plans = [
{ code: 'mvp', name: 'Starter', features: '10 GB mail · 100 GB drive · 5 video rooms' },
{ code: 'pro', name: 'Business', features: '50 GB mail · 1 TB drive · unlimited video · MFA', best: true },
{ code: 'enterprise', name: 'Enterprise', features: 'Custom quotas · SSO · audit log · 24/7 support' },
] as const
type PlanLabel = (typeof plans)[number]['name']
type PlanCode = (typeof plans)[number]['code']
// Live catalog. We fetch all active price rows once when the wizard mounts
// and look them up by (plan, cycle) as the user changes selectors. Empty
// catalog (no /api/prices configured) leaves all cards showing "Not set"
// — wizard still works, just no number displayed.
interface CatalogRow {
plan: PlanCode
cycle: 'monthly' | 'quarterly' | 'yearly'
amounts: { DKK?: number; EUR?: number; USD?: number }
active: boolean
}
const { data: catalog } = await useFetch<CatalogRow[]>('/api/prices', {
key: 'wizard-catalog',
default: () => [],
})
// Cycle label shown after the slash in "X DKK / seat / mo".
const CYCLE_SUFFIX: Record<'monthly' | 'quarterly' | 'yearly', string> = {
monthly: 'mo',
quarterly: 'quarter',
yearly: 'yr',
}
function reset() {
step.value = 1
// Clearing result here lets the modal reopen on a fresh "step 1" view
// rather than landing on the previous provisioning's done state.
result.value = null
submitError.value = null
}
function close() {
emit('close')
// reset on next tick so the closing animation isn't disturbed
setTimeout(reset, 200)
}
function next() {
if (step.value < LAST_STEP) step.value++
}
function back() {
if (step.value > 1) step.value--
}
// ── Provision (real backend call) ────────────────────────────────────────
const submitting = ref(false)
const submitError = ref<string | null>(null)
// Slug from display name: lowercase, alphanumeric, hyphen-joined, trimmed
// to 40 chars (matches CreateTenantDto.slug regex).
function slugFromName(name: string): string {
return name
.toLowerCase()
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40) || 'tenant'
}
function planCode(label: PlanLabel): PlanCode {
return plans.find((p) => p.name === label)!.code
}
function cycleCode(label: 'Monthly' | 'Quarterly' | 'Yearly'): 'monthly' | 'quarterly' | 'yearly' {
return label.toLowerCase() as 'monthly' | 'quarterly' | 'yearly'
}
// Combines the static plan list with the live catalog: for each plan card,
// look up the price row for the user's currently-selected cycle, then pull
// the amount in their selected currency. Renders a friendly string ready to
// drop into the template.
const visiblePlans = computed(() =>
plans.map((p) => {
const row = catalog.value.find((r) => r.plan === p.code && r.cycle === cycleCode(form.cycle))
const minor = row?.amounts[form.currency]
const cycleSuffix = CYCLE_SUFFIX[cycleCode(form.cycle)]
let priceLabel: string
let available = true
if (p.code === 'enterprise' && minor === undefined) {
priceLabel = 'Custom'
} else if (minor === undefined) {
// Catalog row exists but no price in this currency, OR no row at all.
priceLabel = `Not sold in ${form.currency}`
available = false
} else {
priceLabel = `${(minor / 100).toLocaleString('da-DK')} ${form.currency} / seat / ${cycleSuffix}`
}
return { ...p, priceLabel, available }
}),
)
// Total per cycle in the chosen currency. Drives the "you'll pay" line.
const totalPerCycle = computed(() => {
const p = visiblePlans.value.find((x) => x.name === form.plan)
if (!p || !p.available) return null
const row = catalog.value.find((r) => r.plan === p.code && r.cycle === cycleCode(form.cycle))
const minor = row?.amounts[form.currency]
if (minor === undefined || !form.seats) return null
const total = (minor * form.seats) / 100
const cycleSuffix = CYCLE_SUFFIX[cycleCode(form.cycle)]
return `${total.toLocaleString('da-DK')} ${form.currency} / ${cycleSuffix}`
})
// Result we hand to the "Provisioned" view after submit succeeds. The
// tenant create always succeeds when we get here; the admin invite may
// have failed independently (caught server-side and returned as `error`).
interface AdminCredentials {
link?: string
tempPassword?: string
attached?: boolean
error?: string
}
const result = ref<{ tenantName: string; adminEmail: string; admin?: AdminCredentials } | null>(null)
const copied = ref(false)
async function copyToClipboard(value: string) {
try {
await navigator.clipboard.writeText(value)
copied.value = true
setTimeout(() => (copied.value = false), 2000)
} catch {
// Non-secure context — user selects the readonly input.
}
}
async function submit() {
submitError.value = null
const displayName = form.displayName.trim()
if (!displayName) {
submitError.value = 'Display name is required'
step.value = 1
return
}
submitting.value = true
try {
const adminName = `${form.adminFirst.trim()} ${form.adminLast.trim()}`.trim()
const adminEmail = form.adminEmail.trim()
const payload = {
slug: slugFromName(displayName),
name: displayName,
plan: planCode(form.plan),
cycle: form.cycle.toLowerCase() as 'monthly' | 'quarterly' | 'yearly',
currency: form.currency,
seats: form.seats,
...(form.domain.trim() && { domains: [form.domain.trim()] }),
billingInfo: {
...(form.legalName.trim() && { companyName: form.legalName.trim() }),
...(form.cvr.trim() && { vatId: form.cvr.trim() }),
...(form.country && { country: form.country }),
...(adminEmail && { contactEmail: adminEmail }),
},
// Only send admin info when both name + email are present. Backend
// skips the invite if either is missing; we mirror that on the
// client so the wizard never sends half-filled admin payloads.
...(adminName && adminEmail && { adminName, adminEmail }),
}
const res = await request<{
tenant: { name: string }
adminInvite?: AdminCredentials | { error: string }
}>('/api/partner/tenants', { method: 'POST', body: payload })
result.value = {
tenantName: res.tenant.name,
adminEmail,
admin: res.adminInvite as AdminCredentials | undefined,
}
emit('done') // refresh the customers table + sidebar in the background
} catch (err: unknown) {
const e = err as { data?: { data?: { message?: string }; message?: string; statusMessage?: string } }
submitError.value =
e.data?.data?.message || e.data?.message || e.data?.statusMessage || 'Provisioning failed'
} finally {
submitting.value = false
}
}
function finish() {
result.value = null
close()
}
</script>
<template>
<Modal
:open="open"
size="lg"
:eyebrow="result ? 'Provisioned' : `Step ${step} of ${STEPS.length}`"
title="Provision new customer organization"
@close="close"
>
<!-- Step rail -->
<div v-if="!result" class="rail">
<template v-for="(s, idx) in STEPS" :key="s.n">
<div class="rail-step" :class="{ done: s.n < step, active: s.n === step }">
<div class="bubble">
<UiIcon v-if="s.n < step" name="check" :size="11" :stroke-width="2.6" />
<template v-else>{{ s.n }}</template>
</div>
<span class="lab">{{ s.label }}</span>
</div>
<div v-if="idx < STEPS.length - 1" class="rail-line" />
</template>
</div>
<!-- 1. Organization -->
<div v-if="step === 1 && !result" class="form">
<label class="field">
<Eyebrow>Legal name</Eyebrow>
<input v-model="form.legalName" />
</label>
<label class="field">
<Eyebrow>Display name · shown to users</Eyebrow>
<input v-model="form.displayName" />
</label>
<div class="row-2">
<label class="field">
<Eyebrow>CVR</Eyebrow>
<input v-model="form.cvr" />
</label>
<label class="field">
<Eyebrow>Country</Eyebrow>
<CountrySelect v-model="form.country" />
</label>
</div>
<label class="field">
<Eyebrow>Address</Eyebrow>
<input v-model="form.address" />
</label>
</div>
<!-- 2. Domain -->
<div v-if="step === 2 && !result" class="form">
<label class="field">
<Eyebrow>Primary domain</Eyebrow>
<input v-model="form.domain" />
</label>
<div class="info-box">
<Eyebrow>DNS verification</Eyebrow>
<p>
We'll send the customer their DNS records during onboarding. For now we just register
the intent. You can pre-fill MX/SPF if they've delegated DNS to you.
</p>
<label class="cb-row">
<input v-model="form.preconfigureDns" type="checkbox" />
Pre-configure DNS records on the customer's behalf (NordicMSP manages their DNS)
</label>
</div>
</div>
<!-- 3. First admin -->
<div v-if="step === 3 && !result" class="form">
<p class="hint">We'll send this person an invitation email. They become the first customer admin.</p>
<div class="row-2">
<label class="field">
<Eyebrow>First name</Eyebrow>
<input v-model="form.adminFirst" />
</label>
<label class="field">
<Eyebrow>Last name</Eyebrow>
<input v-model="form.adminLast" />
</label>
</div>
<label class="field">
<Eyebrow>Email</Eyebrow>
<input v-model="form.adminEmail" />
</label>
<label class="field">
<Eyebrow>Phone</Eyebrow>
<input v-model="form.adminPhone" />
</label>
<label class="cb-row">
<input v-model="form.sendWelcome" type="checkbox" />
Send welcome email immediately upon provisioning
</label>
</div>
<!-- 4. Plan -->
<div v-if="step === 4 && !result" class="form">
<!-- Seats / cycle / currency drive the plan-card prices below keep
them at the top so the user picks them first, then plans update
live. -->
<div class="row-3">
<label class="field">
<Eyebrow>Initial seats</Eyebrow>
<input v-model.number="form.seats" type="number" min="1" />
</label>
<label class="field">
<Eyebrow>Billing cycle</Eyebrow>
<select v-model="form.cycle">
<option value="Monthly">Monthly</option>
<option value="Quarterly">Quarterly</option>
<option value="Yearly">Yearly</option>
</select>
</label>
<label class="field">
<Eyebrow>Currency</Eyebrow>
<select v-model="form.currency">
<option value="DKK">DKK</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
</select>
</label>
</div>
<label
v-for="p in visiblePlans"
:key="p.name"
class="plan"
:class="{ selected: form.plan === p.name, disabled: !p.available }"
@click="p.available && (form.plan = p.name as any)"
>
<span v-if="(p as any).best" class="rec">RECOMMENDED</span>
<span class="radio" :class="{ on: form.plan === p.name }">
<span v-if="form.plan === p.name" class="radio-inner" />
</span>
<div class="plan-body">
<div class="plan-head">
<span class="plan-name">{{ p.name }}</span>
<Mono dim>{{ p.priceLabel }}</Mono>
</div>
<Mono dim>{{ p.features }}</Mono>
</div>
</label>
<p v-if="totalPerCycle" class="total-line">
<Mono dim>Total · {{ form.seats }} {{ form.seats === 1 ? 'seat' : 'seats' }}</Mono>
<strong>{{ totalPerCycle }}</strong>
</p>
</div>
<!-- 5. Review -->
<div v-if="step === 5 && !result" class="form">
<div class="review-hero">
<Eyebrow>You're provisioning</Eyebrow>
<div class="review-name">{{ form.displayName }}</div>
<Mono dim>{{ form.domain }} · {{ form.plan }} · {{ form.seats }} seats</Mono>
</div>
<dl class="def">
<div class="def-row"><dt>Admin</dt><dd>{{ form.adminFirst }} {{ form.adminLast }} · {{ form.adminEmail }}</dd></div>
<div class="def-row"><dt>Plan</dt><dd>{{ form.plan }} · {{ form.seats }} seats · {{ form.cycle.toLowerCase() }}</dd></div>
<div class="def-row"><dt>Branding</dt><dd>Defaults · customize after provisioning</dd></div>
<div class="def-row"><dt>Onboarding</dt><dd>Welcome email sent on creation</dd></div>
</dl>
<div class="prov-note">
<Mono dim>// provisioning</Mono>
<p>
On confirm we'll create the tenant in Dezky and trigger the
background provisioner (Authentik tenant, OCIS space, Stalwart
mailboxes). {{ form.displayName }} will appear in your portfolio
as soon as the database write completes.
</p>
</div>
</div>
<p v-if="submitError" class="err">{{ submitError }}</p>
<!-- Provisioned: tenant + admin credentials handoff. Shown after
submit() completes, hides the wizard step content. -->
<div v-if="result" class="provisioned">
<Badge tone="ok" dot>provisioned</Badge>
<h3>{{ result.tenantName }} is live</h3>
<template v-if="result.admin && !result.admin.error">
<template v-if="result.admin.attached">
<p class="ok-msg">
<Mono>{{ result.adminEmail }}</Mono> already existed in Authentik
and was attached as an admin on this tenant. They sign in with
their existing credentials.
</p>
</template>
<template v-else-if="result.admin.link">
<p class="ok-msg">
Share this single-use link with the admin they'll set their
own password and enroll MFA.
</p>
<div class="cred-row">
<input :value="result.admin.link" readonly @focus="($event.target as HTMLInputElement).select()" />
<UiButton variant="secondary" @click="copyToClipboard(result.admin!.link!)">
{{ copied ? 'Copied' : 'Copy' }}
</UiButton>
</div>
</template>
<template v-else-if="result.admin.tempPassword">
<p class="ok-msg">
Authentik has no recovery flow configured, so we set a
temporary password — share it with the admin and they'll be
prompted to change it on first login.
</p>
<div class="cred-row">
<input :value="result.adminEmail" readonly @focus="($event.target as HTMLInputElement).select()" />
<UiButton variant="secondary" @click="copyToClipboard(result.adminEmail)">Copy</UiButton>
</div>
<div class="cred-row">
<input :value="result.admin.tempPassword" readonly @focus="($event.target as HTMLInputElement).select()" />
<UiButton variant="secondary" @click="copyToClipboard(result.admin!.tempPassword!)">
{{ copied ? 'Copied' : 'Copy' }}
</UiButton>
</div>
</template>
</template>
<template v-else-if="result.admin?.error">
<p class="warn-msg">
Tenant was created, but the admin invite failed:
<Mono>{{ result.admin.error }}</Mono>. Retry the invite from
<Mono>/partner/customers</Mono>.
</p>
</template>
<template v-else>
<p class="ok-msg">
No first-admin info was provided. Invite an admin later from
<Mono>/partner/customers</Mono>.
</p>
</template>
</div>
<template #footer>
<template v-if="!result">
<UiButton variant="ghost" :disabled="submitting" @click="close">Cancel</UiButton>
<div style="flex:1" />
<UiButton v-if="step > 1" variant="secondary" :disabled="submitting" @click="back">Back</UiButton>
<UiButton v-if="step < LAST_STEP" variant="primary" @click="next">Continue</UiButton>
<UiButton v-else variant="primary" :disabled="submitting" @click="submit">
<template #leading><UiIcon name="check" :size="14" /></template>
{{ submitting ? 'Provisioning…' : 'Provision customer' }}
</UiButton>
</template>
<template v-else>
<div style="flex:1" />
<UiButton variant="primary" @click="finish">Done</UiButton>
</template>
</template>
</Modal>
</template>
<style scoped>
.rail {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 24px;
}
.rail-step {
display: flex;
align-items: center;
gap: 8px;
opacity: 0.45;
}
.rail-step.active, .rail-step.done { opacity: 1; }
.bubble {
width: 22px;
height: 22px;
border-radius: 999px;
background: var(--surface);
color: var(--text-mute);
border: 1px solid var(--border);
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
}
.rail-step.done .bubble { background: var(--text); color: var(--bg); border-color: var(--text); }
.rail-step.active .bubble { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
.lab {
font-size: 12px;
font-weight: 500;
color: var(--text-mute);
white-space: nowrap;
}
.rail-step.active .lab { color: var(--text); font-weight: 600; }
.rail-step.done .lab { color: var(--text); }
.rail-line {
flex: 1;
height: 1px;
background: var(--border);
}
.form { display: flex; flex-direction: column; gap: 14px; }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field input,
.field select {
padding: 9px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
color: var(--text);
}
.field select {
appearance: none;
background-image: linear-gradient(45deg, transparent 50%, var(--text-mute) 50%),
linear-gradient(135deg, var(--text-mute) 50%, transparent 50%);
background-position: calc(100% - 16px) 50%, calc(100% - 12px) 50%;
background-size: 4px 4px;
background-repeat: no-repeat;
padding-right: 28px;
cursor: pointer;
}
.field input:focus,
.field select:focus { outline: none; border-color: var(--border-hi); }
.hint { font-size: 13px; color: var(--text-dim); margin: 0 0 4px 0; line-height: 1.5; }
.info-box {
padding: 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.info-box p { font-size: 13px; color: var(--text-dim); margin: 8px 0 12px 0; line-height: 1.55; }
.cb-row {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--text);
}
.cb-row input[type='checkbox'] { width: 14px; height: 14px; accent-color: var(--text); }
.plan {
position: relative;
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
cursor: pointer;
}
.plan.selected { border-color: var(--text); background: var(--bg); }
.plan.disabled { opacity: 0.45; cursor: not-allowed; }
.plan.disabled:hover { background: var(--surface); }
.total-line {
display: flex;
justify-content: space-between;
align-items: baseline;
margin: 4px 0 0;
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.total-line strong {
font-family: var(--font-display);
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.rec {
position: absolute;
top: -8px;
right: 12px;
background: var(--accent);
color: var(--accent-fg);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 3px;
letter-spacing: 0.06em;
}
.radio {
width: 18px;
height: 18px;
border-radius: 999px;
border: 2px solid var(--border-hi);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.radio.on { border-color: var(--text); }
.radio-inner { width: 8px; height: 8px; border-radius: 999px; background: var(--text); }
.plan-body { flex: 1; }
.plan-head { display: flex; align-items: baseline; gap: 10px; }
.plan-name { font-family: var(--font-display); font-size: 17px; font-weight: 600; }
.brand-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.def { display: flex; flex-direction: column; gap: 8px; margin: 0; }
.def-row { display: grid; grid-template-columns: 160px 1fr; gap: 12px; font-size: 13px; }
.def-row dt { color: var(--text-mute); font-family: var(--font-mono); font-size: 11px; padding-top: 1px; }
.def-row dd { margin: 0; color: var(--text); }
.color-row { display: flex; align-items: center; gap: 8px; }
.color-swatch { width: 14px; height: 14px; border-radius: 3px; display: inline-block; }
.review-hero {
padding: 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 16px;
}
.review-name {
font-family: var(--font-display);
font-size: 22px;
font-weight: 600;
letter-spacing: -0.02em;
margin-top: 6px;
}
.prov-note {
margin-top: 16px;
padding: 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.prov-note p { font-size: 12px; color: var(--text-dim); line-height: 1.6; margin: 6px 0 0 0; }
.err {
margin: 12px 0 0;
padding: 10px 12px;
font-size: 12px;
color: var(--bad);
background: rgba(226, 48, 48, 0.08);
border: 1px solid rgba(226, 48, 48, 0.2);
border-radius: 6px;
}
/* Provisioned view — shown after submit() succeeds. */
.provisioned {
display: flex;
flex-direction: column;
gap: 14px;
padding: 4px 0;
}
.provisioned h3 {
font-family: var(--font-display);
font-weight: 600;
font-size: 22px;
letter-spacing: -0.02em;
margin: 0;
}
.ok-msg,
.warn-msg {
margin: 0;
font-size: 13px;
color: var(--text-dim);
line-height: 1.55;
}
.warn-msg {
padding: 10px 12px;
background: rgba(232, 154, 31, 0.08);
border: 1px solid rgba(232, 154, 31, 0.24);
border-radius: 6px;
color: var(--warn);
}
.cred-row {
display: flex;
align-items: center;
gap: 8px;
}
.cred-row input {
flex: 1;
padding: 9px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text);
}
</style>