feat(group): 1st implementation of Groups

this implements first version (manageable only by admin right now)

    routes:

        GET /api/groups
        List subject groups (paginated). Admin-only.

        POST /api/groups
        Create a new ReBAC subject group. Admin-only. The name must match the RFC 5321 local-part shape and be globally unique (case-insensitive).

        GET /api/groups/search
        Search non-virtual groups by name substring. Authenticated only (no admin role required) — backs the share-dialog recipient autocomplete.

        GET /api/groups/{id}
        Fetch a single group's details. Admin-only.

        DELETE /api/groups/{id}
        Delete a group. Cascades to `subject_group_members` (FK) and to `access_grants` rows referencing this group as a subject. Admin-only.

        PATCH /api/groups/{id}
        Update a group's metadata. Admin-only. v1 only persists name renames.

        GET /api/groups/{id}/effective-members
        List every user transitively reached through this group (members of members of members, etc.). Used by admin / audit tooling. Admin-only.

        GET /api/groups/{id}/members
        List the *direct* members of a group (one level only). Admin-only.

        POST /api/groups/{id}/members
        Add a member to a group. Exactly one of `user_id` / `group_id` must be provided. Adding a group-member runs a write-time cycle check and a nesting-depth check (max 8). Admin-only.

        DELETE /api/groups/{id}/members/group/{gid}
        Remove a nested group-member from a group. Admin-only.

        DELETE /api/groups/{id}/members/user/{uid}
        Remove a user-member from a group. Admin-only.

fix hurl

groups

round

groups
This commit is contained in:
Edouard Vanbelle
2026-05-30 23:35:47 +02:00
parent 41356b6490
commit 09985f8a95
54 changed files with 6421 additions and 145 deletions
+86
View File
@@ -0,0 +1,86 @@
// @ts-check
/**
* Display helpers for ReBAC subject groups.
*
* Server-side names of virtual groups (`Internal`, future `Everyone`, …) are
* fixed RFC 5321 local-part strings so they can be email-addressable. The UI
* surfaces them with a localised, capitalised label and a distinct icon.
*
* "Add a new virtual group" — frontend cost is:
* 1. Add an entry to `VIRTUAL_NAME_KEYS` mapping the well-known UUID to an
* `i18n` key.
* 2. Add the i18n key + translations in the 16 locale files.
*
* Everything else (search results, vignettes, member rows, autocomplete)
* picks the new group up automatically because the backend now returns
* virtual groups in `/api/groups/search`.
*/
import { i18n } from '../core/i18n.js';
import { INTERNAL_GROUP_ID } from '../model/groups.js';
/**
* Map of well-known virtual-group UUIDs → i18n key for the human-readable
* display name. Anything not in this map falls back to `group.name`.
*
* @type {Record<string, string>}
*/
const VIRTUAL_NAME_KEYS = {
[INTERNAL_GROUP_ID]: 'groups.virtual_internal_name'
};
/**
* Minimal shape needed by the display helpers. Both `GroupItem` (from
* `/api/groups`) and shareModal's `GroupSuggestion` satisfy it, so callers
* can pass either without an awkward upcast.
*
* @typedef {{id: string, name: string, is_virtual: boolean}} GroupDisplay
*/
/**
* Human-readable display name for a group. Virtual groups get a translated
* label; user-defined groups display their raw name.
*
* @param {GroupDisplay} group
* @returns {string}
*/
export function groupDisplayName(group) {
if (group.is_virtual) {
const key = VIRTUAL_NAME_KEYS[group.id];
if (key) return i18n.t(key, group.name);
}
return group.name;
}
/** FA class for system-managed (virtual) groups. `fa-people-roof` evokes a
* shared roof / community, distinguishing virtual instance-wide groups
* (Internal, future Everyone, …) from user-defined groups. Change here to
* re-skin every virtual-group surface in the app in one place. */
const VIRTUAL_ICON = 'fa-people-roof';
/** FA class for user-defined groups. */
const REGULAR_ICON = 'fa-user-group';
/**
* Pick the Font Awesome icon class for a group vignette. Virtual groups use
* `VIRTUAL_ICON`; user-defined groups use `REGULAR_ICON`.
*
* @param {GroupDisplay} group
* @returns {string}
*/
export function groupIconClass(group) {
return group.is_virtual ? VIRTUAL_ICON : REGULAR_ICON;
}
/**
* Same as `groupIconClass` but for call sites that hold only the
* `is_virtual` boolean — e.g. `MemberEntry._isVirtual` in shareModal,
* where the full `GroupItem` isn't kept around.
*
* @param {boolean | undefined} isVirtual
* @returns {string}
*/
export function groupIconClassByVirtual(isVirtual) {
return isVirtual ? VIRTUAL_ICON : REGULAR_ICON;
}
+42
View File
@@ -0,0 +1,42 @@
// @ts-check
/**
* Inline element representing a ReBAC subject group: user-group icon +
* the group's name. Used by the share dialog (to display groups as share
* recipients) and by the group-management view (to display nested-group
* members).
*
* Visually mirrors `createUserVignette` from `./userVignette.js` so a row
* built from one can swap in the other without layout shift. Picks
* `fa-user-group` (a *people* icon) rather than `fa-layer-group`, which is
* reserved across the app for the *grouping operator* on group-by menu pills
* — keeping the two concepts visually distinct.
*
* subject group (this file) fa-user-group
* grouping operator (group-by pills) fa-layer-group
*/
import { escapeHtml } from '../core/formatters.js';
/**
* Build the inline vignette.
*
* @param {string} name
* Display name of the group (escaped before injection).
* @param {'xs'|'sm'|'md'|'list'} [size='sm']
* Matches the size scale of `createUserVignette`. The size class is
* `user-vignette--${size}`; see `static/css/components/userVignette.css`.
* @param {{ icon?: string }} [opts]
* `icon`: FA class string without the `fa-` prefix (defaults to
* `'fa-user-group'`). Used to signal virtual groups visually — see
* `groupIconClass()` / `groupIconClassByVirtual()` in `./groupDisplay.js`
* return a distinct icon for system-wide virtual groups (Internal,
* future Everyone, …).
* @returns {HTMLElement}
*/
export function createGroupVignette(name, size = 'sm', { icon = 'fa-user-group' } = {}) {
const el = document.createElement('div');
el.className = `user-vignette user-vignette-group user-vignette--${size}`;
el.innerHTML = `<span class="user-vignette__avatar"><i class="fas ${escapeHtml(icon)}"></i></span><span class="user-vignette__name">${escapeHtml(name)}</span>`;
return el;
}
+81 -4
View File
@@ -15,6 +15,8 @@ import { fileSharing } from '../features/sharing/fileSharing.js';
import { grants } from '../model/grants.js';
import { buildExpiryChip } from '../utils/expiryChip.js';
import { buildPasswordChip } from '../utils/passwordChip.js';
import { groupDisplayName, groupIconClass } from './groupDisplay.js';
import { createGroupVignette } from './groupVignette.js';
import { buildLinkChip } from './linkChip.js';
import { buildResourceIcon } from './resourceIcon.js';
import { buildRoleChip, roleLabel } from './roleChip.js';
@@ -40,6 +42,26 @@ function _expiryState(expiresAt) {
return 'active';
}
/**
* Extract the unique group subject IDs across all grants in a page.
* Callers feed the result to `groups.resolveGroups(...)` so rows can render
* the group's display name instead of its UUID.
*
* @param {OutgoingResourceItem[]} items
* @returns {Set<string>}
*/
function collectGroupSubjectIds(items) {
const out = new Set();
for (const item of items) {
for (const g of item.grants) {
if (g.subject_type === 'group') out.add(g.subject_id);
}
}
return out;
}
export { collectGroupSubjectIds };
class MySharesList {
/**
* @param {HTMLElement} container
@@ -55,6 +77,47 @@ class MySharesList {
this._lastSwimKey = null;
/** @type {HTMLElement|null} */
this._lastSwimEl = null;
/**
* Cached map of group subject UUID → full GroupItem. Populated by
* the view via `setGroupMeta()` before each `render()` / `append()`
* so group lane headers and identity rows render with the localised
* name + virtual-aware icon.
* @type {Record<string, import('../core/types.js').GroupItem>}
*/
this._groupMeta = {};
}
/**
* Provide a resolved id→GroupItem map for group subjects expected in
* the next render / append call. Replaces (does not merge) any previous
* map.
* @param {Record<string, import('../core/types.js').GroupItem>} map
*/
setGroupMeta(map) {
this._groupMeta = map;
}
/**
* Best-effort display name for a group subject. Falls back to the UUID
* when no entry has been resolved yet — better than nothing while the
* resolve query is in flight.
* @param {string} groupId
* @returns {string}
*/
_groupName(groupId) {
const g = this._groupMeta[groupId];
return g ? groupDisplayName(g) : groupId;
}
/**
* Icon class for a group subject. Falls back to the regular group icon
* if the entry hasn't been resolved yet.
* @param {string} groupId
* @returns {string}
*/
_groupIcon(groupId) {
const g = this._groupMeta[groupId];
return g ? groupIconClass(g) : 'fa-user-group';
}
clear() {
@@ -119,6 +182,8 @@ class MySharesList {
let swimKey;
if (grant.subject_type === 'user') {
swimKey = `user:${grant.subject_id}`;
} else if (grant.subject_type === 'group') {
swimKey = `group:${grant.subject_id}`;
} else if (grant.has_password) {
swimKey = 'links:password';
} else {
@@ -201,6 +266,11 @@ class MySharesList {
if (swimKey.startsWith('user:')) {
return createUserVignette(grant.subject_id, 'list');
}
if (swimKey.startsWith('group:')) {
return createGroupVignette(this._groupName(grant.subject_id), 'list', {
icon: this._groupIcon(grant.subject_id)
});
}
const el = document.createElement('div');
el.className = 'ms-link-lane-label';
const icon = document.createElement('i');
@@ -258,8 +328,8 @@ class MySharesList {
const el = document.createElement('div');
el.className = 'ms-grant-row__identity';
if (grant.subject_type === 'user' && viewMode === 'sharedWith') {
// Lane header is already the user — show the resource instead
if ((grant.subject_type === 'user' || grant.subject_type === 'group') && viewMode === 'sharedWith') {
// Lane header is already the subject — show the resource instead.
el.appendChild(buildResourceIcon(item.resource, item.resource_type));
const nameLink = document.createElement('a');
nameLink.className = 'ms-identity__resource-name';
@@ -272,6 +342,12 @@ class MySharesList {
el.appendChild(nameLink);
} else if (grant.subject_type === 'user') {
el.appendChild(createUserVignette(grant.subject_id, 'xs'));
} else if (grant.subject_type === 'group') {
el.appendChild(
createGroupVignette(this._groupName(grant.subject_id), 'xs', {
icon: this._groupIcon(grant.subject_id)
})
);
} else {
// Token — link chip handles icon + label + copy-on-click
el.appendChild(buildLinkChip(grant));
@@ -355,7 +431,7 @@ class MySharesList {
// Current expiry as YYYY-MM-DD (or null)
const initialExpiry = grant.expires_at ? String(grant.expires_at).slice(0, 10) : null;
if (grant.subject_type === 'user') {
if (grant.subject_type === 'user' || grant.subject_type === 'group') {
for (const role of /** @type {('admin'|'editor'|'viewer')[]} */ (['admin', 'editor', 'viewer'])) {
const isCurrent = grant.role === role;
const mi = this._menuItem(isCurrent ? 'fas fa-check' : '', roleLabel(role), false, async () => {
@@ -376,8 +452,9 @@ class MySharesList {
menu.appendChild(this._menuSeparator());
menu.appendChild(this._menuExpiryRow(grant, item, rowEl, initialExpiry));
menu.appendChild(this._menuSeparator());
const removeIcon = grant.subject_type === 'group' ? 'fas fa-user-group' : 'fas fa-user-times';
menu.appendChild(
this._menuItem('fas fa-user-times', i18n.t('myshares.removeAccess', 'Remove access'), true, async () => {
this._menuItem(removeIcon, i18n.t('myshares.removeAccess', 'Remove access'), true, async () => {
menu.remove();
await grants.revokeGrant(grant.grant_id);
this._removeRowAndCleanLane(rowEl);
+111 -22
View File
@@ -19,14 +19,29 @@ import { i18n } from '../core/i18n.js';
import { fileSharing } from '../features/sharing/fileSharing.js';
import { addressBook, SYSTEM_BOOK_ID } from '../model/addressBook.js';
import { grants } from '../model/grants.js';
import { groups } from '../model/groups.js';
import { systemUsers } from '../model/systemUsers.js';
import { buildExpiryChip } from '../utils/expiryChip.js';
import { buildPasswordChip } from '../utils/passwordChip.js';
import { groupDisplayName, groupIconClass, groupIconClassByVirtual } from './groupDisplay.js';
import { createGroupVignette } from './groupVignette.js';
import { Modal } from './modal.js';
import { createUserVignette } from './userVignette.js';
/** @import {FileItem, FolderItem, Grant, ContactItem, MemberEntry, LinkEntry, DraftLink, ShareRoleEnum} from '../core/types.js' */
/**
* A ReBAC subject group surfaced by `/api/groups/search`. Shape is a
* deliberate superset of `ContactItem` so the staging / chip / commit code
* paths can treat both uniformly, discriminating on the `_kind` field.
*
* @typedef {Object} GroupSuggestion
* @property {string} id
* @property {string} name
* @property {boolean} is_virtual
* @property {'group'} _kind
*/
/** Permissions that belong to each role (must mirror the Rust DTO). */
const ROLE_PERMISSIONS = {
viewer: ['read'],
@@ -34,6 +49,32 @@ const ROLE_PERMISSIONS = {
admin: ['read', 'comment', 'create', 'update', 'share', 'delete']
};
/**
* Fetch up to ~8 ReBAC subject groups whose name matches `q`. Authenticated
* endpoint; returns `[]` on any failure so the autocomplete degrades to
* contacts-only rather than breaking the dialog.
* @param {string} q
* @returns {Promise<GroupSuggestion[]>}
*/
async function _searchGroups(q) {
try {
const res = await fetch(`/api/groups/search?q=${encodeURIComponent(q)}&limit=8`, {
credentials: 'include'
});
if (!res.ok) return [];
/** @type {Array<{id:string,name:string,is_virtual:boolean}>} */
const items = await res.json();
return items.map((g) => ({
id: g.id,
name: g.name,
is_virtual: !!g.is_virtual,
_kind: /** @type {'group'} */ ('group')
}));
} catch {
return [];
}
}
/**
* Derive the highest role a set of grants represents for one subject.
* @param {Grant[]} subjectGrants
@@ -95,7 +136,7 @@ const shareModal = {
/** @type {DraftLink[]} */
_newLinks: [],
/** @type {ContactItem[]} */
/** @type {Array<ContactItem | GroupSuggestion>} */
_stagedUsers: [],
/** @type {ShareRoleEnum} */
@@ -157,6 +198,26 @@ const shareModal = {
this._localMembers = _buildMembers(grantList);
this._localLinks = linkList.map((share) => /** @type {LinkEntry} */ ({ share, _op: 'keep', _draft: null }));
// Group subjects in grants only carry their UUID — resolve full
// GroupItem records so member rows render the localised name and
// pick the correct icon (virtual groups get people-roof via
// `groupIconClass`).
const groupIds = new Set(this._localMembers.filter((m) => m.grant.subject.type === 'group').map((m) => m.grant.subject.id));
if (groupIds.size > 0) {
const resolved = await groups.resolveGroups(groupIds);
for (const m of this._localMembers) {
if (m.grant.subject.type === 'group') {
const g = resolved[m.grant.subject.id];
if (g) {
m._displayName = groupDisplayName(g);
m._isVirtual = g.is_virtual;
} else {
m._displayName = m.grant.subject.id;
}
}
}
}
} catch (err) {
console.error('shareModal: load error', err);
}
@@ -307,7 +368,10 @@ const shareModal = {
return;
}
debounce = setTimeout(async () => {
const results = await addressBook.searchContacts(q, [SYSTEM_BOOK_ID]);
// Search contacts (users) and ReBAC subject groups in parallel.
// Group results are tagged with `_kind='group'` so the rest of
// the dialog can render and commit them as group subjects.
const [contacts, groupItems] = await Promise.all([addressBook.searchContacts(q, [SYSTEM_BOOK_ID]), _searchGroups(q)]);
// Filter out the currently logged-in user — they cannot share with themselves
const currentUserId = (() => {
try {
@@ -316,9 +380,12 @@ const shareModal = {
return null;
}
})();
const filtered = currentUserId ? results.filter((c) => c.id !== currentUserId) : results;
this._renderSuggestions(dropdown, filtered.slice(0, 8), (contact) => {
this._stageUser(contact, input, dropdown, addBtn);
const filtered = currentUserId ? contacts.filter((c) => c.id !== currentUserId) : contacts;
// Groups first (they're a smaller, distinctively-iconed set),
// then contacts. Cap at 8 combined.
const combined = [...groupItems, ...filtered].slice(0, 8);
this._renderSuggestions(dropdown, combined, (item) => {
this._stageUser(item, input, dropdown, addBtn);
});
}, 200);
});
@@ -349,9 +416,9 @@ const shareModal = {
},
/**
* @param {HTMLElement} container
* @param {ContactItem[]} results
* @param {(c: ContactItem) => void} onSelect
* @param {HTMLElement} container
* @param {Array<ContactItem | GroupSuggestion>} results
* @param {(c: ContactItem | GroupSuggestion) => void} onSelect
*/
_renderSuggestions(container, results, onSelect) {
container.replaceChildren();
@@ -364,7 +431,12 @@ const shareModal = {
item.className = 'smd-suggestion-item';
item.tabIndex = 0;
item.appendChild(createUserVignette(c.id, 'sm', { showEmail: true }));
if (c._kind === 'group') {
const g = /** @type {GroupSuggestion} */ (c);
item.appendChild(createGroupVignette(groupDisplayName(g), 'sm', { icon: groupIconClass(g) }));
} else {
item.appendChild(createUserVignette(c.id, 'sm', { showEmail: true }));
}
const select = () => onSelect(c);
item.addEventListener('click', select);
@@ -377,15 +449,18 @@ const shareModal = {
},
/**
* @param {ContactItem} contact
* @param {HTMLInputElement} inputEl
* @param {HTMLElement} dropdown
* @param {HTMLButtonElement} addBtn
* @param {ContactItem | GroupSuggestion} contact
* @param {HTMLInputElement} inputEl
* @param {HTMLElement} dropdown
* @param {HTMLButtonElement} addBtn
*/
_stageUser(contact, inputEl, dropdown, addBtn) {
// Idempotent: skip duplicates and already-existing members
const alreadyMember = this._localMembers.some((m) => m.grant.subject.id === contact.id && m._op !== 'remove');
const alreadyStaged = this._stagedUsers.some((u) => u.id === contact.id);
// Idempotent: skip duplicates and already-existing members. Match on
// id *and* kind so a user and a group sharing a UUID collision (in
// theory impossible; in practice harmless) wouldn't shadow each other.
const kind = contact._kind === 'group' ? 'group' : 'user';
const alreadyMember = this._localMembers.some((m) => m.grant.subject.id === contact.id && m.grant.subject.type === kind && m._op !== 'remove');
const alreadyStaged = this._stagedUsers.some((u) => u.id === contact.id && (u._kind ?? 'user') === kind);
if (alreadyMember || alreadyStaged) return;
this._stagedUsers.push(contact);
@@ -422,20 +497,27 @@ const shareModal = {
const chip = document.createElement('div');
chip.className = 'smd-chip';
const vignette = createUserVignette(c.id, 'xs');
const visual =
c._kind === 'group'
? (() => {
const g = /** @type {GroupSuggestion} */ (c);
return createGroupVignette(groupDisplayName(g), 'xs', { icon: groupIconClass(g) });
})()
: createUserVignette(c.id, 'xs');
const rm = document.createElement('button');
rm.className = 'smd-chip-remove';
rm.innerHTML = '&times;';
rm.title = i18n.t('actions.remove', 'Remove');
const kind = c._kind === 'group' ? 'group' : 'user';
rm.addEventListener('click', () => {
this._stagedUsers = this._stagedUsers.filter((u) => u.id !== c.id);
this._stagedUsers = this._stagedUsers.filter((u) => !(u.id === c.id && (u._kind ?? 'user') === kind));
this._refreshChips();
const addBtn = /** @type {HTMLButtonElement|null} */ (document.querySelector('.smd-add-btn'));
if (addBtn) addBtn.disabled = this._stagedUsers.length === 0;
});
chip.appendChild(vignette);
chip.appendChild(visual);
chip.appendChild(rm);
container.appendChild(chip);
});
@@ -443,12 +525,13 @@ const shareModal = {
_commitStagedUsers() {
for (const contact of this._stagedUsers) {
const subjectType = contact._kind === 'group' ? 'group' : 'user';
/** @type {Grant} */
const placeholderGrant = {
id: '', // not yet persisted
granted_at: '',
granted_by: '',
subject: { type: 'user', id: contact.id },
subject: { type: subjectType, id: contact.id },
permission: /** @type {import('../core/types.js').PermissionTypeEnum} */ (ROLE_PERMISSIONS[this._stagedRole][0]),
resource: { type: this._itemType, id: this._item?.id ?? '' }
};
@@ -457,7 +540,8 @@ const shareModal = {
_grants: [], // no server grants yet — nothing to revoke on remove
role: this._stagedRole,
_op: 'new',
expires_at: this._stagedExpiry
expires_at: this._stagedExpiry,
_displayName: contact._kind === 'group' ? /** @type {GroupSuggestion} */ (contact).name : undefined
});
}
this._stagedUsers = [];
@@ -528,7 +612,12 @@ const shareModal = {
const row = document.createElement('div');
row.className = 'smd-member-row';
const vignette = createUserVignette(entry.grant.subject.id, 'md');
const vignette =
entry.grant.subject.type === 'group'
? createGroupVignette(entry._displayName ?? entry.grant.subject.id, 'md', {
icon: groupIconClassByVirtual(entry._isVirtual)
})
: createUserVignette(entry.grant.subject.id, 'md');
const roleSelect = document.createElement('select');
roleSelect.className = 'smd-member-role-select';