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

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>