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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = '×';
|
||||
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';
|
||||
|
||||
Reference in New Issue
Block a user