import type { MemberRole, MemberView, SeatedMemberRole } from '@dorfteich/shared'; import { UserMinus, UserPlus } from 'lucide-react'; import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ApiError } from '../lib/api'; import { useMemberMutations, usePondMembers } from './use-pond-members'; import { IconButton } from '../components/IconButton'; /** Turns an ApiError code into a translated message; other errors are generic. */ function useErrorText(): (error: unknown) => string { const { t } = useTranslation('errors'); return (error) => { if (error instanceof ApiError) { return t(error.body.code, { defaultValue: error.body.message, ...(error.body.details ?? {}), }); } return t('internal_error'); }; } const ROLE_ORDER: MemberRole[] = ['pond_admin', 'editor', 'reader']; const SEATED_ROLES: SeatedMemberRole[] = ['editor', 'reader']; /** * Pond-settings member management (issue #54). Lists members grouped by role * with a search filter and seat usage; Pond Admins can add by exact username or * e-mail (no directory browsing — privacy), change roles, and remove. Adding is * immediate (there is no invitation to accept). Non-admins see the list * read-only. The api enforces every rule; this UI only reflects it. */ export function MemberManager({ pondId }: { pondId: string }): React.JSX.Element { const { t } = useTranslation('members'); const query = usePondMembers(pondId); const mutations = useMemberMutations(pondId); const errorText = useErrorText(); const [error, setError] = useState(null); const [search, setSearch] = useState(''); const [identifier, setIdentifier] = useState(''); const [addRole, setAddRole] = useState('reader'); const view = query.data; const filtered = useMemo(() => { const needle = search.trim().toLowerCase(); const members = view?.members ?? []; if (!needle) return members; return members.filter( (m) => m.displayName.toLowerCase().includes(needle) || m.username.toLowerCase().includes(needle), ); }, [view?.members, search]); if (query.isLoading) return

; if (query.error || !view) { return (

{t('loadError')}

); } const { seats, pondType, canManage } = view; // Personal ponds have a single administrator (their owner) — no admin option. const addableRoles: MemberRole[] = pondType === 'personal' ? ['editor', 'reader'] : ['pond_admin', 'editor', 'reader']; const seatFull = (role: MemberRole): boolean => (role === 'editor' || role === 'reader') && seats[role].used >= seats[role].limit; const addBlockedByQuota = seatFull(addRole); const run = async (action: () => Promise): Promise => { setError(null); try { await action(); } catch (err) { setError(errorText(err)); } }; const submitAdd = async (event: React.FormEvent): Promise => { event.preventDefault(); const usernameOrEmail = identifier.trim(); if (!usernameOrEmail || addBlockedByQuota) return; await run(async () => { await mutations.add({ usernameOrEmail, role: addRole }); setIdentifier(''); }); }; const roleLabel = (role: MemberRole): string => t(`roles.${role}`); return (

{t('description')}

    {SEATED_ROLES.map((role) => (
  • {t(`seats.${role}`, { used: seats[role].used, limit: seats[role].limit })}
  • ))}
{pondType === 'personal' && (

{t('personalPondNote')}

)} {error && (

{error}

)} {canManage ? (
{addBlockedByQuota && (

{t('add.quotaFull', { role: roleLabel(addRole) })}

)}
) : (

{t('readOnlyNote')}

)} setSearch(e.target.value)} /> {filtered.length === 0 ? (

{view.members.length === 0 ? t('empty') : t('search.empty')}

) : ( ROLE_ORDER.filter((role) => filtered.some((m) => m.role === role)).map((role) => (

{t(`groups.${role}`)}

    {filtered .filter((m) => m.role === role) .map((member) => ( run(() => mutations.changeRole(member.userId, next))} onRemove={() => run(() => mutations.remove(member.userId))} /> ))}
)) )}
); } function MemberRow({ member, canManage, roleLabel, onChangeRole, onRemove, }: { member: MemberView; canManage: boolean; roleLabel: (role: MemberRole) => string; onChangeRole: (role: MemberRole) => void; onRemove: () => void; }): React.JSX.Element { const { t } = useTranslation('members'); // The owner's own membership is fixed; everyone else is manageable by admins. const manageable = canManage && !member.isOwner; return (
  • {member.displayName} @{member.username} {member.isOwner && · {t('ownerBadge')}} {manageable ? ( ) : ( {roleLabel(member.role)} )}
  • ); }