diff --git a/static/css/components/shareModal.css b/static/css/components/shareModal.css index 48b9db26..688f7c8d 100644 --- a/static/css/components/shareModal.css +++ b/static/css/components/shareModal.css @@ -138,6 +138,22 @@ min-width: 0; } +/* "Invite by email" synthetic suggestion (PR 11.3). The hint label sits + to the right of the pending-email vignette and stays muted so the + row reads as auxiliary — the action it commits is more consequential + than a regular contact pick. */ +.smd-suggestion-item--email { + border-top: 1px solid var(--color-border); +} + +.smd-suggestion-hint { + margin-left: auto; + color: var(--color-text-faint); + font-size: 12px; + font-style: italic; + flex-shrink: 0; +} + /* Role picker beside the search box */ .smd-role-select { padding: 9px 10px; diff --git a/static/css/components/userVignette.css b/static/css/components/userVignette.css index b11bb042..121869e2 100644 --- a/static/css/components/userVignette.css +++ b/static/css/components/userVignette.css @@ -32,6 +32,8 @@ width: 24px; height: 24px; font-size: 10px; + /* Anchor for the bottom-right `__origin` badge. */ + position: relative; } .user-vignette__name { @@ -176,3 +178,37 @@ width: 100%; height: 100%; } + +/* ── Origin badge (external-user marker) ─────────────────────────────────── + * Tiny FontAwesome icon overlaid on the bottom-right of the avatar circle. + * Shown ONLY for external users — internal users render the bare avatar + * (Ed's "external only" preference: quiet UI for the common case). + * + * The white background ring lifts the icon off coloured avatars so it + * stays readable across the full palette. */ + +.user-vignette__origin { + position: absolute; + /* Bottom-right corner, slightly tucked past the avatar's edge. */ + right: -2px; + bottom: -2px; + /* Scale to ~40% of avatar diameter via em — the parent's font-size + changes per size variant, so the badge naturally tracks. */ + font-size: 0.9em; + line-height: 1; + background: var(--color-bg-surface); + color: var(--color-text-secondary); + border-radius: 50%; + /* Halo ring so the icon visually detaches from coloured avatars. */ + box-shadow: 0 0 0 1.5px var(--color-bg-surface); + /* Don't intercept hover events on the avatar itself. */ + pointer-events: auto; +} + +.user-vignette__origin--external { + color: var(--color-warning-orange-text); +} + +.user-vignette__origin.hidden { + display: none; +} diff --git a/static/js/app/authSession.js b/static/js/app/authSession.js index b02fe59d..4d3ab246 100644 --- a/static/js/app/authSession.js +++ b/static/js/app/authSession.js @@ -3,7 +3,6 @@ */ import { getCsrfHeaders } from '../core/csrf.js'; -import { loadFiles } from './filesView.js'; import { updateStorageUsageDisplay } from './main.js'; import { app } from './state.js'; import { ui } from './ui.js'; @@ -40,6 +39,7 @@ async function refreshUserData() { console.log('Storage from server: used=', userData.storage_used_bytes, 'quota=', userData.storage_quota_bytes); localStorage.setItem(USER_DATA_KEY, JSON.stringify(userData)); + app.isExternalUser = !!userData.is_external; updateStorageUsageDisplay(userData); return userData; } catch (error) { @@ -93,8 +93,15 @@ async function checkAuthentication() { // Check session validity by calling /api/auth/me (cookie auto-sent) console.log('Checking session via /api/auth/me...'); + /** @type {User} */ /** @type {User} */ const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}'); + // Restore the external-user flag eagerly from cache so the + // resolveHomeFolder short-circuit fires before the /api/auth/me + // refresh completes. The parse may produce a sparse object on + // first load — `is_external` defaulting to falsy is correct + // for the internal-user-by-default contract. + app.isExternalUser = !!userData.is_external; if (userData.username) { // We have cached user data — render immediately, refresh in background updateUserMenuData(); @@ -134,14 +141,22 @@ async function checkAuthentication() { await resolveHomeFolder(); window.dispatchEvent(new CustomEvent('authenticationDone')); } else { - // No cached user data — must verify session from server + // No cached user data — must verify session from server. + // This is the first-load path for magic-link redemptions + // (cookies set server-side, no prior localStorage). console.log('No cached user data, fetching from server'); try { const freshData = await refreshUserData(); if (freshData?.username) { updateUserMenuData(); updateStorageUsageDisplay(freshData); - resolveHomeFolder().then(() => loadFiles()); + await resolveHomeFolder(); + // Defer to the `authenticationDone` listener in main.js + // so the hash-driven section + path init runs in one + // place (was previously a `loadFiles()` here which + // bypassed the hash context and produced + // `/api/folders//resources` for external users). + window.dispatchEvent(new CustomEvent('authenticationDone')); } else { console.warn('Could not retrieve user data, redirecting to login'); localStorage.removeItem(USER_DATA_KEY); @@ -162,6 +177,16 @@ async function checkAuthentication() { async function resolveHomeFolder() { if (app.userHomeFolderId) return; + // External users (grant-only recipients) do not own a home folder + // by design — see `HomeFolderLifecycleHook::provision_if_needed` + // which short-circuits on `is_external`. Skip the fetch + leave + // `userHomeFolderId` null so downstream code knows to land them on + // /#/sharedwithme instead of /files. + if (app.isExternalUser) { + console.log('External user — skipping home-folder resolution'); + app.breadcrumbPath = []; + return; + } try { const response = await fetch('/api/folders', { credentials: 'same-origin' diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index 33100d85..39cafa5f 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -405,6 +405,17 @@ async function loadFiles(options = { insertHistory: true }) { try { if (!app.userHomeFolderId) await resolveHomeFolder(); + // External users have no home folder. If they land on /files + // without a specific folder id in the URL, redirect them to + // /#/sharedwithme — their actual landing page. This guards + // against `fetchResourcesPage('')` building `/api/folders//resources`. + if (app.isExternalUser && (!app.currentPath || app.currentPath === '')) { + clearTimeout(spinnerTimeout); + _loading = false; + window.location.hash = '#/sharedwithme'; + return; + } + // Resolve path to home folder when none is set if (!app.currentPath || app.currentPath === '') { if (app.userHomeFolderId) { diff --git a/static/js/app/main.js b/static/js/app/main.js index f2328cd4..8e1e6678 100644 --- a/static/js/app/main.js +++ b/static/js/app/main.js @@ -407,8 +407,11 @@ function setupActionsBarDelegation() { function deserializeHash() { const hashContext = /** type {OxiContext} */ {}; + // External users have no home folder; default them to /#/sharedwithme + // (their actual landing) so the URL bar reflects what they'll see. + // Internal users default to the Files section. // FIXME rename files into drive ? - hashContext.section = 'files'; + hashContext.section = app.isExternalUser ? 'sharedwithme' : 'files'; const hash_elements = window.location.hash.split('/'); diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index 09eaab5b..88b0e64a 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -299,9 +299,15 @@ function switchToFilesSection() { //reset files view + remove any error ui.resetFilesList(); - // Reset to home folder and update breadcrumb - app.currentPath = app.userHomeFolderId || ''; - app.breadcrumbPath = []; + // Reset to home folder and update breadcrumb. External users have no + // home — leave `currentPath` as the caller set it (e.g. the magic-link + // landing's hash context) so loadFiles() doesn't fall through to + // `/api/folders//resources`. If `currentPath` is still empty by the + // time loadFiles() runs, it self-redirects to /#/sharedwithme. + if (!app.isExternalUser) { + app.currentPath = app.userHomeFolderId || ''; + app.breadcrumbPath = []; + } ui.updateBreadcrumb(); if (batchToolbar) batchToolbar.clear(); diff --git a/static/js/app/state.js b/static/js/app/state.js index 3d030c84..2d300336 100644 --- a/static/js/app/state.js +++ b/static/js/app/state.js @@ -59,6 +59,16 @@ export const app = { /** @type {string | null} */ userHomeFolderName: null, + /** + * `true` when the authenticated caller is an external (grant-only) + * user. Externals don't own a home folder, can't enumerate users, + * and land on `/#/sharedwithme` by default. Set by `refreshUserData` + * and the cached-data load path from the `is_external` field of + * `/api/auth/me`'s response. + * @type {boolean} + */ + isExternalUser: false, + /** @type {Array<{id: string, name: string}>} */ breadcrumbPath: [], // Array of {id, name} tracking folder navigation hierarchy diff --git a/static/js/components/pendingEmailVignette.js b/static/js/components/pendingEmailVignette.js new file mode 100644 index 00000000..a0c9e81d --- /dev/null +++ b/static/js/components/pendingEmailVignette.js @@ -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; +} diff --git a/static/js/components/shareModal.js b/static/js/components/shareModal.js index a85099e8..4706ec92 100644 --- a/static/js/components/shareModal.js +++ b/static/js/components/shareModal.js @@ -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} */ + /** @type {Array} */ _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} results - * @param {(c: ContactItem | GroupSuggestion) => void} onSelect + * @param {HTMLElement} container + * @param {Array} 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 diff --git a/static/js/components/userVignette.js b/static/js/components/userVignette.js index 70088abd..f6e0f6e0 100644 --- a/static/js/components/userVignette.js +++ b/static/js/components/userVignette.js @@ -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; } diff --git a/static/js/core/icons.js b/static/js/core/icons.js index 2da09719..15b56daf 100644 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -73,6 +73,14 @@ const OxiIcons = { 576, 'M566.6 54.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192-34.7-34.7c-4.2-4.2-10-6.6-16-6.6c-12.5 0-22.6 10.1-22.6 22.6l0 29.1L364.3 320l29.1 0c12.5 0 22.6-10.1 22.6-22.6c0-6-2.4-11.8-6.6-16l-34.7-34.7 192-192zM341.1 353.4L222.6 234.9c-42.7-3.7-85.2 11.7-115.8 42.3l-8 8C76.5 307.5 64 337.7 64 369.2c0 6.8 7.1 11.2 13.2 8.2l51.1-25.5c5-2.5 9.5 4.1 5.4 7.9L7.3 473.4C2.7 477.6 0 483.6 0 489.9C0 502.1 9.9 512 22.1 512l173.3 0c38.8 0 75.9-15.4 103.4-42.8c30.6-30.6 45.9-73.1 42.3-115.8z' ], + 'building-circle-check': [ + 576, + 'M96 0C60.7 0 32 28.7 32 64l0 384c0 35.3 28.7 64 64 64l180 0c-10.5-14.6-19-30.7-25.1-48l-74.9 0 0-80c0-17.7 14.3-32 32-32l32 0c2 0 4 .2 5.9 .5 6-23.6 16.3-45.4 30.1-64.5l-4 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 4c27.5-19.8 60.3-32.4 96-35.4L416 64c0-35.3-28.7-64-64-64L96 0zm32 112c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM272 96l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM128 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM576 400a144 144 0 1 0 -288 0 144 144 0 1 0 288 0zm-86.6-60.9c7.1 5.2 8.7 15.2 3.5 22.3l-64 88c-2.8 3.8-7 6.2-11.7 6.5s-9.3-1.3-12.6-4.6l-40-40c-6.2-6.2-6.2-16.4 0-22.6s16.4-6.2 22.6 0l26.8 26.8 53-72.9c5.2-7.1 15.2-8.7 22.4-3.5z' + ], + 'building-circle-xmark': [ + 576, + 'M96 0C60.7 0 32 28.7 32 64l0 384c0 35.3 28.7 64 64 64l180 0c-10.5-14.6-19-30.7-25.1-48l-74.9 0 0-80c0-17.7 14.3-32 32-32l32 0c2 0 4 .2 5.9 .5 6-23.6 16.3-45.4 30.1-64.5l-4 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 4c27.5-19.8 60.3-32.4 96-35.4L416 64c0-35.3-28.7-64-64-64L96 0zm32 112c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM272 96l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM128 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM432 544a144 144 0 1 0 0-288 144 144 0 1 0 0 288zm22.6-144l36.7 36.7c6.2 6.2 6.2 16.4 0 22.6s-16.4 6.2-22.6 0l-36.7-36.7-36.7 36.7c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l36.7-36.7-36.7-36.7c-6.2-6.2-6.2-16.4 0-22.6s16.4-6.2 22.6 0l36.7 36.7 36.7-36.7c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6L454.6 400z' + ], calendar: [ 512, 'M120 0c13.3 0 24 10.7 24 24l0 40 160 0 0-40c0-13.3 10.7-24 24-24s24 10.7 24 24l0 40 32 0c35.3 0 64 28.7 64 64l0 288c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 128C0 92.7 28.7 64 64 64l32 0 0-40c0-13.3 10.7-24 24-24zm0 112l-56 0c-8.8 0-16 7.2-16 16l0 48 352 0 0-48c0-8.8-7.2-16-16-16l-264 0zM48 224l0 192c0 8.8 7.2 16 16 16l320 0c8.8 0 16-7.2 16-16l0-192-352 0z' diff --git a/static/js/core/types.js b/static/js/core/types.js index 3eff214b..ef006901 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -141,6 +141,12 @@ */ /** + * Wire shape of `UserDto` (backend: `src/application/dtos/user_dto.rs`). + * Returned by `/api/auth/me`, `/api/users/{id}`, the login response, and + * the admin user-management endpoints. Optional fields (`image`, + * `given_name`, `family_name`) are omitted when null on the wire — the + * `?` markers below reflect that. + * * @typedef {Object} User * @property {string} id * @property {string} username @@ -148,11 +154,16 @@ * @property {string} role * @property {number} storage_quota_bytes * @property {number} storage_used_bytes - * @property {number} created_at - * @property {number} updated_at - * @property {number} last_login_at + * @property {string} created_at ISO 8601 timestamp + * @property {string} updated_at ISO 8601 timestamp + * @property {string|null} [last_login_at] ISO 8601 timestamp; null until first login * @property {boolean} active - * @property {string} auth_provider + * @property {string} auth_provider "local" or OIDC provider id + * @property {string|null} [image] Avatar URL or data URI + * @property {boolean} can_edit_image False for OIDC-only users + * @property {boolean} is_external True for magic-link / OIDC-only / OCM recipients + * @property {string} [given_name] OIDC `given_name` claim, when set + * @property {string} [family_name] OIDC `family_name` claim, when set */ /** @@ -447,6 +458,12 @@ * @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. + * @property {string} [_invitedEmail] - Transient marker for an + * email-invite that hasn't been committed yet. When set, the row + * renders with `pendingEmailVignette` (no UUID known) and + * `_applyAll` POSTs `subject.type=email` to `/api/grants`. Cleared + * on the next `fetchOutgoingGrants` refresh once the server has + * resolved the recipient to a real user UUID. */ /** diff --git a/static/js/model/systemUsers.js b/static/js/model/systemUsers.js index 7e9360ce..f7f040b3 100644 --- a/static/js/model/systemUsers.js +++ b/static/js/model/systemUsers.js @@ -15,7 +15,7 @@ * returns false and `getDisplayName()` returns a shortened UUID. */ -/** @import {ContactItem} from '../core/types.js' */ +/** @import {ContactItem, User} from '../core/types.js' */ import { addressBook, SYSTEM_BOOK_ID } from './addressBook.js'; @@ -28,6 +28,16 @@ let _photoIndex = null; /** @type {Map | null} userId → primary email (or null), built lazily */ let _emailIndex = null; +/** @type {Map | null} userId → is_external flag, built lazily. + * The system-book bulk load populates `false` for every entry (PR 6 filters + * externals out of the system book). Externals appear only when their UUID + * shows up in a grant — `_resolveMissing` then back-fills via `/api/users/{id}`. + */ +let _externalIndex = null; + +/** @type {Map>} userId → in-flight fetch (de-dupe). */ +const _inflight = new Map(); + /** * Derive the best human-readable name from a contact. * Priority: "First Last" → full_name → primary email → shortened id. @@ -61,12 +71,16 @@ async function _ensureIndex() { return [c.id, primary]; }) ); + // System book is internal-only post-PR-6 → every entry here is is_external=false. + _externalIndex = new Map(contacts.map((c) => [c.id, false])); // Inject the current user if they are not already in the index try { const raw = localStorage.getItem('oxicloud_user'); if (raw) { - const u = /** @type {{id?:string, display_name?:string, username?:string, email?:string, image?:string|null}} */ (JSON.parse(raw)); + const u = /** @type {{id?:string, display_name?:string, username?:string, email?:string, image?:string|null, is_external?:boolean}} */ ( + JSON.parse(raw) + ); if (u?.id) { if (!_index.has(u.id)) { const name = u.display_name || u.username || u.email || `${u.id.slice(0, 8)}…`; @@ -78,6 +92,9 @@ async function _ensureIndex() { if (!_emailIndex.has(u.id)) { _emailIndex.set(u.id, u.email ?? null); } + if (!_externalIndex.has(u.id)) { + _externalIndex.set(u.id, u.is_external ?? false); + } } } } catch { @@ -85,6 +102,46 @@ async function _ensureIndex() { } } +/** + * Fetch a single user profile from `/api/users/{id}` and back-fill every + * cache map. Used when a userId surfaces (e.g. via a grant) that wasn't + * part of the bulk system-book load — typically external users. + * + * In-flight requests are de-duplicated through `_inflight` so concurrent + * vignette renders for the same external userId issue only one HTTP call. + * Failures (404 / 403 / 429 / network) leave the caches in their + * default-unknown state; callers fall back to UUID-prefix display. + * + * @param {string} userId + * @returns {Promise} + */ +async function _resolveMissing(userId) { + if (_index?.has(userId)) return; + const pending = _inflight.get(userId); + if (pending) return pending; + + const promise = (async () => { + try { + const resp = await fetch(`/api/users/${encodeURIComponent(userId)}`, { + credentials: 'same-origin' + }); + if (!resp.ok) return; + /** @type {User} */ + const u = await resp.json(); + _index?.set(u.id, u.username || u.email || `${u.id.slice(0, 8)}…`); + _photoIndex?.set(u.id, u.image ?? null); + _emailIndex?.set(u.id, u.email ?? null); + _externalIndex?.set(u.id, !!u.is_external); + } catch { + // network error — caches stay unset; getters fall back to defaults + } finally { + _inflight.delete(userId); + } + })(); + _inflight.set(userId, promise); + return promise; +} + // ── Public API ──────────────────────────────────────────────────────────────── /** @@ -111,13 +168,17 @@ function getDisplayNameSync(userId) { /** * Resolve a user UUID to a display name. - * Awaits the first load if not yet cached; subsequent calls resolve instantly. + * Awaits the first load if not yet cached; subsequent calls resolve + * instantly. On a system-book miss (e.g. external users, which are + * filtered out of the system address book), back-fills via + * `/api/users/{id}` once per session. * * @param {string} userId * @returns {Promise} */ async function getDisplayName(userId) { await _ensureIndex(); + if (!_index?.has(userId)) await _resolveMissing(userId); return _index?.get(userId) ?? `${userId.slice(0, 8)}…`; } @@ -130,6 +191,7 @@ async function getDisplayName(userId) { */ async function getPhoto(userId) { await _ensureIndex(); + if (!_index?.has(userId)) await _resolveMissing(userId); return _photoIndex?.get(userId) ?? null; } @@ -142,9 +204,26 @@ async function getPhoto(userId) { */ async function getEmail(userId) { await _ensureIndex(); + if (!_index?.has(userId)) await _resolveMissing(userId); return _emailIndex?.get(userId) ?? null; } +/** + * Resolve a user UUID to whether they are an external (grant-only) + * recipient. Defaults to `false` (internal-by-assumption) for unknown + * UUIDs so callers can render without an extra null-check. + * Awaits the first system-book load; falls back to `/api/users/{id}` + * on miss — externals are excluded from the system book per PR 6. + * + * @param {string} userId + * @returns {Promise} + */ +async function getIsExternal(userId) { + await _ensureIndex(); + if (!_externalIndex?.has(userId)) await _resolveMissing(userId); + return _externalIndex?.get(userId) ?? false; +} + /** * Force-refresh the current user's photo entry in the index from localStorage. * Call this after saving a new avatar on the profile page so that existing @@ -172,4 +251,13 @@ function isAvailable() { return addressBook.isSystemAvailable(); } -export const systemUsers = { prefetch, getDisplayName, getDisplayNameSync, getPhoto, getEmail, refreshCurrentUserPhoto, isAvailable }; +export const systemUsers = { + prefetch, + getDisplayName, + getDisplayNameSync, + getPhoto, + getEmail, + getIsExternal, + refreshCurrentUserPhoto, + isAvailable +}; diff --git a/static/locales/en.json b/static/locales/en.json index 38eec795..cc4dda02 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -157,7 +157,8 @@ "shareCopied": "Link copied to clipboard", "shareCreated": "Share link created successfully", "shareUpdated": "Share settings updated successfully", - "shareRemoved": "Share removed successfully" + "shareRemoved": "Share removed successfully", + "inviteByEmail": "Invite by email — invitation will be sent" }, "share_dialogTitle": "Share Link", "share_linkLabel": "Share Link:", diff --git a/static/locales/fr.json b/static/locales/fr.json index 1d96c7c1..1433798a 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -157,7 +157,8 @@ "shareCopied": "Lien copié dans le presse-papiers", "shareCreated": "Lien de partage créé avec succès", "shareUpdated": "Paramètres de partage mis à jour", - "shareRemoved": "Partage supprimé avec succès" + "shareRemoved": "Partage supprimé avec succès", + "inviteByEmail": "Inviter par e-mail — une invitation sera envoyée" }, "share_dialogTitle": "Lien de partage", "share_linkLabel": "Lien partagé :",