89691626f4
Backend (platform-api): computed tenant health plus industry/brandColor; partner-scoped tenant update/suspend/resume guarded by assertPartnerOwnsTenant; enriched partner users (MFA + access level) with invite/remove; partner settings and whitelabel branding persistence; Authentik authenticator counting and group removal. Audit on every mutation. Frontend (portal): all five partner pages on real data — dashboard alerts, customers edit/suspend, team MFA/access with invite/remove, editable settings, branding fetch/save. Operator: dashboard and infrastructure service health driven by real liveness probes; fabricated uptime/p95/error-rate removed.
232 lines
6.7 KiB
Vue
232 lines
6.7 KiB
Vue
<script setup lang="ts">
|
|
// Edit modal for the partner's own brand identity. Includes a small live
|
|
// preview of how the partner topbar/header will look with the picked
|
|
// primary color + display name.
|
|
|
|
export interface BrandIdentity {
|
|
displayName?: string
|
|
logoUrl?: string
|
|
markUrl?: string
|
|
faviconUrl?: string
|
|
primaryColor?: string
|
|
supportEmail?: string
|
|
supportPhone?: string
|
|
website?: string
|
|
replyTo?: string
|
|
}
|
|
|
|
const props = defineProps<{ open: boolean; identity?: BrandIdentity }>()
|
|
const emit = defineEmits<{ close: []; save: [payload: BrandIdentity] }>()
|
|
|
|
const name = ref('')
|
|
const color = ref('#3F6BFF')
|
|
const supportEmail = ref('')
|
|
const supportPhone = ref('')
|
|
const website = ref('')
|
|
const replyTo = ref('')
|
|
|
|
// Seed the form from the current identity each time the modal opens.
|
|
function seed() {
|
|
const i = props.identity ?? {}
|
|
name.value = i.displayName ?? ''
|
|
color.value = i.primaryColor ?? '#3F6BFF'
|
|
supportEmail.value = i.supportEmail ?? ''
|
|
supportPhone.value = i.supportPhone ?? ''
|
|
website.value = i.website ?? ''
|
|
replyTo.value = i.replyTo ?? ''
|
|
}
|
|
watch(() => props.open, (o) => { if (o) seed() }, { immediate: true })
|
|
|
|
const SWATCHES = ['#3F6BFF', '#0A2540', '#0066CC', '#5B8C5A', '#D4FF3A']
|
|
|
|
function save() {
|
|
// Emit only `save` — the parent closes the modal after the async save
|
|
// succeeds, so a failed save keeps the form open instead of losing edits.
|
|
emit('save', {
|
|
displayName: name.value,
|
|
primaryColor: color.value,
|
|
supportEmail: supportEmail.value,
|
|
supportPhone: supportPhone.value,
|
|
website: website.value,
|
|
replyTo: replyTo.value,
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Modal
|
|
:open="open"
|
|
eyebrow="Partner · identity"
|
|
title="Edit NordicMSP identity"
|
|
size="md"
|
|
@close="emit('close')"
|
|
>
|
|
<div class="form">
|
|
<div class="info">
|
|
<UiIcon name="shield" :size="14" />
|
|
<p>
|
|
This identity appears in the partner console and on emails sent by your team. It is
|
|
<b>not</b> what your customers see — they see their own branding (or the defaults you set below).
|
|
</p>
|
|
</div>
|
|
|
|
<label class="field">
|
|
<Eyebrow>Display name</Eyebrow>
|
|
<input v-model="name" />
|
|
</label>
|
|
|
|
<div>
|
|
<Eyebrow>Logo & mark</Eyebrow>
|
|
<div class="upload-grid">
|
|
<div class="upload-row">
|
|
<div class="upload-pv" :style="{ background: color }">{{ name[0]?.toLowerCase() || 'n' }}</div>
|
|
<div class="upload-meta">
|
|
<div class="upload-l">Full logo</div>
|
|
<Mono dim>nordic-logo.svg · 24 KB</Mono>
|
|
</div>
|
|
<UiButton size="sm" variant="ghost">Replace</UiButton>
|
|
</div>
|
|
<div class="upload-row">
|
|
<div class="upload-pv" :style="{ background: color }">{{ name[0]?.toLowerCase() || 'n' }}</div>
|
|
<div class="upload-meta">
|
|
<div class="upload-l">Square mark</div>
|
|
<Mono dim>nordic-mark.svg · 8 KB</Mono>
|
|
</div>
|
|
<UiButton size="sm" variant="ghost">Replace</UiButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<Eyebrow>Primary color</Eyebrow>
|
|
<div class="color-row">
|
|
<div class="swatches">
|
|
<button
|
|
v-for="c in SWATCHES"
|
|
:key="c"
|
|
type="button"
|
|
class="sw"
|
|
:class="{ selected: color === c }"
|
|
:style="{ background: c }"
|
|
@click="color = c"
|
|
/>
|
|
</div>
|
|
<input v-model="color" class="hex" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row-2">
|
|
<label class="field"><Eyebrow>Support email</Eyebrow><input v-model="supportEmail" /></label>
|
|
<label class="field"><Eyebrow>Support phone</Eyebrow><input v-model="supportPhone" /></label>
|
|
<label class="field"><Eyebrow>Website</Eyebrow><input v-model="website" /></label>
|
|
<label class="field"><Eyebrow>Reply-to address</Eyebrow><input v-model="replyTo" /></label>
|
|
</div>
|
|
|
|
<div class="preview">
|
|
<div class="pv-mark" :style="{ background: color }">{{ name[0]?.toLowerCase() || 'n' }}</div>
|
|
<div class="pv-meta">
|
|
<div class="pv-name">{{ name }}</div>
|
|
<Mono dim>preview · partner console header + email signature</Mono>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
|
|
<UiButton variant="primary" @click="save">
|
|
<template #leading><UiIcon name="check" :size="14" /></template>
|
|
Save identity
|
|
</UiButton>
|
|
</template>
|
|
</Modal>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.form { display: flex; flex-direction: column; gap: 16px; }
|
|
|
|
.info {
|
|
display: flex;
|
|
gap: 10px;
|
|
padding: 12px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
}
|
|
.info p { font-size: 12px; color: var(--text-dim); margin: 0; line-height: 1.55; }
|
|
.info :deep(svg) { color: var(--text-mute); margin-top: 2px; flex-shrink: 0; }
|
|
|
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
|
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
|
|
.field input, .hex {
|
|
padding: 9px 12px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
color: var(--text);
|
|
}
|
|
.field input:focus, .hex:focus { outline: none; border-color: var(--border-hi); }
|
|
|
|
.upload-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; }
|
|
.upload-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 12px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
}
|
|
.upload-pv {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 6px;
|
|
color: #fff;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-family: var(--font-mono);
|
|
font-weight: 700;
|
|
font-size: 16px;
|
|
}
|
|
.upload-meta { flex: 1; min-width: 0; }
|
|
.upload-l { font-size: 13px; font-weight: 500; }
|
|
|
|
.color-row { display: flex; align-items: center; gap: 10px; }
|
|
.swatches { display: flex; gap: 8px; }
|
|
.sw {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border);
|
|
cursor: pointer;
|
|
}
|
|
.sw.selected { border: 2px solid var(--text); }
|
|
.hex { flex: 1; font-family: var(--font-mono); }
|
|
|
|
.preview {
|
|
padding: 12px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
.pv-mark {
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 6px;
|
|
color: #fff;
|
|
font-family: var(--font-mono);
|
|
font-weight: 700;
|
|
font-size: 13px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.pv-name { font-size: 13px; font-weight: 500; }
|
|
</style>
|