mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
1f392c1ea6
commit
5139ade7e0
3 changed files with 807 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
|
|||
491
apps/mana/apps/web/src/routes/(app)/spaces/members/+page.svelte
Normal file
491
apps/mana/apps/web/src/routes/(app)/spaces/members/+page.svelte
Normal 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>
|
||||
307
apps/mana/apps/web/src/routes/accept-invitation/+page.svelte
Normal file
307
apps/mana/apps/web/src/routes/accept-invitation/+page.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue