feat(communication): federate tenant collaboration with signed delivery

This commit is contained in:
2026-09-30 14:48:05 +02:00
parent ea641bb4d0
commit 7b5696eda4
16 changed files with 914 additions and 14 deletions

View File

@@ -2,7 +2,7 @@
const { $api } = useNuxtApp()
const route = useRoute()
const state = ref({ rooms: [], invitations: [], managedRooms: [], projects: [], canManage: false, contact: '' })
const contacts = ref([]), selected = ref(''), error = ref(''), busy = ref(false), creating = ref(false), showCreate = ref(false), showManagement = ref(false)
const contacts = ref([]), selected = ref(''), error = ref(''), busy = ref(false), showCreate = ref(false), showManagement = ref(false)
const management = ref({ members: [], invitations: [] }), inviteResult = ref(null)
const create = reactive({ name: '', topic: '', projectId: String(route.query.project || '') })
const invite = reactive({ kind: 'user', userId: '', name: '', email: '', contact: '', role: 'participant', overviewAllowed: false, expiresAt: '', sendEmail: true })
@@ -92,7 +92,8 @@ onBeforeUnmount(() => clearInterval(timer))
<section v-if="federation" class="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
<div class="flex justify-between"><h2 class="font-semibold">Freigegebene Partnerinstanzen</h2><button @click="federation = null">Schließen</button></div>
<p class="my-2 text-sm text-gray-500">Beide Firmen geben die jeweils andere Instanz frei. Einzelne Personen erhalten anschließend eine eigene Einladung.</p>
<form class="flex flex-wrap gap-2" @submit.prevent="savePartner(partnerUrl)"><label class="flex-1"><span class="sr-only">Adresse der Partnerinstanz</span><input v-model="partnerUrl" type="url" required placeholder="https://partner.example/backend" class="field" /></label><UButton type="submit" :loading="busy">Partner freigeben</UButton></form>
<p v-if="!federation.enabled" class="my-2 text-sm text-amber-700">Die Föderation muss zuerst vom Betreiber aktiviert werden.</p>
<form v-if="federation.enabled" class="flex flex-wrap gap-2" @submit.prevent="savePartner(partnerUrl)"><label class="flex-1"><span class="sr-only">Adresse der Partnerinstanz</span><input v-model="partnerUrl" type="url" required placeholder="https://partner.example/backend" class="field" /></label><UButton type="submit" :loading="busy">Partner freigeben</UButton></form>
<div v-for="partner in federation.partners" :key="partner.origin" class="mt-3 flex items-center justify-between gap-3 text-sm"><span>{{ partner.origin }} · {{ partner.active ? 'Freigegeben' : 'Gesperrt' }}</span><UButton variant="outline" size="xs" :disabled="busy" @click="savePartner(partner.origin, !partner.active)">{{ partner.active ? 'Sperren' : 'Freigeben' }}</UButton></div>
</section>
<form v-if="showCreate" class="grid gap-3 rounded-xl border border-gray-200 p-4 sm:grid-cols-2 dark:border-gray-700" @submit.prevent="createRoom">
@@ -144,7 +145,7 @@ onBeforeUnmount(() => clearInterval(timer))
</form>
</div>
</div>
<div v-if="management.invitations.some(i => i.status === 'pending')" class="space-y-2"><h3 class="font-semibold">Offene Einladungen</h3><div v-for="item in management.invitations.filter(i => i.status === 'pending')" :key="item.id" class="flex items-center justify-between gap-2 text-sm"><span>{{ item.name }} · bis {{ new Date(item.expiresAt).toLocaleDateString('de-DE') }}</span><UButton variant="outline" size="xs" :disabled="busy" @click="revoke(item.id, true)">Widerrufen</UButton></div></div>
<div v-if="management.invitations.some(i => i.status === 'pending')" class="space-y-2"><h3 class="font-semibold">Offene Einladungen</h3><div v-for="item in management.invitations.filter(i => i.status === 'pending')" :key="item.id" class="flex items-center justify-between gap-2 text-sm"><span>{{ item.name }} · bis {{ new Date(item.expiresAt).toLocaleDateString('de-DE') }}<span v-if="item.delivery" class="block text-xs">{{ item.delivery.status === 'sent' ? 'Bei Partnerinstanz zugestellt' : item.delivery.status === 'failed' ? 'Zustellung fehlgeschlagen' : 'Wartet auf Zustellung' }}</span></span><UButton v-if="item.delivery?.status === 'failed'" variant="outline" size="xs" :disabled="busy" @click="run(async () => { await $api(`/api/collaboration/rooms/${selected}/invitations/${item.id}/retry`, { method: 'POST' }); await loadManagement() })">Erneut zustellen</UButton><UButton variant="outline" size="xs" :disabled="busy" @click="revoke(item.id, true)">Widerrufen</UButton></div></div>
<form class="space-y-3 border-t border-gray-200 pt-4 dark:border-gray-700" @submit.prevent="saveRoom">
<h3 class="font-semibold">Unterraum und freigegebene Informationen</h3>
<label class="block text-sm">Raumname<input v-model="details.name" required maxlength="160" class="field" /></label>

View File

@@ -8,6 +8,7 @@ const api = async (path, options = {}) => $fetch(path, {
baseURL: config.public.apiBase, credentials: 'omit', ...options,
headers: { ...options.headers, ...(token.value ? { Authorization: `Bearer ${token.value}` } : {}) }
})
const loginUrl = computed(() => `${import.meta.client ? window.location.origin : ''}/guest?workspace=${workspaceId.value}`)
const message = e => e?.data?.message || e?.data?.error || 'Die Aktion konnte nicht ausgeführt werden.'
function expire() { token.value = ''; rooms.value = []; selected.value = ''; invitation.value = null; sessionStorage.removeItem('fedeo-guest-session') }
async function run(fn) { busy.value = true; error.value = ''; try { await fn() } catch (e) { error.value = message(e) } finally { busy.value = false } }
@@ -38,6 +39,7 @@ async function accept() {
await run(async () => {
const result = await api(`/guest/api/invitations/${invitation.value.id}/accept`, { method: 'POST', body: { token: invitationToken.value } })
invitation.value = null; invitationToken.value = ''; sessionStorage.removeItem('fedeo-guest-invite')
history.replaceState(null, '', `/guest?workspace=${workspaceId.value}`)
await refresh(); selected.value = result.roomId
})
}
@@ -47,8 +49,8 @@ onMounted(async () => {
const fragment = new URLSearchParams(window.location.hash.slice(1))
invitationToken.value = fragment.get('invite') || sessionStorage.getItem('fedeo-guest-invite') || ''
if (invitationToken.value) sessionStorage.setItem('fedeo-guest-invite', invitationToken.value)
history.replaceState(null, '', window.location.pathname)
workspaceId.value = localStorage.getItem('fedeo-guest-workspace') || ''
workspaceId.value = new URLSearchParams(window.location.search).get('workspace') || localStorage.getItem('fedeo-guest-workspace') || ''
history.replaceState(null, '', workspaceId.value ? `/guest?workspace=${encodeURIComponent(workspaceId.value)}` : '/guest')
token.value = sessionStorage.getItem('fedeo-guest-session') || ''
if (token.value) {
await run(async () => {
@@ -84,6 +86,7 @@ onBeforeUnmount(() => clearInterval(timer))
<p class="text-sm">Dein Zugriff gilt für diesen Unterraum. Du siehst die dort eingeladenen Teilnehmer und Nachrichten ab deinem Beitritt.</p>
<UButton :loading="busy" @click="accept">Einladung annehmen</UButton>
</section>
<p v-if="token && workspaceId" class="text-sm text-gray-500">Für spätere Anmeldungen kannst du <a :href="loginUrl" class="text-green-700 underline">diese Gastanmeldung als Lesezeichen speichern</a>.</p>
<template v-if="token"><nav v-if="rooms.length" aria-label="Freigegebene Unterräume" class="flex flex-wrap gap-2"><UButton v-for="room in rooms" :key="room.id" :variant="selected === room.id ? 'solid' : 'outline'" @click="selected = room.id">{{ room.name }}{{ room.unread ? ` (${room.unread})` : '' }}</UButton></nav><p v-else-if="!invitation" class="rounded-xl border border-gray-200 p-6 text-gray-500">Derzeit sind keine Unterräume für dich freigegeben.</p><CollaborationRoom v-if="selected" :key="selected" :room-id="selected" :api="api" base="/guest/api" @changed="refresh" @unauthorized="expire" /></template>
</main>
</template>