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
+308
View File
@@ -0,0 +1,308 @@
/* ── Groups management modal — content styles ─────────────────────────────────
*
* Hosted by Modal.openPanel(). Only this file's content scrolls; the overlay,
* container, header and footer come from modals.css.
*
* Two states share the same body: list (rows of groups + Create button) and
* detail (single-group editor). Switching is a body-level replaceChildren
* call; the modal frame stays open.
*
* BEM root: `.groups-modal`. All colours via design tokens.
* ───────────────────────────────────────────────────────────────────────── */
.groups-modal {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 20px;
}
/* ── Common ─────────────────────────────────────────────────────────────── */
.groups-modal__status,
.groups-modal__empty-line {
color: var(--color-text-subtle);
font-size: 13px;
padding: 8px 0;
}
.groups-modal__error {
color: var(--color-danger-text);
background: var(--color-danger-bg);
border: 1px solid var(--color-danger-alt);
border-radius: 6px;
padding: 10px 12px;
font-size: 13px;
}
.groups-modal__inline-error {
margin: 0 0 8px 0;
color: var(--color-danger-text);
background: var(--color-danger-bg);
border: 1px solid var(--color-danger-alt);
border-radius: 6px;
padding: 8px 12px;
font-size: 13px;
}
.groups-modal__section-title {
font-size: 12px;
font-weight: 600;
color: var(--color-text-subtle);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 8px;
}
/* ── List view ──────────────────────────────────────────────────────────── */
.groups-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.groups-modal__subtitle {
font-size: 14px;
color: var(--color-text-secondary);
}
.groups-modal__create-btn {
flex-shrink: 0;
}
.groups-modal__list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 60vh;
overflow-y: auto;
}
.groups-modal__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.12s ease;
}
.groups-modal__row:hover,
.groups-modal__row:focus-visible {
background: var(--color-bg-hover);
outline: none;
}
.groups-modal__row-main {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.groups-modal__row-desc {
color: var(--color-text-subtle);
font-size: 12px;
margin-left: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
}
.groups-modal__row-badge {
background: var(--color-bg-muted);
color: var(--color-text-subtle);
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
flex-shrink: 0;
margin-left: 8px;
}
.groups-modal__row-count {
color: var(--color-text-subtle);
font-size: 12px;
flex-shrink: 0;
margin-left: auto;
padding-left: 8px;
white-space: nowrap;
}
.groups-modal__load-more {
align-self: center;
margin-top: 12px;
}
.groups-modal__empty {
text-align: center;
color: var(--color-text-subtle);
padding: 32px 12px;
}
.groups-modal__empty-icon {
font-size: 32px;
color: var(--color-text-faint);
margin-bottom: 8px;
display: block;
}
/* ── Detail view ────────────────────────────────────────────────────────── */
.groups-modal__detail-header {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--color-border);
}
.groups-modal__back-btn {
flex-shrink: 0;
}
.groups-modal__detail-title-wrap {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.groups-modal__members {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 40vh;
overflow-y: auto;
}
.groups-modal__member-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
border-radius: 6px;
transition: background-color 0.12s ease;
}
.groups-modal__member-row:hover {
background: var(--color-bg-hover);
}
.groups-modal__member-remove {
flex-shrink: 0;
color: var(--color-text-subtle);
width: 28px;
height: 28px;
padding: 0;
border-radius: 50%;
}
.groups-modal__member-remove:hover {
color: var(--color-danger-text);
background: var(--color-danger-bg);
}
/* ── Add-member row ─────────────────────────────────────────────────────── */
.groups-modal__add-row {
position: relative;
margin-top: 8px;
}
.groups-modal__add-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-surface);
color: var(--color-text);
font-size: 13px;
}
.groups-modal__add-input:focus {
outline: 2px solid var(--color-accent-ring);
outline-offset: -1px;
border-color: var(--color-accent);
}
.groups-modal__add-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 6px;
box-shadow: 0 4px 12px var(--color-shadow);
max-height: 240px;
overflow-y: auto;
z-index: 10;
}
.groups-modal__add-dropdown.hidden {
display: none;
}
.groups-modal__add-item {
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.12s ease;
}
.groups-modal__add-item:hover,
.groups-modal__add-item:focus-visible {
background: var(--color-bg-hover);
outline: none;
}
/* ── Destructive footer ─────────────────────────────────────────────────── */
.groups-modal__footer {
display: flex;
justify-content: flex-start;
padding-top: 12px;
border-top: 1px solid var(--color-border);
margin-top: 8px;
}
/* ── Inline create / rename / confirm-delete forms ──────────────────────── */
/* Modal is a singleton; nesting Modal.prompt inside an open openPanel()
* can't surface its input. We render the form inside the panel body
* instead, swapping back to list / detail on submit or cancel. */
.groups-modal__form {
display: flex;
flex-direction: column;
gap: 12px;
padding: 8px 0;
}
.groups-modal__form-label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: var(--color-text-secondary);
}
.groups-modal__form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
.groups-modal__inline-error.hidden {
display: none;
}
+30 -1
View File
@@ -140,10 +140,39 @@
/* When a photo is available the JS replaces the initials text with an <img>.
The avatar <span> keeps its background color as a fallback while loading. */
.user-vignette__avatar img {
.user-vignette__avatar img,
.user-vignette__avatar .oxi-icon {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
display: block;
}
/* ── Group variant ──────────────────────────────────────────────────────────
* `.user-vignette-group` is built by `components/groupVignette.js`. It reuses
* the user-vignette layout + size modifiers (`--xs/sm/md/list/lg/menu/xl`)
* so a row built from a group can swap in for a user without layout shift.
*
* The avatar circle is filled with a neutral surface colour and contains a
* `fa-user-group` icon instead of initials/photo. Single hue for every group
* (rather than the deterministic palette user avatars use) — this is a quick
* visual signal that the row represents a group, not a person. */
.user-vignette-group .user-vignette__avatar {
background: var(--color-badge-blue-bg);
color: var(--color-badge-blue-text);
}
.user-vignette-group .user-vignette__avatar i {
/* Scale the inline-SVG icon (oxi-icon, replaced by icons.js) to fit the
circle. The base `<i>` is 1em — adjust so it sits centred with a small
padding inside the badge regardless of size modifier. */
width: 62%;
height: 62%;
}
.user-vignette-group .user-vignette__avatar .oxi-icon {
width: 100%;
height: 100%;
}
+1
View File
@@ -22,6 +22,7 @@
@import url("./components/modals.css");
@import url("./components/shareDialog.css");
@import url("./components/shareModal.css");
@import url("./components/groupsModal.css");
@import url("./components/userVignette.css");
@import url("./components/linkChip.css");
@import url("./components/uploadDropdown.css");
+4
View File
@@ -198,6 +198,10 @@
<i class="fas fa-cogs"></i>
<span data-i18n="user_menu.admin_panel">Admin panel</span>
</button>
<button class="user-menu-item user-menu-groups hidden" id="user-menu-groups">
<i class="fas fa-user-group"></i>
<span data-i18n="user_menu.manage_groups">Manage groups</span>
</button>
<button class="user-menu-item" id="user-menu-profile">
<i class="fas fa-user-circle"></i>
<span data-i18n="user_menu.profile">My profile</span>
+15
View File
@@ -6,6 +6,7 @@ import { createUserVignette } from '../components/userVignette.js';
import { getCsrfHeaders } from '../core/csrf.js';
import { formatFileSize, formatQuotaSize } from '../core/formatters.js';
import { i18n } from '../core/i18n.js';
import { groupsView } from '../views/groups/groupsView.js';
function setupUserMenu() {
const wrapper = document.getElementById('user-menu-wrapper');
@@ -15,6 +16,7 @@ function setupUserMenu() {
const themeSegmented = document.getElementById('user-menu-theme-segmented');
const aboutBtn = document.getElementById('user-menu-about');
const adminBtn = document.getElementById('user-menu-admin');
const groupsBtn = document.getElementById('user-menu-groups');
const adminDivider = document.getElementById('user-menu-admin-divider');
const profileBtn = document.getElementById('user-menu-profile');
const roleBadge = document.getElementById('user-menu-role-badge');
@@ -42,6 +44,12 @@ function setupUserMenu() {
if (adminBtn) {
isAdmin ? adminBtn.classList.remove('hidden') : adminBtn.classList.add('hidden');
}
if (groupsBtn) {
// v1: admin-only. v2 will broaden to "has any manageable
// group" — change the right-hand side here without touching
// anything else.
isAdmin ? groupsBtn.classList.remove('hidden') : groupsBtn.classList.add('hidden');
}
if (adminDivider) {
isAdmin ? adminDivider.classList.remove('hidden') : adminDivider.classList.add('hidden');
}
@@ -117,6 +125,13 @@ function setupUserMenu() {
});
}
if (groupsBtn) {
groupsBtn.addEventListener('click', () => {
wrapper.classList.remove('open');
groupsView.open();
});
}
if (profileBtn) {
profileBtn.addEventListener('click', () => {
wrapper.classList.remove('open');
+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';
+9
View File
@@ -161,6 +161,7 @@ const OxiIcons = {
512,
'M288 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 242.7-73.4-73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l128 128c12.5 12.5 32.8 12.5 45.3 0l128-128c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L288 274.7 288 32zM64 352c-35.3 0-64 28.7-64 64l0 32c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-32c0-35.3-28.7-64-64-64l-101.5 0-45.3 45.3c-25 25-65.5 25-90.5 0L165.5 352 64 352zm368 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z'
],
'ellipsis-v': [128, 'M64 360a56 56 0 1 0 0 112 56 56 0 1 0 0-112zm0-160a56 56 0 1 0 0 112 56 56 0 1 0 0-112zM120 96A56 56 0 1 0 8 96a56 56 0 1 0 112 0z'],
envelope: [
512,
'M48 64C21.5 64 0 85.5 0 112c0 15.1 7.1 29.3 19.2 38.4L236.8 313.6c11.4 8.5 27 8.5 38.4 0L492.8 150.4c12.1-9.1 19.2-23.3 19.2-38.4c0-26.5-21.5-48-48-48L48 64zM0 176L0 384c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-208L294.4 339.2c-22.8 17.1-54 17.1-76.8 0L0 176z'
@@ -341,6 +342,10 @@ const OxiIcons = {
512,
'M36.4 353.2c4.1-14.6 11.8-27.9 22.6-38.7l181.2-181.2 33.9-33.9c16.6 16.6 51.3 51.3 104 104l33.9 33.9-33.9 33.9-181.2 181.2c-10.7 10.7-24.1 18.5-38.7 22.6L30.4 510.6c-8.3 2.3-17.3 0-23.4-6.2S-1.4 489.3 .9 481L36.4 353.2zm55.6-3.7c-4.4 4.7-7.6 10.4-9.3 16.6l-24.1 86.9 86.9-24.1c6.4-1.8 12.2-5.1 17-9.7L91.9 349.5zm354-146.1c-16.6-16.6-51.3-51.3-104-104L308 65.5C334.5 39 349.4 24.1 352.9 20.6 366.4 7 384.8-.6 404-.6S441.6 7 455.1 20.6l35.7 35.7C504.4 69.9 512 88.3 512 107.4s-7.6 37.6-21.2 51.1c-3.5 3.5-18.4 18.4-44.9 44.9z'
],
'people-roof': [
576,
'M302.3-12.6c-9-4.5-19.6-4.5-28.6 0l-256 128C1.9 123.3-4.5 142.5 3.4 158.3s27.1 22.2 42.9 14.3L288 51.8 529.7 172.6c15.8 7.9 35 1.5 42.9-14.3s1.5-35-14.3-42.9l-256-128zM288 272a56 56 0 1 0 0-112 56 56 0 1 0 0 112zm0 48c-53 0-96 43-96 96l0 32c0 17.7 14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-32c0-53-43-96-96-96zM160 256a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zm352 0a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM112 336c-44.2 0-80 35.8-80 80l0 33.1c0 17 13.8 30.9 30.9 30.9l87.8 0c-4.3-9.8-6.7-20.6-6.7-32l0-48c0-18.4 3.5-36 9.8-52.2-12.2-7.5-26.5-11.8-41.8-11.8zM425.4 480l87.8 0c17 0 30.9-13.8 30.9-30.9l0-33.1c0-44.2-35.8-80-80-80-15.3 0-29.6 4.3-41.8 11.8 6.3 16.2 9.8 33.8 9.8 52.2l0 48c0 11.4-2.4 22.2-6.7 32z'
],
play: [
384,
'M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80L0 432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z'
@@ -453,6 +458,10 @@ const OxiIcons = {
512,
'M399 384.2C376.9 345.8 335.4 320 288 320l-64 0c-47.4 0-88.9 25.8-111 64.2c35.2 39.2 86.2 63.8 143 63.8s107.8-24.7 143-63.8zM0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256zm256 16a72 72 0 1 0 0-144 72 72 0 1 0 0 144z'
],
'user-group': [
640,
'M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304l91.4 0C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7L29.7 512C13.3 512 0 498.7 0 482.3zM609.3 512l-137.8 0c5.4-9.4 8.6-20.3 8.6-32l0-8c0-60.7-27.1-115.2-69.8-151.8c2.4-.1 4.7-.2 7.1-.2l61.4 0C567.8 320 640 392.2 640 481.3c0 17-13.8 30.7-30.7 30.7zM432 256c-31 0-59-12.6-79.3-32.9C372.4 196.5 384 163.6 384 128c0-26.8-6.6-52.1-18.3-74.3C384.3 40.1 407.2 32 432 32c61.9 0 112 50.1 112 112s-50.1 112-112 112z'
],
'user-plus': [
640,
'M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304l91.4 0C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7L29.7 512C13.3 512 0 498.7 0 482.3zM504 312l0-64-64 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l64 0 0-64c0-13.3 10.7-24 24-24s24 10.7 24 24l0 64 64 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-64 0 0 64c0 13.3-10.7 24-24 24s-24-10.7-24-24z'
+46 -8
View File
@@ -338,15 +338,20 @@
/**
* One (subject, permissions) entry within an outgoing resource item.
* Mirrors the server's `OutgoingResourceGrantDto`. `subject_type` is the
* full set the backend may emit; the UI for My Shares filters out `'group'`
* before rendering (see `_excludeGroupGrants` in mySharesList.js) so only
* `'user'` and `'token'` rows actually reach the view layer there.
*
* @typedef {Object} OutgoingResourceGrant
* @property {string} grant_id
* @property {'user'|'token'} subject_type
* @property {string} subject_id
* @property {string} subject_display - Username (users) or share name (tokens).
* @property {'viewer'|'editor'|'admin'} role
* @property {string} granted_at - ISO-8601
* @property {string|null} [expires_at] - ISO-8601 or absent.
* @property {boolean} has_password - True when a token subject has a password set.
* @property {string} grant_id
* @property {'user'|'group'|'token'|'external'} subject_type
* @property {string} subject_id
* @property {string} subject_display - Username (users) or share name (tokens).
* @property {'viewer'|'editor'|'admin'} role
* @property {string} granted_at - ISO-8601
* @property {string|null} [expires_at] - ISO-8601 or absent.
* @property {boolean} has_password - True when a token subject has a password set.
*/
/**
@@ -406,6 +411,8 @@
* @property {string} created_at - ISO-8601
* @property {string} updated_at - ISO-8601
* @property {string} etag
* @property {'user'|'group'} [_kind] - Discriminator added by the share-modal autocomplete when merging contacts with ReBAC subject groups. Absent (or 'user') for plain contacts; 'group' indicates the row is a subject-group suggestion with a `name` field instead of contact details.
* @property {string} [name] - Present only when `_kind === 'group'` — the subject-group's display name.
*/
/**
@@ -438,6 +445,8 @@
* @property {ShareRoleEnum} role - Derived role label shown in the UI.
* @property {'keep'|'remove'|'change'|'new'} _op - Pending local operation.
* @property {string|null} [expires_at] - YYYY-MM-DD expiry date string, or null for no expiry.
* @property {string} [_displayName] - Optional human-readable label (set for group subjects so the row can show the group name; user subjects resolve their name via `createUserVignette`).
* @property {boolean} [_isVirtual] - True when this row's subject is a virtual (system-managed) group, so the vignette renders with the virtual-group icon.
*/
/**
@@ -456,3 +465,32 @@
* @property {string|null} expires_at - ISO-8601 date string or null.
*/
// ------------------- ReBAC subject groups
/**
* Mirrors `GroupDto` on the server (`subject_group_handler.rs::GroupDto`).
* @typedef {Object} GroupItem
* @property {string} id
* @property {string} name
* @property {string|null} [description]
* @property {boolean} is_virtual
* @property {string} created_at - ISO-8601
* @property {string} updated_at - ISO-8601
* @property {boolean} can_manage - True if the current caller may rename / delete / curate the membership.
* @property {number} member_count - Direct-member count (users + nested groups, one level). The
* `/groups/search` endpoint emits 0 to skip a per-row COUNT(*);
* list/get/create/update return the real value.
*/
/**
* Response from `GET /api/groups` — paginated list of groups.
* @typedef {Object} GroupListResponse
* @property {GroupItem[]} items
* @property {number} total
*/
/**
* One direct member of a group (tagged union: user or nested group).
* @typedef {{kind: 'user', id: string} | {kind: 'group', id: string}} GroupMemberItem
*/
+248
View File
@@ -0,0 +1,248 @@
// @ts-check
/**
* @import {GroupItem, GroupListResponse, GroupMemberItem} from '../core/types.js'
*/
/**
* Thin API client for ReBAC subject groups (`/api/groups/*`).
*
* Uses the global `fetch` (intercepted in `core/fetchWrapper.js` for
* 401-refresh-retry and `ApiError` translation) and `getCsrfHeaders()` for
* mutating verbs.
*
* v1: most endpoints are admin-only on the backend; `/api/groups/search` is
* authenticated-only and powers the share-dialog recipient autocomplete.
* v2 will relax the admin guard to per-group `Manage` permissions — every
* caller here will keep working unchanged, but a non-admin may start
* receiving 403s on `get`/`listMembers` for groups they can't see.
*/
import { getCsrfHeaders } from '../core/csrf.js';
/** Well-known UUID of the predefined Internal virtual group (matches the
* Rust constant `INTERNAL_GROUP_ID` in `src/domain/entities/subject_group.rs`). */
const INTERNAL_GROUP_ID = '00000000-0000-0000-0000-000000000001';
const groups = {
/**
* Paginated list. Admin-only on the server today.
* @param {{limit?: number, offset?: number, q?: string|null}} [opts]
* @returns {Promise<GroupListResponse>}
*/
async list({ limit = 50, offset = 0, q = null } = {}) {
const params = new URLSearchParams();
params.set('limit', String(limit));
params.set('offset', String(offset));
if (q) params.set('q', q);
const res = await fetch(`/api/groups?${params}`);
if (!res.ok) throw await _err(res, 'list groups');
return res.json();
},
/**
* Search up to ~8 non-virtual groups whose name matches `q`.
* Authenticated (not admin-gated) — used by the share-dialog autocomplete.
* @param {string} q
* @param {number} [limit=8]
* @returns {Promise<GroupItem[]>}
*/
async search(q, limit = 8) {
const params = new URLSearchParams({ q, limit: String(limit) });
const res = await fetch(`/api/groups/search?${params}`);
if (!res.ok) throw await _err(res, 'search groups');
return res.json();
},
/**
* Resolve a set of group IDs to full `GroupItem` records. Used after
* loading grants (which only carry `subject_id`) so the UI can render
* the group's name and pick a virtual-aware icon.
*
* Strategy: a single `/api/groups/search` call with empty `q` and a
* generous limit covers any caller (admin or not) without needing the
* admin-gated `GET /api/groups/{id}`. Virtual groups are now returned
* by the search endpoint, so no special-casing is needed here.
*
* Unresolved IDs (deleted groups, or beyond the search limit) get a
* synthetic stub so call sites never have to handle missing entries.
*
* @param {Iterable<string>} ids
* @returns {Promise<Record<string, GroupItem>>}
*/
async resolveGroups(ids) {
const wanted = new Set(ids);
/** @type {Record<string, GroupItem>} */
const out = {};
if (wanted.size === 0) return out;
try {
const items = await this.search('', 200);
for (const g of items) {
if (wanted.has(g.id)) out[g.id] = g;
}
} catch {
// Network / auth failure — fall through to the stub fallback below.
}
// Anything still unresolved → readable stub so the UI never shows a
// raw UUID. `is_virtual: false` matches the safer (more restrictive)
// visual treatment when in doubt.
const now = new Date().toISOString();
for (const id of wanted) {
if (!(id in out)) {
out[id] = {
id,
name: `Group ${id.slice(0, 8)}…`,
description: null,
is_virtual: false,
created_at: now,
updated_at: now,
can_manage: false,
member_count: 0
};
}
}
return out;
},
/**
* @param {string} id
* @returns {Promise<GroupItem>}
*/
async get(id) {
const res = await fetch(`/api/groups/${encodeURIComponent(id)}`);
if (!res.ok) throw await _err(res, 'get group');
return res.json();
},
/**
* @param {{name: string, description?: string|null}} body
* @returns {Promise<GroupItem>}
*/
async create(body) {
const res = await fetch('/api/groups', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: JSON.stringify(body)
});
if (!res.ok) throw await _err(res, 'create group');
return res.json();
},
/**
* @param {string} id
* @param {string} newName
* @returns {Promise<GroupItem>}
*/
async rename(id, newName) {
const res = await fetch(`/api/groups/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: JSON.stringify({ name: newName })
});
if (!res.ok) throw await _err(res, 'rename group');
return res.json();
},
/**
* @param {string} id
* @returns {Promise<void>}
*/
async deleteGroup(id) {
const res = await fetch(`/api/groups/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: getCsrfHeaders()
});
if (!res.ok) throw await _err(res, 'delete group');
},
/**
* Direct members (one level only, not transitive).
* @param {string} id
* @returns {Promise<GroupMemberItem[]>}
*/
async listMembers(id) {
const res = await fetch(`/api/groups/${encodeURIComponent(id)}/members`);
if (!res.ok) throw await _err(res, 'list members');
return res.json();
},
/**
* Add a user as a member.
* @param {string} groupId
* @param {string} userId
* @returns {Promise<void>}
*/
async addUserMember(groupId, userId) {
const res = await fetch(`/api/groups/${encodeURIComponent(groupId)}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: JSON.stringify({ user_id: userId })
});
if (!res.ok) throw await _err(res, 'add user member');
},
/**
* Add another group as a nested member.
* Backend runs the cycle + depth checks at write time.
* @param {string} groupId
* @param {string} memberGroupId
* @returns {Promise<void>}
*/
async addGroupMember(groupId, memberGroupId) {
const res = await fetch(`/api/groups/${encodeURIComponent(groupId)}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: JSON.stringify({ group_id: memberGroupId })
});
if (!res.ok) throw await _err(res, 'add group member');
},
/**
* @param {string} groupId
* @param {string} userId
* @returns {Promise<void>}
*/
async removeUserMember(groupId, userId) {
const res = await fetch(`/api/groups/${encodeURIComponent(groupId)}/members/user/${encodeURIComponent(userId)}`, {
method: 'DELETE',
headers: getCsrfHeaders()
});
if (!res.ok) throw await _err(res, 'remove user member');
},
/**
* @param {string} groupId
* @param {string} memberGroupId
* @returns {Promise<void>}
*/
async removeGroupMember(groupId, memberGroupId) {
const res = await fetch(`/api/groups/${encodeURIComponent(groupId)}/members/group/${encodeURIComponent(memberGroupId)}`, {
method: 'DELETE',
headers: getCsrfHeaders()
});
if (!res.ok) throw await _err(res, 'remove group member');
}
};
/**
* Build a thrown Error from a non-OK Response. The fetch interceptor turns
* structured API errors into `ApiError`, so anything that lands here is
* either a network failure or an error the interceptor already enriched.
* @param {Response} res
* @param {string} context
* @returns {Promise<Error>}
*/
async function _err(res, context) {
let detail = `${res.status} ${res.statusText}`;
try {
const body = await res.text();
if (body) detail += `: ${body}`;
} catch {
// body unreadable — fall through with status only.
}
return new Error(`${context} failed: ${detail}`);
}
export { groups, INTERNAL_GROUP_ID };
+825
View File
@@ -0,0 +1,825 @@
// @ts-check
/**
* Subject-group management view.
*
* Reached from the user-menu "Manage groups" entry. Opens a `Modal.openPanel`
* with a two-state UI:
*
* list — paginated list of groups + Create button
* detail — single-group editor: members, add/remove, rename, delete
*
* Mutations commit immediately (POST/DELETE per click). The two states are
* rendered into the same panel body — `_renderListInto()` and
* `_renderDetailInto()` swap the body element, the modal frame stays open.
*
* Action buttons (Create, Rename, Delete, Add/Remove member) read
* `group.can_manage` from the backend DTO and are hidden/disabled when
* `false`. v1 returns `can_manage = (role === "admin")`; v2 will return it
* from per-group `Manage` grants — no JS change required at that point.
*/
import { groupDisplayName, groupIconClass } from '../../components/groupDisplay.js';
import { createGroupVignette } from '../../components/groupVignette.js';
import { Modal } from '../../components/modal.js';
import { createUserVignette } from '../../components/userVignette.js';
import { escapeHtml } from '../../core/formatters.js';
import { i18n } from '../../core/i18n.js';
import { addressBook, SYSTEM_BOOK_ID } from '../../model/addressBook.js';
import { groups } from '../../model/groups.js';
/**
* @import {ContactItem, GroupItem, GroupMemberItem} from '../../core/types.js'
*/
const PAGE_SIZE = 50;
/**
* Localised "(N members)" label for a group list row. The project's i18n
* helper is key→string with no built-in pluralisation, so we branch on the
* three forms named by the plan and substitute `{count}` ourselves.
*
* @param {number} count
* @returns {string}
*/
function _memberCountLabel(count) {
if (count === 0) return i18n.t('groups.member_count_zero', 'no members');
if (count === 1) return i18n.t('groups.member_count_one', '1 member');
return i18n.t('groups.member_count_other', '{count} members').replace('{count}', String(count));
}
const groupsView = {
// ── State ─────────────────────────────────────────────────────────────
/** @type {HTMLElement|null} — current panel body container */
_bodyEl: null,
/** @type {GroupItem|null} — populated when in detail state */
_currentGroup: null,
/** @type {GroupMemberItem[]} — direct members of the current group */
_members: [],
/** @type {GroupItem[]} — most recent list page */
_items: [],
_nextOffset: 0,
_hasMore: false,
// ── Public entry ──────────────────────────────────────────────────────
/** Open the management modal at the list view. */
async open() {
this._currentGroup = null;
this._members = [];
this._items = [];
this._nextOffset = 0;
this._hasMore = false;
this._bodyEl = document.createElement('div');
this._bodyEl.className = 'groups-modal';
Modal.openPanel({
title: i18n.t('groups.title', 'Manage groups'),
icon: 'fa-user-group',
content: this._bodyEl,
confirmText: i18n.t('actions.close', 'Close'),
cancelText: '',
onConfirm: null
});
await this._renderListInto(this._bodyEl);
},
// ── List view ─────────────────────────────────────────────────────────
/** @param {HTMLElement} root */
async _renderListInto(root) {
root.replaceChildren();
const header = document.createElement('div');
header.className = 'groups-modal__header';
const subtitle = document.createElement('div');
subtitle.className = 'groups-modal__subtitle';
subtitle.textContent = i18n.t('groups.title', 'Manage groups');
const createBtn = document.createElement('button');
createBtn.type = 'button';
createBtn.className = 'btn btn-primary groups-modal__create-btn';
createBtn.innerHTML = `<i class="fas fa-plus"></i> ${escapeHtml(i18n.t('groups.create_button', 'Create group'))}`;
createBtn.addEventListener('click', () => this._promptCreate());
header.appendChild(subtitle);
header.appendChild(createBtn);
root.appendChild(header);
const list = document.createElement('div');
list.className = 'groups-modal__list';
root.appendChild(list);
const status = document.createElement('div');
status.className = 'groups-modal__status';
status.textContent = i18n.t('groups.loading', 'Loading…');
list.appendChild(status);
try {
const page = await groups.list({ limit: PAGE_SIZE, offset: 0 });
this._items = page.items;
this._nextOffset = page.items.length;
this._hasMore = page.items.length < page.total;
list.replaceChildren();
if (page.items.length === 0) {
const empty = document.createElement('div');
empty.className = 'groups-modal__empty';
empty.innerHTML = `<i class="fas fa-user-group groups-modal__empty-icon"></i><p>${escapeHtml(i18n.t('groups.empty_state', 'No groups yet.'))}</p>`;
list.appendChild(empty);
return;
}
for (const g of page.items) {
list.appendChild(this._buildListRow(g));
}
if (this._hasMore) {
const more = document.createElement('button');
more.type = 'button';
more.className = 'btn btn-ghost groups-modal__load-more';
more.textContent = i18n.t('groups.load_more', 'Load more');
more.addEventListener('click', async () => {
more.disabled = true;
const next = await groups.list({ limit: PAGE_SIZE, offset: this._nextOffset });
more.remove();
for (const g of next.items) list.appendChild(this._buildListRow(g));
this._items = [...this._items, ...next.items];
this._nextOffset += next.items.length;
this._hasMore = this._items.length < next.total;
if (this._hasMore) list.appendChild(more);
});
list.appendChild(more);
}
} catch (err) {
list.replaceChildren();
const errEl = document.createElement('div');
errEl.className = 'groups-modal__error';
errEl.textContent = /** @type {Error} */ (err).message;
list.appendChild(errEl);
}
},
/**
* @param {GroupItem} g
* @returns {HTMLElement}
*/
_buildListRow(g) {
const row = document.createElement('div');
row.className = 'groups-modal__row';
row.tabIndex = 0;
// Vignette + meta
const main = document.createElement('div');
main.className = 'groups-modal__row-main';
main.appendChild(createGroupVignette(groupDisplayName(g), 'md', { icon: groupIconClass(g) }));
if (g.description) {
const desc = document.createElement('div');
desc.className = 'groups-modal__row-desc';
desc.textContent = g.description;
main.appendChild(desc);
}
if (g.is_virtual) {
const badge = document.createElement('span');
badge.className = 'groups-modal__row-badge';
badge.textContent = i18n.t('groups.virtual_badge', 'System');
main.appendChild(badge);
}
// Virtual groups (Internal, future Everyone, …) have no direct members
// by construction — membership is computed implicitly by the engine —
// so a literal "no members" chip would be misleading. Skip it.
if (!g.is_virtual) {
const memberChip = document.createElement('span');
memberChip.className = 'groups-modal__row-count';
memberChip.textContent = _memberCountLabel(g.member_count);
main.appendChild(memberChip);
}
row.appendChild(main);
// Click row → open detail. Virtual groups are read-only but can still
// be inspected.
const openDetail = () => this._showDetail(g.id);
row.addEventListener('click', openDetail);
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openDetail();
}
});
return row;
},
/**
* Render an inline name-entry form into the panel body. The Modal is a
* singleton — calling `Modal.prompt()` from inside an open `openPanel()`
* mutates the same overlay and doesn't surface a usable input field, so
* we keep the create / rename flows inside this view's own body.
* @param {HTMLElement} root
*/
_renderCreateInto(root) {
root.replaceChildren();
const header = document.createElement('div');
header.className = 'groups-modal__detail-header';
const back = document.createElement('button');
back.type = 'button';
back.className = 'btn btn-ghost groups-modal__back-btn';
back.setAttribute('aria-label', i18n.t('groups.back_to_list', 'Back'));
back.innerHTML = '<i class="fas fa-arrow-left"></i>';
back.addEventListener('click', () => this._renderListInto(root));
header.appendChild(back);
const title = document.createElement('div');
title.className = 'groups-modal__subtitle';
title.textContent = i18n.t('groups.create_dialog_title', 'New group');
header.appendChild(title);
root.appendChild(header);
const form = document.createElement('div');
form.className = 'groups-modal__form';
const label = document.createElement('label');
label.className = 'groups-modal__form-label';
label.textContent = i18n.t('groups.name_label', 'Name');
form.appendChild(label);
const input = document.createElement('input');
input.type = 'text';
input.className = 'groups-modal__add-input';
input.placeholder = i18n.t('groups.name_placeholder', 'engineering');
input.autocomplete = 'off';
label.appendChild(input);
const err = document.createElement('div');
err.className = 'groups-modal__inline-error hidden';
form.appendChild(err);
const actions = document.createElement('div');
actions.className = 'groups-modal__form-actions';
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.className = 'btn btn-ghost';
cancel.textContent = i18n.t('actions.cancel', 'Cancel');
cancel.addEventListener('click', () => this._renderListInto(root));
const save = document.createElement('button');
save.type = 'button';
save.className = 'btn btn-primary';
save.textContent = i18n.t('actions.create', 'Create');
const submit = async () => {
const value = input.value.trim();
if (!value) {
err.textContent = i18n.t('errors.group_name_invalid', 'Invalid name.');
err.classList.remove('hidden');
input.focus();
return;
}
save.disabled = true;
try {
await groups.create({ name: value });
await this._renderListInto(root);
} catch (e) {
err.textContent = /** @type {Error} */ (e).message;
err.classList.remove('hidden');
save.disabled = false;
}
};
save.addEventListener('click', submit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
submit();
} else if (e.key === 'Escape') {
e.preventDefault();
this._renderListInto(root);
}
});
actions.appendChild(cancel);
actions.appendChild(save);
form.appendChild(actions);
root.appendChild(form);
// Focus the input on next tick so the panel finishes rendering first.
setTimeout(() => input.focus(), 0);
},
_promptCreate() {
if (this._bodyEl) this._renderCreateInto(this._bodyEl);
},
// ── Detail view ───────────────────────────────────────────────────────
/** @param {string} groupId */
async _showDetail(groupId) {
if (!this._bodyEl) return;
try {
const [group, members] = await Promise.all([groups.get(groupId), groups.listMembers(groupId)]);
this._currentGroup = group;
this._members = members;
this._renderDetailInto(this._bodyEl);
} catch (err) {
this._showFatalError(/** @type {Error} */ (err).message);
}
},
/** @param {HTMLElement} root */
_renderDetailInto(root) {
const group = this._currentGroup;
if (!group) return;
root.replaceChildren();
// ── Header (back arrow + name + meta) ─────────────────────────────
const header = document.createElement('div');
header.className = 'groups-modal__detail-header';
const back = document.createElement('button');
back.type = 'button';
back.className = 'btn btn-ghost groups-modal__back-btn';
back.setAttribute('aria-label', i18n.t('groups.back_to_list', 'Back'));
back.innerHTML = '<i class="fas fa-arrow-left"></i>';
back.addEventListener('click', () => this._renderListInto(root));
header.appendChild(back);
const titleWrap = document.createElement('div');
titleWrap.className = 'groups-modal__detail-title-wrap';
titleWrap.appendChild(createGroupVignette(groupDisplayName(group), 'md', { icon: groupIconClass(group) }));
if (group.is_virtual) {
const badge = document.createElement('span');
badge.className = 'groups-modal__row-badge';
badge.textContent = i18n.t('groups.virtual_badge', 'System');
titleWrap.appendChild(badge);
}
header.appendChild(titleWrap);
if (group.can_manage && !group.is_virtual) {
const rename = document.createElement('button');
rename.type = 'button';
rename.className = 'btn btn-ghost';
rename.innerHTML = `<i class="fas fa-pen"></i>`;
rename.title = i18n.t('actions.rename', 'Rename');
rename.addEventListener('click', () => this._promptRename(group));
header.appendChild(rename);
}
root.appendChild(header);
// ── Members section ───────────────────────────────────────────────
const membersSection = document.createElement('div');
membersSection.className = 'groups-modal__members';
const membersHeader = document.createElement('div');
membersHeader.className = 'groups-modal__section-title';
membersHeader.textContent = i18n.t('groups.members_section', 'Members');
membersSection.appendChild(membersHeader);
if (this._members.length === 0) {
const empty = document.createElement('div');
empty.className = 'groups-modal__empty-line';
empty.textContent = i18n.t('groups.no_members', 'No members yet.');
membersSection.appendChild(empty);
} else {
for (const m of this._members) {
membersSection.appendChild(this._buildMemberRow(m));
}
}
root.appendChild(membersSection);
// ── Add-member row (only if can_manage) ───────────────────────────
if (group.can_manage && !group.is_virtual) {
root.appendChild(this._buildAddMemberRow());
}
// ── Delete group (destructive footer) ─────────────────────────────
if (group.can_manage && !group.is_virtual) {
const footer = document.createElement('div');
footer.className = 'groups-modal__footer';
const del = document.createElement('button');
del.type = 'button';
del.className = 'btn btn-danger';
del.innerHTML = `<i class="fas fa-trash"></i> ${escapeHtml(i18n.t('groups.delete_group', 'Delete group'))}`;
del.addEventListener('click', () => this._confirmDelete(group));
footer.appendChild(del);
root.appendChild(footer);
}
},
/**
* @param {GroupMemberItem} m
* @returns {HTMLElement}
*/
_buildMemberRow(m) {
const row = document.createElement('div');
row.className = 'groups-modal__member-row';
if (m.kind === 'user') {
row.appendChild(createUserVignette(m.id, 'sm', { showEmail: true }));
} else {
// Nested group — show the vignette. We don't pre-load the name
// (it's just the id from the API). To get the name we'd need an
// extra fetch; for v1, show the id-as-name (small UX cost) and
// upgrade once `list_direct_members` returns enriched rows.
row.appendChild(createGroupVignette(m.id, 'sm'));
}
if (this._currentGroup?.can_manage && !this._currentGroup?.is_virtual) {
const rm = document.createElement('button');
rm.type = 'button';
rm.className = 'btn btn-ghost groups-modal__member-remove';
rm.innerHTML = '&times;';
rm.title = i18n.t('groups.remove_member', 'Remove');
rm.addEventListener('click', () => this._removeMember(m));
row.appendChild(rm);
}
return row;
},
_buildAddMemberRow() {
const group = this._currentGroup;
if (!group) return document.createElement('div');
const wrap = document.createElement('div');
wrap.className = 'groups-modal__add-row';
const input = document.createElement('input');
input.type = 'text';
input.className = 'groups-modal__add-input';
input.placeholder = i18n.t('groups.add_member_placeholder', 'Add a user or group…');
const dropdown = document.createElement('div');
dropdown.className = 'groups-modal__add-dropdown hidden';
/** @type {ReturnType<typeof setTimeout>|null} */
let debounce = null;
/** @param {GroupMemberItem} m */
const knownMemberKey = (m) => `${m.kind}:${m.id}`;
const seen = new Set(this._members.map(knownMemberKey));
input.addEventListener('input', () => {
if (debounce) clearTimeout(debounce);
const q = input.value.trim();
if (!q) {
dropdown.classList.add('hidden');
dropdown.replaceChildren();
return;
}
debounce = setTimeout(async () => {
try {
const [contacts, groupResults] = await Promise.all([addressBook.searchContacts(q, [SYSTEM_BOOK_ID]), groups.search(q)]);
// Filter out the current group itself + already-members.
const userHits = contacts.filter((c) => !seen.has(`user:${c.id}`)).slice(0, 5);
const groupHits = groupResults.filter((g) => g.id !== group.id && !seen.has(`group:${g.id}`)).slice(0, 5);
this._renderAddSuggestions(dropdown, userHits, groupHits);
} catch (err) {
dropdown.replaceChildren();
const e = document.createElement('div');
e.className = 'groups-modal__error';
e.textContent = /** @type {Error} */ (err).message;
dropdown.appendChild(e);
dropdown.classList.remove('hidden');
}
}, 200);
});
document.addEventListener(
'click',
(e) => {
if (!wrap.contains(/** @type {Node} */ (e.target))) {
dropdown.classList.add('hidden');
}
},
{ once: false }
);
wrap.appendChild(input);
wrap.appendChild(dropdown);
return wrap;
},
/**
* @param {HTMLElement} dropdown
* @param {ContactItem[]} userHits
* @param {GroupItem[]} groupHits
*/
_renderAddSuggestions(dropdown, userHits, groupHits) {
dropdown.replaceChildren();
if (userHits.length === 0 && groupHits.length === 0) {
dropdown.classList.add('hidden');
return;
}
const group = this._currentGroup;
if (!group) return;
for (const g of groupHits) {
const item = document.createElement('div');
item.className = 'groups-modal__add-item';
item.tabIndex = 0;
item.appendChild(createGroupVignette(groupDisplayName(g), 'sm', { icon: groupIconClass(g) }));
item.addEventListener('click', async () => {
dropdown.classList.add('hidden');
await this._addGroupMember(g.id);
});
dropdown.appendChild(item);
}
for (const c of userHits) {
const item = document.createElement('div');
item.className = 'groups-modal__add-item';
item.tabIndex = 0;
item.appendChild(createUserVignette(c.id, 'sm', { showEmail: true }));
item.addEventListener('click', async () => {
dropdown.classList.add('hidden');
await this._addUserMember(c.id);
});
dropdown.appendChild(item);
}
dropdown.classList.remove('hidden');
},
/**
* Inline rename form. Replaces the detail view body. Same singleton-modal
* constraint as `_renderCreateInto` — we keep all forms inside the panel.
* @param {GroupItem} group
*/
_promptRename(group) {
if (!this._bodyEl) return;
const root = this._bodyEl;
root.replaceChildren();
const header = document.createElement('div');
header.className = 'groups-modal__detail-header';
const back = document.createElement('button');
back.type = 'button';
back.className = 'btn btn-ghost groups-modal__back-btn';
back.setAttribute('aria-label', i18n.t('groups.back_to_list', 'Back'));
back.innerHTML = '<i class="fas fa-arrow-left"></i>';
back.addEventListener('click', () => this._renderDetailInto(root));
header.appendChild(back);
const title = document.createElement('div');
title.className = 'groups-modal__subtitle';
title.textContent = i18n.t('groups.edit_dialog_title', 'Rename group');
header.appendChild(title);
root.appendChild(header);
const form = document.createElement('div');
form.className = 'groups-modal__form';
const label = document.createElement('label');
label.className = 'groups-modal__form-label';
label.textContent = i18n.t('groups.name_label', 'Name');
form.appendChild(label);
const input = document.createElement('input');
input.type = 'text';
input.className = 'groups-modal__add-input';
input.value = group.name;
input.autocomplete = 'off';
label.appendChild(input);
const err = document.createElement('div');
err.className = 'groups-modal__inline-error hidden';
form.appendChild(err);
const actions = document.createElement('div');
actions.className = 'groups-modal__form-actions';
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.className = 'btn btn-ghost';
cancel.textContent = i18n.t('actions.cancel', 'Cancel');
cancel.addEventListener('click', () => this._renderDetailInto(root));
const save = document.createElement('button');
save.type = 'button';
save.className = 'btn btn-primary';
save.textContent = i18n.t('actions.rename', 'Rename');
const submit = async () => {
const value = input.value.trim();
if (!value || value === group.name) {
this._renderDetailInto(root);
return;
}
save.disabled = true;
try {
const updated = await groups.rename(group.id, value);
this._currentGroup = updated;
this._renderDetailInto(root);
} catch (e) {
err.textContent = /** @type {Error} */ (e).message;
err.classList.remove('hidden');
save.disabled = false;
}
};
save.addEventListener('click', submit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
submit();
} else if (e.key === 'Escape') {
e.preventDefault();
this._renderDetailInto(root);
}
});
actions.appendChild(cancel);
actions.appendChild(save);
form.appendChild(actions);
root.appendChild(form);
setTimeout(() => {
input.focus();
input.select();
}, 0);
},
/**
* Inline confirmation form for group deletion. Requires the user to type
* the group name (safer than DELETE keyword — name is visible above).
* @param {GroupItem} group
*/
_confirmDelete(group) {
if (!this._bodyEl) return;
const root = this._bodyEl;
root.replaceChildren();
const header = document.createElement('div');
header.className = 'groups-modal__detail-header';
const back = document.createElement('button');
back.type = 'button';
back.className = 'btn btn-ghost groups-modal__back-btn';
back.setAttribute('aria-label', i18n.t('groups.back_to_list', 'Back'));
back.innerHTML = '<i class="fas fa-arrow-left"></i>';
back.addEventListener('click', () => this._renderDetailInto(root));
header.appendChild(back);
const title = document.createElement('div');
title.className = 'groups-modal__subtitle';
title.textContent = i18n.t('groups.delete_group', 'Delete group');
header.appendChild(title);
root.appendChild(header);
const form = document.createElement('div');
form.className = 'groups-modal__form';
const warning = document.createElement('div');
warning.className = 'groups-modal__inline-error';
warning.textContent = i18n.t('groups.delete_confirm', 'Delete the group "{name}"?').replace('{name}', group.name);
form.appendChild(warning);
const label = document.createElement('label');
label.className = 'groups-modal__form-label';
label.textContent = i18n.t('groups.delete_confirm_label', 'Type the group name to confirm:');
form.appendChild(label);
const input = document.createElement('input');
input.type = 'text';
input.className = 'groups-modal__add-input';
input.autocomplete = 'off';
input.placeholder = group.name;
label.appendChild(input);
const err = document.createElement('div');
err.className = 'groups-modal__inline-error hidden';
form.appendChild(err);
const actions = document.createElement('div');
actions.className = 'groups-modal__form-actions';
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.className = 'btn btn-ghost';
cancel.textContent = i18n.t('actions.cancel', 'Cancel');
cancel.addEventListener('click', () => this._renderDetailInto(root));
const del = document.createElement('button');
del.type = 'button';
del.className = 'btn btn-danger';
del.textContent = i18n.t('actions.delete', 'Delete');
const submit = async () => {
if (input.value !== group.name) {
err.textContent = i18n.t('groups.delete_confirm_mismatch', 'Type the group name exactly to confirm.');
err.classList.remove('hidden');
input.focus();
return;
}
del.disabled = true;
try {
await groups.deleteGroup(group.id);
this._currentGroup = null;
this._members = [];
this._renderListInto(root);
} catch (e) {
err.textContent = /** @type {Error} */ (e).message;
err.classList.remove('hidden');
del.disabled = false;
}
};
del.addEventListener('click', submit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
submit();
} else if (e.key === 'Escape') {
e.preventDefault();
this._renderDetailInto(root);
}
});
actions.appendChild(cancel);
actions.appendChild(del);
form.appendChild(actions);
root.appendChild(form);
setTimeout(() => input.focus(), 0);
},
/** @param {string} userId */
async _addUserMember(userId) {
const group = this._currentGroup;
if (!group || !this._bodyEl) return;
try {
await groups.addUserMember(group.id, userId);
this._members = await groups.listMembers(group.id);
this._renderDetailInto(this._bodyEl);
} catch (err) {
this._showInlineError(/** @type {Error} */ (err).message);
}
},
/** @param {string} groupId */
async _addGroupMember(groupId) {
const group = this._currentGroup;
if (!group || !this._bodyEl) return;
try {
await groups.addGroupMember(group.id, groupId);
this._members = await groups.listMembers(group.id);
this._renderDetailInto(this._bodyEl);
} catch (err) {
this._showInlineError(/** @type {Error} */ (err).message);
}
},
/** @param {GroupMemberItem} m */
async _removeMember(m) {
const group = this._currentGroup;
if (!group || !this._bodyEl) return;
try {
if (m.kind === 'user') {
await groups.removeUserMember(group.id, m.id);
} else {
await groups.removeGroupMember(group.id, m.id);
}
this._members = await groups.listMembers(group.id);
this._renderDetailInto(this._bodyEl);
} catch (err) {
this._showInlineError(/** @type {Error} */ (err).message);
}
},
/** @param {string} message */
_showInlineError(message) {
if (!this._bodyEl) return;
const existing = this._bodyEl.querySelector('.groups-modal__inline-error');
if (existing) existing.remove();
const el = document.createElement('div');
el.className = 'groups-modal__inline-error';
el.textContent = message;
this._bodyEl.prepend(el);
setTimeout(() => el.remove(), 5000);
},
/** @param {string} message */
_showFatalError(message) {
if (!this._bodyEl) return;
this._bodyEl.replaceChildren();
const el = document.createElement('div');
el.className = 'groups-modal__error';
el.textContent = message;
this._bodyEl.appendChild(el);
}
};
export { groupsView };
+28 -1
View File
@@ -12,12 +12,13 @@
*/
import { ui } from '../../app/ui.js';
import { MySharesList } from '../../components/mySharesList.js';
import { collectGroupSubjectIds, MySharesList } from '../../components/mySharesList.js';
import { shareModal } from '../../components/shareModal.js';
import { i18n } from '../../core/i18n.js';
import * as viewPrefs from '../../core/viewPrefs.js';
import * as itemTooltip from '../../features/itemTooltip.js';
import { grants } from '../../model/grants.js';
import { groups } from '../../model/groups.js';
/** @import {FileItem, FolderItem} from '../../core/types.js' */
@@ -69,6 +70,14 @@ const mySharesView = {
/** @type {MySharesList|null} */
_component: null,
/**
* Cumulative id→GroupItem map of every group subject seen so far in this
* session. Each `_loadPage()` merges newly resolved entries so subsequent
* pages don't re-resolve already-known groups.
* @type {Record<string, import('../../core/types.js').GroupItem>}
*/
_knownGroupMeta: {},
/** @type {string} */
_groupBy: '',
@@ -188,6 +197,24 @@ const mySharesView = {
return;
}
// Resolve full GroupItem records for any group-subject grants on
// this page so lane headers and identity rows render the localised
// name + virtual-aware icon. Cheap — one search call shared across
// every group visible on the page.
if (this._component) {
const groupIds = collectGroupSubjectIds(data.items);
if (groupIds.size > 0) {
try {
const resolved = await groups.resolveGroups(groupIds);
const merged = { ...this._knownGroupMeta, ...resolved };
this._component.setGroupMeta(merged);
this._knownGroupMeta = merged;
} catch (err) {
console.warn('mySharesView: failed to resolve group names', err);
}
}
}
if (isFirstPage) {
this._component?.render(data.items, mode.viewMode);
} else {
+35 -2
View File
@@ -131,7 +131,8 @@
"light": "فاتح",
"dark": "داكن",
"auto": "مثل النظام"
}
},
"manage_groups": "إدارة المجموعات"
},
"share": {
"dialogTitle": "رابط المشاركة",
@@ -336,7 +337,13 @@
"move_error": "خطأ في النقل",
"empty_name": "لا يمكن أن يكون الاسم فارغاً",
"name_exists": "ملف أو مجلد بهذا الاسم موجود بالفعل",
"generic_error": "حدث خطأ"
"generic_error": "حدث خطأ",
"group_name_invalid": "يجب أن يتطابق اسم المجموعة مع صيغة بادئة البريد الإلكتروني (حروف، أرقام، نقطة، شرطة، شرطة سفلية؛ 1–64 حرفًا).",
"group_cycle": "سينشئ هذا العضو مرجعًا دائريًا بين المجموعات.",
"group_depth_exceeded": "تتجاوز عمق التعشيش الحد الأقصى المسموح (8).",
"group_virtual_immutable": "مجموعة «Internal» تدار من قبل النظام ولا يمكن تعديلها.",
"group_not_found": "المجموعة غير موجودة.",
"group_name_taken": "توجد بالفعل مجموعة بهذا الاسم."
},
"breadcrumb": {
"home": "الرئيسية"
@@ -755,5 +762,31 @@
"today": "اليوم",
"last7days": "آخر 7 أيام",
"last30days": "آخر 30 يومًا"
},
"groups": {
"title": "إدارة المجموعات",
"create_button": "إنشاء مجموعة",
"create_dialog_title": "مجموعة جديدة",
"edit_dialog_title": "إعادة تسمية المجموعة",
"name_label": "الاسم",
"name_placeholder": "engineering",
"description_label": "الوصف (اختياري)",
"members_section": "الأعضاء",
"add_member_placeholder": "إضافة مستخدم أو مجموعة…",
"no_members": "لا يوجد أعضاء بعد.",
"remove_member": "إزالة",
"delete_group": "حذف المجموعة",
"delete_confirm": "حذف المجموعة \"{name}\"؟ سيتم إلغاء الصلاحيات المرتبطة بهذه المجموعة.",
"empty_state": "لا توجد مجموعات بعد.",
"load_more": "تحميل المزيد",
"back_to_list": "رجوع",
"loading": "جارٍ التحميل…",
"virtual_badge": "النظام",
"member_count_zero": "لا يوجد أعضاء",
"member_count_one": "عضو واحد",
"member_count_other": "{count} أعضاء",
"delete_confirm_label": "اكتب اسم المجموعة للتأكيد:",
"delete_confirm_mismatch": "اكتب اسم المجموعة كما هو للتأكيد.",
"virtual_internal_name": "داخلي"
}
}
+35 -2
View File
@@ -131,7 +131,8 @@
"light": "Hell",
"dark": "Dunkel",
"auto": "Wie System"
}
},
"manage_groups": "Gruppen verwalten"
},
"share": {
"dialogTitle": "Link teilen",
@@ -336,7 +337,13 @@
"move_error": "Fehler beim Verschieben",
"empty_name": "Der Name darf nicht leer sein",
"name_exists": "Eine Datei oder ein Ordner mit diesem Namen existiert bereits",
"generic_error": "Ein Fehler ist aufgetreten"
"generic_error": "Ein Fehler ist aufgetreten",
"group_name_invalid": "Der Gruppenname muss dem E-Mail-Präfix-Format entsprechen (Buchstaben, Ziffern, Punkt, Bindestrich, Unterstrich; 1–64 Zeichen).",
"group_cycle": "Dieses Mitglied würde einen Gruppen-Zirkelbezug erzeugen.",
"group_depth_exceeded": "Die Verschachtelungstiefe überschreitet das zulässige Maximum (8).",
"group_virtual_immutable": "Die Gruppe „Internal“ wird vom System verwaltet und kann nicht geändert werden.",
"group_not_found": "Gruppe nicht gefunden.",
"group_name_taken": "Eine Gruppe mit diesem Namen existiert bereits."
},
"breadcrumb": {
"home": "Startseite"
@@ -755,5 +762,31 @@
"today": "Heute",
"last7days": "Letzte 7 Tage",
"last30days": "Letzte 30 Tage"
},
"groups": {
"title": "Gruppen verwalten",
"create_button": "Gruppe erstellen",
"create_dialog_title": "Neue Gruppe",
"edit_dialog_title": "Gruppe umbenennen",
"name_label": "Name",
"name_placeholder": "engineering",
"description_label": "Beschreibung (optional)",
"members_section": "Mitglieder",
"add_member_placeholder": "Benutzer oder Gruppe hinzufügen…",
"no_members": "Noch keine Mitglieder.",
"remove_member": "Entfernen",
"delete_group": "Gruppe löschen",
"delete_confirm": "Die Gruppe „{name}\" löschen? Auf diese Gruppe verweisende Berechtigungen werden widerrufen.",
"empty_state": "Noch keine Gruppen.",
"load_more": "Mehr laden",
"back_to_list": "Zurück",
"loading": "Wird geladen…",
"virtual_badge": "System",
"member_count_zero": "Keine Mitglieder",
"member_count_one": "1 Mitglied",
"member_count_other": "{count} Mitglieder",
"delete_confirm_label": "Tippe den Gruppennamen zur Bestätigung ein:",
"delete_confirm_mismatch": "Tippe den Gruppennamen exakt zur Bestätigung ein.",
"virtual_internal_name": "Intern"
}
}
+35 -2
View File
@@ -131,7 +131,8 @@
"light": "Light",
"dark": "Dark",
"auto": "Like OS"
}
},
"manage_groups": "Manage groups"
},
"share": {
"dialogTitle": "Share Link",
@@ -336,7 +337,13 @@
"move_error": "Error moving",
"empty_name": "Name cannot be empty",
"name_exists": "A file or folder with that name already exists",
"generic_error": "An error has occurred"
"generic_error": "An error has occurred",
"group_name_invalid": "Group name must match the email-prefix format (letters, digits, dot, dash, underscore; 1–64 chars).",
"group_cycle": "This member would create a circular group reference.",
"group_depth_exceeded": "This nesting depth exceeds the maximum allowed (8).",
"group_virtual_immutable": "The 'Internal' group is system-managed and cannot be modified.",
"group_not_found": "Group not found.",
"group_name_taken": "A group with this name already exists."
},
"breadcrumb": {
"home": "Home"
@@ -755,5 +762,31 @@
"today": "Today",
"last7days": "Last 7 days",
"last30days": "Last 30 days"
},
"groups": {
"title": "Manage groups",
"create_button": "Create group",
"create_dialog_title": "New group",
"edit_dialog_title": "Rename group",
"name_label": "Name",
"name_placeholder": "engineering",
"description_label": "Description (optional)",
"members_section": "Members",
"add_member_placeholder": "Add a user or group…",
"no_members": "No members yet.",
"remove_member": "Remove",
"delete_group": "Delete group",
"delete_confirm": "Delete the group \"{name}\"? Grants referencing this group will be revoked.",
"empty_state": "No groups yet.",
"load_more": "Load more",
"back_to_list": "Back",
"loading": "Loading…",
"virtual_badge": "System",
"member_count_zero": "no members",
"member_count_one": "1 member",
"member_count_other": "{count} members",
"delete_confirm_label": "Type the group name to confirm:",
"delete_confirm_mismatch": "Type the group name exactly to confirm.",
"virtual_internal_name": "Internal"
}
}
+35 -2
View File
@@ -250,7 +250,8 @@
"light": "Claro",
"dark": "Oscuro",
"auto": "Como el sistema"
}
},
"manage_groups": "Gestionar grupos"
},
"files": {
"name": "Nombre",
@@ -336,7 +337,13 @@
"move_error": "Error al mover",
"empty_name": "El nombre no puede estar vacío",
"name_exists": "Ya existe un archivo o carpeta con ese nombre",
"generic_error": "Ha ocurrido un error"
"generic_error": "Ha ocurrido un error",
"group_name_invalid": "El nombre del grupo debe seguir el formato de prefijo de correo (letras, dígitos, punto, guión, guion bajo; 1–64 caracteres).",
"group_cycle": "Este miembro creaería una referencia circular entre grupos.",
"group_depth_exceeded": "Esta profundidad de anidamiento excede el máximo permitido (8).",
"group_virtual_immutable": "El grupo «Internal» es gestionado por el sistema y no se puede modificar.",
"group_not_found": "Grupo no encontrado.",
"group_name_taken": "Ya existe un grupo con este nombre."
},
"breadcrumb": {
"home": "Inicio"
@@ -755,5 +762,31 @@
"today": "Hoy",
"last7days": "Últimos 7 días",
"last30days": "Últimos 30 días"
},
"groups": {
"title": "Gestionar grupos",
"create_button": "Crear grupo",
"create_dialog_title": "Nuevo grupo",
"edit_dialog_title": "Renombrar grupo",
"name_label": "Nombre",
"name_placeholder": "ingenieria",
"description_label": "Descripción (opcional)",
"members_section": "Miembros",
"add_member_placeholder": "Añadir un usuario o grupo…",
"no_members": "Aún no hay miembros.",
"remove_member": "Eliminar",
"delete_group": "Eliminar grupo",
"delete_confirm": "¿Eliminar el grupo «{name}»? Se revocarán las concesiones que hagan referencia a este grupo.",
"empty_state": "Aún no hay grupos.",
"load_more": "Cargar más",
"back_to_list": "Volver",
"loading": "Cargando…",
"virtual_badge": "Sistema",
"member_count_zero": "Sin miembros",
"member_count_one": "1 miembro",
"member_count_other": "{count} miembros",
"delete_confirm_label": "Escribe el nombre del grupo para confirmar:",
"delete_confirm_mismatch": "Escribe el nombre del grupo exactamente para confirmar.",
"virtual_internal_name": "Interno"
}
}
+35 -2
View File
@@ -131,7 +131,8 @@
"light": "روشن",
"dark": "تاریک",
"auto": "مانند سیستم"
}
},
"manage_groups": "مدیریت گروه‌ها"
},
"share": {
"dialogTitle": "پیوند هم‌رسانی",
@@ -336,7 +337,13 @@
"move_error": "خطا در انتقال",
"empty_name": "نام نمی‌تواند خالی باشد",
"name_exists": "پرونده یا پوشه‌ای با این نام قبلا وجود دارد",
"generic_error": "خطایی رخ داده است"
"generic_error": "خطایی رخ داده است",
"group_name_invalid": "نام گروه باید با قالب پیشوند ایمیل مطابقت داشته باشد (حروف، ارقام، نقطه، خط تیره، زیرخط؛ 1–64 نویسه).",
"group_cycle": "این عضو باعث ایجاد ارجاع چرخه‌ای بین گروه‌ها می‌شود.",
"group_depth_exceeded": "عمق تودرتو بیش از حداکثر مجاز (8) است.",
"group_virtual_immutable": "گروه «Internal» توسط سامانه مدیریت می‌شود و قابل تغییر نیست.",
"group_not_found": "گروه پیدا نشد.",
"group_name_taken": "گروهی با این نام پیش‌از این وجود دارد."
},
"breadcrumb": {
"home": "صفحه اصلی"
@@ -755,5 +762,31 @@
"today": "امروز",
"last7days": "۷ روز گذشته",
"last30days": "۳۰ روز گذشته"
},
"groups": {
"title": "مدیریت گروه‌ها",
"create_button": "ایجاد گروه",
"create_dialog_title": "گروه جدید",
"edit_dialog_title": "تغییر نام گروه",
"name_label": "نام",
"name_placeholder": "engineering",
"description_label": "توضیحات (اختیاری)",
"members_section": "اعضا",
"add_member_placeholder": "افزودن کاربر یا گروه…",
"no_members": "هنوز عضوی وجود ندارد.",
"remove_member": "حذف",
"delete_group": "حذف گروه",
"delete_confirm": "گروه «{name}» حذف شود؟ مجوزهای مرتبط با این گروه باطل خواهند شد.",
"empty_state": "هنوز گروهی وجود ندارد.",
"load_more": "بارگیری بیشتر",
"back_to_list": "بازگشت",
"loading": "در حال بارگذاری…",
"virtual_badge": "سامانه",
"member_count_zero": "بدون عضو",
"member_count_one": "۱ عضو",
"member_count_other": "{count} عضو",
"delete_confirm_label": "نام گروه را برای تأیید وارد کنید:",
"delete_confirm_mismatch": "نام گروه را دقیقاً برای تأیید وارد کنید.",
"virtual_internal_name": "داخلی"
}
}
+35 -2
View File
@@ -131,7 +131,8 @@
"light": "Clair",
"dark": "Sombre",
"auto": "Comme le système"
}
},
"manage_groups": "Gérer les groupes"
},
"share": {
"dialogTitle": "Lien de partage",
@@ -336,7 +337,13 @@
"move_error": "Erreur lors du déplacement",
"empty_name": "Le nom ne peut pas être vide",
"name_exists": "Un fichier ou dossier portant ce nom existe déjà",
"generic_error": "Une erreur est survenue"
"generic_error": "Une erreur est survenue",
"group_name_invalid": "Le nom du groupe doit respecter le format préfixe d'email (lettres, chiffres, point, tiret, souligné ; 1–64 caractères).",
"group_cycle": "Ce membre créerait une référence circulaire entre groupes.",
"group_depth_exceeded": "Cette profondeur d'imbrication dépasse le maximum autorisé (8).",
"group_virtual_immutable": "Le groupe « Internal » est géré par le système et ne peut pas être modifié.",
"group_not_found": "Groupe introuvable.",
"group_name_taken": "Un groupe portant ce nom existe déjà."
},
"breadcrumb": {
"home": "Accueil"
@@ -755,5 +762,31 @@
"today": "Aujourd'hui",
"last7days": "7 derniers jours",
"last30days": "30 derniers jours"
},
"groups": {
"title": "Gérer les groupes",
"create_button": "Créer un groupe",
"create_dialog_title": "Nouveau groupe",
"edit_dialog_title": "Renommer le groupe",
"name_label": "Nom",
"name_placeholder": "ingenierie",
"description_label": "Description (facultatif)",
"members_section": "Membres",
"add_member_placeholder": "Ajouter un utilisateur ou un groupe…",
"no_members": "Aucun membre pour le moment.",
"remove_member": "Retirer",
"delete_group": "Supprimer le groupe",
"delete_confirm": "Supprimer le groupe « {name} » ? Les autorisations associées à ce groupe seront révoquées.",
"empty_state": "Aucun groupe pour le moment.",
"load_more": "Charger plus",
"back_to_list": "Retour",
"loading": "Chargement…",
"virtual_badge": "Système",
"member_count_zero": "Aucun membre",
"member_count_one": "1 membre",
"member_count_other": "{count} membres",
"delete_confirm_label": "Tapez le nom du groupe pour confirmer :",
"delete_confirm_mismatch": "Tapez le nom du groupe exactement pour confirmer.",
"virtual_internal_name": "Interne"
}
}
+35 -2
View File
@@ -131,7 +131,8 @@
"light": "हल्का",
"dark": "गहरा",
"auto": "सिस्टम जैसा"
}
},
"manage_groups": "समूह प्रबंधित करें"
},
"share": {
"dialogTitle": "शेयर लिंक",
@@ -336,7 +337,13 @@
"move_error": "ले जाने में त्रुटि",
"empty_name": "नाम खाली नहीं हो सकता",
"name_exists": "इस नाम की फ़ाइल या फ़ोल्डर पहले से मौजूद है",
"generic_error": "एक त्रुटि हुई है"
"generic_error": "एक त्रुटि हुई है",
"group_name_invalid": "समूह का नाम ईमेल उपसर्ग प्रारूप के अनुरूप होना चाहिए (अक्षर, अंक, बिंदु, डैश, अंडरस्कोर; 1–64 वर्ण).",
"group_cycle": "यह सदस्य समूहों के बीच चक्रीय संदर्भ बनाएगा।",
"group_depth_exceeded": "यह नेस्टिंग गहराई अनुमत अधिकतम (8) से अधिक है।",
"group_virtual_immutable": "«Internal» समूह सिस्टम द्वारा प्रबंधित है और इसे संशोधित नहीं किया जा सकता।",
"group_not_found": "समूह नहीं मिला।",
"group_name_taken": "इस नाम का एक समूह पहले से मौजूद है।"
},
"breadcrumb": {
"home": "होम"
@@ -755,5 +762,31 @@
"today": "आज",
"last7days": "पिछले 7 दिन",
"last30days": "पिछले 30 दिन"
},
"groups": {
"title": "समूह प्रबंधित करें",
"create_button": "समूह बनाएँ",
"create_dialog_title": "नया समूह",
"edit_dialog_title": "समूह का नाम बदलें",
"name_label": "नाम",
"name_placeholder": "engineering",
"description_label": "विवरण (वैकल्पिक)",
"members_section": "सदस्य",
"add_member_placeholder": "उपयोगकर्ता या समूह जोड़ें…",
"no_members": "अभी तक कोई सदस्य नहीं।",
"remove_member": "हटाएँ",
"delete_group": "समूह हटाएँ",
"delete_confirm": "समूह \"{name}\" को हटाएँ? इस समूह से जुड़ी अनुमतियाँ रद्द कर दी जाएँगी।",
"empty_state": "अभी तक कोई समूह नहीं।",
"load_more": "और लोड करें",
"back_to_list": "वापस",
"loading": "लोड हो रहा है…",
"virtual_badge": "सिस्टम",
"member_count_zero": "कोई सदस्य नहीं",
"member_count_one": "1 सदस्य",
"member_count_other": "{count} सदस्य",
"delete_confirm_label": "पुष्टि के लिए समूह का नाम लिखें:",
"delete_confirm_mismatch": "पुष्टि के लिए समूह का नाम बिल्कुल वैसा ही लिखें।",
"virtual_internal_name": "आंतरिक"
}
}
+35 -2
View File
@@ -131,7 +131,8 @@
"light": "Chiaro",
"dark": "Scuro",
"auto": "Come il sistema"
}
},
"manage_groups": "Gestisci gruppi"
},
"share": {
"dialogTitle": "Link di condivisione",
@@ -336,7 +337,13 @@
"move_error": "Errore durante lo spostamento",
"empty_name": "Il nome non può essere vuoto",
"name_exists": "Un file o una cartella con quel nome esiste già",
"generic_error": "Si è verificato un errore"
"generic_error": "Si è verificato un errore",
"group_name_invalid": "Il nome del gruppo deve rispettare il formato del prefisso email (lettere, cifre, punto, trattino, trattino basso; 1–64 caratteri).",
"group_cycle": "Questo membro creerebbe un riferimento circolare tra gruppi.",
"group_depth_exceeded": "Questa profondità di annidamento supera il massimo consentito (8).",
"group_virtual_immutable": "Il gruppo «Internal» è gestito dal sistema e non può essere modificato.",
"group_not_found": "Gruppo non trovato.",
"group_name_taken": "Un gruppo con questo nome esiste già."
},
"breadcrumb": {
"home": "Home"
@@ -755,5 +762,31 @@
"today": "Oggi",
"last7days": "Ultimi 7 giorni",
"last30days": "Ultimi 30 giorni"
},
"groups": {
"title": "Gestisci gruppi",
"create_button": "Crea gruppo",
"create_dialog_title": "Nuovo gruppo",
"edit_dialog_title": "Rinomina gruppo",
"name_label": "Nome",
"name_placeholder": "ingegneria",
"description_label": "Descrizione (opzionale)",
"members_section": "Membri",
"add_member_placeholder": "Aggiungi un utente o un gruppo…",
"no_members": "Nessun membro al momento.",
"remove_member": "Rimuovi",
"delete_group": "Elimina gruppo",
"delete_confirm": "Eliminare il gruppo \"{name}\"? Le autorizzazioni che fanno riferimento a questo gruppo saranno revocate.",
"empty_state": "Nessun gruppo al momento.",
"load_more": "Carica altro",
"back_to_list": "Indietro",
"loading": "Caricamento…",
"virtual_badge": "Sistema",
"member_count_zero": "Nessun membro",
"member_count_one": "1 membro",
"member_count_other": "{count} membri",
"delete_confirm_label": "Digita il nome del gruppo per confermare:",
"delete_confirm_mismatch": "Digita esattamente il nome del gruppo per confermare.",
"virtual_internal_name": "Interno"
}
}
+35 -2
View File
@@ -131,7 +131,8 @@
"light": "ライト",
"dark": "ダーク",
"auto": "システムに合わせる"
}
},
"manage_groups": "グループを管理"
},
"share": {
"dialogTitle": "共有リンク",
@@ -336,7 +337,13 @@
"move_error": "移動エラー",
"empty_name": "名前を空にすることはできません",
"name_exists": "同じ名前のファイルまたはフォルダが既に存在します",
"generic_error": "エラーが発生しました"
"generic_error": "エラーが発生しました",
"group_name_invalid": "グループ名はメールプレフィックス形式に一致している必要があります(文字、数字、ドット、ダッシュ、アンダースコア;1~64文字)。",
"group_cycle": "このメンバーはグループ間で循環参照を作成します。",
"group_depth_exceeded": "ネストの深さが許容されている最大値(8)を超えています。",
"group_virtual_immutable": "「Internal」グループはシステム管理であり、変更できません。",
"group_not_found": "グループが見つかりません。",
"group_name_taken": "この名前のグループはすでに存在します。"
},
"breadcrumb": {
"home": "ホーム"
@@ -755,5 +762,31 @@
"today": "今日",
"last7days": "過去7日間",
"last30days": "過去30日間"
},
"groups": {
"title": "グループを管理",
"create_button": "グループを作成",
"create_dialog_title": "新規グループ",
"edit_dialog_title": "グループ名を変更",
"name_label": "名前",
"name_placeholder": "engineering",
"description_label": "説明(任意)",
"members_section": "メンバー",
"add_member_placeholder": "ユーザーまたはグループを追加…",
"no_members": "メンバーはまだいません。",
"remove_member": "削除",
"delete_group": "グループを削除",
"delete_confirm": "グループ「{name}」を削除しますか?このグループを参照しているすべての権限が取り消されます。",
"empty_state": "グループはまだありません。",
"load_more": "もっと読み込む",
"back_to_list": "戻る",
"loading": "読み込み中…",
"virtual_badge": "システム",
"member_count_zero": "メンバーなし",
"member_count_one": "1 メンバー",
"member_count_other": "{count} メンバー",
"delete_confirm_label": "確認のためにグループ名を入力してください:",
"delete_confirm_mismatch": "確認のためにグループ名を正確に入力してください。",
"virtual_internal_name": "内部"
}
}
+35 -2
View File
@@ -131,7 +131,8 @@
"light": "라이트",
"dark": "다크",
"auto": "시스템과 동일"
}
},
"manage_groups": "그룹 관리"
},
"share": {
"dialogTitle": "공유 링크",
@@ -336,7 +337,13 @@
"move_error": "이동 오류",
"empty_name": "이름은 비워둘 수 없습니다",
"name_exists": "같은 이름의 파일 또는 폴더가 이미 존재합니다",
"generic_error": "오류가 발생했습니다"
"generic_error": "오류가 발생했습니다",
"group_name_invalid": "그룹 이름은 이메일 접두사 형식과 일치해야 합니다(문자, 숫자, 점, 대시, 밑줄; 1–64자).",
"group_cycle": "이 구성원은 그룹 간 순환 참조를 만들 것입니다.",
"group_depth_exceeded": "중첩 깊이가 허용 최대값(8)을 초과합니다.",
"group_virtual_immutable": "«Internal» 그룹은 시스템이 관리하며 수정할 수 없습니다.",
"group_not_found": "그룹을 찾을 수 없습니다.",
"group_name_taken": "이 이름의 그룹이 이미 존재합니다."
},
"breadcrumb": {
"home": "홈"
@@ -755,5 +762,31 @@
"today": "오늘",
"last7days": "최근 7일",
"last30days": "최근 30일"
},
"groups": {
"title": "그룹 관리",
"create_button": "그룹 생성",
"create_dialog_title": "새 그룹",
"edit_dialog_title": "그룹 이름 변경",
"name_label": "이름",
"name_placeholder": "engineering",
"description_label": "설명(선택사항)",
"members_section": "구성원",
"add_member_placeholder": "사용자 또는 그룹 추가…",
"no_members": "아직 구성원이 없습니다.",
"remove_member": "제거",
"delete_group": "그룹 삭제",
"delete_confirm": "\"{name}\" 그룹을 삭제하시겠습니까? 이 그룹을 참조하는 모든 권한이 해제됩니다.",
"empty_state": "아직 그룹이 없습니다.",
"load_more": "더 보기",
"back_to_list": "뒤로",
"loading": "로딩 중…",
"virtual_badge": "시스템",
"member_count_zero": "구성원 없음",
"member_count_one": "구성원 1명",
"member_count_other": "구성원 {count}명",
"delete_confirm_label": "확인을 위해 그룹 이름을 입력하세요:",
"delete_confirm_mismatch": "확인을 위해 그룹 이름을 정확히 입력하세요.",
"virtual_internal_name": "내부"
}
}
+35 -2
View File
@@ -131,7 +131,8 @@
"light": "Licht",
"dark": "Donker",
"auto": "Zoals systeem"
}
},
"manage_groups": "Groepen beheren"
},
"share": {
"dialogTitle": "Deellink",
@@ -336,7 +337,13 @@
"move_error": "Fout bij verplaatsen",
"empty_name": "Naam mag niet leeg zijn",
"name_exists": "Een bestand of map met deze naam bestaat al",
"generic_error": "Er is een fout opgetreden"
"generic_error": "Er is een fout opgetreden",
"group_name_invalid": "De groepsnaam moet voldoen aan het e-mailprefix-formaat (letters, cijfers, punt, streepje, underscore; 1–64 tekens).",
"group_cycle": "Dit lid zou een circulaire groepsverwijzing veroorzaken.",
"group_depth_exceeded": "Deze nestdiepte overschrijdt het maximum (8).",
"group_virtual_immutable": "De groep 'Internal' wordt door het systeem beheerd en kan niet worden gewijzigd.",
"group_not_found": "Groep niet gevonden.",
"group_name_taken": "Er bestaat al een groep met deze naam."
},
"breadcrumb": {
"home": "Start"
@@ -755,5 +762,31 @@
"today": "Vandaag",
"last7days": "Afgelopen 7 dagen",
"last30days": "Afgelopen 30 dagen"
},
"groups": {
"title": "Groepen beheren",
"create_button": "Groep maken",
"create_dialog_title": "Nieuwe groep",
"edit_dialog_title": "Groep hernoemen",
"name_label": "Naam",
"name_placeholder": "engineering",
"description_label": "Beschrijving (optioneel)",
"members_section": "Leden",
"add_member_placeholder": "Een gebruiker of groep toevoegen…",
"no_members": "Nog geen leden.",
"remove_member": "Verwijderen",
"delete_group": "Groep verwijderen",
"delete_confirm": "De groep \"{name}\" verwijderen? Aan deze groep gekoppelde rechten worden ingetrokken.",
"empty_state": "Nog geen groepen.",
"load_more": "Meer laden",
"back_to_list": "Terug",
"loading": "Bezig met laden…",
"virtual_badge": "Systeem",
"member_count_zero": "Geen leden",
"member_count_one": "1 lid",
"member_count_other": "{count} leden",
"delete_confirm_label": "Typ de groepsnaam ter bevestiging:",
"delete_confirm_mismatch": "Typ de groepsnaam exact om te bevestigen.",
"virtual_internal_name": "Intern"
}
}
+35 -2
View File
@@ -131,7 +131,8 @@
"light": "Jasny",
"dark": "Ciemny",
"auto": "Jak system"
}
},
"manage_groups": "Zarządzaj grupami"
},
"share": {
"dialogTitle": "Link udostępniania",
@@ -336,7 +337,13 @@
"move_error": "Błąd podczas przenoszenia",
"empty_name": "Nazwa nie może być pusta",
"name_exists": "Plik lub folder o tej nazwie już istnieje",
"generic_error": "Wystąpił błąd"
"generic_error": "Wystąpił błąd",
"group_name_invalid": "Nazwa grupy musi spełniać format prefiksu e-mail (litery, cyfry, kropka, myślnik, podkreślnik; 1–64 znaków).",
"group_cycle": "Ten członek utworzyłby cykliczne odwołanie między grupami.",
"group_depth_exceeded": "Ta głębokość zagnieżdżenia przekracza maksymalną dozwoloną (8).",
"group_virtual_immutable": "Grupa „Internal” jest zarządzana przez system i nie może być modyfikowana.",
"group_not_found": "Grupa nie znaleziona.",
"group_name_taken": "Grupa o tej nazwie już istnieje."
},
"breadcrumb": {
"home": "Strona główna"
@@ -755,5 +762,31 @@
"today": "Dzisiaj",
"last7days": "Ostatnie 7 dni",
"last30days": "Ostatnie 30 dni"
},
"groups": {
"title": "Zarządzaj grupami",
"create_button": "Utwórz grupę",
"create_dialog_title": "Nowa grupa",
"edit_dialog_title": "Zmień nazwę grupy",
"name_label": "Nazwa",
"name_placeholder": "inzynieria",
"description_label": "Opis (opcjonalny)",
"members_section": "Członkowie",
"add_member_placeholder": "Dodaj użytkownika lub grupę…",
"no_members": "Brak członków.",
"remove_member": "Usuń",
"delete_group": "Usuń grupę",
"delete_confirm": "Usunąć grupę „{name}\"? Uprawnienia odwołujące się do tej grupy zostaną cofnięte.",
"empty_state": "Brak grup.",
"load_more": "Załaduj więcej",
"back_to_list": "Wstecz",
"loading": "Ładowanie…",
"virtual_badge": "System",
"member_count_zero": "Brak członków",
"member_count_one": "1 członek",
"member_count_other": "{count} członków",
"delete_confirm_label": "Wpisz nazwę grupy, aby potwierdzić:",
"delete_confirm_mismatch": "Wpisz nazwę grupy dokładnie, aby potwierdzić.",
"virtual_internal_name": "Wewnętrzni"
}
}
+35 -2
View File
@@ -131,7 +131,8 @@
"light": "Claro",
"dark": "Escuro",
"auto": "Como o sistema"
}
},
"manage_groups": "Gerenciar grupos"
},
"share": {
"dialogTitle": "Link de compartilhamento",
@@ -336,7 +337,13 @@
"move_error": "Erro ao mover",
"empty_name": "O nome não pode estar vazio",
"name_exists": "Já existe um arquivo ou pasta com esse nome",
"generic_error": "Ocorreu um erro"
"generic_error": "Ocorreu um erro",
"group_name_invalid": "O nome do grupo deve seguir o formato de prefixo de e-mail (letras, dígitos, ponto, hífen, sublinhado; 1–64 caracteres).",
"group_cycle": "Este membro criaria uma referência circular entre grupos.",
"group_depth_exceeded": "Esta profundidade de aninhamento excede o máximo permitido (8).",
"group_virtual_immutable": "O grupo «Internal» é gerenciado pelo sistema e não pode ser modificado.",
"group_not_found": "Grupo não encontrado.",
"group_name_taken": "Já existe um grupo com este nome."
},
"breadcrumb": {
"home": "Início"
@@ -755,5 +762,31 @@
"today": "Hoje",
"last7days": "Últimos 7 dias",
"last30days": "Últimos 30 dias"
},
"groups": {
"title": "Gerenciar grupos",
"create_button": "Criar grupo",
"create_dialog_title": "Novo grupo",
"edit_dialog_title": "Renomear grupo",
"name_label": "Nome",
"name_placeholder": "engenharia",
"description_label": "Descrição (opcional)",
"members_section": "Membros",
"add_member_placeholder": "Adicionar um usuário ou grupo…",
"no_members": "Ainda não há membros.",
"remove_member": "Remover",
"delete_group": "Excluir grupo",
"delete_confirm": "Excluir o grupo \"{name}\"? As concessões que referenciam este grupo serão revogadas.",
"empty_state": "Ainda não há grupos.",
"load_more": "Carregar mais",
"back_to_list": "Voltar",
"loading": "Carregando…",
"virtual_badge": "Sistema",
"member_count_zero": "Sem membros",
"member_count_one": "1 membro",
"member_count_other": "{count} membros",
"delete_confirm_label": "Digite o nome do grupo para confirmar:",
"delete_confirm_mismatch": "Digite o nome do grupo exatamente para confirmar.",
"virtual_internal_name": "Interno"
}
}
+35 -2
View File
@@ -131,7 +131,8 @@
"light": "Светлая",
"dark": "Тёмная",
"auto": "Как в системе"
}
},
"manage_groups": "Управление группами"
},
"share": {
"dialogTitle": "Ссылка для обмена",
@@ -336,7 +337,13 @@
"move_error": "Ошибка перемещения",
"empty_name": "Имя не может быть пустым",
"name_exists": "Файл или папка с таким именем уже существует",
"generic_error": "Произошла ошибка"
"generic_error": "Произошла ошибка",
"group_name_invalid": "Имя группы должно соответствовать формату префикса эл. почты (буквы, цифры, точка, дефис, подчёркивание; 1–64 символов).",
"group_cycle": "Этот участник создаст циклическую ссылку между группами.",
"group_depth_exceeded": "Глубина вложенности превышает допустимый максимум (8).",
"group_virtual_immutable": "Группа «Internal» управляется системой и не может быть изменена.",
"group_not_found": "Группа не найдена.",
"group_name_taken": "Группа с таким именем уже существует."
},
"breadcrumb": {
"home": "Главная"
@@ -755,5 +762,31 @@
"today": "Сегодня",
"last7days": "Последние 7 дней",
"last30days": "Последние 30 дней"
},
"groups": {
"title": "Управление группами",
"create_button": "Создать группу",
"create_dialog_title": "Новая группа",
"edit_dialog_title": "Переименовать группу",
"name_label": "Имя",
"name_placeholder": "инженеры",
"description_label": "Описание (необязательно)",
"members_section": "Участники",
"add_member_placeholder": "Добавить пользователя или группу…",
"no_members": "Пока нет участников.",
"remove_member": "Удалить",
"delete_group": "Удалить группу",
"delete_confirm": "Удалить группу «{name}»? Все привязанные к ней разрешения будут отозваны.",
"empty_state": "Пока нет групп.",
"load_more": "Загрузить ещё",
"back_to_list": "Назад",
"loading": "Загрузка…",
"virtual_badge": "Системная",
"member_count_zero": "Нет участников",
"member_count_one": "1 участник",
"member_count_other": "{count} участников",
"delete_confirm_label": "Введите имя группы для подтверждения:",
"delete_confirm_mismatch": "Введите имя группы точно для подтверждения.",
"virtual_internal_name": "Внутренние"
}
}
+35 -2
View File
@@ -131,7 +131,8 @@
"light": "淺色",
"dark": "深色",
"auto": "跟隨系統"
}
},
"manage_groups": "管理群組"
},
"share": {
"dialogTitle": "共享連結",
@@ -336,7 +337,13 @@
"move_error": "移動時出錯",
"empty_name": "名稱不能為空",
"name_exists": "已存在同名檔案或資料夾",
"generic_error": "發生錯誤"
"generic_error": "發生錯誤",
"group_name_invalid": "群組名稱必須符合電子郵件前綴格式(字母、數字、點、連字符、下劃線;1–64 個字元)。",
"group_cycle": "此成員會在群組之間形成循環參照。",
"group_depth_exceeded": "嵌套深度超過允許的最大值(8)。",
"group_virtual_immutable": "「Internal」群組由系統管理,無法修改。",
"group_not_found": "找不到群組。",
"group_name_taken": "已存在同名群組。"
},
"breadcrumb": {
"home": "主頁"
@@ -755,5 +762,31 @@
"today": "今天",
"last7days": "近7天",
"last30days": "近30天"
},
"groups": {
"title": "管理群組",
"create_button": "建立群組",
"create_dialog_title": "新群組",
"edit_dialog_title": "重新命名群組",
"name_label": "名稱",
"name_placeholder": "engineering",
"description_label": "描述(選填)",
"members_section": "成員",
"add_member_placeholder": "新增使用者或群組…",
"no_members": "尚無成員。",
"remove_member": "移除",
"delete_group": "刪除群組",
"delete_confirm": "刪除群組「{name}」?引用此群組的所有授權將被撤銷。",
"empty_state": "尚無群組。",
"load_more": "載入更多",
"back_to_list": "返回",
"loading": "載入中…",
"virtual_badge": "系統",
"member_count_zero": "無成員",
"member_count_one": "1 個成員",
"member_count_other": "{count} 個成員",
"delete_confirm_label": "請輸入群組名稱以確認:",
"delete_confirm_mismatch": "請準確輸入群組名稱以確認。",
"virtual_internal_name": "內部"
}
}
+35 -2
View File
@@ -131,7 +131,8 @@
"light": "浅色",
"dark": "深色",
"auto": "跟随系统"
}
},
"manage_groups": "管理群组"
},
"share": {
"dialogTitle": "共享链接",
@@ -336,7 +337,13 @@
"move_error": "移动时出错",
"empty_name": "名称不能为空",
"name_exists": "已存在同名文件或文件夹",
"generic_error": "发生错误"
"generic_error": "发生错误",
"group_name_invalid": "组名必须符合邮件前缀格式(字母、数字、点、连字符、下划线;1–64个字符)。",
"group_cycle": "此成员会在组之间形成循环引用。",
"group_depth_exceeded": "嵌套深度超出允许的最大值(8)。",
"group_virtual_immutable": "“Internal”组由系统管理,无法修改。",
"group_not_found": "未找到组。",
"group_name_taken": "同名组已存在。"
},
"breadcrumb": {
"home": "主页"
@@ -755,5 +762,31 @@
"today": "今天",
"last7days": "近7天",
"last30days": "近30天"
},
"groups": {
"title": "管理群组",
"create_button": "创建群组",
"create_dialog_title": "新群组",
"edit_dialog_title": "重命名群组",
"name_label": "名称",
"name_placeholder": "engineering",
"description_label": "描述(可选)",
"members_section": "成员",
"add_member_placeholder": "添加用户或群组…",
"no_members": "暂无成员。",
"remove_member": "移除",
"delete_group": "删除群组",
"delete_confirm": "删除群组 \"{name}\"?引用此群组的所有权限将被撤销。",
"empty_state": "暂无群组。",
"load_more": "加载更多",
"back_to_list": "返回",
"loading": "加载中…",
"virtual_badge": "系统",
"member_count_zero": "无成员",
"member_count_one": "1 个成员",
"member_count_other": "{count} 个成员",
"delete_confirm_label": "请输入群组名称以确认:",
"delete_confirm_mismatch": "请准确输入群组名称以确认。",
"virtual_internal_name": "内部"
}
}