feat(communication): federate tenant collaboration with signed delivery
This commit is contained in:
@@ -7,9 +7,14 @@ let generation = 0, timer, polling = false, pending = null
|
||||
const endpoint = (id, suffix = '') => `${props.base}/rooms/${encodeURIComponent(id)}${suffix}`
|
||||
const merge = (incoming) => {
|
||||
const byId = new Map(messages.value.map(m => [m.id, m]))
|
||||
incoming.forEach(m => byId.set(m.id, m))
|
||||
messages.value = [...byId.values()].sort((a, b) => a.seq - b.seq)
|
||||
incoming.forEach(m => {
|
||||
if (m.own && m.seq) for (const [id, old] of byId) if (old.own && old.clientId === m.clientId && !old.seq) byId.delete(id)
|
||||
if (!m.seq && [...byId.values()].some(old => old.own && old.seq && old.clientId === m.clientId)) return
|
||||
byId.set(m.id, m)
|
||||
})
|
||||
messages.value = [...byId.values()].sort((a, b) => (a.seq || Number.MAX_SAFE_INTEGER) - (b.seq || Number.MAX_SAFE_INTEGER))
|
||||
}
|
||||
const lastSequence = () => Math.max(0, ...messages.value.map(m => m.seq || 0))
|
||||
const messageError = e => e?.data?.message || e?.data?.error || 'Unterraum konnte nicht geladen werden.'
|
||||
const scroll = async () => { await nextTick(); if (viewport.value) viewport.value.scrollTop = viewport.value.scrollHeight }
|
||||
function failed(e) {
|
||||
@@ -21,7 +26,8 @@ function failed(e) {
|
||||
}
|
||||
async function read(id, version) {
|
||||
if (version !== generation || document.hidden || !messages.value.length) return
|
||||
await props.api(endpoint(id, '/read'), { method: 'POST', body: { seq: messages.value.at(-1).seq } })
|
||||
if (!lastSequence()) return
|
||||
await props.api(endpoint(id, '/read'), { method: 'POST', body: { seq: lastSequence() } })
|
||||
if (version === generation) emit('changed')
|
||||
}
|
||||
async function load() {
|
||||
@@ -41,10 +47,12 @@ async function poll() {
|
||||
const version = generation, id = props.roomId
|
||||
polling = true
|
||||
try {
|
||||
const after = messages.value.at(-1)?.seq || 0
|
||||
const after = lastSequence()
|
||||
const [detail, result] = await Promise.all([props.api(endpoint(id)), props.api(endpoint(id, `/messages?after=${after}`))])
|
||||
if (version !== generation) return
|
||||
const rightsChanged = room.value?.revision !== detail.revision
|
||||
room.value = detail
|
||||
messages.value = messages.value.filter(m => m.seq ? m.seq >= detail.historyFromSeq : !rightsChanged)
|
||||
const nearBottom = !viewport.value || viewport.value.scrollHeight - viewport.value.scrollTop - viewport.value.clientHeight < 90
|
||||
if (result.messages.length) {
|
||||
merge(result.messages)
|
||||
@@ -94,6 +102,7 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
|
||||
<p v-if="members.length" class="mt-2 text-xs text-gray-500">{{ members.map(m => `${m.name}${m.kind === 'guest' ? ' (Gast)' : ''}`).join(' · ') }}</p>
|
||||
</header>
|
||||
<p v-if="error" role="alert" class="m-4 rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }} <button class="ml-2 underline" @click="load">Erneut laden</button></p>
|
||||
<p v-if="room?.offline" role="status" class="mx-4 mt-4 rounded-lg bg-amber-50 p-3 text-sm text-amber-900">Die Partnerinstanz ist gerade nicht erreichbar. Du siehst bereits geladene Nachrichten. Neue Nachrichten warten auf Zustellung.</p>
|
||||
<div v-if="room?.overview && Object.keys(room.overview).length" class="m-4 rounded-lg bg-green-50 p-3 text-sm text-gray-800">
|
||||
<h3 class="mb-1 font-semibold">Freigegebene Projektinformationen</h3>
|
||||
<p v-if="room.overview.title">{{ room.overview.title }}</p>
|
||||
@@ -109,7 +118,7 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
|
||||
<div class="max-w-[85%] rounded-xl px-4 py-2" :class="message.own ? 'bg-green-700 text-white' : 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100'">
|
||||
<p class="text-xs font-semibold">{{ message.own ? 'Du' : message.senderName }}</p>
|
||||
<p class="whitespace-pre-wrap break-words text-sm">{{ message.body }}</p>
|
||||
<p class="mt-1 text-right text-[10px] opacity-70">{{ time(message.createdAt) }}</p>
|
||||
<p class="mt-1 text-right text-[10px] opacity-70">{{ time(message.createdAt) }}{{ message.pending ? ' · Wartet auf Zustellung' : message.failed ? ' · Zustellung fehlgeschlagen' : ' · Bestätigt' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user