feat(ui): add share modal and users

- fix(external users): fix app starting for external users
This commit is contained in:
Edouard Vanbelle
2026-06-02 12:25:44 +02:00
parent ec72374651
commit 9fac34f91e
15 changed files with 468 additions and 54 deletions
@@ -0,0 +1,70 @@
// @ts-check
/**
* PendingEmailVignette — visual for an external invite *before* the
* server has resolved (or lazily created) the recipient user.
*
* Used by the share modal's email-input UX:
* 1. The user types an address in the search input.
* 2. When the address matches no existing contact but parses as an
* email, the dropdown surfaces an "Invite by email" suggestion
* rendered with this vignette.
* 3. Clicking the suggestion stages an email-typed chip that also
* uses this vignette.
* 4. On Apply, the modal POSTs `subject.type=email` and reloads the
* grant list — at which point the resolved real userId takes
* over via the regular `createUserVignette`, which paints the
* same `fa-building-circle-xmark` badge (PR 11.2). Visual
* continuity is intentional: the chip's look doesn't change
* across the commit boundary.
*
* Reuses the userVignette CSS so size variants (xs/sm/md/list/lg/menu/xl),
* colour palette, and the external-badge styling all apply unchanged.
* The external badge here is FORCED visible — by definition an email
* we don't recognise is going to mint an external user.
*/
import { _colorIndex, _initials } from './userVignette.js';
/** @typedef {'xs'|'sm'|'list'|'md'|'lg'|'menu'|'xl'} VignetteSize */
/**
* Build a transient vignette seeded from an email address (no UUID yet).
*
* @param {string} email
* @param {VignetteSize} [size='sm']
* @returns {HTMLElement}
*/
export function createPendingEmailVignette(email, size = 'sm') {
const trimmed = email.trim();
const colorIdx = _colorIndex(trimmed);
const wrapper = document.createElement('span');
wrapper.className = `user-vignette user-vignette--${size}`;
const avatar = document.createElement('span');
avatar.className = `user-vignette__avatar uv-color-${colorIdx}`;
// Synthesize initials: local-part initial + domain initial when
// possible, otherwise fall back to the first two chars.
const [local, domain] = trimmed.split('@');
const synthName = local && domain ? `${local[0]} ${domain[0]}` : trimmed.slice(0, 2);
avatar.textContent = _initials(synthName);
// Forced external badge — this is the whole point of the component.
const badge = document.createElement('i');
badge.className = 'user-vignette__origin user-vignette__origin--external fa-solid fa-building-circle-xmark';
badge.title = 'External invitation';
badge.setAttribute('aria-hidden', 'true');
avatar.appendChild(badge);
wrapper.appendChild(avatar);
// The "name" for a pending invite is just the email itself — there
// is no separate display name yet.
const nameEl = document.createElement('span');
nameEl.className = 'user-vignette__name';
nameEl.textContent = trimmed;
wrapper.appendChild(nameEl);
return wrapper;
}
+115 -26
View File
@@ -26,6 +26,7 @@ import { buildPasswordChip } from '../utils/passwordChip.js';
import { groupDisplayName, groupIconClass, groupIconClassByVirtual } from './groupDisplay.js';
import { createGroupVignette } from './groupVignette.js';
import { Modal } from './modal.js';
import { createPendingEmailVignette } from './pendingEmailVignette.js';
import { createUserVignette } from './userVignette.js';
/** @import {FileItem, FolderItem, Grant, ContactItem, MemberEntry, LinkEntry, DraftLink, ShareRoleEnum} from '../core/types.js' */
@@ -42,6 +43,36 @@ import { createUserVignette } from './userVignette.js';
* @property {'group'} _kind
*/
/**
* Synthetic "invite by email" suggestion injected at the bottom of the
* autocomplete dropdown when the query parses as an email and matches
* no existing contact. Same overall shape as `GroupSuggestion` so the
* staging / chip / commit paths can treat all three suggestion kinds
* uniformly via the `_kind` discriminator.
*
* `id` here is the email itself — it's a stable dedup key pre-resolution.
* The server replaces it with a real user UUID on Apply.
*
* @typedef {Object} EmailSuggestion
* @property {string} id Lowercased trimmed email (also the dedup key).
* @property {string} email Display form (lowercased trimmed).
* @property {'email'} _kind
*/
/**
* Permissive client-side email regex — matches anything with at least
* one non-whitespace local-part, an `@`, and a domain with a dot.
* The server's `normalize_email` is the authority; this is just enough
* to decide whether to surface the synthetic "invite by email"
* suggestion in the dropdown.
*
* @param {string} q
* @returns {boolean}
*/
function _looksLikeEmail(q) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q);
}
/** Permissions that belong to each role (must mirror the Rust DTO). */
const ROLE_PERMISSIONS = {
viewer: ['read'],
@@ -136,7 +167,7 @@ const shareModal = {
/** @type {DraftLink[]} */
_newLinks: [],
/** @type {Array<ContactItem | GroupSuggestion>} */
/** @type {Array<ContactItem | GroupSuggestion | EmailSuggestion>} */
_stagedUsers: [],
/** @type {ShareRoleEnum} */
@@ -381,9 +412,27 @@ const shareModal = {
}
})();
const filtered = currentUserId ? contacts.filter((c) => c.id !== currentUserId) : contacts;
// Synthesize an "invite by email" row when the query parses
// as an email AND no existing contact already matches that
// address (we don't want to compete with the existing
// contact suggestion). Lowercased+trimmed for the dedup
// key — same shape the server applies via normalize_email.
/** @type {EmailSuggestion[]} */
let emailItems = [];
if (_looksLikeEmail(q)) {
const normalised = q.trim().toLowerCase();
const existing = filtered.some((c) => (c.email ?? []).some((e) => e.email.toLowerCase() === normalised));
if (!existing) {
emailItems = [{ id: normalised, email: normalised, _kind: 'email' }];
}
}
// Groups first (they're a smaller, distinctively-iconed set),
// then contacts. Cap at 8 combined.
const combined = [...groupItems, ...filtered].slice(0, 8);
// then contacts, then the email-invite suggestion at the
// bottom (it's the catch-all when nothing else matches).
// Cap at 8 combined.
const combined = [...groupItems, ...filtered, ...emailItems].slice(0, 8);
this._renderSuggestions(dropdown, combined, (item) => {
this._stageUser(item, input, dropdown, addBtn);
});
@@ -416,9 +465,9 @@ const shareModal = {
},
/**
* @param {HTMLElement} container
* @param {Array<ContactItem | GroupSuggestion>} results
* @param {(c: ContactItem | GroupSuggestion) => void} onSelect
* @param {HTMLElement} container
* @param {Array<ContactItem | GroupSuggestion | EmailSuggestion>} results
* @param {(c: ContactItem | GroupSuggestion | EmailSuggestion) => void} onSelect
*/
_renderSuggestions(container, results, onSelect) {
container.replaceChildren();
@@ -434,6 +483,14 @@ const shareModal = {
if (c._kind === 'group') {
const g = /** @type {GroupSuggestion} */ (c);
item.appendChild(createGroupVignette(groupDisplayName(g), 'sm', { icon: groupIconClass(g) }));
} else if (c._kind === 'email') {
const e = /** @type {EmailSuggestion} */ (c);
item.classList.add('smd-suggestion-item--email');
item.appendChild(createPendingEmailVignette(e.email, 'sm'));
const hint = document.createElement('span');
hint.className = 'smd-suggestion-hint';
hint.textContent = i18n.t('share.inviteByEmail', 'Invite by email — invitation will be sent');
item.appendChild(hint);
} else {
item.appendChild(createUserVignette(c.id, 'sm', { showEmail: true }));
}
@@ -449,17 +506,25 @@ const shareModal = {
},
/**
* @param {ContactItem | GroupSuggestion} contact
* @param {HTMLInputElement} inputEl
* @param {HTMLElement} dropdown
* @param {HTMLButtonElement} addBtn
* @param {ContactItem | GroupSuggestion | EmailSuggestion} contact
* @param {HTMLInputElement} inputEl
* @param {HTMLElement} dropdown
* @param {HTMLButtonElement} addBtn
*/
_stageUser(contact, inputEl, dropdown, addBtn) {
// 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');
// id *and* kind so a user / group / email-invite sharing the same
// string value (unlikely but harmless) wouldn't shadow each other.
const kind = contact._kind === 'group' ? 'group' : contact._kind === 'email' ? 'email' : 'user';
const alreadyMember = this._localMembers.some((m) => {
if (m._op === 'remove') return false;
// Match against existing committed members on (type, id) — for
// email-staged members, the dedup happens via `_invitedEmail`.
if (kind === 'email') {
return m._invitedEmail?.toLowerCase() === contact.id.toLowerCase();
}
return m.grant.subject.id === contact.id && m.grant.subject.type === kind;
});
const alreadyStaged = this._stagedUsers.some((u) => u.id === contact.id && (u._kind ?? 'user') === kind);
if (alreadyMember || alreadyStaged) return;
@@ -497,19 +562,22 @@ const shareModal = {
const chip = document.createElement('div');
chip.className = 'smd-chip';
const visual =
c._kind === 'group'
? (() => {
const g = /** @type {GroupSuggestion} */ (c);
return createGroupVignette(groupDisplayName(g), 'xs', { icon: groupIconClass(g) });
})()
: createUserVignette(c.id, 'xs');
let visual;
if (c._kind === 'group') {
const g = /** @type {GroupSuggestion} */ (c);
visual = createGroupVignette(groupDisplayName(g), 'xs', { icon: groupIconClass(g) });
} else if (c._kind === 'email') {
const e = /** @type {EmailSuggestion} */ (c);
visual = createPendingEmailVignette(e.email, 'xs');
} else {
visual = 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';
const kind = c._kind === 'group' ? 'group' : c._kind === 'email' ? 'email' : 'user';
rm.addEventListener('click', () => {
this._stagedUsers = this._stagedUsers.filter((u) => !(u.id === c.id && (u._kind ?? 'user') === kind));
this._refreshChips();
@@ -525,7 +593,13 @@ const shareModal = {
_commitStagedUsers() {
for (const contact of this._stagedUsers) {
const subjectType = contact._kind === 'group' ? 'group' : 'user';
// Email-typed stagings carry a transient `_invitedEmail` on the
// resulting MemberEntry. The pre-commit MemberRow rendering
// (`_buildMemberRow`) and the `_applyAll` API-call branch both
// key off that field — they don't try to read a UUID out of
// `subject.id` (which is the email string in this case, not a
// real user UUID until the server resolves it).
const subjectType = contact._kind === 'group' ? 'group' : contact._kind === 'email' ? 'user' : 'user';
/** @type {Grant} */
const placeholderGrant = {
id: '', // not yet persisted
@@ -541,7 +615,8 @@ const shareModal = {
role: this._stagedRole,
_op: 'new',
expires_at: this._stagedExpiry,
_displayName: contact._kind === 'group' ? /** @type {GroupSuggestion} */ (contact).name : undefined
_displayName: contact._kind === 'group' ? /** @type {GroupSuggestion} */ (contact).name : undefined,
_invitedEmail: contact._kind === 'email' ? /** @type {EmailSuggestion} */ (contact).email : undefined
});
}
this._stagedUsers = [];
@@ -616,12 +691,20 @@ const shareModal = {
const row = document.createElement('div');
row.className = 'smd-member-row';
// Three rendering paths:
// - Group subject → group vignette
// - Pre-commit email-invite (carries `_invitedEmail`) → pending
// vignette seeded from the email; no UUID exists yet.
// - Regular user subject → user vignette (which itself renders
// the external badge automatically via systemUsers).
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');
: entry._invitedEmail
? createPendingEmailVignette(entry._invitedEmail, 'md')
: createUserVignette(entry.grant.subject.id, 'md');
const roleSelect = document.createElement('select');
roleSelect.className = 'smd-member-role-select';
@@ -939,8 +1022,14 @@ const shareModal = {
expires_at: expiresIso
});
} else if (m._op === 'new') {
// Email-invite path: the staged MemberEntry carries
// `_invitedEmail`; the server resolves it to (or
// creates) an external user and returns the actual
// user_id in the grant DTO. Until `fetchOutgoingGrants`
// refreshes below, the row keeps the pending vignette.
const subject = m._invitedEmail ? { type: 'email', email: m._invitedEmail } : { type: m.grant.subject.type, id: m.grant.subject.id };
await grants.createGrant({
subject: { type: m.grant.subject.type, id: m.grant.subject.id },
subject,
resource: { type: itemType, id: item.id },
role: m.role,
expires_at: expiresIso
+44 -11
View File
@@ -90,6 +90,12 @@ function _applyPhoto(avatar, photoUrl, name) {
* When true (and showName is true), the primary email address is shown below
* the name in a lighter style. Name and email are wrapped in a
* `.user-vignette__info` column. Has no effect when showName is false.
* @property {boolean} [showOrigin=true]
* When true (the default), an `is_external` badge overlays the
* bottom-right of the avatar for external users only — internal
* users render unchanged. Set false to suppress the badge in
* contexts where the distinction would be noise (e.g. the
* logged-in-user menu, where the caller is implicitly internal).
*/
/**
@@ -101,7 +107,7 @@ function _applyPhoto(avatar, photoUrl, name) {
* @param {VignetteOptions} [options]
* @returns {HTMLElement}
*/
export function createUserVignette(userId, size = 'sm', { showName = true, showEmail = false } = {}) {
export function createUserVignette(userId, size = 'sm', { showName = true, showEmail = false, showOrigin = true } = {}) {
const colorIdx = _colorIndex(userId);
const wrapper = /** @type {HTMLElement} */ (document.createElement('span'));
@@ -113,6 +119,18 @@ export function createUserVignette(userId, size = 'sm', { showName = true, showE
avatar.textContent = userId.slice(0, 2).toUpperCase();
wrapper.appendChild(avatar);
// Origin badge (external-only — internal users render unchanged).
// Created hidden; revealed once `getIsExternal` resolves to true.
// FontAwesome glyph classes are added alongside the component
// class so the icon renders as a building-with-x glyph.
/** @type {HTMLElement | null} */
const originEl = showOrigin ? document.createElement('i') : null;
if (originEl) {
originEl.className = 'user-vignette__origin user-vignette__origin--external hidden fa-solid fa-building-circle-xmark';
originEl.setAttribute('aria-hidden', 'true');
avatar.appendChild(originEl);
}
/** @type {HTMLElement | null} */
const nameEl = showName ? document.createElement('span') : null;
@@ -136,18 +154,33 @@ export function createUserVignette(userId, size = 'sm', { showName = true, showE
}
}
// Resolve name, photo, and (when requested) email asynchronously.
Promise.all([systemUsers.getDisplayName(userId), systemUsers.getPhoto(userId), emailEl ? systemUsers.getEmail(userId) : Promise.resolve(null)]).then(
([name, photo, email]) => {
if (nameEl) nameEl.textContent = name;
if (emailEl) emailEl.textContent = email ?? '';
if (photo) {
_applyPhoto(avatar, photo, name);
} else {
avatar.textContent = _initials(name);
// Resolve name, photo, email, and (when requested) is_external
// asynchronously. All four go through the systemUsers cache so a
// single fetch back-fills every facet.
Promise.all([
systemUsers.getDisplayName(userId),
systemUsers.getPhoto(userId),
emailEl ? systemUsers.getEmail(userId) : Promise.resolve(null),
originEl ? systemUsers.getIsExternal(userId) : Promise.resolve(false)
]).then(([name, photo, email, isExternal]) => {
if (nameEl) nameEl.textContent = name;
if (emailEl) emailEl.textContent = email ?? '';
if (photo) {
_applyPhoto(avatar, photo, name);
} else {
avatar.textContent = _initials(name);
}
// Both branches above replace the avatar's children, wiping the
// pre-attached badge node. Re-attach AFTER the avatar's content
// is final so the badge sits on top.
if (originEl) {
avatar.appendChild(originEl);
if (isExternal) {
originEl.classList.remove('hidden');
originEl.title = 'External user';
}
}
);
});
return wrapper;
}