multiple groups per user

This commit is contained in:
azykov@mail.ru
2026-09-30 09:06:10 +03:00
parent 45158cde38
commit c197e5978a
21 changed files with 749 additions and 277 deletions
+6 -3
View File
@@ -28,8 +28,10 @@ export function App() {
function Shell({ me, groupEpoch, onGroupChanged }: { me: MeResponse; groupEpoch: number; onGroupChanged: () => Promise<void> }) {
const path = usePath();
// Rights in the current group (admin role is per group; superadmin is global).
const isAdmin = me.role !== 'user';
const isSuper = me.role === 'superadmin';
const isSuper = me.superadmin;
const canSwitch = isSuper || me.groups.length > 1;
const tabs = [
...(isAdmin ? [{ path: '/cards', label: 'Cards' }, { path: '/users', label: 'Users' }] : []),
@@ -61,7 +63,7 @@ function Shell({ me, groupEpoch, onGroupChanged }: { me: MeResponse; groupEpoch:
))}
</nav>
<div className="topbar-right">
{isSuper ? <GroupSwitcher me={me} onChanged={onGroupChanged} epoch={groupEpoch} /> : <span className="pill">{me.currentGroup.name}</span>}
{canSwitch ? <GroupSwitcher me={me} onChanged={onGroupChanged} epoch={groupEpoch} /> : <span className="pill">{me.currentGroup.name}</span>}
<span className="muted small" title={me.role}>
{me.email} · {me.role}
</span>
@@ -72,8 +74,9 @@ function Shell({ me, groupEpoch, onGroupChanged }: { me: MeResponse; groupEpoch:
);
}
/** Superadmins switch between all groups, everyone else between their own groups. */
function GroupSwitcher({ me, onChanged, epoch }: { me: MeResponse; onChanged: () => Promise<void>; epoch: number }) {
const groups = useLoad(api.groups, [epoch]);
const groups = useLoad(() => (me.superadmin ? api.groups() : Promise.resolve(me.groups)), [epoch, me.superadmin]);
const [error, setError] = useState<string>();
return (
<label className="group-switcher">
+6 -4
View File
@@ -1,4 +1,4 @@
import type { AccessKeyView, Card, CardFields, CardPatch, Group, MeResponse, Role, UserView } from '@cardy/shared';
import type { AccessKeyView, Card, CardFields, CardPatch, Group, GroupRole, MeResponse, UserView } from '@cardy/shared';
// Reverse proxy: /api/admin/* goes through Authelia to server-be.
const BASE = '/api/admin';
@@ -75,10 +75,12 @@ export const api = {
setCurrentGroup: (groupId: string) => request<Group>('PUT', '/current-group', { groupId }),
users: () => request<UserView[]>('GET', '/users'),
createUser: (email: string, role: Role) => request<UserView>('POST', '/users', { email, role }),
updateUser: (email: string, patch: { role?: Role; groupId?: string }) =>
/** Adds a user to the current group (creates the user when the email is new). */
addUser: (email: string, role: GroupRole) => request<UserView>('POST', '/users', { email, role }),
updateUser: (email: string, patch: { role?: GroupRole; superadmin?: boolean }) =>
request<UserView>('PATCH', `/users/${enc(email)}`, patch),
deleteUser: (email: string) => request<void>('DELETE', `/users/${enc(email)}`),
/** Removes the user from the current group; a user without other groups is deleted. */
removeUser: (email: string) => request<{ deleted: boolean }>('DELETE', `/users/${enc(email)}`),
createKey: (email: string, label: string) => request<AccessKeyView>('POST', `/users/${enc(email)}/keys`, { label }),
deleteKey: (email: string, keyId: string) => request<void>('DELETE', `/users/${enc(email)}/keys/${enc(keyId)}`),
+6 -3
View File
@@ -34,7 +34,7 @@ export function GroupsPage({ me, onGroupsChanged }: { me: MeResponse; onGroupsCh
<ErrorText error={action.error ?? groups.error} />
<ul className="list">
{groups.data?.map((g) => (
<GroupRow key={g.id} group={g} isCurrent={g.id === me.currentGroup.id} isOwn={g.id === me.group.id} onChanged={changed} />
<GroupRow key={g.id} group={g} isCurrent={g.id === me.currentGroup.id} isOwn={me.groups.some((m) => m.id === g.id)} onChanged={changed} />
))}
</ul>
</section>
@@ -72,7 +72,7 @@ function GroupRow({ group, isCurrent, isOwn, onChanged }: { group: Group; isCurr
<div className="grow">
{group.name}
{isCurrent && <span className="pill accent">current</span>}
{isOwn && <span className="pill">yours</span>}
{isOwn && <span className="pill">member</span>}
</div>
<button className="secondary" onClick={() => api.setCurrentGroup(group.id).then(onChanged)} disabled={isCurrent}>
Select
@@ -84,7 +84,10 @@ function GroupRow({ group, isCurrent, isOwn, onChanged }: { group: Group; isCurr
className="danger"
disabled={action.busy}
onClick={async () => {
const ok = confirm(`Delete group "${group.name}"?\n\nAll its users, their keys and all cards with images will be deleted permanently.`);
const ok = confirm(
`Delete group "${group.name}"?\n\nAll its cards with images are deleted permanently; members keep their other groups. ` +
'Not possible while it is the only group of some user.',
);
if (ok && (await action.run(async () => (await api.deleteGroup(group.id), true)))) await onChanged();
}}
>
+50 -56
View File
@@ -1,33 +1,33 @@
import { useState } from 'react';
import { ROLES, type AccessKeyView, type Group, type MeResponse, type Role, type UserView } from '@cardy/shared';
import { GROUP_ROLES, type AccessKeyView, type GroupRole, type MeResponse, type UserView } from '@cardy/shared';
import { api } from '../api.js';
import { ErrorText, KeyQrModal, formatDate, useAction, useLoad } from '../components.js';
export function UsersPage({ me }: { me: MeResponse }) {
const users = useLoad(api.users);
const isSuper = me.role === 'superadmin';
const groups = useLoad(() => (isSuper ? api.groups() : Promise.resolve([] as Group[])));
const assignableRoles: Role[] = isSuper ? [...ROLES] : ['user', 'admin'];
return (
<section>
<h1>Users</h1>
<p className="muted">Users of group “{me.currentGroup.name}”. Users sign in to this site through Authelia with the same email.</p>
<AddUser roles={assignableRoles} onAdded={users.reload} />
<p className="muted">
Members of group “{me.currentGroup.name}”. A user can belong to several groups with a separate role in each; users sign in to
this site through Authelia with the same email.
</p>
<AddUser onAdded={users.reload} />
<ErrorText error={users.error} />
{users.data?.length === 0 && <p className="empty">No users in this group.</p>}
<ul className="list">
{users.data?.map((u) => (
<UserRow key={u.email} user={u} me={me} roles={assignableRoles} groups={groups.data ?? []} onChanged={users.reload} />
<UserRow key={u.email} user={u} me={me} onChanged={users.reload} />
))}
</ul>
</section>
);
}
function AddUser({ roles, onAdded }: { roles: Role[]; onAdded: () => Promise<void> }) {
function AddUser({ onAdded }: { onAdded: () => Promise<void> }) {
const [email, setEmail] = useState('');
const [role, setRole] = useState<Role>('user');
const [role, setRole] = useState<GroupRole>('user');
const action = useAction();
return (
<>
@@ -35,7 +35,7 @@ function AddUser({ roles, onAdded }: { roles: Role[]; onAdded: () => Promise<voi
className="row"
onSubmit={async (e) => {
e.preventDefault();
if (await action.run(() => api.createUser(email.trim(), role))) {
if (await action.run(() => api.addUser(email.trim(), role))) {
setEmail('');
setRole('user');
await onAdded();
@@ -43,42 +43,40 @@ function AddUser({ roles, onAdded }: { roles: Role[]; onAdded: () => Promise<voi
}}
>
<input type="email" placeholder="email@example.com" value={email} onChange={(e) => setEmail(e.target.value)} required />
<select value={role} onChange={(e) => setRole(e.target.value as Role)}>
{roles.map((r) => (
<select value={role} onChange={(e) => setRole(e.target.value as GroupRole)} aria-label="Role in this group">
{GROUP_ROLES.map((r) => (
<option key={r}>{r}</option>
))}
</select>
<button type="submit" disabled={action.busy}>
Add user
Add to group
</button>
</form>
<p className="muted small">An email that already exists in another group is added to this group as well.</p>
<ErrorText error={action.error} />
</>
);
}
function UserRow({
user,
me,
roles,
groups,
onChanged,
}: {
user: UserView;
me: MeResponse;
roles: Role[];
groups: Group[];
onChanged: () => Promise<void>;
}) {
function UserRow({ user, me, onChanged }: { user: UserView; me: MeResponse; onChanged: () => Promise<void> }) {
const [expanded, setExpanded] = useState(false);
const action = useAction();
const isSelf = user.email === me.email;
const canManage = me.role === 'superadmin' || user.role !== 'superadmin';
const canManage = me.superadmin || !user.superadmin;
const otherGroups = user.groups.filter((g) => g.id !== me.currentGroup.id);
const update = async (patch: { role?: Role; groupId?: string }) => {
const update = async (patch: { role?: GroupRole; superadmin?: boolean }) => {
if (await action.run(() => api.updateUser(user.email, patch))) await onChanged();
};
const remove = async () => {
const question = otherGroups.length
? `Remove ${user.email} from “${me.currentGroup.name}”? They stay in ${otherGroups.map((g) => `“${g.name}”`).join(', ')}.`
: `Delete ${user.email}? “${me.currentGroup.name}” is their only group, so the user and all their keys are deleted.`;
if (!confirm(question)) return;
if (await action.run(() => api.removeUser(user.email))) await onChanged();
};
return (
<li className="list-item column">
<div className="row">
@@ -86,50 +84,45 @@ function UserRow({
<div>
{user.email}
{isSelf && <span className="pill">you</span>}
{user.superadmin && <span className="pill accent">superadmin</span>}
</div>
<div className="muted small">
{user.keys.length} key{user.keys.length === 1 ? '' : 's'}
{otherGroups.length > 0 && ` · also in ${otherGroups.map((g) => `${g.name} (${g.role})`).join(', ')}`}
</div>
</div>
<select
value={user.role}
disabled={!canManage || isSelf || action.busy}
onChange={(e) => update({ role: e.target.value as Role })}
aria-label="Role"
onChange={(e) => update({ role: e.target.value as GroupRole })}
aria-label="Role in this group"
title="Role in this group"
>
{(roles.includes(user.role) ? roles : [...roles, user.role]).map((r) => (
{GROUP_ROLES.map((r) => (
<option key={r}>{r}</option>
))}
</select>
{me.role === 'superadmin' && groups.length > 0 && (
<select
value={user.groupId}
disabled={action.busy}
onChange={(e) => {
const target = groups.find((g) => g.id === e.target.value);
if (confirm(`Move ${user.email} to group "${target?.name}"?`)) void update({ groupId: e.target.value });
}}
aria-label="Group"
>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
{me.superadmin && (
<label className="checkbox" title="Superadmins manage all groups">
<input
type="checkbox"
checked={user.superadmin}
disabled={isSelf || action.busy}
onChange={(e) => {
const on = e.target.checked;
if (confirm(on ? `Make ${user.email} a superadmin of all groups?` : `Remove superadmin rights from ${user.email}?`)) {
void update({ superadmin: on });
}
}}
/>
superadmin
</label>
)}
<button className="secondary" onClick={() => setExpanded((x) => !x)} disabled={!canManage}>
{expanded ? 'Hide keys' : 'Keys'}
</button>
<button
className="danger"
disabled={!canManage || isSelf || action.busy}
onClick={async () => {
if (!confirm(`Delete user ${user.email} and all their keys?`)) return;
if (await action.run(async () => (await api.deleteUser(user.email), true))) await onChanged();
}}
>
Delete
<button className="danger" disabled={!canManage || isSelf || action.busy} onClick={remove}>
{otherGroups.length ? 'Remove from group' : 'Delete user'}
</button>
</div>
<ErrorText error={action.error} />
@@ -145,6 +138,7 @@ function UserKeys({ user, onChanged }: { user: UserView; onChanged: () => Promis
return (
<div className="subpanel">
<p className="muted small">A key signs the app in to all groups of the user.</p>
{user.keys.length === 0 && <p className="muted small">No keys.</p>}
<ul className="list compact">
{user.keys.map((k) => (