Stack & Setup
- Dockerisierte Dev-Umgebung (PHP 8.4-FPM, Nginx, MariaDB 11.4)
- Symfony 7.4 + API Platform 4.3, Doctrine ORM, LexikJWT, Messenger
- Vue 3 + TS (Vite), Vue Router, Pinia, Axios
Kern-Domäne & Auth
- Entitäten: User, PlatformPlan, Reseller, Company, Domain, Location,
Employee, ContactLink (UUIDv7)
- JWT-Login (/api/login), Rollen-Hierarchie, /api/me
- Mandantentrennung via API-Platform-Query-Extension (Lesen) +
TenantStampProcessor (Schreiben)
Öffentliche Profile (SSR)
- Profil-Landingpage, vCard-Download, QR-Code im Marken-Look
- Stabiler NFC/QR-Kurz-Link /t/{code} -> Redirect aufs aktuelle Profil
- Firmenspezifisches Branding (Farben/Logo) auf der Profilseite
Verwaltungsoberfläche (SPA)
- Brand-Look (dunkle Sidebar), rollenbasierte Navigation
- Dashboard, Reseller (+Provisioning), Firmen, Mitarbeiter, Standorte,
Domains, Design/Branding mit Live-Vorschau
Konzept & Doku: docs/KONZEPT.md (inkl. Wallet/Sync §12), README.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
119 lines
5.6 KiB
Vue
119 lines
5.6 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { list, create, remove } from '@/api/resources'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import Modal from '@/components/Modal.vue'
|
|
|
|
interface Domain {
|
|
'@id': string; id: string; hostname: string; type: string; status: string; tlsStatus: string; company: string
|
|
}
|
|
interface Company { '@id': string; name: string }
|
|
|
|
const auth = useAuthStore()
|
|
// Beispiel-IP für Custom-Domains (A-Record). In Prod aus Config.
|
|
const PLATFORM_IP = '203.0.113.10'
|
|
|
|
const domains = ref<Domain[]>([])
|
|
const companies = ref<Company[]>([])
|
|
const loading = ref(true)
|
|
const showForm = ref(false)
|
|
const saving = ref(false)
|
|
const error = ref('')
|
|
const blank = () => ({ hostname: '', type: 'custom', company: '' })
|
|
const form = ref(blank())
|
|
|
|
const ownCompanyIri = computed(() =>
|
|
auth.user?.company ? `/api/companies/${auth.user.company.id}` : companies.value[0]?.['@id'] ?? '',
|
|
)
|
|
const companyMap = computed(() => Object.fromEntries(companies.value.map((c) => [c['@id'], c.name])))
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
;[companies.value, domains.value] = await Promise.all([
|
|
list<Company>('companies').then((r) => r.member).catch(() => []),
|
|
list<Domain>('domains').then((r) => r.member),
|
|
])
|
|
loading.value = false
|
|
}
|
|
function openCreate() { form.value = blank(); form.value.company = ownCompanyIri.value; error.value = ''; showForm.value = true }
|
|
async function submit() {
|
|
saving.value = true; error.value = ''
|
|
try {
|
|
await create('domains', { hostname: form.value.hostname, type: form.value.type, company: form.value.company || ownCompanyIri.value })
|
|
showForm.value = false; await load()
|
|
} catch { error.value = 'Speichern fehlgeschlagen (Hostname evtl. schon vergeben).' } finally { saving.value = false }
|
|
}
|
|
async function del(d: Domain) { if (confirm(`Domain „${d.hostname}" löschen?`)) { await remove(d['@id']); await load() } }
|
|
function statusClass(s: string) { return s === 'verified' ? 'badge-active' : 'badge-inactive' }
|
|
onMounted(load)
|
|
</script>
|
|
|
|
<template>
|
|
<section>
|
|
<div class="page-head">
|
|
<div><h1>Domains</h1><p class="muted">Subdomains & eigene Domains der Firmenkunden</p></div>
|
|
<button class="btn btn-primary" @click="openCreate">+ Domain hinzufügen</button>
|
|
</div>
|
|
<div class="card">
|
|
<table class="tbl">
|
|
<thead><tr><th>Hostname</th><th>Typ</th><th>Status</th><th>TLS</th><th>Firma</th><th></th></tr></thead>
|
|
<tbody>
|
|
<tr v-if="loading"><td colspan="6" class="empty">Lädt…</td></tr>
|
|
<tr v-else-if="!domains.length"><td colspan="6" class="empty">Noch keine Domains.</td></tr>
|
|
<tr v-for="d in domains" :key="d.id">
|
|
<td><strong>{{ d.hostname }}</strong></td>
|
|
<td class="muted">{{ d.type === 'custom' ? 'Eigene' : 'Subdomain' }}</td>
|
|
<td><span class="badge" :class="statusClass(d.status)">{{ d.status }}</span></td>
|
|
<td class="muted">{{ d.tlsStatus }}</td>
|
|
<td class="muted">{{ companyMap[d.company] ?? '' }}</td>
|
|
<td class="right"><button class="btn btn-ghost btn-sm" @click="del(d)">Löschen</button></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<Modal v-if="showForm" title="Domain hinzufügen" @close="showForm = false">
|
|
<form @submit.prevent="submit">
|
|
<div class="field">
|
|
<label>Typ</label>
|
|
<select class="input" v-model="form.type">
|
|
<option value="custom">Eigene Domain</option>
|
|
<option value="subdomain">Subdomain</option>
|
|
</select>
|
|
</div>
|
|
<div class="field"><label>Hostname</label><input class="input" v-model="form.hostname" required placeholder="visitenkarte.firma.de" /></div>
|
|
<div v-if="form.type === 'custom'" class="hint">
|
|
Nach dem Anlegen einen <strong>A-Record</strong> auf <code>{{ PLATFORM_IP }}</code> setzen.
|
|
Die Plattform prüft die Domain und stellt automatisch ein TLS-Zertifikat aus.
|
|
</div>
|
|
<div class="field" v-if="auth.isResellerAdmin || auth.isPlatformAdmin">
|
|
<label>Firma</label>
|
|
<select class="input" v-model="form.company">
|
|
<option v-for="c in companies" :key="c['@id']" :value="c['@id']">{{ c.name }}</option>
|
|
</select>
|
|
</div>
|
|
<p v-if="error" class="error">{{ error }}</p>
|
|
<div class="actions">
|
|
<button type="button" class="btn btn-ghost" @click="showForm = false">Abbrechen</button>
|
|
<button class="btn btn-primary" :disabled="saving">{{ saving ? 'Speichern…' : 'Speichern' }}</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.4rem; }
|
|
.page-head .muted { margin: .2rem 0 0; }
|
|
.tbl { width: 100%; border-collapse: collapse; }
|
|
.tbl th { text-align: left; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); padding: .9rem 1.2rem; border-bottom: 1px solid var(--line); }
|
|
.tbl td { padding: .9rem 1.2rem; border-bottom: 1px solid #f4f4f4; }
|
|
.tbl tr:last-child td { border-bottom: none; }
|
|
.right { text-align: right; }
|
|
.empty { text-align: center; color: var(--muted); padding: 2rem; }
|
|
.hint { background: var(--psc-orange-soft-2); border: 1px solid var(--psc-border); border-radius: var(--radius-sm); padding: .8rem .9rem; font-size: .85rem; margin-bottom: .9rem; }
|
|
.hint code { background: #fff; padding: 1px 6px; border-radius: 5px; }
|
|
.actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1rem; }
|
|
.error { color: var(--danger); font-size: .88rem; }
|
|
</style>
|