multiple groups per user
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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)}`),
|
||||
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user