feat(communication): add project subrooms and isolated guest access

This commit is contained in:
2026-09-30 14:23:03 +02:00
parent 21fe4723e3
commit ea641bb4d0
19 changed files with 1454 additions and 2 deletions

View File

@@ -0,0 +1,121 @@
<script setup>
const props = defineProps({ roomId: { type: String, required: true }, api: { type: Function, required: true }, base: { type: String, default: '/api/collaboration' } })
const emit = defineEmits(['changed', 'unauthorized'])
const room = ref(null), messages = ref([]), members = ref([]), draft = ref(''), error = ref('')
const loading = ref(false), sending = ref(false), olderLoading = ref(false), hasMore = ref(false), viewport = ref(null)
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)
}
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) {
error.value = messageError(e)
if ([401, 403, 404, 410].includes(e?.statusCode || e?.status)) {
room.value = null; messages.value = []; members.value = []
if ((e?.statusCode || e?.status) === 401) emit('unauthorized')
}
}
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 (version === generation) emit('changed')
}
async function load() {
const version = ++generation, id = props.roomId
room.value = null; messages.value = []; members.value = []; draft.value = ''; error.value = ''; pending = null
loading.value = true
try {
const [detail, result, people] = await Promise.all([props.api(endpoint(id)), props.api(endpoint(id, '/messages')), props.api(endpoint(id, '/members'))])
if (version !== generation) return
room.value = detail; members.value = people.members; merge(result.messages); hasMore.value = result.hasMore
await scroll(); await read(id, version)
} catch (e) { if (version === generation) failed(e) }
finally { if (version === generation) loading.value = false }
}
async function poll() {
if (polling || loading.value || !room.value || document.hidden) return
const version = generation, id = props.roomId
polling = true
try {
const after = messages.value.at(-1)?.seq || 0
const [detail, result] = await Promise.all([props.api(endpoint(id)), props.api(endpoint(id, `/messages?after=${after}`))])
if (version !== generation) return
room.value = detail
const nearBottom = !viewport.value || viewport.value.scrollHeight - viewport.value.scrollTop - viewport.value.clientHeight < 90
if (result.messages.length) {
merge(result.messages)
if (nearBottom) { await scroll(); await read(id, version) }
}
error.value = ''
} catch (e) { if (version === generation) failed(e) }
finally { polling = false }
}
async function older() {
if (!messages.value.length || olderLoading.value) return
const version = generation, id = props.roomId, height = viewport.value?.scrollHeight || 0
olderLoading.value = true
try {
const result = await props.api(endpoint(id, `/messages?before=${messages.value[0].seq}`))
if (version !== generation) return
merge(result.messages); hasMore.value = result.hasMore
await nextTick(); if (viewport.value) viewport.value.scrollTop += viewport.value.scrollHeight - height
} catch (e) { if (version === generation) failed(e) }
finally { olderLoading.value = false }
}
async function send() {
const text = draft.value.trim()
if (!text || sending.value || !room.value?.canSend) return
const version = generation, id = props.roomId
if (!pending || pending.text !== text) pending = { text, clientId: crypto.randomUUID() }
sending.value = true; error.value = ''
try {
const message = await props.api(endpoint(id, '/messages'), { method: 'POST', body: pending })
if (version !== generation) return
merge([message]); draft.value = ''; pending = null
await scroll(); await read(id, version)
} catch (e) { if (version === generation) failed(e) }
finally { sending.value = false }
}
const time = value => new Date(value).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
watch(() => props.roomId, load, { immediate: true })
onMounted(() => { timer = setInterval(poll, 3000) })
onBeforeUnmount(() => { ++generation; clearInterval(timer) })
</script>
<template>
<section class="flex min-h-[32rem] min-w-0 flex-col rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900">
<header class="border-b border-gray-200 p-4 dark:border-gray-700">
<h2 class="text-lg font-semibold">{{ room?.name || 'Unterraum' }}</h2>
<p v-if="room?.topic" class="mt-1 text-sm text-gray-500">{{ room.topic }}</p>
<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>
<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>
<p v-if="room.overview.status">Status: {{ room.overview.status }}</p>
<p v-if="room.overview.date">Termin: {{ room.overview.date }}</p>
<p v-if="room.overview.description" class="mt-1 whitespace-pre-wrap">{{ room.overview.description }}</p>
</div>
<div ref="viewport" class="max-h-[60vh] min-h-[18rem] flex-1 space-y-3 overflow-y-auto p-4" aria-live="polite">
<p v-if="loading" class="text-sm text-gray-500">Nachrichten werden geladen …</p>
<button v-if="hasMore" class="block w-full text-center text-sm text-green-700 underline" :disabled="olderLoading" @click="older">{{ olderLoading ? 'Wird geladen …' : 'Ältere Nachrichten laden' }}</button>
<p v-if="room && !loading && !messages.length" class="py-12 text-center text-sm text-gray-500">Hier beginnt eure Unterhaltung. Du siehst Nachrichten ab deinem Beitritt.</p>
<div v-for="message in messages" :key="message.id" class="flex" :class="message.own ? 'justify-end' : 'justify-start'">
<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>
</div>
</div>
</div>
<form v-if="room" class="flex items-end gap-3 border-t border-gray-200 p-4 dark:border-gray-700" @submit.prevent="send">
<label class="min-w-0 flex-1"><span class="sr-only">Nachricht</span><textarea v-model="draft" rows="2" maxlength="10000" :disabled="!room.canSend" :placeholder="room.canSend ? 'Nachricht schreiben …' : 'Dieser Unterraum ist nur lesbar'" class="w-full resize-none rounded-lg border border-gray-300 p-2 text-sm dark:border-gray-600" @keydown.enter.exact.prevent="send" /></label>
<UButton type="submit" :loading="sending" :disabled="!room.canSend || !draft.trim()">Senden</UButton>
</form>
</section>
</template>

View File

@@ -193,6 +193,7 @@ const invitePortalUser = async () => {
>{{item ? `${dataType.labelSingle}${props.item[dataType.templateColumns.find(i => i.title).key] ? ': ' + props.item[dataType.templateColumns.find(i => i.title).key] : ''}`: '' }}</h1>
</template>
<template #right>
<UButton v-if="type === 'projects'" icon="i-heroicons-user-group" variant="outline" :to="`/communication/collaboration?project=${item.id}`">Zusammenarbeit</UButton>
<UButton
@click="modal.close()"
color="red"

View File

@@ -80,6 +80,11 @@ const links = computed(() => {
]
const communicationChildren = [
{
label: "Zusammenarbeit",
to: "/communication/collaboration",
icon: "i-heroicons-user-group"
},
{
label: "Chat",
to: "/communication/chat",

View File

@@ -1,4 +1,6 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
// Guest sessions are handled only by the dedicated collaboration guest API.
if (to.path === '/guest') return
const auth = useAuthStore()
// DEBUG: Was sieht die Middleware wirklich?

View File

@@ -0,0 +1,168 @@
<script setup>
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 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 })
const details = reactive({ name: '', topic: '', status: 'active', overview: { title: '', status: '', date: '', description: '' } })
const federation = ref(null), partnerUrl = ref('')
const selectedManaged = computed(() => state.value.managedRooms.find(r => r.id === selected.value))
const visibleRooms = computed(() => {
const rooms = new Map(state.value.rooms.map(r => [r.id, r]))
state.value.managedRooms.filter(r => !route.query.project || Number(r.projectId) === Number(route.query.project)).forEach(r => rooms.set(r.id, { ...rooms.get(r.id), ...r }))
return [...rooms.values()]
})
const readable = computed(() => state.value.rooms.some(r => r.id === selected.value))
const message = e => e?.data?.message || e?.data?.error || 'Die Aktion konnte nicht ausgeführt werden.'
async function refresh() {
try {
state.value = await $api('/api/collaboration')
if (!selected.value && visibleRooms.value.length) selected.value = visibleRooms.value[0].id
} catch (e) { error.value = message(e) }
}
async function run(fn) {
busy.value = true; error.value = ''
try { await fn() } catch (e) { error.value = message(e) } finally { busy.value = false }
}
async function createRoom() {
await run(async () => {
const room = await $api('/api/collaboration/rooms', { method: 'POST', body: { ...create, projectId: create.projectId ? Number(create.projectId) : null } })
await refresh(); selected.value = room.id; showCreate.value = false; create.name = ''; create.topic = ''
})
}
async function loadManagement() {
if (!selectedManaged.value) return
const roomId = selected.value
const [result, people] = await Promise.all([$api(`/api/collaboration/rooms/${roomId}/management`), $api('/api/collaboration/contacts')])
if (roomId !== selected.value) return
management.value = result; contacts.value = people.contacts
Object.assign(details, { name: selectedManaged.value.name, topic: selectedManaged.value.topic, status: selectedManaged.value.status, overview: { title: '', status: '', date: '', description: '', ...selectedManaged.value.overview } })
}
async function invitePerson() {
await run(async () => {
inviteResult.value = await $api(`/api/collaboration/rooms/${selected.value}/invitations`, { method: 'POST', body: { ...invite, expiresAt: invite.expiresAt ? new Date(`${invite.expiresAt}T23:59:59`).toISOString() : null } })
await loadManagement(); await refresh()
})
}
async function respond(id, accept) {
await run(async () => {
const result = await $api(`/api/collaboration/invitations/${id}/${accept ? 'accept' : 'decline'}`, { method: 'POST' })
await refresh(); if (accept && result.roomId) selected.value = result.roomId
})
}
async function revoke(id, invitation = false) {
await run(async () => {
await $api(`/api/collaboration/rooms/${selected.value}/${invitation ? 'invitations' : 'members'}/${id}`, { method: 'DELETE' })
await loadManagement(); await refresh()
})
}
async function saveRoom() {
await run(async () => {
await $api(`/api/collaboration/rooms/${selected.value}`, { method: 'PATCH', body: details })
await refresh(); await loadManagement()
})
}
async function copy(value) { await run(() => navigator.clipboard.writeText(value)) }
async function partners() { await run(async () => { federation.value = await $api('/api/collaboration/federation') }) }
async function savePartner(origin, active = true) {
await run(async () => {
federation.value = await $api('/api/collaboration/federation', { method: 'POST', body: { origin, active } })
partnerUrl.value = ''
})
}
watch(selected, () => { showManagement.value = false; inviteResult.value = null; management.value = { members: [], invitations: [] } })
let timer
onMounted(async () => { await refresh(); timer = setInterval(() => { if (!document.hidden && !busy.value) void refresh() }, 15000) })
onBeforeUnmount(() => clearInterval(timer))
</script>
<template>
<div class="w-full space-y-5 p-4 sm:p-6">
<header class="flex flex-wrap items-start justify-between gap-3">
<div><h1 class="text-2xl font-semibold">Zusammenarbeit</h1><p class="mt-1 text-sm text-gray-500">Gemeinsame Chats und geschützte Projekt-Unterräume</p></div>
<div class="flex flex-wrap gap-2">
<UButton v-if="state.contact" variant="outline" @click="copy(state.contact)">Meine Kontaktadresse kopieren</UButton>
<UButton v-if="state.canManage" variant="outline" @click="partners">Partnerinstanzen</UButton>
<UButton v-if="state.canManage" @click="showCreate = !showCreate">Unterraum erstellen</UButton>
</div>
</header>
<p v-if="error" role="alert" class="rounded-lg bg-red-50 p-3 text-red-700">{{ error }}</p>
<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>
<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">
<label class="text-sm">Projekt<select v-model="create.projectId" class="field"><option value="">Freier Partnerchat</option><option v-for="project in state.projects" :key="project.id" :value="String(project.id)">{{ project.name }}</option></select></label>
<label class="text-sm">Name des Unterraums<input v-model="create.name" required maxlength="160" class="field" placeholder="z. B. Abstimmung Gerüstbau" /></label>
<label class="text-sm sm:col-span-2">Beschreibung<input v-model="create.topic" maxlength="1000" class="field" /></label>
<p class="text-xs text-gray-500">Du nimmst als Ersteller am Chat teil. Weitere Personen werden ausdrücklich eingeladen.</p>
<div class="flex justify-end gap-2"><UButton variant="ghost" @click="showCreate = false">Abbrechen</UButton><UButton type="submit" :loading="busy">Erstellen</UButton></div>
</form>
<section v-if="state.invitations.length" class="space-y-2">
<h2 class="font-semibold">Einladungen</h2>
<div v-for="invitation in state.invitations" :key="invitation.id" class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-green-200 bg-green-50 p-3 text-gray-900">
<div><p class="font-medium">{{ invitation.name }}</p><p class="text-sm">{{ invitation.ownerName }} · {{ invitation.role === 'reader' ? 'Nur lesen' : 'Chatteilnahme' }}{{ invitation.overviewAllowed ? ' · Projektübersicht freigegeben' : '' }}</p></div>
<div class="flex gap-2"><UButton variant="outline" :disabled="busy" @click="respond(invitation.id, false)">Ablehnen</UButton><UButton :disabled="busy" @click="respond(invitation.id, true)">Annehmen</UButton></div>
</div>
</section>
<div class="grid items-start gap-5 lg:grid-cols-[16rem_minmax(0,1fr)]">
<aside class="space-y-2">
<h2 class="mb-3 font-semibold">Meine Unterräume</h2>
<p v-if="!visibleRooms.length" class="text-sm text-gray-500">Hier erscheinen deine Einladungen und freigegebenen Unterräume.</p>
<button v-for="item in visibleRooms" :key="item.id" class="w-full rounded-xl border p-3 text-left" :class="selected === item.id ? 'border-green-600 bg-green-50 text-gray-900' : 'border-gray-200 dark:border-gray-700'" @click="selected = item.id">
<span class="flex items-center justify-between gap-2"><span class="font-medium">{{ item.name }}</span><span v-if="item.unread" class="rounded-full bg-green-700 px-2 text-xs text-white">{{ item.unread }}</span></span>
<span class="mt-1 block text-xs text-gray-500">{{ item.ownerName || (item.projectId ? 'Projekt-Unterraum' : 'Partnerchat') }}{{ item.remote ? ' · Extern' : '' }}{{ item.status !== 'active' ? ` · ${item.status === 'blocked' ? 'Gesperrt' : 'Archiviert'}` : '' }}</span>
</button>
</aside>
<div class="min-w-0 space-y-4">
<UButton v-if="selectedManaged" variant="outline" :disabled="busy" @click="run(async () => { await loadManagement(); showManagement = !showManagement })">Teilnehmer und Freigaben verwalten</UButton>
<section v-if="showManagement && selectedManaged" class="space-y-5 rounded-xl border border-gray-200 p-4 dark:border-gray-700">
<form class="space-y-3" @submit.prevent="invitePerson">
<h2 class="font-semibold">Person in diesen Unterraum einladen</h2>
<label class="block text-sm">Teilnehmerart<select v-model="invite.kind" class="field"><option value="user">Eigene Mitarbeiter</option><option value="contact">FEDEO-Kontakt</option><option v-if="selectedManaged.projectId" value="guest">Gast ohne FEDEO-Konto</option></select></label>
<label v-if="invite.kind === 'user'" class="block text-sm">Person<select v-model="invite.userId" class="field" required><option value="">Bitte auswählen</option><option v-for="person in contacts" :key="person.id" :value="person.id">{{ person.name }}</option></select></label>
<template v-else-if="invite.kind === 'guest'"><label class="block text-sm">Name<input v-model="invite.name" required class="field" /></label><label class="block text-sm">E-Mail-Adresse<input v-model="invite.email" type="email" required class="field" /></label><label class="flex items-center gap-2 text-sm"><input v-model="invite.sendEmail" type="checkbox" /> Einladung per E-Mail senden</label></template>
<label v-else class="block text-sm">Persönliche FEDEO-Kontaktadresse<input v-model="invite.contact" type="url" required class="field" placeholder="Kontaktadresse der Person einfügen" /></label>
<div class="grid gap-3 sm:grid-cols-2"><label class="text-sm">Rechte<select v-model="invite.role" class="field"><option value="participant">Lesen und schreiben</option><option value="reader">Nur lesen</option></select></label><label class="text-sm">Zugang bis (optional)<input v-model="invite.expiresAt" type="date" class="field" /></label></div>
<label class="flex items-center gap-2 text-sm"><input v-model="invite.overviewAllowed" type="checkbox" /> Freigegebene Projektübersicht anzeigen</label>
<p class="text-xs text-gray-500">Die Einladung gilt nur für diesen Unterraum. Nachrichten sind ab dem Beitritt sichtbar. Gastzugänge gelten ohne gewähltes Ablaufdatum 30 Tage.</p>
<UButton type="submit" :loading="busy">Einladen</UButton>
</form>
<div v-if="inviteResult" role="status" class="space-y-2 rounded-lg bg-green-50 p-3 text-sm text-gray-900"><p>Einladung erstellt. {{ inviteResult.mailSent ? 'E-Mail wurde versendet.' : '' }}</p><template v-if="inviteResult.url"><p v-if="invite.sendEmail && !inviteResult.mailSent" class="text-amber-800">Die E-Mail konnte nicht versendet werden. Du kannst den Einladungslink kopieren.</p><UButton variant="outline" size="sm" @click="copy(inviteResult.url)">Einladungslink kopieren</UButton></template></div>
<div class="space-y-3"><h3 class="font-semibold">Teilnehmer</h3>
<div v-for="member in management.members" :key="member.id" class="space-y-2 rounded-lg border border-gray-200 p-3 text-sm dark:border-gray-700">
<p class="font-medium">{{ member.name }}{{ member.kind === 'guest' ? ' · Gast' : '' }}{{ member.status === 'revoked' ? ' · Entzogen' : '' }}</p>
<form v-if="member.status === 'active'" class="flex flex-wrap items-center gap-3" @submit.prevent="run(async () => { await $api(`/api/collaboration/rooms/${selected}/members/${member.id}`, { method: 'PATCH', body: { role: member.role, overviewAllowed: member.overviewAllowed } }); await loadManagement(); await refresh() })">
<label>Rolle<select v-model="member.role" class="field"><option value="participant">Lesen und schreiben</option><option value="reader">Nur lesen</option></select></label>
<label class="flex items-center gap-2"><input v-model="member.overviewAllowed" type="checkbox" /> Projektübersicht</label>
<UButton type="submit" variant="outline" size="xs" :disabled="busy">Rechte speichern</UButton>
<UButton color="error" variant="outline" size="xs" :disabled="busy" @click="revoke(member.id)">Zugang entziehen</UButton>
</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>
<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>
<label class="block text-sm">Beschreibung<input v-model="details.topic" maxlength="1000" class="field" /></label>
<label class="block text-sm">Status<select v-model="details.status" class="field"><option value="active">Aktiv</option><option value="archived">Archiviert – nur lesen</option><option value="blocked">Gesperrt – kein Zugriff</option></select></label>
<p class="text-xs text-gray-500">Die folgenden Angaben sehen ausschließlich Teilnehmer mit freigegebener Projektübersicht.</p>
<div class="grid gap-3 sm:grid-cols-2"><label class="text-sm">Externer Projekttitel<input v-model="details.overview.title" maxlength="160" class="field" /></label><label class="text-sm">Projektstatus<input v-model="details.overview.status" maxlength="160" class="field" /></label><label class="text-sm">Termin<input v-model="details.overview.date" maxlength="160" class="field" /></label></div>
<label class="block text-sm">Freigegebene Hinweise<textarea v-model="details.overview.description" maxlength="2000" rows="3" class="field" /></label>
<UButton type="submit" :loading="busy">Speichern</UButton>
</form>
</section>
<CollaborationRoom v-if="selected && readable" :key="selected" :room-id="selected" :api="$api" @changed="refresh" />
<p v-else-if="selected" class="rounded-xl border border-gray-200 p-6 text-sm text-gray-500 dark:border-gray-700">Du hast derzeit keinen Lesezugriff auf diesen Unterraum. Die Verwaltung ist davon unabhängig.</p>
</div>
</div>
</div>
</template>
<style scoped>
.field { display: block; width: 100%; margin-top: .25rem; border: 1px solid #9ca3af; border-radius: .5rem; padding: .55rem .7rem; background: transparent; }
</style>

89
frontend/pages/guest.vue Normal file
View File

@@ -0,0 +1,89 @@
<script setup>
definePageMeta({ layout: 'notLoggedIn' })
useHead({ title: 'FEDEO · Gastzugang', meta: [{ name: 'referrer', content: 'no-referrer' }, { name: 'robots', content: 'noindex,nofollow' }] })
const config = useRuntimeConfig()
const token = ref(''), invitationToken = ref(''), workspaceId = ref(''), address = ref(''), challengeId = ref(''), code = ref('')
const invitation = ref(null), rooms = ref([]), selected = ref(''), error = ref(''), busy = ref(false), sent = ref(false)
const api = async (path, options = {}) => $fetch(path, {
baseURL: config.public.apiBase, credentials: 'omit', ...options,
headers: { ...options.headers, ...(token.value ? { Authorization: `Bearer ${token.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 } }
async function refresh() {
if (!token.value) return
try {
rooms.value = (await api('/guest/api/rooms')).rooms
if (!rooms.value.some(r => r.id === selected.value)) selected.value = rooms.value[0]?.id || ''
} catch (e) { error.value = message(e); if ([401, 403].includes(e?.statusCode || e?.status)) expire() }
}
async function requestCode() {
await run(async () => {
const result = await api('/guest/api/auth/request', { method: 'POST', body: invitationToken.value ? { invitationToken: invitationToken.value } : { workspaceId: workspaceId.value, email: address.value } })
challengeId.value = result.challengeId; sent.value = true
})
}
async function verify() {
await run(async () => {
const result = await api('/guest/api/auth/verify', { method: 'POST', body: { challengeId: challengeId.value, code: code.value } })
token.value = result.token; workspaceId.value = result.workspaceId; code.value = ''
sessionStorage.setItem('fedeo-guest-session', result.token)
localStorage.setItem('fedeo-guest-workspace', result.workspaceId)
if (invitationToken.value) invitation.value = await api('/guest/api/invitation', { method: 'POST', body: { token: invitationToken.value } })
await refresh()
})
}
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')
await refresh(); selected.value = result.roomId
})
}
async function logout() { try { await api('/guest/api/logout', { method: 'POST' }) } finally { expire(); sent.value = false } }
let timer
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') || ''
token.value = sessionStorage.getItem('fedeo-guest-session') || ''
if (token.value) {
await run(async () => {
if (invitationToken.value) invitation.value = await api('/guest/api/invitation', { method: 'POST', body: { token: invitationToken.value } })
await refresh()
})
}
timer = setInterval(() => { if (!document.hidden && !busy.value) void refresh() }, 15000)
})
onBeforeUnmount(() => clearInterval(timer))
</script>
<template>
<main class="mx-auto min-h-screen max-w-5xl space-y-5 p-4 sm:p-8">
<header class="flex items-center justify-between gap-3"><div><p class="text-sm font-semibold text-green-700">FEDEO</p><h1 class="text-2xl font-semibold">Deine Projekt-Unterräume</h1></div><UButton v-if="token" variant="outline" @click="logout">Abmelden</UButton></header>
<p v-if="error" role="alert" class="rounded-lg bg-red-50 p-3 text-red-700">{{ error }}</p>
<section v-if="!token" class="mx-auto max-w-md space-y-4 rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">
<h2 class="text-lg font-semibold">Als Gast anmelden</h2>
<p class="text-sm text-gray-500">Du erhältst einen Anmeldecode an die E-Mail-Adresse deiner Einladung. Deine Anmeldung gilt nur für freigegebene Unterräume.</p>
<form v-if="!sent" class="space-y-4" @submit.prevent="requestCode">
<template v-if="!invitationToken"><label class="block text-sm">E-Mail-Adresse<input v-model="address" type="email" required autocomplete="email" class="mt-1 w-full rounded-lg border border-gray-300 p-2" /></label><p v-if="!workspaceId" class="text-sm text-gray-500">Öffne zuerst deinen persönlichen Einladungslink.</p></template>
<UButton type="submit" :loading="busy" :disabled="!invitationToken && !workspaceId">Anmeldecode anfordern</UButton>
</form>
<form v-else class="space-y-4" @submit.prevent="verify">
<p role="status" class="text-sm">Wenn eine gültige Einladung oder ein Zugang vorliegt, wurde ein Code versendet.</p>
<label class="block text-sm">Anmeldecode<input v-model="code" required inputmode="numeric" autocomplete="one-time-code" maxlength="8" class="mt-1 w-full rounded-lg border border-gray-300 p-2 tracking-widest" /></label>
<div class="flex gap-2"><UButton type="submit" :loading="busy">Anmelden</UButton><UButton variant="ghost" :disabled="busy" @click="sent = false">Zurück</UButton></div>
</form>
</section>
<section v-if="token && invitation" class="space-y-3 rounded-xl border border-green-200 bg-green-50 p-5 text-gray-900">
<h2 class="font-semibold">Einladung: {{ invitation.name }}</h2><p class="text-sm">{{ invitation.topic }}</p>
<p class="text-sm">{{ invitation.role === 'reader' ? 'Du kannst Nachrichten lesen.' : 'Du kannst Nachrichten lesen und schreiben.' }} {{ invitation.overviewAllowed ? 'Eine begrenzte Projektübersicht ist freigegeben.' : '' }}</p>
<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>
<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>

View File

@@ -1,4 +1,5 @@
export default defineNuxtPlugin(async () => {
if (window.location.pathname === '/guest') return
const auth = useAuthStore()
console.log("Auth init")
@@ -6,4 +7,4 @@ export default defineNuxtPlugin(async () => {
await auth.initStore()
})
})