All checks were successful
CI / Auth e2e pack (pull_request) Successful in 8m36s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CI / Lint, typecheck, test (pull_request) Successful in 6m22s
CI / Build container images (pull_request) Successful in 3m51s
CD / Build and push images (push) Successful in 15s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 13s
CI / Lint, typecheck, test (push) Successful in 6m32s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m25s
CI / Import/export fidelity gate (push) Successful in 58s
The notification bell sat higher and larger than search and the theme toggle next to it. The cause was not the glyph: `.notifications-bell__button` carried its own rules with neither flex centring nor an icon size, so the svg was laid out inline on the text baseline and rendered at lucide's 24px default instead of the 1.15rem the shared `.icon-button` enforces. Route every icon-only control through the shared components instead: - `IconLink` joins `IconButton`, sharing one class helper. Three controls navigate (pond settings, graph, trash) and are links, not buttons — without a link twin they would have stayed the one group gluing the class on by hand. - 17 hand-applied `className="icon-button …"` usages across nine files now go through the components, which is what enforces the accessible name on a control that shows only an icon. - The bell's unread count reaches assistive technology. The badge sits inside the control, so `aria-label` hid it and a screen reader announced "Notifications" without ever saying how many. An ESLint rule keeps it that way: `icon-button` on a raw button, anchor or Link is now an error, in both string and template-literal form. The plugin uninstall button keeps a title that differs from its name (it explains why a required plugin is locked); IconButton spreads rest last, so the explicit title still wins. Also drops the graphify block from CLAUDE.md — it duplicates the workspace-level instructions.
251 lines
8.3 KiB
TypeScript
251 lines
8.3 KiB
TypeScript
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<string | null>(null);
|
|
const [search, setSearch] = useState('');
|
|
const [identifier, setIdentifier] = useState('');
|
|
const [addRole, setAddRole] = useState<MemberRole>('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 <p aria-busy="true" />;
|
|
if (query.error || !view) {
|
|
return (
|
|
<p className="form-banner form-banner--error" role="alert">
|
|
{t('loadError')}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
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<void>): Promise<void> => {
|
|
setError(null);
|
|
try {
|
|
await action();
|
|
} catch (err) {
|
|
setError(errorText(err));
|
|
}
|
|
};
|
|
|
|
const submitAdd = async (event: React.FormEvent): Promise<void> => {
|
|
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 (
|
|
<div className="member-manager">
|
|
<p className="member-manager__intro">{t('description')}</p>
|
|
|
|
<ul className="member-seats">
|
|
{SEATED_ROLES.map((role) => (
|
|
<li key={role} className={`member-seats__item member-seats__item--${role}`}>
|
|
{t(`seats.${role}`, { used: seats[role].used, limit: seats[role].limit })}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
{pondType === 'personal' && (
|
|
<p className="member-manager__note" role="note">
|
|
{t('personalPondNote')}
|
|
</p>
|
|
)}
|
|
|
|
{error && (
|
|
<p className="form-banner form-banner--error" role="alert">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{canManage ? (
|
|
<form className="member-add" onSubmit={submitAdd}>
|
|
<label className="member-add__field">
|
|
<span>{t('add.identifier')}</span>
|
|
<input
|
|
className="member-add__identifier"
|
|
value={identifier}
|
|
placeholder={t('add.identifierPlaceholder')}
|
|
onChange={(e) => setIdentifier(e.target.value)}
|
|
autoComplete="off"
|
|
/>
|
|
</label>
|
|
<label className="member-add__field">
|
|
<span>{t('add.role')}</span>
|
|
<select
|
|
className="member-add__role"
|
|
value={addRole}
|
|
onChange={(e) => setAddRole(e.target.value as MemberRole)}
|
|
>
|
|
{addableRoles.map((role) => (
|
|
<option key={role} value={role}>
|
|
{roleLabel(role)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<IconButton
|
|
className="member-add__submit"
|
|
type="submit"
|
|
disabled={addBlockedByQuota}
|
|
label={t('add.submit')}
|
|
>
|
|
<UserPlus aria-hidden />
|
|
</IconButton>
|
|
{addBlockedByQuota && (
|
|
<p className="member-add__quota-full" role="note">
|
|
{t('add.quotaFull', { role: roleLabel(addRole) })}
|
|
</p>
|
|
)}
|
|
</form>
|
|
) : (
|
|
<p className="member-manager__note" role="note">
|
|
{t('readOnlyNote')}
|
|
</p>
|
|
)}
|
|
|
|
<input
|
|
className="member-search"
|
|
type="search"
|
|
value={search}
|
|
placeholder={t('search.placeholder')}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
|
|
{filtered.length === 0 ? (
|
|
<p className="member-manager__empty">
|
|
{view.members.length === 0 ? t('empty') : t('search.empty')}
|
|
</p>
|
|
) : (
|
|
ROLE_ORDER.filter((role) => filtered.some((m) => m.role === role)).map((role) => (
|
|
<section key={role} className="member-group">
|
|
<h3 className="member-group__title">{t(`groups.${role}`)}</h3>
|
|
<ul className="member-group__list">
|
|
{filtered
|
|
.filter((m) => m.role === role)
|
|
.map((member) => (
|
|
<MemberRow
|
|
key={member.userId}
|
|
member={member}
|
|
canManage={canManage}
|
|
roleLabel={roleLabel}
|
|
onChangeRole={(next) => run(() => mutations.changeRole(member.userId, next))}
|
|
onRemove={() => run(() => mutations.remove(member.userId))}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
))
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<li className="member-row" data-username={member.username}>
|
|
<span className="member-row__name">
|
|
{member.displayName}
|
|
<span className="member-row__username"> @{member.username}</span>
|
|
{member.isOwner && <span className="member-row__owner-badge"> · {t('ownerBadge')}</span>}
|
|
</span>
|
|
{manageable ? (
|
|
<span className="member-row__actions">
|
|
<label className="member-row__role-field">
|
|
<span className="visually-hidden">{t('actions.role')}</span>
|
|
<select
|
|
className="member-row__role"
|
|
value={member.role}
|
|
onChange={(e) => onChangeRole(e.target.value as MemberRole)}
|
|
>
|
|
{ROLE_ORDER.map((role) => (
|
|
<option key={role} value={role}>
|
|
{roleLabel(role)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<IconButton className="member-row__remove" label={t('actions.remove')} onClick={onRemove}>
|
|
<UserMinus aria-hidden />
|
|
</IconButton>
|
|
</span>
|
|
) : (
|
|
<span className="member-row__role-label">{roleLabel(member.role)}</span>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|