feat(spaces): invite + accept flow (member management UI)

First user-facing surface for multi-tenant Space sharing. Two new routes:

/spaces/members — Space member management (inside app gate)
- Lists current members with role chips + avatars.
- Einladen-Form for owners/admins: email + role (member/admin) →
  POST /api/auth/organization/invite-member. Better Auth's existing
  sendInvitationEmail handler (wired in better-auth.config.ts) mails
  the invitee a link to /accept-invitation?id=X.
- Pending-invitations list with Stornieren button.
- Personal Spaces show a hint panel instead — they can't have members
  by design.
- Remove Mitglied button (not for owner-role).

/accept-invitation — landing page for the invite email link (outside
(app) guard so logged-out invitees can see it).
- Fetches invitation details via /organization/get-invitation.
- If unauthenticated: "Einloggen & annehmen" routes through /login
  with a callbackURL back to the landing — the flow resumes after
  sign-in.
- Accept: POST /organization/accept-invitation + /set-active so the
  newly-joined space is active when the user lands in the app.
- Decline: POST /organization/reject-invitation.
- Already-accepted / expired / canceled states each get their own copy.

SpaceSwitcher gets a "Mitglieder verwalten …" entry in the dropdown,
visible only when the active Space isn't personal.

What this does NOT do yet (separate commits):
- Membership-Lookup in mana-sync — Users A and B can now be in the
  same space on paper, but mana-sync's RLS only lets members see
  their own authored records until the lookup is wired.
- Encryption skip for shared-space rows — records in an encrypted
  table still get wrapped with the author's user key, so member B
  can't decrypt member A's writes.

Both follow in the next two commits.

0 errors across 7194 files.

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 20:38:55 +02:00
parent 1f392c1ea6
commit 5139ade7e0
3 changed files with 807 additions and 0 deletions

View file

@ -144,6 +144,11 @@
{/each}
{/if}
<hr />
{#if active && active.type !== 'personal'}
<a class="item manage" href="/spaces/members" onclick={() => (open = false)}>
{locale === 'de' ? 'Mitglieder verwalten …' : 'Manage members …'}
</a>
{/if}
<button
type="button"
class="item create"
@ -291,6 +296,10 @@
color: var(--color-primary, hsl(230 80% 50%));
}
.item.manage {
color: var(--color-text-muted, hsl(0 0% 45%));
}
.empty,
.error {
padding: 0.5rem 0.625rem;

View file

@ -0,0 +1,491 @@
<script lang="ts">
/**
* /spaces/members — member management for the active Space.
*
* Shows who is a member, pending invitations, and an Einladen form
* that posts to Better Auth's /organization/invite-member endpoint.
* Better Auth itself sends the invitation email (via the handler
* wired in services/mana-auth/src/auth/better-auth.config.ts).
*
* First shared-space surface — dogfoods the whole multi-tenancy
* pipeline end-to-end.
*/
import { onMount } from 'svelte';
import { getActiveSpace } from '$lib/data/scope';
import { SPACE_TYPE_LABELS } from '@mana/shared-branding';
interface Member {
id: string;
userId: string;
role: string;
createdAt: string;
user?: { email?: string; name?: string | null; image?: string | null };
}
interface Invitation {
id: string;
email: string;
role: string;
status: string;
expiresAt: string;
inviterId: string;
}
let members = $state<Member[]>([]);
let invitations = $state<Invitation[]>([]);
let loading = $state(true);
let loadError = $state<string | null>(null);
let inviteEmail = $state('');
let inviteRole = $state<'owner' | 'admin' | 'member'>('member');
let inviteSubmitting = $state(false);
let inviteError = $state<string | null>(null);
let inviteSuccess = $state<string | null>(null);
const activeSpace = $derived(getActiveSpace());
const canManage = $derived(activeSpace?.role === 'owner' || activeSpace?.role === 'admin');
async function refresh() {
if (!activeSpace) return;
loading = true;
loadError = null;
try {
const [memRes, invRes] = await Promise.all([
fetch(
`/api/auth/organization/list-members?organizationId=${encodeURIComponent(activeSpace.id)}`,
{ credentials: 'include' }
),
fetch(
`/api/auth/organization/list-invitations?organizationId=${encodeURIComponent(activeSpace.id)}`,
{ credentials: 'include' }
),
]);
if (memRes.ok) {
const data = (await memRes.json()) as { members?: Member[] } | Member[];
members = Array.isArray(data) ? data : (data.members ?? []);
}
if (invRes.ok) {
const data = (await invRes.json()) as Invitation[] | { invitations?: Invitation[] };
invitations = Array.isArray(data) ? data : (data.invitations ?? []);
}
} catch (err) {
loadError = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
onMount(() => {
void refresh();
});
async function submitInvite(e: Event) {
e.preventDefault();
if (!activeSpace) return;
if (inviteSubmitting) return;
if (!inviteEmail.trim() || !inviteEmail.includes('@')) {
inviteError = 'Bitte eine gültige E-Mail angeben';
return;
}
inviteSubmitting = true;
inviteError = null;
inviteSuccess = null;
try {
const res = await fetch('/api/auth/organization/invite-member', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
email: inviteEmail.trim(),
role: inviteRole,
organizationId: activeSpace.id,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `invite failed: ${res.status}`);
}
inviteSuccess = `Einladung an ${inviteEmail.trim()} gesendet`;
inviteEmail = '';
await refresh();
} catch (err) {
inviteError = err instanceof Error ? err.message : String(err);
} finally {
inviteSubmitting = false;
}
}
async function cancelInvitation(id: string) {
const res = await fetch('/api/auth/organization/cancel-invitation', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ invitationId: id }),
});
if (res.ok) await refresh();
}
async function removeMember(userId: string) {
if (!activeSpace) return;
if (!confirm('Mitglied wirklich entfernen?')) return;
const res = await fetch('/api/auth/organization/remove-member', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ memberIdOrEmail: userId, organizationId: activeSpace.id }),
});
if (res.ok) await refresh();
}
function relativeDate(iso: string): string {
const d = new Date(iso);
const now = Date.now();
const diff = now - d.getTime();
const sec = Math.floor(diff / 1000);
if (sec < 60) return 'gerade eben';
if (sec < 3600) return `vor ${Math.floor(sec / 60)} min`;
if (sec < 86400) return `vor ${Math.floor(sec / 3600)} h`;
return d.toLocaleDateString('de-DE');
}
</script>
<div class="container">
<header class="page-head">
<h1>Mitglieder</h1>
{#if activeSpace}
<p class="subtitle">
<strong>{activeSpace.name}</strong>
<span class="type-chip" data-type={activeSpace.type}
>{SPACE_TYPE_LABELS.de[activeSpace.type]}</span
>
</p>
{/if}
</header>
{#if !activeSpace}
<p class="empty">Lade aktiven Space …</p>
{:else if activeSpace.type === 'personal'}
<div class="hint-card">
<p>
Dein Personal-Space kann keine weiteren Mitglieder haben — er ist bewusst nur für dich. Für
geteilte Bereiche (Familie, Team, Marke, Verein) lege einen neuen Space an und lade dann
hier ein.
</p>
</div>
{:else}
{#if canManage}
<section class="panel">
<h2>Einladen</h2>
<form onsubmit={submitInvite} class="invite-form">
<input
type="email"
bind:value={inviteEmail}
placeholder="name@beispiel.de"
required
disabled={inviteSubmitting}
/>
<select bind:value={inviteRole} disabled={inviteSubmitting}>
<option value="member">Mitglied</option>
<option value="admin">Admin</option>
</select>
<button type="submit" disabled={inviteSubmitting}>
{inviteSubmitting ? 'Sende …' : 'Einladen'}
</button>
</form>
{#if inviteError}<p class="error">{inviteError}</p>{/if}
{#if inviteSuccess}<p class="success">{inviteSuccess}</p>{/if}
</section>
{/if}
<section class="panel">
<h2>Mitglieder ({members.length})</h2>
{#if loading}
<p class="empty">Lädt …</p>
{:else if loadError}
<p class="error">{loadError}</p>
{:else if members.length === 0}
<p class="empty">Nur du bist Mitglied.</p>
{:else}
<ul class="member-list">
{#each members as m (m.id)}
<li class="member-row">
<div class="member-info">
<div class="avatar" aria-hidden="true">
{(m.user?.name ?? m.user?.email ?? '?').charAt(0).toUpperCase()}
</div>
<div>
<div class="member-name">{m.user?.name ?? m.user?.email ?? m.userId}</div>
{#if m.user?.email && m.user?.name}
<div class="member-meta">{m.user.email}</div>
{/if}
</div>
</div>
<div class="member-actions">
<span class="role-badge">{m.role}</span>
{#if canManage && m.role !== 'owner'}
<button
type="button"
class="remove-btn"
onclick={() => removeMember(m.userId)}
aria-label="Entfernen"
>
Entfernen
</button>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</section>
{#if invitations.length > 0}
<section class="panel">
<h2>Offene Einladungen ({invitations.length})</h2>
<ul class="invite-list">
{#each invitations.filter((i) => i.status === 'pending') as inv (inv.id)}
<li class="invite-row">
<div>
<div class="invite-email">{inv.email}</div>
<div class="invite-meta">
{inv.role} · verschickt {relativeDate(inv.expiresAt)}
</div>
</div>
{#if canManage}
<button type="button" class="remove-btn" onclick={() => cancelInvitation(inv.id)}
>Stornieren</button
>
{/if}
</li>
{/each}
</ul>
</section>
{/if}
{/if}
</div>
<style>
.container {
max-width: 640px;
margin: 0 auto;
padding: 1rem;
}
.page-head {
margin-bottom: 1.5rem;
}
.page-head h1 {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.subtitle {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
color: var(--color-text-muted, hsl(0 0% 45%));
font-size: 0.875rem;
}
.type-chip {
font-size: 0.6875rem;
padding: 0.125rem 0.5rem;
border-radius: var(--radius-sm, 4px);
background: var(--color-surface-2, hsl(0 0% 94%));
color: var(--color-text-muted, hsl(0 0% 45%));
text-transform: uppercase;
letter-spacing: 0.02em;
}
.type-chip[data-type='brand'] {
background: hsl(260 70% 94%);
color: hsl(260 60% 35%);
}
.type-chip[data-type='club'] {
background: hsl(160 50% 92%);
color: hsl(160 60% 28%);
}
.type-chip[data-type='family'] {
background: hsl(30 80% 92%);
color: hsl(30 60% 35%);
}
.type-chip[data-type='team'] {
background: hsl(210 60% 92%);
color: hsl(210 60% 32%);
}
.type-chip[data-type='practice'] {
background: hsl(340 50% 92%);
color: hsl(340 55% 38%);
}
.hint-card {
background: var(--color-surface-2, hsl(0 0% 97%));
border: 1px solid var(--color-border, hsl(0 0% 88%));
border-radius: var(--radius-md, 6px);
padding: 1rem;
font-size: 0.875rem;
color: var(--color-text-muted, hsl(0 0% 45%));
line-height: 1.5;
}
.panel {
background: var(--color-surface-1, white);
border: 1px solid var(--color-border, hsl(0 0% 88%));
border-radius: var(--radius-md, 6px);
padding: 1rem;
margin-bottom: 1rem;
}
.panel h2 {
font-size: 0.9375rem;
font-weight: 600;
margin: 0 0 0.75rem;
}
.invite-form {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.invite-form input[type='email'] {
flex: 1 1 200px;
}
.invite-form input,
.invite-form select {
padding: 0.5rem 0.625rem;
border: 1px solid var(--color-border, hsl(0 0% 88%));
border-radius: var(--radius-md, 6px);
background: var(--color-surface-1, white);
color: var(--color-text, inherit);
font: inherit;
font-size: 0.875rem;
}
.invite-form button {
padding: 0.5rem 1rem;
background: var(--color-primary, hsl(230 80% 50%));
color: white;
border: 0;
border-radius: var(--radius-md, 6px);
font: inherit;
font-size: 0.875rem;
cursor: pointer;
}
.invite-form button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.error {
color: hsl(0 60% 42%);
font-size: 0.8125rem;
margin: 0.5rem 0 0;
}
.success {
color: hsl(140 50% 34%);
font-size: 0.8125rem;
margin: 0.5rem 0 0;
}
.empty {
color: var(--color-text-muted, hsl(0 0% 45%));
font-size: 0.875rem;
margin: 0;
}
.member-list,
.invite-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.member-row,
.invite-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 0.75rem;
border-radius: var(--radius-md, 6px);
background: var(--color-surface-2, hsl(0 0% 97%));
}
.member-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--color-primary, hsl(230 80% 50%));
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
font-weight: 500;
}
.member-name {
font-size: 0.875rem;
font-weight: 500;
}
.member-meta,
.invite-meta {
font-size: 0.75rem;
color: var(--color-text-muted, hsl(0 0% 45%));
}
.invite-email {
font-size: 0.875rem;
font-weight: 500;
}
.member-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.role-badge {
font-size: 0.6875rem;
padding: 0.125rem 0.5rem;
border-radius: var(--radius-sm, 4px);
background: var(--color-surface-1, white);
border: 1px solid var(--color-border, hsl(0 0% 88%));
color: var(--color-text-muted, hsl(0 0% 45%));
text-transform: uppercase;
}
.remove-btn {
padding: 0.25rem 0.625rem;
background: transparent;
border: 1px solid var(--color-border, hsl(0 0% 88%));
border-radius: var(--radius-sm, 4px);
color: var(--color-text-muted, hsl(0 0% 45%));
font: inherit;
font-size: 0.75rem;
cursor: pointer;
}
.remove-btn:hover {
color: hsl(0 60% 42%);
border-color: hsl(0 60% 80%);
}
</style>

View file

@ -0,0 +1,307 @@
<script lang="ts">
/**
* /accept-invitation?id=<invitationId> — landing page for Space
* invitation links sent by Better Auth's sendInvitationEmail handler.
*
* Lives outside the (app) guard so a logged-out invitee can see the
* landing and be prompted to sign up or sign in before accepting. If
* the user is already authenticated, the accept / decline actions
* fire against Better Auth directly and redirect into the new space
* on success.
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { authStore } from '$lib/stores/auth.svelte';
import { SPACE_TYPE_LABELS } from '@mana/shared-branding';
import { isSpaceType, type SpaceType } from '@mana/shared-types';
import { loadActiveSpace } from '$lib/data/scope';
interface InvitationPayload {
id: string;
email: string;
role: string;
status: string;
organizationId: string;
organizationName?: string;
organizationMetadata?: unknown;
inviterEmail?: string;
expiresAt: string;
}
let invitation = $state<InvitationPayload | null>(null);
let loading = $state(true);
let loadError = $state<string | null>(null);
let actionError = $state<string | null>(null);
let submitting = $state(false);
const invitationId = $derived($page.url.searchParams.get('id') ?? '');
const spaceType = $derived.by<SpaceType>(() => {
const meta = (invitation?.organizationMetadata ?? {}) as { type?: unknown };
return isSpaceType(meta.type) ? meta.type : 'personal';
});
async function loadInvitation() {
if (!invitationId) {
loadError = 'Kein Einladungs-Code in der URL';
loading = false;
return;
}
loading = true;
loadError = null;
try {
const res = await fetch(
`/api/auth/organization/get-invitation?id=${encodeURIComponent(invitationId)}`,
{ credentials: 'include' }
);
if (!res.ok) {
throw new Error(`Einladung nicht gefunden (${res.status})`);
}
invitation = (await res.json()) as InvitationPayload;
} catch (err) {
loadError = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
onMount(() => {
void loadInvitation();
});
async function accept() {
if (!invitation || submitting) return;
if (!authStore.isAuthenticated) {
// Route to login with callback back here so the flow resumes.
const callback = encodeURIComponent(`/accept-invitation?id=${invitationId}`);
goto(`/login?callbackURL=${callback}`);
return;
}
submitting = true;
actionError = null;
try {
const res = await fetch('/api/auth/organization/accept-invitation', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ invitationId }),
});
if (!res.ok) {
throw new Error(await res.text());
}
// Activate the newly-joined space so the dashboard opens inside it.
await fetch('/api/auth/organization/set-active', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ organizationId: invitation.organizationId }),
});
await loadActiveSpace({ force: true });
goto('/');
} catch (err) {
actionError = err instanceof Error ? err.message : String(err);
submitting = false;
}
}
async function decline() {
if (!invitation || submitting) return;
submitting = true;
actionError = null;
try {
const res = await fetch('/api/auth/organization/reject-invitation', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ invitationId }),
});
if (!res.ok) throw new Error(await res.text());
goto('/');
} catch (err) {
actionError = err instanceof Error ? err.message : String(err);
submitting = false;
}
}
</script>
<div class="page">
<div class="card">
{#if loading}
<p class="state">Lade Einladung …</p>
{:else if loadError}
<h1>Einladung nicht abrufbar</h1>
<p class="error">{loadError}</p>
<p class="hint">Der Link ist möglicherweise abgelaufen oder schon benutzt.</p>
{:else if invitation}
{#if invitation.status === 'accepted'}
<h1>Schon angenommen</h1>
<p class="hint">Diese Einladung ist bereits angenommen worden.</p>
<a href="/" class="btn primary">Zur App</a>
{:else if invitation.status === 'rejected' || invitation.status === 'canceled'}
<h1>Einladung abgelaufen</h1>
<p class="hint">Diese Einladung ist nicht mehr gültig.</p>
{:else}
<p class="eyebrow">Einladung</p>
<h1>
{invitation.inviterEmail ?? 'Jemand'} lädt dich in
<strong>{invitation.organizationName ?? 'einen Space'}</strong> ein
</h1>
<p class="subtitle">
<span class="type-chip" data-type={spaceType}>{SPACE_TYPE_LABELS.de[spaceType]}</span>
<span>Rolle: {invitation.role}</span>
</p>
<p class="hint">
Nach Annahme kannst du in diesem Space mitarbeiten — sehen, was andere schreiben, und
selbst Einträge anlegen. Deine persönlichen Daten bleiben in deinem Personal-Space,
getrennt.
</p>
{#if actionError}<p class="error">{actionError}</p>{/if}
<div class="actions">
<button class="btn secondary" onclick={decline} disabled={submitting}>Ablehnen</button>
<button class="btn primary" onclick={accept} disabled={submitting}>
{#if submitting}
Bearbeite …
{:else if !authStore.isAuthenticated}
Einloggen & annehmen
{:else}
Annehmen
{/if}
</button>
</div>
{/if}
{/if}
</div>
</div>
<style>
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: var(--color-surface-2, hsl(0 0% 97%));
}
.card {
background: var(--color-surface-1, white);
border: 1px solid var(--color-border, hsl(0 0% 88%));
border-radius: var(--radius-lg, 10px);
padding: 2rem;
max-width: 460px;
width: 100%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.06);
}
.eyebrow {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text-muted, hsl(0 0% 45%));
text-transform: uppercase;
letter-spacing: 0.04em;
margin: 0 0 0.5rem;
}
h1 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.75rem;
line-height: 1.4;
}
.subtitle {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--color-text-muted, hsl(0 0% 45%));
margin: 0 0 1rem;
}
.type-chip {
font-size: 0.6875rem;
padding: 0.125rem 0.5rem;
border-radius: var(--radius-sm, 4px);
background: var(--color-surface-2, hsl(0 0% 94%));
text-transform: uppercase;
letter-spacing: 0.02em;
}
.type-chip[data-type='brand'] {
background: hsl(260 70% 94%);
color: hsl(260 60% 35%);
}
.type-chip[data-type='club'] {
background: hsl(160 50% 92%);
color: hsl(160 60% 28%);
}
.type-chip[data-type='family'] {
background: hsl(30 80% 92%);
color: hsl(30 60% 35%);
}
.type-chip[data-type='team'] {
background: hsl(210 60% 92%);
color: hsl(210 60% 32%);
}
.type-chip[data-type='practice'] {
background: hsl(340 50% 92%);
color: hsl(340 55% 38%);
}
.hint {
font-size: 0.875rem;
color: var(--color-text-muted, hsl(0 0% 45%));
line-height: 1.5;
margin: 0 0 1.5rem;
}
.state {
text-align: center;
color: var(--color-text-muted, hsl(0 0% 45%));
}
.error {
color: hsl(0 60% 42%);
font-size: 0.875rem;
margin: 0 0 1rem;
}
.actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.btn {
padding: 0.625rem 1.125rem;
border-radius: var(--radius-md, 6px);
font: inherit;
font-size: 0.875rem;
cursor: pointer;
border: 1px solid transparent;
text-decoration: none;
display: inline-block;
}
.btn.primary {
background: var(--color-primary, hsl(230 80% 50%));
color: white;
}
.btn.primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn.secondary {
background: transparent;
border-color: var(--color-border, hsl(0 0% 88%));
color: var(--color-text, inherit);
}
.btn.secondary:hover:not(:disabled) {
background: var(--color-surface-2, hsl(0 0% 97%));
}
</style>