mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(spaces): Spaces as workbench card + canonical /spaces route
Extract member management from /spaces/members into a reusable
workbench-card ListView so users can drop the surface into any scene.
- lib/modules/spaces/ListView.svelte — hint + invite + members + pending
invitations, all theme-token driven
- APP_ICONS.spaces icon (three-silhouette cluster, teal→indigo)
- MANA_APPS entry id=spaces (beta tier, shared-space management)
- registerApp({ id: 'spaces' }) so the card is scene-droppable
- /spaces/+page.svelte as the new canonical route wrapper
- /spaces/members/+page.svelte kept as legacy alias
- SpaceSwitcher menu now links to /spaces
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5924f4fac3
commit
88eca8a759
7 changed files with 555 additions and 492 deletions
|
|
@ -1278,6 +1278,16 @@ registerApp({
|
|||
],
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'spaces',
|
||||
name: 'Spaces',
|
||||
color: '#14b8a6',
|
||||
icon: UserCircle,
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/spaces/ListView.svelte') },
|
||||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'quiz',
|
||||
name: 'Quiz',
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@
|
|||
<div class="menu-divider"></div>
|
||||
|
||||
{#if active && active.type !== 'personal'}
|
||||
<a class="menu-item menu-link" href="/spaces/members" onclick={closeDropdown} role="menuitem">
|
||||
<a class="menu-item menu-link" href="/spaces" onclick={closeDropdown} role="menuitem">
|
||||
<span class="icon-placeholder" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||
<path
|
||||
|
|
|
|||
501
apps/mana/apps/web/src/lib/modules/spaces/ListView.svelte
Normal file
501
apps/mana/apps/web/src/lib/modules/spaces/ListView.svelte
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
<!--
|
||||
Spaces app — workbench card.
|
||||
|
||||
Manages members + invitations of the currently active Space. Personal
|
||||
spaces show a hint card only; shared spaces (brand/club/family/team/
|
||||
practice) show the invite form, member list, and pending invitations.
|
||||
|
||||
Renders equally well inside the workbench or standalone at /spaces.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { getActiveSpace, authFetch } 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([
|
||||
authFetch(
|
||||
`/api/auth/organization/list-members?organizationId=${encodeURIComponent(activeSpace.id)}`
|
||||
),
|
||||
authFetch(
|
||||
`/api/auth/organization/list-invitations?organizationId=${encodeURIComponent(activeSpace.id)}`
|
||||
),
|
||||
]);
|
||||
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 authFetch('/api/auth/organization/invite-member', {
|
||||
method: 'POST',
|
||||
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 authFetch('/api/auth/organization/cancel-invitation', {
|
||||
method: 'POST',
|
||||
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 authFetch('/api/auth/organization/remove-member', {
|
||||
method: 'POST',
|
||||
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="pane">
|
||||
<header class="bar">
|
||||
<div class="title">
|
||||
<strong>Mitglieder</strong>
|
||||
{#if activeSpace}
|
||||
<span class="sub">
|
||||
{activeSpace.name} · {SPACE_TYPE_LABELS.de[activeSpace.type]}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</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>
|
||||
.pane {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.bar .title strong {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bar .sub {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground, 0 0% 50%));
|
||||
}
|
||||
|
||||
.hint-card {
|
||||
background: hsl(var(--color-muted, 0 0% 97%));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground, 0 0% 50%));
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: hsl(var(--color-card));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 12px;
|
||||
padding: 1.125rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 1px 2px hsl(0 0% 0% / 0.04);
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
margin: 0 0 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground, 0 0% 50%));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.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.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--color-input, var(--color-background, var(--color-card))));
|
||||
color: hsl(var(--color-foreground));
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 120ms ease;
|
||||
}
|
||||
|
||||
.invite-form input::placeholder {
|
||||
color: hsl(var(--color-muted-foreground, 0 0% 50%) / 0.7);
|
||||
}
|
||||
|
||||
.invite-form input:focus,
|
||||
.invite-form select:focus {
|
||||
outline: none;
|
||||
border-color: var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%)));
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%))) 20%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.invite-form button {
|
||||
padding: 0.5rem 1.125rem;
|
||||
background: var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%)));
|
||||
color: hsl(var(--color-primary-foreground, 0 0% 100%));
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: filter 120ms ease;
|
||||
}
|
||||
|
||||
.invite-form button:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.invite-form button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: hsl(0 70% 55%);
|
||||
font-size: 0.8125rem;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
:global(.dark) .error {
|
||||
color: hsl(0 80% 72%);
|
||||
}
|
||||
|
||||
.success {
|
||||
color: hsl(140 50% 40%);
|
||||
font-size: 0.8125rem;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
:global(.dark) .success {
|
||||
color: hsl(140 60% 65%);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: hsl(var(--color-muted-foreground, 0 0% 50%));
|
||||
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;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
border-radius: 10px;
|
||||
background: hsl(var(--color-muted, 0 0% 97%));
|
||||
border: 1px solid hsl(var(--color-border) / 0.5);
|
||||
}
|
||||
|
||||
.member-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background: var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%)));
|
||||
color: hsl(var(--color-primary-foreground, 0 0% 100%));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-meta,
|
||||
.invite-meta {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground, 0 0% 55%));
|
||||
}
|
||||
|
||||
.invite-email {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.member-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-card));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
color: hsl(var(--color-muted-foreground, 0 0% 50%));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: transparent;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--color-muted-foreground, 0 0% 55%));
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 120ms ease,
|
||||
border-color 120ms ease,
|
||||
background 120ms ease;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
color: hsl(0 70% 55%);
|
||||
border-color: color-mix(in srgb, hsl(0 70% 55%) 40%, transparent);
|
||||
background: color-mix(in srgb, hsl(0 70% 55%) 8%, transparent);
|
||||
}
|
||||
</style>
|
||||
13
apps/mana/apps/web/src/routes/(app)/spaces/+page.svelte
Normal file
13
apps/mana/apps/web/src/routes/(app)/spaces/+page.svelte
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* /spaces — Spaces top-level route.
|
||||
*
|
||||
* Renders the workbench-card ListView from `$lib/modules/spaces/`.
|
||||
* Same component runs inside the workbench via registerApp and here
|
||||
* as a standalone page.
|
||||
*/
|
||||
|
||||
import ListView from '$lib/modules/spaces/ListView.svelte';
|
||||
</script>
|
||||
|
||||
<ListView />
|
||||
|
|
@ -1,498 +1,13 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* /spaces/members — member management for the active Space.
|
||||
* /spaces/members — legacy alias.
|
||||
*
|
||||
* 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.
|
||||
* The member-management UI moved to `/spaces` as the canonical route
|
||||
* (and workbench card). This path stays as a passthrough so existing
|
||||
* links (SpaceSwitcher, SpaceCreateDialog) keep working.
|
||||
*/
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { PageHeader } from '@mana/shared-ui';
|
||||
import { getActiveSpace, authFetch } 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([
|
||||
authFetch(
|
||||
`/api/auth/organization/list-members?organizationId=${encodeURIComponent(activeSpace.id)}`
|
||||
),
|
||||
authFetch(
|
||||
`/api/auth/organization/list-invitations?organizationId=${encodeURIComponent(activeSpace.id)}`
|
||||
),
|
||||
]);
|
||||
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 authFetch('/api/auth/organization/invite-member', {
|
||||
method: 'POST',
|
||||
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 authFetch('/api/auth/organization/cancel-invitation', {
|
||||
method: 'POST',
|
||||
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 authFetch('/api/auth/organization/remove-member', {
|
||||
method: 'POST',
|
||||
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');
|
||||
}
|
||||
import ListView from '$lib/modules/spaces/ListView.svelte';
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<PageHeader
|
||||
title="Mitglieder"
|
||||
description={activeSpace
|
||||
? `${activeSpace.name} · ${SPACE_TYPE_LABELS.de[activeSpace.type]}`
|
||||
: undefined}
|
||||
backHref="/"
|
||||
size="lg"
|
||||
>
|
||||
{#snippet breadcrumb()}
|
||||
<a href="/">Workbench</a> <span class="crumb-sep">›</span> Mitglieder verwalten
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
{#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>
|
||||
/* Theme-token driven — mirrors @mana/shared-ui Pill so panels and
|
||||
inputs follow whatever theme is active (light / dark / variants)
|
||||
instead of falling back to hardcoded white. */
|
||||
|
||||
.container {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem 4rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.crumb-sep {
|
||||
margin: 0 0.25rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.hint-card {
|
||||
background: hsl(var(--color-muted, 0 0% 97%));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground, 0 0% 50%));
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: hsl(var(--color-card));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 12px;
|
||||
padding: 1.125rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 1px 2px hsl(0 0% 0% / 0.04);
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
margin: 0 0 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground, 0 0% 50%));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.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.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--color-input, var(--color-background, var(--color-card))));
|
||||
color: hsl(var(--color-foreground));
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 120ms ease;
|
||||
}
|
||||
|
||||
.invite-form input::placeholder {
|
||||
color: hsl(var(--color-muted-foreground, 0 0% 50%) / 0.7);
|
||||
}
|
||||
|
||||
.invite-form input:focus,
|
||||
.invite-form select:focus {
|
||||
outline: none;
|
||||
border-color: var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%)));
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%))) 20%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.invite-form button {
|
||||
padding: 0.5rem 1.125rem;
|
||||
background: var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%)));
|
||||
color: hsl(var(--color-primary-foreground, 0 0% 100%));
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: filter 120ms ease;
|
||||
}
|
||||
|
||||
.invite-form button:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.invite-form button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: hsl(0 70% 55%);
|
||||
font-size: 0.8125rem;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
:global(.dark) .error {
|
||||
color: hsl(0 80% 72%);
|
||||
}
|
||||
|
||||
.success {
|
||||
color: hsl(140 50% 40%);
|
||||
font-size: 0.8125rem;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
:global(.dark) .success {
|
||||
color: hsl(140 60% 65%);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: hsl(var(--color-muted-foreground, 0 0% 50%));
|
||||
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;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
border-radius: 10px;
|
||||
background: hsl(var(--color-muted, 0 0% 97%));
|
||||
border: 1px solid hsl(var(--color-border) / 0.5);
|
||||
}
|
||||
|
||||
.member-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background: var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%)));
|
||||
color: hsl(var(--color-primary-foreground, 0 0% 100%));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-meta,
|
||||
.invite-meta {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground, 0 0% 55%));
|
||||
}
|
||||
|
||||
.invite-email {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.member-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-card));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
color: hsl(var(--color-muted-foreground, 0 0% 50%));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: transparent;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--color-muted-foreground, 0 0% 55%));
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 120ms ease,
|
||||
border-color 120ms ease,
|
||||
background 120ms ease;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
color: hsl(0 70% 55%);
|
||||
border-color: color-mix(in srgb, hsl(0 70% 55%) 40%, transparent);
|
||||
background: color-mix(in srgb, hsl(0 70% 55%) 8%, transparent);
|
||||
}
|
||||
</style>
|
||||
<ListView />
|
||||
|
|
|
|||
|
|
@ -260,6 +260,13 @@ export const APP_ICONS = {
|
|||
// while still reading as "chronological" in the AI Workbench family.
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="tl" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f59e0b"/><stop offset="100%" style="stop-color:#ea580c"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#tl)"/><line x1="34" y1="22" x2="34" y2="78" stroke="white" stroke-width="3" stroke-linecap="round" opacity="0.55"/><circle cx="34" cy="30" r="5" fill="white"/><circle cx="34" cy="50" r="5" fill="white"/><circle cx="34" cy="70" r="5" fill="white"/><rect x="44" y="26" width="32" height="8" rx="2" fill="white" fill-opacity="0.95"/><rect x="44" y="46" width="26" height="8" rx="2" fill="white" fill-opacity="0.8"/><rect x="44" y="66" width="30" height="8" rx="2" fill="white" fill-opacity="0.9"/></svg>`
|
||||
),
|
||||
spaces: svgToDataUrl(
|
||||
// Three people-silhouettes clustered in the tile — the Spaces primitive
|
||||
// is about shared workspaces, so the icon emphasises "group". Teal→indigo
|
||||
// gradient sits next to contacts (green) and chat (indigo) in the
|
||||
// communication family without competing with either.
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="sp" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#14b8a6"/><stop offset="100%" style="stop-color:#6366f1"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#sp)"/><circle cx="30" cy="38" r="9" fill="white" fill-opacity="0.85"/><path d="M14 70c0-9 7-16 16-16s16 7 16 16v4H14v-4z" fill="white" fill-opacity="0.85"/><circle cx="70" cy="38" r="9" fill="white" fill-opacity="0.85"/><path d="M54 70c0-9 7-16 16-16s16 7 16 16v4H54v-4z" fill="white" fill-opacity="0.85"/><circle cx="50" cy="32" r="11" fill="white"/><path d="M30 76c0-11 9-20 20-20s20 9 20 20v4H30v-4z" fill="white"/></svg>`
|
||||
),
|
||||
} as const;
|
||||
|
||||
export type AppIconId = keyof typeof APP_ICONS;
|
||||
|
|
|
|||
|
|
@ -1105,6 +1105,23 @@ export const MANA_APPS: ManaApp[] = [
|
|||
status: 'beta',
|
||||
requiredTier: 'beta',
|
||||
},
|
||||
{
|
||||
id: 'spaces',
|
||||
name: 'Spaces',
|
||||
description: {
|
||||
de: 'Geteilte Bereiche & Mitglieder',
|
||||
en: 'Shared spaces & members',
|
||||
},
|
||||
longDescription: {
|
||||
de: 'Verwalte den aktiven Space und seine Mitglieder — lade Personen per E-Mail ein, vergib Rollen (Admin/Mitglied) und widerrufe offene Einladungen. Personal-Spaces zeigen nur den Hinweis, dass sie bewusst nur für dich sind; geteilte Spaces (Familie, Team, Marke, Verein, Praxis) bekommen das volle Member-Management.',
|
||||
en: 'Manage the active Space and its members — invite people by email, assign roles (admin/member) and revoke pending invitations. Personal spaces are single-user by design; shared spaces (family, team, brand, club, practice) get full member management.',
|
||||
},
|
||||
icon: APP_ICONS.spaces,
|
||||
color: '#14b8a6',
|
||||
comingSoon: false,
|
||||
status: 'beta',
|
||||
requiredTier: 'beta',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue