feat(operator): partner-style tenant provisioning wizard + admin invite
ci / tc_portal (push) Has been skipped
ci / changes (push) Successful in 4s
ci / tc_booking (push) Has been skipped
ci / tc_website (push) Has been skipped
ci / tc_platform_api (push) Successful in 22s
ci / tc_operator (push) Successful in 24s
ci / build_portal (push) Has been skipped
ci / build_booking (push) Has been skipped
ci / test_platform_api (push) Successful in 32s
ci / build_operator (push) Successful in 31s
ci / build_platform_api (push) Successful in 15s
ci / deploy (push) Successful in 41s
ci / tc_portal (push) Has been skipped
ci / changes (push) Successful in 4s
ci / tc_booking (push) Has been skipped
ci / tc_website (push) Has been skipped
ci / tc_platform_api (push) Successful in 22s
ci / tc_operator (push) Successful in 24s
ci / build_portal (push) Has been skipped
ci / build_booking (push) Has been skipped
ci / test_platform_api (push) Successful in 32s
ci / build_operator (push) Successful in 31s
ci / build_platform_api (push) Successful in 15s
ci / deploy (push) Successful in 41s
The minimal create modal silently dropped adminName/adminEmail — the invite only existed in the partner wizard's server path. Operator now gets the same 5-step wizard UX (organization, domain, first admin, plan with live price catalog, review) composed client-side: POST /tenants creates + provisions, then POST /users/invite-tenant-admin (new, operator-only — lives in UsersModule because UsersModule already imports TenantsModule and the reverse would be circular) runs the same inviteTenantAdmin flow the partner gets, and the result view hands over the single-use recovery link or temp password. Tenant detail page gains an Invite admin action for retries/successors. PLATFORM_TENANT_SLUG back to 'dezky' (the recreated company tenant) + config-rev bump to roll platform-api.
This commit is contained in:
@@ -0,0 +1,311 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Invite (or attach) a tenant's first admin — the operator counterpart of
|
||||||
|
// the partner wizard's admin step, reusable any time from the tenant page.
|
||||||
|
// Surfaces the recovery link / temp password for the operator to share
|
||||||
|
// manually until outbound SMTP is wired.
|
||||||
|
|
||||||
|
interface InviteResult {
|
||||||
|
subject: string
|
||||||
|
userId: string
|
||||||
|
// attached = the email already existed in Authentik and was added to the
|
||||||
|
// tenant group; otherwise exactly one of link / tempPassword is set.
|
||||||
|
attached?: boolean
|
||||||
|
link?: string
|
||||||
|
tempPassword?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ open: boolean; tenantSlug: string; tenantName: string }>()
|
||||||
|
const emit = defineEmits<{ close: []; invited: [InviteResult] }>()
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
|
const email = ref('')
|
||||||
|
const busy = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const result = ref<InviteResult | null>(null)
|
||||||
|
const copied = ref(false)
|
||||||
|
|
||||||
|
// Reset state every time the modal opens so previous results don't linger.
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(v) => {
|
||||||
|
if (v) {
|
||||||
|
name.value = ''
|
||||||
|
email.value = ''
|
||||||
|
busy.value = false
|
||||||
|
error.value = null
|
||||||
|
result.value = null
|
||||||
|
copied.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && props.open && !busy.value) emit('close')
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
return (
|
||||||
|
!busy.value &&
|
||||||
|
name.value.trim().length >= 2 &&
|
||||||
|
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value.trim())
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
busy.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
result.value = await $fetch<InviteResult>('/api/users/invite-tenant-admin', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { tenantSlug: props.tenantSlug, name: name.value.trim(), email: email.value.trim() },
|
||||||
|
})
|
||||||
|
emit('invited', result.value)
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as { data?: { data?: { message?: string }; message?: string; statusMessage?: string } }
|
||||||
|
error.value =
|
||||||
|
err.data?.data?.message ||
|
||||||
|
err.data?.message ||
|
||||||
|
err.data?.statusMessage ||
|
||||||
|
'Invite failed'
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard(value: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => (copied.value = false), 2000)
|
||||||
|
} catch {
|
||||||
|
// Some browsers reject clipboard in non-secure contexts. Fall back to
|
||||||
|
// user manually selecting the text in the readonly input below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function done() {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="open" class="backdrop" @click="!busy && emit('close')">
|
||||||
|
<div class="modal" role="dialog" aria-label="Invite tenant admin" @click.stop>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<Eyebrow>Tenant · {{ tenantSlug }}</Eyebrow>
|
||||||
|
<h2>Invite admin</h2>
|
||||||
|
</div>
|
||||||
|
<button class="x" type="button" aria-label="Close" :disabled="busy" @click="emit('close')">
|
||||||
|
<UiIcon name="x" :size="12" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Step 1: collect details -->
|
||||||
|
<div v-if="!result" class="body">
|
||||||
|
<section>
|
||||||
|
<label class="label">Name</label>
|
||||||
|
<input v-model="name" type="text" placeholder="Anne Baslund" :disabled="busy" />
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<label class="label">Email</label>
|
||||||
|
<input v-model="email" type="email" placeholder="anne@dezky.com" :disabled="busy" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<UiIcon name="shield" :size="13" />
|
||||||
|
<Mono dim>
|
||||||
|
Creates (or attaches) the user in Authentik and adds them to the
|
||||||
|
<strong>{{ tenantSlug }}</strong> group as this tenant's admin. You'll get a
|
||||||
|
single-use credential to share.
|
||||||
|
</Mono>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="err">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: show the credential to share -->
|
||||||
|
<div v-else class="body result">
|
||||||
|
<Badge tone="ok" dot>invited</Badge>
|
||||||
|
|
||||||
|
<!-- Existing Authentik user — attached to the tenant, no new credential -->
|
||||||
|
<template v-if="result.attached">
|
||||||
|
<p class="success">
|
||||||
|
<Mono>{{ email }}</Mono> already existed in Authentik and was attached as an
|
||||||
|
admin of <Mono>{{ tenantSlug }}</Mono>. They sign in with their existing
|
||||||
|
credentials — nothing to share.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Preferred path: Authentik issued a recovery link -->
|
||||||
|
<template v-else-if="result.link">
|
||||||
|
<p class="success">
|
||||||
|
{{ name }} (<Mono>{{ email }}</Mono>) is now an admin of
|
||||||
|
<Mono>{{ tenantSlug }}</Mono>. Share the link below — it's single-use
|
||||||
|
and they'll set their own password + MFA.
|
||||||
|
</p>
|
||||||
|
<div class="cred-row">
|
||||||
|
<input :value="result.link" readonly @focus="($event.target as HTMLInputElement).select()" />
|
||||||
|
<UiButton variant="secondary" @click="copyToClipboard(result.link!)">
|
||||||
|
{{ copied ? 'Copied' : 'Copy' }}
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Fallback path: no recovery flow configured in Authentik, share temp password -->
|
||||||
|
<template v-else-if="result.tempPassword">
|
||||||
|
<p class="success">
|
||||||
|
{{ name }} (<Mono>{{ email }}</Mono>) is now an admin of
|
||||||
|
<Mono>{{ tenantSlug }}</Mono>. Authentik doesn't have a recovery flow
|
||||||
|
configured yet, so we set a temporary password — share it with them
|
||||||
|
out-of-band, they'll be prompted to change it on first login.
|
||||||
|
</p>
|
||||||
|
<section>
|
||||||
|
<label class="label">Username / email</label>
|
||||||
|
<div class="cred-row">
|
||||||
|
<input :value="email" readonly @focus="($event.target as HTMLInputElement).select()" />
|
||||||
|
<UiButton variant="secondary" @click="copyToClipboard(email)">
|
||||||
|
Copy
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<label class="label">Temporary password</label>
|
||||||
|
<div class="cred-row">
|
||||||
|
<input :value="result.tempPassword" readonly @focus="($event.target as HTMLInputElement).select()" />
|
||||||
|
<UiButton variant="secondary" @click="copyToClipboard(result.tempPassword!)">
|
||||||
|
{{ copied ? 'Copied' : 'Copy' }}
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<Mono dim>
|
||||||
|
// configure a recovery flow in Authentik (Flows → recovery) to
|
||||||
|
switch this to a self-service link · once SMTP is wired the
|
||||||
|
credential gets emailed automatically
|
||||||
|
</Mono>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer v-if="!result">
|
||||||
|
<UiButton variant="ghost" :disabled="busy" @click="emit('close')">Cancel</UiButton>
|
||||||
|
<UiButton variant="primary" :disabled="!canSubmit" @click="submit">
|
||||||
|
{{ busy ? 'Inviting…' : 'Send invite' }}
|
||||||
|
</UiButton>
|
||||||
|
</footer>
|
||||||
|
<footer v-else>
|
||||||
|
<UiButton variant="primary" @click="done">Done</UiButton>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
z-index: 180;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: 100%; max-width: 520px;
|
||||||
|
background: var(--elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex; justify-content: space-between; align-items: flex-start;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
h2 { margin: 4px 0 0 0; font-family: var(--font-display); font-weight: 600; font-size: 18px; }
|
||||||
|
.x {
|
||||||
|
width: 26px; height: 26px;
|
||||||
|
border: 0; border-radius: 6px; background: transparent;
|
||||||
|
color: var(--text-mute); cursor: pointer;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.x:hover:not(:disabled) { background: var(--surface); color: var(--text); }
|
||||||
|
.x:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex; flex-direction: column; gap: 14px;
|
||||||
|
}
|
||||||
|
section { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-mute);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
input:focus { border-color: var(--border-hi); }
|
||||||
|
input:disabled { opacity: 0.6; }
|
||||||
|
|
||||||
|
.note {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
}
|
||||||
|
.note strong { color: var(--text); font-family: var(--font-mono); font-weight: 600; }
|
||||||
|
|
||||||
|
.err {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(240, 88, 88, 0.08);
|
||||||
|
border: 1px solid rgba(240, 88, 88, 0.24);
|
||||||
|
color: var(--bad);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result .success {
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.cred-row { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.cred-row input {
|
||||||
|
flex: 1;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
padding: 12px 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,692 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// 5-step wizard for provisioning a tenant from the operator — the operator
|
||||||
|
// counterpart of the partner portal's CustomerCreateWizard, sharing its step
|
||||||
|
// flow and result UX. Submit is two calls composed client-side: POST
|
||||||
|
// /api/tenants creates + provisions the tenant, then (when admin fields are
|
||||||
|
// filled) POST /api/users/invite-tenant-admin runs the same invite flow the
|
||||||
|
// partner wizard gets server-side. An invite failure never rolls back the
|
||||||
|
// tenant — the result view says exactly what happened.
|
||||||
|
|
||||||
|
const props = defineProps<{ open: boolean }>()
|
||||||
|
const emit = defineEmits<{ close: []; done: [] }>()
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
legalName: '',
|
||||||
|
displayName: '',
|
||||||
|
slug: '',
|
||||||
|
slugTouched: false,
|
||||||
|
cvr: '',
|
||||||
|
country: '',
|
||||||
|
domain: '',
|
||||||
|
adminFirst: '',
|
||||||
|
adminLast: '',
|
||||||
|
adminEmail: '',
|
||||||
|
plan: 'Business' as PlanLabel,
|
||||||
|
seats: 5,
|
||||||
|
cycle: 'Monthly' as 'Monthly' | 'Quarterly' | 'Yearly',
|
||||||
|
currency: 'DKK' as 'DKK' | 'EUR' | 'USD',
|
||||||
|
})
|
||||||
|
|
||||||
|
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']
|
||||||
|
|
||||||
|
interface CatalogRow {
|
||||||
|
plan: PlanCode
|
||||||
|
cycle: 'monthly' | 'quarterly' | 'yearly'
|
||||||
|
amounts: { DKK?: number; EUR?: number; USD?: number }
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
const { data: catalog } = useLazyFetch<CatalogRow[]>('/api/prices', {
|
||||||
|
key: 'tenant-wizard-catalog',
|
||||||
|
default: () => [],
|
||||||
|
server: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const CYCLE_SUFFIX: Record<'monthly' | 'quarterly' | 'yearly', string> = {
|
||||||
|
monthly: 'mo',
|
||||||
|
quarterly: 'quarter',
|
||||||
|
yearly: 'yr',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slug auto-derives from the display name until the operator edits it.
|
||||||
|
function slugFromName(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 40)
|
||||||
|
}
|
||||||
|
watch(
|
||||||
|
() => form.displayName,
|
||||||
|
(v) => {
|
||||||
|
if (!form.slugTouched) form.slug = slugFromName(v)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
step.value = 1
|
||||||
|
result.value = null
|
||||||
|
submitError.value = null
|
||||||
|
Object.assign(form, {
|
||||||
|
legalName: '', displayName: '', slug: '', slugTouched: false, cvr: '',
|
||||||
|
country: '', domain: '', adminFirst: '', adminLast: '', adminEmail: '',
|
||||||
|
plan: 'Business', seats: 5, cycle: 'Monthly', currency: 'DKK',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('close')
|
||||||
|
setTimeout(reset, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
if (step.value < LAST_STEP) step.value++
|
||||||
|
}
|
||||||
|
function back() {
|
||||||
|
if (step.value > 1) step.value--
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
const submitError = ref<string | null>(null)
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
priceLabel = `Not sold in ${form.currency}`
|
||||||
|
available = false
|
||||||
|
} else {
|
||||||
|
priceLabel = `${(minor / 100).toLocaleString('da-DK')} ${form.currency} / seat / ${cycleSuffix}`
|
||||||
|
}
|
||||||
|
return { ...p, priceLabel, available }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
return `${total.toLocaleString('da-DK')} ${form.currency} / ${CYCLE_SUFFIX[cycleCode(form.cycle)]}`
|
||||||
|
})
|
||||||
|
|
||||||
|
interface AdminCredentials {
|
||||||
|
link?: string
|
||||||
|
tempPassword?: string
|
||||||
|
attached?: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
const result = ref<{
|
||||||
|
tenantName: string
|
||||||
|
tenantSlug: 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 instead.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
submitError.value = null
|
||||||
|
const displayName = form.displayName.trim()
|
||||||
|
const slug = form.slug.trim()
|
||||||
|
if (!displayName || !slug) {
|
||||||
|
submitError.value = 'Display name and slug are required'
|
||||||
|
step.value = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const adminName = `${form.adminFirst.trim()} ${form.adminLast.trim()}`.trim()
|
||||||
|
const adminEmail = form.adminEmail.trim()
|
||||||
|
const domain = form.domain.trim().toLowerCase()
|
||||||
|
const tenant = await $fetch<{ name: string; slug: string }>('/api/tenants', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
slug,
|
||||||
|
name: displayName,
|
||||||
|
plan: planCode(form.plan),
|
||||||
|
cycle: cycleCode(form.cycle),
|
||||||
|
currency: form.currency,
|
||||||
|
seats: form.seats,
|
||||||
|
...(domain && { domains: [domain] }),
|
||||||
|
billingInfo: {
|
||||||
|
...(form.legalName.trim() && { companyName: form.legalName.trim() }),
|
||||||
|
...(form.cvr.trim() && { vatId: form.cvr.trim() }),
|
||||||
|
...(form.country.trim() && { country: form.country.trim().toUpperCase().slice(0, 2) }),
|
||||||
|
...(adminEmail && { contactEmail: adminEmail }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// The tenant exists from here on — an invite failure is reported, never
|
||||||
|
// rolled back (same contract as the partner wizard).
|
||||||
|
let admin: AdminCredentials | undefined
|
||||||
|
if (adminName && adminEmail) {
|
||||||
|
try {
|
||||||
|
admin = await $fetch<AdminCredentials>('/api/users/invite-tenant-admin', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { tenantSlug: tenant.slug, name: adminName, email: adminEmail },
|
||||||
|
})
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { data?: { data?: { message?: string }; message?: string } }
|
||||||
|
admin = { error: e.data?.data?.message || e.data?.message || String(err) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.value = { tenantName: tenant.name, tenantSlug: tenant.slug, adminEmail, admin }
|
||||||
|
emit('done')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { data?: { data?: { message?: string | string[] }; message?: string; statusMessage?: string } }
|
||||||
|
const msg = e.data?.data?.message || e.data?.message || e.data?.statusMessage || 'Provisioning failed'
|
||||||
|
submitError.value = Array.isArray(msg) ? msg.join(' · ') : msg
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finish() {
|
||||||
|
const slug = result.value?.tenantSlug
|
||||||
|
result.value = null
|
||||||
|
emit('close')
|
||||||
|
if (slug) await navigateTo(`/tenants/${slug}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && props.open && !submitting.value) close()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="open" class="backdrop" @click="!submitting && close()">
|
||||||
|
<div class="modal" role="dialog" aria-label="New tenant" @click.stop>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<Eyebrow>{{ result ? 'Provisioned' : `Step ${step} of ${STEPS.length}` }}</Eyebrow>
|
||||||
|
<h2>Provision new tenant</h2>
|
||||||
|
</div>
|
||||||
|
<button class="x" type="button" aria-label="Close" :disabled="submitting" @click="close">
|
||||||
|
<UiIcon name="x" :size="12" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<!-- 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">
|
||||||
|
<span class="label">Display name · shown to users</span>
|
||||||
|
<input v-model="form.displayName" placeholder="e.g. Dezky ApS" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">Slug · URL-safe id, also the Authentik group</span>
|
||||||
|
<input v-model="form.slug" placeholder="e.g. dezky" @input="form.slugTouched = true" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">Legal name · billing</span>
|
||||||
|
<input v-model="form.legalName" placeholder="e.g. Dezky ApS" />
|
||||||
|
</label>
|
||||||
|
<div class="row-2">
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">CVR / VAT id</span>
|
||||||
|
<input v-model="form.cvr" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">Country · ISO-2</span>
|
||||||
|
<input v-model="form.country" placeholder="DK" maxlength="2" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Domain -->
|
||||||
|
<div v-if="step === 2 && !result" class="form">
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">Primary mail domain · optional</span>
|
||||||
|
<input v-model="form.domain" placeholder="e.g. dezky.eu" />
|
||||||
|
</label>
|
||||||
|
<div class="info-box">
|
||||||
|
<span class="label">DNS verification</span>
|
||||||
|
<p>
|
||||||
|
This only records the intent on the tenant. The customer admin adds and
|
||||||
|
verifies the domain on the portal's Domains page, which hands them the
|
||||||
|
exact MX/SPF/DKIM/DMARC records to publish.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. First admin -->
|
||||||
|
<div v-if="step === 3 && !result" class="form">
|
||||||
|
<p class="hint">
|
||||||
|
Optional — creates (or attaches) this person as the tenant's first admin and
|
||||||
|
returns a single-use credential to share. Skip it to invite someone later.
|
||||||
|
</p>
|
||||||
|
<div class="row-2">
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">First name</span>
|
||||||
|
<input v-model="form.adminFirst" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">Last name</span>
|
||||||
|
<input v-model="form.adminLast" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">Email</span>
|
||||||
|
<input v-model="form.adminEmail" type="email" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. Plan -->
|
||||||
|
<div v-if="step === 4 && !result" class="form">
|
||||||
|
<div class="row-3">
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">Initial seats</span>
|
||||||
|
<input v-model.number="form.seats" type="number" min="0" max="10000" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">Billing cycle</span>
|
||||||
|
<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">
|
||||||
|
<span class="label">Currency</span>
|
||||||
|
<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 PlanLabel)"
|
||||||
|
>
|
||||||
|
<span v-if="'best' in p && p.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">
|
||||||
|
<span class="label">You're provisioning</span>
|
||||||
|
<div class="review-name">{{ form.displayName || '—' }}</div>
|
||||||
|
<Mono dim>{{ form.slug }} · {{ form.domain || 'no domain yet' }} · {{ form.plan }} · {{ form.seats }} seats</Mono>
|
||||||
|
</div>
|
||||||
|
<dl class="def">
|
||||||
|
<div class="def-row">
|
||||||
|
<dt>Admin</dt>
|
||||||
|
<dd>{{ form.adminEmail ? `${form.adminFirst} ${form.adminLast} · ${form.adminEmail}` : 'none — invite later' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="def-row"><dt>Plan</dt><dd>{{ form.plan }} · {{ form.seats }} seats · {{ form.cycle.toLowerCase() }} · {{ form.currency }}</dd></div>
|
||||||
|
<div class="def-row"><dt>Billing</dt><dd>{{ form.legalName || form.displayName }}{{ form.cvr ? ` · ${form.cvr}` : '' }}{{ form.country ? ` · ${form.country.toUpperCase()}` : '' }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div class="info-box">
|
||||||
|
<Mono dim>// provisioning</Mono>
|
||||||
|
<p>
|
||||||
|
On confirm the tenant is created and provisioned (Authentik group, Stalwart
|
||||||
|
service domain; OCIS once the files tier is live), the subscription is
|
||||||
|
spun up from the price catalog, and the first admin is invited.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="submitError" class="err">{{ submitError }}</p>
|
||||||
|
|
||||||
|
<!-- Provisioned: credentials handoff -->
|
||||||
|
<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 a temporary password was
|
||||||
|
set — share it with the admin; they'll 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 from the tenant page's
|
||||||
|
"Invite admin" action.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<p class="ok-msg">
|
||||||
|
No first-admin info was provided. Invite an admin from the tenant page
|
||||||
|
whenever you're ready.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 tenant' }}
|
||||||
|
</UiButton>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div style="flex: 1" />
|
||||||
|
<UiButton variant="primary" @click="finish">Open tenant</UiButton>
|
||||||
|
</template>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
z-index: 180;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: 100%; max-width: 640px;
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
background: var(--elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex; justify-content: space-between; align-items: flex-start;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
h2 { margin: 4px 0 0 0; font-family: var(--font-display); font-weight: 600; font-size: 18px; }
|
||||||
|
.x {
|
||||||
|
width: 26px; height: 26px;
|
||||||
|
border: 0; border-radius: 6px; background: transparent;
|
||||||
|
color: var(--text-mute); cursor: pointer;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.x:hover:not(:disabled) { background: var(--surface); color: var(--text); }
|
||||||
|
.x:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.body { padding: 18px 20px; overflow-y: auto; }
|
||||||
|
footer {
|
||||||
|
padding: 14px 20px;
|
||||||
|
display: flex; gap: 8px; align-items: center;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail { display: flex; align-items: center; gap: 6px; margin-bottom: 20px; }
|
||||||
|
.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, var(--bg)); border-color: var(--accent); }
|
||||||
|
.lab { font-size: 12px; font-weight: 500; color: var(--text-mute); white-space: nowrap; }
|
||||||
|
.rail-step.active .lab, .rail-step.done .lab { color: var(--text); }
|
||||||
|
.rail-step.active .lab { font-weight: 600; }
|
||||||
|
.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; }
|
||||||
|
.label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-mute);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.hint { font-size: 13px; color: var(--text-dim); margin: 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 0 0; line-height: 1.55; }
|
||||||
|
|
||||||
|
.plan {
|
||||||
|
position: relative;
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
padding: 14px 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; }
|
||||||
|
.rec {
|
||||||
|
position: absolute; top: -8px; right: 12px;
|
||||||
|
background: var(--accent); color: var(--accent-fg, var(--bg));
|
||||||
|
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);
|
||||||
|
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; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.plan-head { display: flex; align-items: baseline; gap: 10px; }
|
||||||
|
.plan-name { font-family: var(--font-display); font-size: 16px; font-weight: 600; }
|
||||||
|
|
||||||
|
.total-line {
|
||||||
|
display: flex; justify-content: space-between; align-items: baseline;
|
||||||
|
margin: 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; }
|
||||||
|
|
||||||
|
.review-hero {
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.review-name {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin: 6px 0 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.def { display: flex; flex-direction: column; gap: 8px; margin: 0; }
|
||||||
|
.def-row { display: grid; grid-template-columns: 120px 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); }
|
||||||
|
|
||||||
|
.err {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--bad);
|
||||||
|
background: rgba(240, 88, 88, 0.08);
|
||||||
|
border: 1px solid rgba(240, 88, 88, 0.24);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -81,6 +81,10 @@ const INTEGRATION_TONE = {
|
|||||||
const INTEGRATIONS = ['authentik', 'stalwart', 'ocis'] as const
|
const INTEGRATIONS = ['authentik', 'stalwart', 'ocis'] as const
|
||||||
type IntegrationKey = (typeof INTEGRATIONS)[number]
|
type IntegrationKey = (typeof INTEGRATIONS)[number]
|
||||||
|
|
||||||
|
// Invite (or re-invite) the tenant's admin — same flow the create wizard
|
||||||
|
// runs; reusable whenever the first invite failed or someone new takes over.
|
||||||
|
const inviteAdminOpen = ref(false)
|
||||||
|
|
||||||
// ── Danger-zone state ─────────────────────────────────────────────────────
|
// ── Danger-zone state ─────────────────────────────────────────────────────
|
||||||
const dangerAction = ref<'suspend' | 'resume' | 'delete' | 'purge' | null>(null)
|
const dangerAction = ref<'suspend' | 'resume' | 'delete' | 'purge' | null>(null)
|
||||||
const dangerBusy = ref(false)
|
const dangerBusy = ref(false)
|
||||||
@@ -141,6 +145,10 @@ async function reconcile() {
|
|||||||
<template #actions>
|
<template #actions>
|
||||||
<Badge :tone="STATUS_TONE[tenant.status]" dot>{{ tenant.status }}</Badge>
|
<Badge :tone="STATUS_TONE[tenant.status]" dot>{{ tenant.status }}</Badge>
|
||||||
<Badge tone="neutral">{{ tenant.plan }}</Badge>
|
<Badge tone="neutral">{{ tenant.plan }}</Badge>
|
||||||
|
<UiButton variant="secondary" @click="inviteAdminOpen = true">
|
||||||
|
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||||
|
Invite admin
|
||||||
|
</UiButton>
|
||||||
<UiButton variant="secondary" @click="impersonate.open(tenant)">
|
<UiButton variant="secondary" @click="impersonate.open(tenant)">
|
||||||
<template #leading><UiIcon name="key" :size="13" /></template>
|
<template #leading><UiIcon name="key" :size="13" /></template>
|
||||||
Impersonate
|
Impersonate
|
||||||
@@ -392,6 +400,14 @@ async function reconcile() {
|
|||||||
</p>
|
</p>
|
||||||
<p v-if="dangerError" class="danger-err">{{ dangerError }}</p>
|
<p v-if="dangerError" class="danger-err">{{ dangerError }}</p>
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<InviteTenantAdminModal
|
||||||
|
v-if="tenant"
|
||||||
|
:open="inviteAdminOpen"
|
||||||
|
:tenant-slug="tenant.slug"
|
||||||
|
:tenant-name="tenant.name"
|
||||||
|
@close="inviteAdminOpen = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -37,72 +37,16 @@ function navTo(t: Tenant) {
|
|||||||
return navigateTo(`/tenants/${t.slug}`)
|
return navigateTo(`/tenants/${t.slug}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Create modal ──────────────────────────────────────────────────────────
|
// ── Create wizard ─────────────────────────────────────────────────────────
|
||||||
// Operator-created tenants are DIRECT customers (no partnerId — partner-owned
|
// Full partner-style provisioning wizard (TenantCreateWizard) — collects org,
|
||||||
// tenants are created through the partner portal wizard instead). Attach to a
|
// domain, first admin and plan, then creates + invites in one flow and shows
|
||||||
// partner later from the tenant detail page if needed.
|
// the admin credential to share. Operator-created tenants are DIRECT
|
||||||
|
// customers (no partnerId); attach a partner later from the tenant page.
|
||||||
const createOpen = ref(false)
|
const createOpen = ref(false)
|
||||||
const createBusy = ref(false)
|
|
||||||
const createError = ref<string | null>(null)
|
|
||||||
const form = reactive({
|
|
||||||
slug: '',
|
|
||||||
name: '',
|
|
||||||
plan: 'mvp' as 'mvp' | 'pro' | 'enterprise',
|
|
||||||
cycle: 'monthly' as 'monthly' | 'quarterly' | 'yearly',
|
|
||||||
currency: 'DKK' as 'DKK' | 'EUR' | 'USD',
|
|
||||||
seats: 5,
|
|
||||||
domain: '',
|
|
||||||
adminName: '',
|
|
||||||
adminEmail: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
Object.assign(form, {
|
|
||||||
slug: '',
|
|
||||||
name: '',
|
|
||||||
plan: 'mvp',
|
|
||||||
cycle: 'monthly',
|
|
||||||
currency: 'DKK',
|
|
||||||
seats: 5,
|
|
||||||
domain: '',
|
|
||||||
adminName: '',
|
|
||||||
adminEmail: '',
|
|
||||||
})
|
|
||||||
createError.value = null
|
|
||||||
createOpen.value = true
|
createOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitCreate() {
|
|
||||||
createBusy.value = true
|
|
||||||
createError.value = null
|
|
||||||
try {
|
|
||||||
const domain = form.domain.trim().toLowerCase()
|
|
||||||
const created = await $fetch<Tenant>('/api/tenants', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
slug: form.slug.trim(),
|
|
||||||
name: form.name.trim(),
|
|
||||||
plan: form.plan,
|
|
||||||
cycle: form.cycle,
|
|
||||||
currency: form.currency,
|
|
||||||
seats: form.seats,
|
|
||||||
...(domain ? { domains: [domain] } : {}),
|
|
||||||
...(form.adminName.trim() && form.adminEmail.trim()
|
|
||||||
? { adminName: form.adminName.trim(), adminEmail: form.adminEmail.trim() }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
createOpen.value = false
|
|
||||||
await refresh()
|
|
||||||
await navigateTo(`/tenants/${created.slug}`)
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const e = err as { data?: { data?: { message?: string | string[] }; message?: string } }
|
|
||||||
const msg = e.data?.data?.message ?? e.data?.message ?? String(err)
|
|
||||||
createError.value = Array.isArray(msg) ? msg.join(' · ') : msg
|
|
||||||
} finally {
|
|
||||||
createBusy.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -195,69 +139,7 @@ async function submitCreate() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<TenantCreateWizard :open="createOpen" @close="createOpen = false" @done="refresh()" />
|
||||||
:open="createOpen"
|
|
||||||
eyebrow="New tenant"
|
|
||||||
title="Create direct customer"
|
|
||||||
confirm-label="Create"
|
|
||||||
:busy="createBusy"
|
|
||||||
@close="createOpen = false"
|
|
||||||
@confirm="submitCreate"
|
|
||||||
>
|
|
||||||
<form class="form" @submit.prevent="submitCreate">
|
|
||||||
<label>
|
|
||||||
<span>Slug · URL-safe id</span>
|
|
||||||
<input v-model="form.slug" placeholder="e.g. dezky" autocomplete="off" required />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Display name</span>
|
|
||||||
<input v-model="form.name" placeholder="e.g. Dezky ApS" required />
|
|
||||||
</label>
|
|
||||||
<div class="form-row">
|
|
||||||
<label>
|
|
||||||
<span>Plan</span>
|
|
||||||
<select v-model="form.plan">
|
|
||||||
<option value="mvp">mvp</option>
|
|
||||||
<option value="pro">pro</option>
|
|
||||||
<option value="enterprise">enterprise</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Cycle</span>
|
|
||||||
<select v-model="form.cycle">
|
|
||||||
<option value="monthly">monthly</option>
|
|
||||||
<option value="quarterly">quarterly</option>
|
|
||||||
<option value="yearly">yearly</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Currency</span>
|
|
||||||
<select v-model="form.currency">
|
|
||||||
<option value="DKK">DKK</option>
|
|
||||||
<option value="EUR">EUR</option>
|
|
||||||
<option value="USD">USD</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Seats</span>
|
|
||||||
<input v-model.number="form.seats" type="number" min="0" max="10000" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
<span>Primary mail domain · optional</span>
|
|
||||||
<input v-model="form.domain" placeholder="e.g. dezky.eu" autocomplete="off" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>First admin name · optional</span>
|
|
||||||
<input v-model="form.adminName" placeholder="e.g. Ronni Baslund" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>First admin email · optional</span>
|
|
||||||
<input v-model="form.adminEmail" type="email" placeholder="e.g. ronni@dezky.eu" />
|
|
||||||
</label>
|
|
||||||
</form>
|
|
||||||
<p v-if="createError" class="err">{{ createError }}</p>
|
|
||||||
</ConfirmDialog>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -388,36 +270,4 @@ td.td-right { text-align: right; }
|
|||||||
.prov-error { background: var(--bad); }
|
.prov-error { background: var(--bad); }
|
||||||
.prov-pending { background: var(--warn); }
|
.prov-pending { background: var(--warn); }
|
||||||
|
|
||||||
.form { display: flex; flex-direction: column; gap: 12px; }
|
|
||||||
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
|
|
||||||
.form label { display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
.form label span {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-mute);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.form input,
|
|
||||||
.form select {
|
|
||||||
height: 34px;
|
|
||||||
padding: 0 12px;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.form input:focus,
|
|
||||||
.form select:focus { border-color: var(--accent); }
|
|
||||||
|
|
||||||
.err {
|
|
||||||
margin: 12px 0 0 0;
|
|
||||||
color: var(--bad);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { platformApi } from '~~/server/utils/platform-api'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event)
|
||||||
|
return platformApi(event, '/users/invite-tenant-admin', { method: 'POST', body })
|
||||||
|
})
|
||||||
@@ -23,7 +23,7 @@ data:
|
|||||||
# (PLATFORM_TENANT_SLUG) may claim the apex; nothing under it can be added
|
# (PLATFORM_TENANT_SLUG) may claim the apex; nothing under it can be added
|
||||||
# as a customer domain.
|
# as a customer domain.
|
||||||
PLATFORM_TENANT_DOMAIN: "dezky.eu"
|
PLATFORM_TENANT_DOMAIN: "dezky.eu"
|
||||||
PLATFORM_TENANT_SLUG: "dezky-aps"
|
PLATFORM_TENANT_SLUG: "dezky"
|
||||||
# JWT validation for portal/operator-issued access tokens. Public Authentik
|
# JWT validation for portal/operator-issued access tokens. Public Authentik
|
||||||
# URLs on purpose: the token `iss` claim is the public URL, and the pod can
|
# URLs on purpose: the token `iss` claim is the public URL, and the pod can
|
||||||
# hairpin to it through the node's public IP.
|
# hairpin to it through the node's public IP.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ spec:
|
|||||||
annotations:
|
annotations:
|
||||||
# Bump to force a rolling restart when only the ConfigMap changed —
|
# Bump to force a rolling restart when only the ConfigMap changed —
|
||||||
# pods read it as env, which is only resolved at container start.
|
# pods read it as env, which is only resolved at container start.
|
||||||
dezky.eu/config-rev: "2"
|
dezky.eu/config-rev: "3"
|
||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: platform-api
|
- name: platform-api
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsEmail, IsString, Matches, MaxLength, MinLength } from 'class-validator'
|
||||||
|
|
||||||
|
// Operator-only: invite (or attach) the first admin of a tenant. Same
|
||||||
|
// inviteTenantAdmin flow the partner wizard uses — creates the Authentik user
|
||||||
|
// (or attaches an existing one by email), adds them to the tenant group and
|
||||||
|
// returns a recovery link / temp password the operator shares manually.
|
||||||
|
export class InviteTenantAdminDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/, { message: 'invalid tenant slug' })
|
||||||
|
tenantSlug!: string
|
||||||
|
|
||||||
|
@IsString() @MinLength(2) @MaxLength(120)
|
||||||
|
name!: string
|
||||||
|
|
||||||
|
@IsEmail() @MaxLength(254)
|
||||||
|
email!: string
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
|
|||||||
import { OperatorGuard } from '../auth/operator.guard.js'
|
import { OperatorGuard } from '../auth/operator.guard.js'
|
||||||
import { CreateUserDto } from './dto/create-user.dto.js'
|
import { CreateUserDto } from './dto/create-user.dto.js'
|
||||||
import { InviteOperatorDto } from './dto/invite-operator.dto.js'
|
import { InviteOperatorDto } from './dto/invite-operator.dto.js'
|
||||||
|
import { InviteTenantAdminDto } from './dto/invite-tenant-admin.dto.js'
|
||||||
import { UpdateUserDto } from './dto/update-user.dto.js'
|
import { UpdateUserDto } from './dto/update-user.dto.js'
|
||||||
import { UsersService } from './users.service.js'
|
import { UsersService } from './users.service.js'
|
||||||
|
|
||||||
@@ -89,6 +90,24 @@ export class UsersController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Operator-only: invite (or attach) a tenant's first admin — the operator
|
||||||
|
// counterpart of the partner wizard's adminName/adminEmail step. Returns
|
||||||
|
// the recovery link / temp password the operator shares manually.
|
||||||
|
@Post('invite-tenant-admin')
|
||||||
|
@UseGuards(OperatorGuard)
|
||||||
|
async inviteTenantAdmin(
|
||||||
|
@Body() dto: InviteTenantAdminDto,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
@Req() req: Parameters<typeof clientIp>[0],
|
||||||
|
) {
|
||||||
|
const actor = await this.actor.resolve(jwt)
|
||||||
|
return this.users.inviteTenantAdminBySlug(
|
||||||
|
dto.tenantSlug,
|
||||||
|
{ name: dto.name, email: dto.email },
|
||||||
|
{ userId: String(actor._id), email: actor.email, ip: clientIp(req) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async findAll(@CurrentUser() jwt: AuthentikJwtPayload) {
|
async findAll(@CurrentUser() jwt: AuthentikJwtPayload) {
|
||||||
const actor = await this.actor.resolve(jwt)
|
const actor = await this.actor.resolve(jwt)
|
||||||
|
|||||||
@@ -1386,6 +1386,30 @@ export class UsersService {
|
|||||||
return { newOwner: freshNewOwner ?? newOwner, previousOwners: freshPrev }
|
return { newOwner: freshNewOwner ?? newOwner, previousOwners: freshPrev }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Operator-side wrapper: resolve the tenant by slug, then run the same
|
||||||
|
// invite flow the partner wizard uses. Lives here (not TenantsService)
|
||||||
|
// because UsersModule already imports TenantsModule — the reverse would be
|
||||||
|
// circular.
|
||||||
|
async inviteTenantAdminBySlug(
|
||||||
|
slug: string,
|
||||||
|
dto: { name: string; email: string },
|
||||||
|
actor?: AuditActor,
|
||||||
|
): Promise<{
|
||||||
|
subject: string
|
||||||
|
userId: string
|
||||||
|
attached?: boolean
|
||||||
|
link?: string
|
||||||
|
tempPassword?: string
|
||||||
|
}> {
|
||||||
|
const tenant = await this.tenantModel.findOne({ slug }).exec()
|
||||||
|
if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)
|
||||||
|
return this.inviteTenantAdmin(
|
||||||
|
{ _id: tenant._id, slug: tenant.slug, authentikGroupId: tenant.authentikGroupId },
|
||||||
|
dto,
|
||||||
|
actor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async inviteTenantAdmin(
|
async inviteTenantAdmin(
|
||||||
tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
|
tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
|
||||||
dto: { name: string; email: string },
|
dto: { name: string; email: string },
|
||||||
|
|||||||
Reference in New Issue
Block a user