0bd4e5498e
- portal: new admin/ and partner/ surfaces with full component library (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables, layouts, partner-routing middleware, and supporting server APIs - pricing: Price schema/module with operator CRUD, pricing.vue catalog UI, Subscription extended with cycle/currency/perSeatAmount/seats snapshots for stable MRR aggregation - partner staff: User.partnerId, invite-partner-user DTO and flow, /partners/:slug/users endpoints, InvitePartnerUserModal, shared dezky-partner-staff Authentik group - /me: partner-aware endpoint returning user + partner context so portal can route between end-user and partner-admin surfaces - tenant: seats field for portfolio displays and future MRR calculations - operator: pricing page, signed-out page, useMe/useToast composables, ToastStack
507 lines
20 KiB
Vue
507 lines
20 KiB
Vue
<script setup lang="ts">
|
|
// Strict port of project/platform-collab.jsx `IntegrationsScreen` (lines
|
|
// 440-575) with IntegrationTile (589) and IntegrationDetail (622).
|
|
// 4 tabs: Marketplace · Connected · Webhooks · API tokens.
|
|
|
|
|
|
import { integrations, integrationCategories, type Integration } from '~/data/workspace'
|
|
|
|
const tab = ref<'marketplace' | 'connected' | 'webhooks' | 'api'>('marketplace')
|
|
const cat = ref<typeof integrationCategories[number]>('All')
|
|
const open = ref<Integration | null>(null)
|
|
const buildCustomOpen = ref(false)
|
|
const newWebhookOpen = ref(false)
|
|
const newTokenOpen = ref(false)
|
|
const disconnectOpen = ref(false)
|
|
const revokeToken = ref<{ name: string; suffix: string } | null>(null)
|
|
|
|
const toast = useToast()
|
|
|
|
function connectedAction(i: Integration, id: string) {
|
|
if (id === 'configure') open.value = i
|
|
else if (id === 'logs') toast.info(`Logs for ${i.name}`)
|
|
else if (id === 'sync') toast.info(`Syncing ${i.name} now`)
|
|
else if (id === 'disconnect') { open.value = i; disconnectOpen.value = true }
|
|
}
|
|
const connectedItems = [
|
|
{ id: 'configure', label: 'Configure', icon: 'brush' as const },
|
|
{ id: 'logs', label: 'View logs', icon: 'file' as const },
|
|
{ id: 'sync', label: 'Sync now', icon: 'refresh' as const },
|
|
{ id: 'sep1', separator: true },
|
|
{ id: 'disconnect', label: 'Disconnect', icon: 'plug' as const, danger: true },
|
|
]
|
|
|
|
function confirmDisconnect() {
|
|
const name = open.value?.name
|
|
disconnectOpen.value = false
|
|
open.value = null
|
|
toast.warn(`${name} disconnected`)
|
|
}
|
|
function confirmRevoke() {
|
|
const name = revokeToken.value?.name
|
|
revokeToken.value = null
|
|
toast.bad(`${name} revoked`)
|
|
}
|
|
|
|
const filtered = computed(() => {
|
|
if (tab.value === 'connected') return integrations.filter((i) => i.connected)
|
|
if (cat.value === 'All') return integrations
|
|
return integrations.filter((i) => i.cat === cat.value)
|
|
})
|
|
|
|
const connectedCount = computed(() => integrations.filter((i) => i.connected).length)
|
|
|
|
const apiTokens = [
|
|
{ name: 'CI deploy token', prefix: 'dz_live_', suffix: 'a91f', scope: 'users:read · billing:read', created: '14 Feb 2026', lastUsed: '2 min ago' },
|
|
{ name: 'Monitoring scrape', prefix: 'dz_live_', suffix: '88ce', scope: 'metrics:read', created: '02 Mar 2026', lastUsed: '14 sec ago' },
|
|
{ name: 'Old migration · revoke', prefix: 'dz_live_', suffix: '441b', scope: 'admin:*', created: '11 Jan 2026', lastUsed: '24 d ago' },
|
|
]
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<PageHeader
|
|
eyebrow="Marketplace"
|
|
title="Integrations"
|
|
subtitle="Connect dezky to the tools your team already uses."
|
|
>
|
|
<template #actions>
|
|
<UiButton variant="secondary" @click="buildCustomOpen = true">
|
|
<template #leading><UiIcon name="plug" :size="14" /></template>
|
|
Build custom · API
|
|
</UiButton>
|
|
</template>
|
|
</PageHeader>
|
|
|
|
<div class="tab-wrap">
|
|
<Tabs
|
|
v-model="tab"
|
|
:items="[
|
|
{ value: 'marketplace', label: 'Marketplace', count: integrations.length },
|
|
{ value: 'connected', label: 'Connected', count: connectedCount },
|
|
{ value: 'webhooks', label: 'Webhooks' },
|
|
{ value: 'api', label: 'API tokens' },
|
|
]"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Marketplace -->
|
|
<div v-if="tab === 'marketplace'" class="content">
|
|
<div class="cat-row">
|
|
<button
|
|
v-for="c in integrationCategories"
|
|
:key="c"
|
|
class="pill"
|
|
:class="{ active: cat === c }"
|
|
@click="cat = c"
|
|
>
|
|
{{ c }}
|
|
<span v-if="c === 'Accounting'" class="dk-flag" :class="{ active: cat === c }">DK</span>
|
|
</button>
|
|
<div class="spacer" />
|
|
<div class="input-search">
|
|
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
|
|
<input placeholder="Search integrations…" />
|
|
</div>
|
|
</div>
|
|
<div class="tile-grid">
|
|
<button v-for="i in filtered" :key="i.id" class="tile" @click="open = i">
|
|
<div class="tile-head">
|
|
<div class="i-icon" :style="{ background: i.color, color: i.accent }">{{ i.icon }}</div>
|
|
<Badge v-if="i.connected" tone="ok" dot>connected</Badge>
|
|
<Badge v-else-if="i.danish" tone="info">DK</Badge>
|
|
</div>
|
|
<div class="tile-body">
|
|
<div class="tile-name-row">
|
|
<span class="tile-name">{{ i.name }}</span>
|
|
<Mono dim>· {{ i.cat }}</Mono>
|
|
</div>
|
|
<div class="tile-desc">{{ i.desc }}</div>
|
|
</div>
|
|
<div class="tile-foot">
|
|
<Mono dim>{{ i.kind }}</Mono>
|
|
<span v-if="i.connected" class="users">{{ i.users }} users</span>
|
|
<span v-else class="connect">Connect</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Connected -->
|
|
<div v-else-if="tab === 'connected'" class="content">
|
|
<Card :pad="0">
|
|
<table class="tbl">
|
|
<thead>
|
|
<tr><th>Integration</th><th>Type</th><th>Users</th><th>Status</th><th /></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="i in filtered" :key="i.id">
|
|
<td>
|
|
<div class="conn-cell">
|
|
<div class="i-icon small" :style="{ background: i.color, color: i.accent }">{{ i.icon }}</div>
|
|
<div>
|
|
<div class="conn-name">{{ i.name }}</div>
|
|
<Mono dim>{{ i.cat }}</Mono>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td><Mono>{{ i.kind }}</Mono></td>
|
|
<td><Mono>{{ i.users || 0 }}</Mono></td>
|
|
<td><Badge tone="ok" dot>connected</Badge></td>
|
|
<td class="right">
|
|
<UiButton size="sm" variant="ghost" @click="open = i">Configure</UiButton>
|
|
<AdminKebabMenu :items="connectedItems" :icon-size="13" @select="(id) => connectedAction(i, id)" />
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</Card>
|
|
</div>
|
|
|
|
<!-- Webhooks -->
|
|
<div v-else-if="tab === 'webhooks'" class="content">
|
|
<div class="empty-card">
|
|
<UiIcon name="plug" :size="28" stroke="var(--text-mute)" />
|
|
<div class="empty-title">No webhooks yet</div>
|
|
<div class="empty-body">Webhooks let external services react to events in dezky (user.created, file.shared, billing.charged, etc.).</div>
|
|
<UiButton variant="primary" @click="newWebhookOpen = true">
|
|
<template #leading><UiIcon name="plus" :size="14" /></template>
|
|
New webhook
|
|
</UiButton>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- API tokens -->
|
|
<div v-else class="content api">
|
|
<div class="row">
|
|
<div class="lead">API tokens authenticate scripts and external services to your workspace. Treat them like passwords.</div>
|
|
<UiButton variant="primary" @click="newTokenOpen = true">
|
|
<template #leading><UiIcon name="plus" :size="14" /></template>
|
|
Generate token
|
|
</UiButton>
|
|
</div>
|
|
<Card :pad="0">
|
|
<table class="tbl">
|
|
<thead>
|
|
<tr><th>Token</th><th>Scope</th><th>Created</th><th>Last used</th><th /></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="t in apiTokens" :key="t.name">
|
|
<td>
|
|
<div class="tok-name">{{ t.name }}</div>
|
|
<Mono dim>{{ t.prefix }}····{{ t.suffix }}</Mono>
|
|
</td>
|
|
<td><Mono dim>{{ t.scope }}</Mono></td>
|
|
<td><Mono dim>{{ t.created }}</Mono></td>
|
|
<td><Mono dim>{{ t.lastUsed }}</Mono></td>
|
|
<td class="right">
|
|
<UiButton size="sm" variant="danger" @click="revokeToken = { name: t.name, suffix: t.suffix }">
|
|
<template #leading><UiIcon name="trash" :size="13" /></template>
|
|
Revoke
|
|
</UiButton>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</Card>
|
|
</div>
|
|
|
|
<!-- Detail side panel -->
|
|
<SidePanel :open="!!open" :eyebrow="open?.cat || ''" :title="open?.name || ''" width="lg" @close="open = null">
|
|
<div v-if="open" class="detail">
|
|
<div class="detail-head">
|
|
<div class="i-icon big" :style="{ background: open.color, color: open.accent }">{{ open.icon }}</div>
|
|
<div class="detail-meta">
|
|
<div class="detail-name">{{ open.name }}</div>
|
|
<Mono dim>{{ open.cat }} · {{ open.kind }}</Mono>
|
|
<div style="margin-top: 8px">
|
|
<Badge v-if="open.connected" tone="ok" dot>connected · {{ open.users }} users</Badge>
|
|
<Badge v-else tone="neutral">not connected</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="detail-desc">{{ open.desc }}</div>
|
|
|
|
<template v-if="!open.connected">
|
|
<Eyebrow>What this integration does</Eyebrow>
|
|
<div class="bullets">
|
|
<div v-for="b in [
|
|
`Provisions users from dezky into ${open.name} on invite`,
|
|
`Single sign-on via Authentik · removes ${open.name} passwords`,
|
|
`Group sync · dezky groups become ${open.name} teams`,
|
|
'Audit trail · sign-ins logged in your global audit log',
|
|
]" :key="b" class="bullet">
|
|
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
|
<span>{{ b }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<Eyebrow>Configuration</Eyebrow>
|
|
<div class="cfg">
|
|
<div class="cfg-row">
|
|
<Mono dim>SSO endpoint</Mono>
|
|
<Mono>https://sso.dezky.com/{{ open.id }}</Mono>
|
|
</div>
|
|
<div class="cfg-row">
|
|
<Mono dim>Last sign-in</Mono>
|
|
<span>2 minutes ago · anne@dezky.com</span>
|
|
</div>
|
|
<div class="cfg-row">
|
|
<Mono dim>Last sync</Mono>
|
|
<span>5 minutes ago · 11 users</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<template #footer>
|
|
<template v-if="open?.connected">
|
|
<UiButton variant="danger" @click="disconnectOpen = true">
|
|
<template #leading><UiIcon name="plug" :size="13" /></template>
|
|
Disconnect
|
|
</UiButton>
|
|
<div style="flex: 1" />
|
|
<UiButton variant="secondary" @click="toast.info(`Logs for ${open?.name}`)">View logs</UiButton>
|
|
<UiButton variant="primary" @click="open = null; toast.ok('Settings saved')">Save changes</UiButton>
|
|
</template>
|
|
<template v-else>
|
|
<UiButton variant="ghost" @click="open = null">Cancel</UiButton>
|
|
<div style="flex: 1" />
|
|
<UiButton variant="primary" @click="toast.ok(`${open?.name} connected`); open = null">
|
|
<template #leading><UiIcon name="plug" :size="13" /></template>
|
|
Connect {{ open?.name }}
|
|
</UiButton>
|
|
</template>
|
|
</template>
|
|
</SidePanel>
|
|
|
|
<!-- Build custom API modal stub -->
|
|
<Modal :open="buildCustomOpen" eyebrow="Integrations · custom" title="Build a custom integration" size="md" @close="buildCustomOpen = false">
|
|
<div class="form-stack">
|
|
<div class="lead">
|
|
Use dezky's REST API + webhooks to wire any system into your workspace. Token-scoped,
|
|
rate-limited, and audit-logged.
|
|
</div>
|
|
<label class="field"><Eyebrow>Integration name</Eyebrow><input class="input" placeholder="Acme finance bridge" /></label>
|
|
<label class="field"><Eyebrow>Description</Eyebrow><input class="input" placeholder="Posts invoice events to /accounts" /></label>
|
|
</div>
|
|
<template #footer>
|
|
<UiButton variant="ghost" @click="buildCustomOpen = false">Cancel</UiButton>
|
|
<UiButton variant="primary" @click="buildCustomOpen = false; tab = 'api'; toast.info('Generate a token to start')">
|
|
<template #leading><UiIcon name="check" :size="13" /></template>
|
|
Continue
|
|
</UiButton>
|
|
</template>
|
|
</Modal>
|
|
|
|
<!-- New webhook modal -->
|
|
<Modal :open="newWebhookOpen" eyebrow="Integrations · webhooks" title="New webhook" size="md" @close="newWebhookOpen = false">
|
|
<div class="form-stack">
|
|
<label class="field"><Eyebrow>Endpoint URL</Eyebrow><input class="input" placeholder="https://example.com/dezky" /></label>
|
|
<label class="field"><Eyebrow>Events</Eyebrow><input class="input" placeholder="user.created, file.shared" /></label>
|
|
<label class="field"><Eyebrow>Signing secret</Eyebrow><input class="input" value="auto-generated · copy after save" /></label>
|
|
</div>
|
|
<template #footer>
|
|
<UiButton variant="ghost" @click="newWebhookOpen = false">Cancel</UiButton>
|
|
<UiButton variant="primary" @click="newWebhookOpen = false; toast.ok('Webhook created')">Create webhook</UiButton>
|
|
</template>
|
|
</Modal>
|
|
|
|
<!-- New API token modal -->
|
|
<Modal :open="newTokenOpen" eyebrow="Integrations · API tokens" title="New API token" size="md" @close="newTokenOpen = false">
|
|
<div class="form-stack">
|
|
<label class="field"><Eyebrow>Token name</Eyebrow><input class="input" placeholder="CI deploy token" /></label>
|
|
<label class="field"><Eyebrow>Scopes</Eyebrow><input class="input" placeholder="users:read · billing:read" /></label>
|
|
<label class="field"><Eyebrow>Expires</Eyebrow>
|
|
<select class="input"><option>30 days</option><option>90 days</option><option>1 year</option><option>Never</option></select>
|
|
</label>
|
|
</div>
|
|
<template #footer>
|
|
<UiButton variant="ghost" @click="newTokenOpen = false">Cancel</UiButton>
|
|
<UiButton variant="primary" @click="newTokenOpen = false; toast.ok('Token created — copy now, it will not be shown again')">
|
|
<template #leading><UiIcon name="key" :size="13" /></template>
|
|
Generate token
|
|
</UiButton>
|
|
</template>
|
|
</Modal>
|
|
|
|
<!-- Confirm disconnect -->
|
|
<ConfirmDialog
|
|
:open="disconnectOpen"
|
|
eyebrow="Integration"
|
|
:title="`Disconnect ${open?.name || ''}?`"
|
|
confirm-label="Disconnect"
|
|
tone="danger"
|
|
@close="disconnectOpen = false"
|
|
@confirm="confirmDisconnect"
|
|
>
|
|
Existing user sessions in {{ open?.name }} will keep working until they expire, but new
|
|
sign-ins and provisioning will stop immediately.
|
|
</ConfirmDialog>
|
|
|
|
<!-- Confirm revoke API token -->
|
|
<ConfirmDialog
|
|
:open="!!revokeToken"
|
|
eyebrow="API token"
|
|
:title="`Revoke ${revokeToken?.name || ''}?`"
|
|
confirm-label="Revoke token"
|
|
tone="danger"
|
|
@close="revokeToken = null"
|
|
@confirm="confirmRevoke"
|
|
>
|
|
Any script using <Mono>dz_live_····{{ revokeToken?.suffix }}</Mono> will fail
|
|
authentication immediately. This cannot be undone.
|
|
</ConfirmDialog>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.tab-wrap { padding: 16px 40px 0 40px; }
|
|
.content { padding: 20px 40px 64px 40px; }
|
|
|
|
.cat-row { display: flex; align-items: center; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
|
|
.pill {
|
|
padding: 6px 12px;
|
|
border-radius: 999px;
|
|
background: transparent;
|
|
color: var(--text-dim);
|
|
border: 1px solid var(--border);
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.pill.active { background: var(--text); color: var(--bg); border-color: var(--text); }
|
|
.dk-flag {
|
|
font-family: var(--font-mono);
|
|
font-size: 9px;
|
|
padding: 1px 5px;
|
|
background: var(--bg);
|
|
color: var(--text-mute);
|
|
border-radius: 2px;
|
|
letter-spacing: 0.06em;
|
|
}
|
|
.dk-flag.active { background: var(--accent); color: var(--accent-fg); }
|
|
.spacer { flex: 1; }
|
|
.input-search {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 0 12px;
|
|
height: 36px;
|
|
width: 240px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
}
|
|
.input-search input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
|
|
|
|
.tile-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
|
.tile {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
padding: 18px;
|
|
text-align: left;
|
|
font-family: inherit;
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
min-height: 168px;
|
|
transition: border-color 120ms;
|
|
}
|
|
.tile:hover { border-color: var(--text); }
|
|
.tile-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
|
.tile-body { flex: 1; }
|
|
.tile-name-row { display: flex; align-items: center; gap: 6px; }
|
|
.tile-name { font-family: var(--font-display); font-weight: 600; font-size: 16px; letter-spacing: -0.015em; }
|
|
.tile-desc { font-size: 12px; color: var(--text-mute); margin-top: 6px; line-height: 1.5; }
|
|
.tile-foot { display: flex; align-items: center; justify-content: space-between; }
|
|
.users { font-size: 12px; color: var(--text-dim); }
|
|
.connect {
|
|
font-size: 12px;
|
|
color: var(--accent-fg);
|
|
background: var(--accent);
|
|
padding: 3px 10px;
|
|
border-radius: 4px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.i-icon {
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: 10px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-family: var(--font-display);
|
|
font-weight: 700;
|
|
font-size: 22px;
|
|
flex-shrink: 0;
|
|
}
|
|
.i-icon.small { width: 32px; height: 32px; font-size: 16px; }
|
|
.i-icon.big { width: 56px; height: 56px; font-size: 28px; }
|
|
|
|
.tbl { width: 100%; border-collapse: collapse; }
|
|
.tbl th {
|
|
text-align: left;
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--text-mute);
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
font-weight: 500;
|
|
}
|
|
.tbl td { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: middle; }
|
|
.tbl tr:last-child td { border-bottom: none; }
|
|
.tbl .right { text-align: right; display: flex; gap: 4px; justify-content: flex-end; }
|
|
tr td.right { display: table-cell; text-align: right; }
|
|
|
|
.conn-cell { display: flex; align-items: center; gap: 12px; }
|
|
.conn-name { font-size: 13px; font-weight: 500; }
|
|
|
|
.empty-card {
|
|
padding: 60px 24px;
|
|
text-align: center;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 12px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
}
|
|
.empty-title { font-family: var(--font-display); font-weight: 600; font-size: 17px; }
|
|
.empty-body { font-size: 13px; color: var(--text-mute); max-width: 420px; line-height: 1.5; }
|
|
|
|
.content.api { max-width: 900px; }
|
|
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
|
.lead { font-size: 13px; color: var(--text-mute); max-width: 540px; line-height: 1.5; }
|
|
.tok-name { font-size: 13px; font-weight: 500; }
|
|
|
|
.detail { padding-bottom: 24px; }
|
|
.detail-head { display: flex; align-items: center; gap: 14px; }
|
|
.detail-meta { flex: 1; }
|
|
.detail-name { font-family: var(--font-display); font-weight: 600; font-size: 20px; letter-spacing: -0.015em; }
|
|
.detail-desc { margin-top: 16px; font-size: 13px; color: var(--text-dim); line-height: 1.6; }
|
|
|
|
.bullets { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
|
|
.bullet { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; }
|
|
.cfg { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
|
|
.cfg-row { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 13px; }
|
|
|
|
/* Modal form helpers */
|
|
.form-stack { display: flex; flex-direction: column; gap: 14px; }
|
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
|
.input { height: 36px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-family: inherit; font-size: 13px; color: var(--text); outline: none; }
|
|
.input:focus { border-color: var(--text); }
|
|
</style>
|