7720e4be83
Migrate the partner-mode customer switcher, in-customer banner, sidebar tile and the team invite/teammate panels off the data/customers fixture onto the real /api/partner/tenants list (shared key, gated to partner-staff so the global shell doesn't 403 for other users). Active customer resolves by tenant _id (the key the customers page already passes to partnerMode.enter); partner-identity labels now use the real partner name from useMe. Removes the now-unused customers + CustomerOrg-list fixture export and the dead setCustomer helper. Verified in UI: switcher + enter/exit show real Baslund Test / Baslund Research ApS.
310 lines
8.9 KiB
Vue
310 lines
8.9 KiB
Vue
<script setup lang="ts">
|
|
// Invite a teammate to the partner organization. Role + customer-access
|
|
// scoping + require-MFA toggle + optional personal note. Invitations expire
|
|
// after 7 days — the design surfaces that explicitly.
|
|
|
|
const { tenants } = usePartnerTenants()
|
|
const PLAN_LABEL: Record<string, string> = { mvp: 'Starter', pro: 'Business', enterprise: 'Enterprise' }
|
|
|
|
defineProps<{ open: boolean }>()
|
|
const emit = defineEmits<{
|
|
close: []
|
|
sent: [
|
|
payload: {
|
|
name: string
|
|
email: string
|
|
role: string
|
|
access: 'all' | 'specific' | 'none'
|
|
specific: string[]
|
|
requireMfa: boolean
|
|
message: string
|
|
},
|
|
]
|
|
}>()
|
|
|
|
const name = ref('')
|
|
const email = ref('')
|
|
const role = ref<'Partner admin' | 'Sales' | 'Support' | 'Billing'>('Sales')
|
|
const access = ref<'all' | 'specific' | 'none'>('all')
|
|
const specific = ref<string[]>([])
|
|
const requireMfa = ref(true)
|
|
const message = ref('')
|
|
|
|
const ROLE_OPTS = [
|
|
{ v: 'Partner admin', d: 'Full access · billing · settings · all customers' },
|
|
{ v: 'Sales', d: 'Customer orgs · provisioning · plan changes' },
|
|
{ v: 'Support', d: 'Enter customers · view tickets · no billing' },
|
|
{ v: 'Billing', d: 'Invoices · payouts · cannot enter customers' },
|
|
] as const
|
|
|
|
const ACCESS_OPTS = [
|
|
{ v: 'all', l: 'All customers', d: 'Including new ones added later' },
|
|
{ v: 'specific', l: 'Specific customers', d: 'Pick from the list below' },
|
|
{ v: 'none', l: 'No customer access', d: 'Partner-only console (for Billing role)' },
|
|
] as const
|
|
|
|
function toggleCustomer(id: string) {
|
|
if (specific.value.includes(id)) specific.value = specific.value.filter((x) => x !== id)
|
|
else specific.value = [...specific.value, id]
|
|
}
|
|
|
|
function planBadgeTone(p: string) {
|
|
return p === 'enterprise' ? 'invert' : 'neutral'
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Modal
|
|
:open="open"
|
|
eyebrow="Partner team · invite"
|
|
title="Invite teammate"
|
|
size="md"
|
|
@close="emit('close')"
|
|
>
|
|
<div class="form">
|
|
<div class="row-2">
|
|
<label class="field">
|
|
<Eyebrow>Full name</Eyebrow>
|
|
<input v-model="name" placeholder="Anne Baslund" />
|
|
</label>
|
|
<label class="field">
|
|
<Eyebrow>Email</Eyebrow>
|
|
<input v-model="email" placeholder="name@nordicmsp.dk" />
|
|
</label>
|
|
</div>
|
|
|
|
<div>
|
|
<Eyebrow>Role</Eyebrow>
|
|
<div class="role-grid">
|
|
<button
|
|
v-for="o in ROLE_OPTS"
|
|
:key="o.v"
|
|
type="button"
|
|
class="role-card"
|
|
:class="{ selected: role === o.v }"
|
|
@click="role = o.v as any"
|
|
>
|
|
<div class="rc-top">
|
|
<span class="rc-name">{{ o.v }}</span>
|
|
<Badge v-if="o.v === 'Partner admin'" tone="invert">all access</Badge>
|
|
</div>
|
|
<Mono dim>{{ o.d }}</Mono>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Eyebrow>Customer access</Eyebrow>
|
|
<div class="access-list">
|
|
<button
|
|
v-for="o in ACCESS_OPTS"
|
|
:key="o.v"
|
|
type="button"
|
|
class="access-row"
|
|
:class="{ selected: access === o.v }"
|
|
@click="access = o.v as any"
|
|
>
|
|
<span class="radio" :class="{ on: access === o.v }">
|
|
<span v-if="access === o.v" class="radio-inner" />
|
|
</span>
|
|
<div class="ar-meta">
|
|
<div class="ar-label">{{ o.l }}</div>
|
|
<Mono dim>{{ o.d }}</Mono>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="access === 'specific'" class="picker">
|
|
<div class="picker-head">
|
|
<Mono dim>{{ specific.length }} of {{ tenants.length }} selected</Mono>
|
|
</div>
|
|
<div class="picker-list">
|
|
<label v-for="c in tenants" :key="c._id" class="picker-row">
|
|
<input
|
|
type="checkbox"
|
|
:checked="specific.includes(c.slug)"
|
|
@change="toggleCustomer(c.slug)"
|
|
/>
|
|
<div class="cust-swatch" :style="{ background: c.brandColor || '#0A0A0A' }" />
|
|
<span class="cust-name">{{ c.name }}</span>
|
|
<Badge :tone="planBadgeTone(c.plan ?? 'pro')">{{ PLAN_LABEL[c.plan ?? 'pro'] }}</Badge>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mfa-row">
|
|
<div>
|
|
<div class="mfa-label">Require MFA on first sign-in</div>
|
|
<Mono dim>recommended for any partner role with customer access</Mono>
|
|
</div>
|
|
<button class="switch" :class="{ on: requireMfa }" @click="requireMfa = !requireMfa">
|
|
<span class="thumb" />
|
|
</button>
|
|
</div>
|
|
|
|
<label class="field">
|
|
<Eyebrow>Personal note · optional</Eyebrow>
|
|
<textarea
|
|
v-model="message"
|
|
rows="3"
|
|
placeholder="Welcome to the team — looking forward to working together."
|
|
/>
|
|
</label>
|
|
|
|
<div class="warn">
|
|
<UiIcon name="shield" :size="14" />
|
|
<p>
|
|
Invitations expire after <b>7 days</b>. The teammate will create their own password and
|
|
complete MFA enrolment before getting access.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
|
|
<UiButton
|
|
variant="primary"
|
|
:disabled="!email || !name"
|
|
@click="emit('sent', { name, email, role, access, specific, requireMfa, message }); emit('close')"
|
|
>
|
|
<template #leading><UiIcon name="mail" :size="14" /></template>
|
|
Send invitation
|
|
</UiButton>
|
|
</template>
|
|
</Modal>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.form { display: flex; flex-direction: column; gap: 16px; }
|
|
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
|
.field input, .field textarea {
|
|
padding: 9px 12px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
color: var(--text);
|
|
}
|
|
.field textarea { resize: vertical; line-height: 1.55; }
|
|
.field input:focus, .field textarea:focus { outline: none; border-color: var(--border-hi); }
|
|
|
|
.role-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 8px;
|
|
margin-top: 8px;
|
|
}
|
|
.role-card {
|
|
padding: 12px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border);
|
|
background: var(--surface);
|
|
text-align: left;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
}
|
|
.role-card.selected { border-color: var(--text); background: var(--bg); }
|
|
.rc-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
|
.rc-name { font-size: 13px; font-weight: 500; }
|
|
|
|
.access-list { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
|
|
.access-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 10px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border);
|
|
background: var(--surface);
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
}
|
|
.access-row.selected { border-color: var(--text); background: var(--bg); }
|
|
.radio {
|
|
width: 14px;
|
|
height: 14px;
|
|
border-radius: 999px;
|
|
border: 1.5px solid var(--border-hi);
|
|
background: var(--bg);
|
|
flex-shrink: 0;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.radio.on { border: 4px solid var(--text); }
|
|
.ar-meta { flex: 1; }
|
|
.ar-label { font-size: 13px; font-weight: 500; }
|
|
|
|
.picker {
|
|
margin-top: 10px;
|
|
padding: 12px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
max-height: 240px;
|
|
overflow-y: auto;
|
|
}
|
|
.picker-head { margin-bottom: 8px; }
|
|
.picker-list { display: flex; flex-direction: column; gap: 6px; }
|
|
.picker-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
}
|
|
.picker-row input[type='checkbox'] { width: 14px; height: 14px; accent-color: var(--text); }
|
|
.cust-swatch { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; }
|
|
.cust-name { flex: 1; }
|
|
|
|
.mfa-row {
|
|
padding: 12px 14px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
.mfa-label { font-size: 13px; font-weight: 500; }
|
|
|
|
.switch {
|
|
width: 36px;
|
|
height: 20px;
|
|
border-radius: 999px;
|
|
background: var(--border);
|
|
border: none;
|
|
padding: 2px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
transition: background 150ms;
|
|
flex-shrink: 0;
|
|
}
|
|
.switch.on { background: var(--text); }
|
|
.thumb {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 999px;
|
|
background: var(--bg);
|
|
transition: transform 150ms;
|
|
}
|
|
.switch.on .thumb { transform: translateX(16px); }
|
|
|
|
.warn {
|
|
padding: 12px;
|
|
background: rgba(232, 154, 31, 0.08);
|
|
border: 1px solid rgba(232, 154, 31, 0.24);
|
|
border-radius: 6px;
|
|
display: flex;
|
|
gap: 10px;
|
|
}
|
|
.warn :deep(svg) { color: var(--warn); margin-top: 2px; flex-shrink: 0; }
|
|
.warn p { font-size: 12px; color: var(--text-dim); line-height: 1.55; margin: 0; }
|
|
</style>
|