Files
dezky/apps/operator/components/NewTenantModal.vue
T
Ronni Baslund be430179d9 feat(operator): create tenant from the operator UI
Wires the previously-dead 'New tenant' button on /tenants to a modal
that collects slug + name + plan + optional primary domain, POSTs to
the existing platform-api /tenants endpoint via a new operator proxy,
and navigates into the freshly-created tenant detail page. Slug
auto-derives from the name until the operator types in the slug field
themselves. Billing details and provisioning are still done from the
tenant detail page after creation — this modal is the minimum that
backend validators will accept.
2026-05-24 22:31:49 +02:00

266 lines
7.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// "New tenant" modal — opens from the Tenants page header. Collects the
// minimum needed to create a tenant (slug + name + plan + optional primary
// domain); billing details, status changes, and provisioning are edited from
// the tenant detail page after creation.
import type { Tenant, TenantPlan } from '~/types/tenant'
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: []; created: [Tenant] }>()
const slug = ref('')
const name = ref('')
const plan = ref<TenantPlan>('mvp')
const domain = ref('')
const slugTouched = ref(false)
const error = ref<string | null>(null)
const busy = ref(false)
const PLANS: TenantPlan[] = ['mvp', 'pro', 'enterprise']
// Reset every time the modal opens so previous attempts don't linger.
watch(
() => props.open,
(v) => {
if (v) {
slug.value = ''
name.value = ''
plan.value = 'mvp'
domain.value = ''
slugTouched.value = false
error.value = null
busy.value = false
}
},
)
// Auto-derive slug from name until the operator types in the slug field
// themselves — once they touch slug we never overwrite it.
watch(name, (next) => {
if (slugTouched.value) return
slug.value = next
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40)
})
onMounted(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.open && !busy.value) emit('close')
}
document.addEventListener('keydown', onKey)
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
})
// Mirrors the platform-api CreateTenantDto validators so we don't fire a
// doomed POST. Backend re-checks on submit.
const slugValid = computed(() =>
/^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/.test(slug.value.trim()),
)
const nameValid = computed(() => name.value.trim().length >= 2 && name.value.trim().length <= 120)
const domainValid = computed(() => {
const d = domain.value.trim()
return d.length === 0 || /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(d)
})
const canSubmit = computed(
() => !busy.value && slugValid.value && nameValid.value && domainValid.value,
)
async function submit() {
if (!canSubmit.value) return
busy.value = true
error.value = null
const d = domain.value.trim()
try {
const created = await $fetch<Tenant>('/api/tenants', {
method: 'POST',
body: {
slug: slug.value.trim(),
name: name.value.trim(),
plan: plan.value,
...(d ? { domains: [d] } : {}),
},
})
emit('created', created)
} 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 ||
'Create failed'
} finally {
busy.value = false
}
}
</script>
<template>
<Teleport to="body">
<div v-if="open" class="backdrop" @click="!busy && emit('close')">
<div class="modal" role="dialog" aria-label="New tenant" @click.stop>
<header>
<div>
<Eyebrow>Customers</Eyebrow>
<h2>New tenant</h2>
</div>
<button class="x" type="button" aria-label="Close" :disabled="busy" @click="emit('close')">
<UiIcon name="x" :size="12" />
</button>
</header>
<div class="body">
<section>
<label class="label">Name</label>
<input v-model="name" type="text" placeholder="Acme ApS" :disabled="busy" />
</section>
<section>
<label class="label">Slug</label>
<input
v-model="slug"
type="text"
placeholder="acme"
:disabled="busy"
@input="slugTouched = true"
/>
<Mono dim>lowercase · 240 chars · az, 09, hyphens · stable URL key</Mono>
</section>
<section>
<label class="label">Plan</label>
<div class="seg three">
<button
v-for="p in PLANS"
:key="p"
type="button"
:class="{ on: plan === p }"
:disabled="busy"
@click="plan = p"
>{{ p }}</button>
</div>
</section>
<section>
<label class="label">Primary domain <span class="hint">(optional)</span></label>
<input v-model="domain" type="text" placeholder="acme.example.com" :disabled="busy" />
<Mono dim>can be added later from the tenant detail page</Mono>
</section>
<p v-if="error" class="err">{{ error }}</p>
</div>
<footer>
<UiButton variant="ghost" :disabled="busy" @click="emit('close')">Cancel</UiButton>
<UiButton variant="primary" :disabled="!canSubmit" @click="submit">
{{ busy ? 'Creating' : 'Create tenant' }}
</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: 480px;
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;
}
.label .hint { color: var(--text-mute); font-weight: 400; text-transform: none; letter-spacing: 0; }
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; }
.seg {
display: grid;
width: 100%;
gap: 2px;
padding: 2px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 7px;
box-sizing: border-box;
}
.seg.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.seg button {
appearance: none;
border: 0;
background: transparent;
color: var(--text-dim);
font-family: inherit;
font-size: 11px;
font-weight: 500;
padding: 6px 10px;
border-radius: 5px;
cursor: pointer;
}
.seg button:hover:not(:disabled) { color: var(--text); }
.seg button.on { background: var(--text); color: var(--bg); }
.seg button:disabled { opacity: 0.5; cursor: not-allowed; }
.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;
}
footer {
padding: 12px 20px;
display: flex;
justify-content: flex-end;
gap: 8px;
border-top: 1px solid var(--border);
background: var(--surface);
}
</style>