feat(ui): add share modal and users
- fix(external users): fix app starting for external users
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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 = '×';
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user