feat(ui): add share modal and users
- fix(external users): fix app starting for external users
This commit is contained in:
@@ -138,6 +138,22 @@
|
|||||||
min-width: 0;
|
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 */
|
/* Role picker beside the search box */
|
||||||
.smd-role-select {
|
.smd-role-select {
|
||||||
padding: 9px 10px;
|
padding: 9px 10px;
|
||||||
|
|||||||
@@ -32,6 +32,8 @@
|
|||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
/* Anchor for the bottom-right `__origin` badge. */
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-vignette__name {
|
.user-vignette__name {
|
||||||
@@ -176,3 +178,37 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getCsrfHeaders } from '../core/csrf.js';
|
import { getCsrfHeaders } from '../core/csrf.js';
|
||||||
import { loadFiles } from './filesView.js';
|
|
||||||
import { updateStorageUsageDisplay } from './main.js';
|
import { updateStorageUsageDisplay } from './main.js';
|
||||||
import { app } from './state.js';
|
import { app } from './state.js';
|
||||||
import { ui } from './ui.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);
|
console.log('Storage from server: used=', userData.storage_used_bytes, 'quota=', userData.storage_quota_bytes);
|
||||||
|
|
||||||
localStorage.setItem(USER_DATA_KEY, JSON.stringify(userData));
|
localStorage.setItem(USER_DATA_KEY, JSON.stringify(userData));
|
||||||
|
app.isExternalUser = !!userData.is_external;
|
||||||
updateStorageUsageDisplay(userData);
|
updateStorageUsageDisplay(userData);
|
||||||
return userData;
|
return userData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -93,8 +93,15 @@ async function checkAuthentication() {
|
|||||||
// Check session validity by calling /api/auth/me (cookie auto-sent)
|
// Check session validity by calling /api/auth/me (cookie auto-sent)
|
||||||
console.log('Checking session via /api/auth/me...');
|
console.log('Checking session via /api/auth/me...');
|
||||||
|
|
||||||
|
/** @type {User} */
|
||||||
/** @type {User} */
|
/** @type {User} */
|
||||||
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
|
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) {
|
if (userData.username) {
|
||||||
// We have cached user data — render immediately, refresh in background
|
// We have cached user data — render immediately, refresh in background
|
||||||
updateUserMenuData();
|
updateUserMenuData();
|
||||||
@@ -134,14 +141,22 @@ async function checkAuthentication() {
|
|||||||
await resolveHomeFolder();
|
await resolveHomeFolder();
|
||||||
window.dispatchEvent(new CustomEvent('authenticationDone'));
|
window.dispatchEvent(new CustomEvent('authenticationDone'));
|
||||||
} else {
|
} 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');
|
console.log('No cached user data, fetching from server');
|
||||||
try {
|
try {
|
||||||
const freshData = await refreshUserData();
|
const freshData = await refreshUserData();
|
||||||
if (freshData?.username) {
|
if (freshData?.username) {
|
||||||
updateUserMenuData();
|
updateUserMenuData();
|
||||||
updateStorageUsageDisplay(freshData);
|
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 {
|
} else {
|
||||||
console.warn('Could not retrieve user data, redirecting to login');
|
console.warn('Could not retrieve user data, redirecting to login');
|
||||||
localStorage.removeItem(USER_DATA_KEY);
|
localStorage.removeItem(USER_DATA_KEY);
|
||||||
@@ -162,6 +177,16 @@ async function checkAuthentication() {
|
|||||||
|
|
||||||
async function resolveHomeFolder() {
|
async function resolveHomeFolder() {
|
||||||
if (app.userHomeFolderId) return;
|
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 {
|
try {
|
||||||
const response = await fetch('/api/folders', {
|
const response = await fetch('/api/folders', {
|
||||||
credentials: 'same-origin'
|
credentials: 'same-origin'
|
||||||
|
|||||||
@@ -405,6 +405,17 @@ async function loadFiles(options = { insertHistory: true }) {
|
|||||||
try {
|
try {
|
||||||
if (!app.userHomeFolderId) await resolveHomeFolder();
|
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
|
// Resolve path to home folder when none is set
|
||||||
if (!app.currentPath || app.currentPath === '') {
|
if (!app.currentPath || app.currentPath === '') {
|
||||||
if (app.userHomeFolderId) {
|
if (app.userHomeFolderId) {
|
||||||
|
|||||||
@@ -407,8 +407,11 @@ function setupActionsBarDelegation() {
|
|||||||
function deserializeHash() {
|
function deserializeHash() {
|
||||||
const hashContext = /** type {OxiContext} */ {};
|
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 ?
|
// FIXME rename files into drive ?
|
||||||
hashContext.section = 'files';
|
hashContext.section = app.isExternalUser ? 'sharedwithme' : 'files';
|
||||||
|
|
||||||
const hash_elements = window.location.hash.split('/');
|
const hash_elements = window.location.hash.split('/');
|
||||||
|
|
||||||
|
|||||||
@@ -299,9 +299,15 @@ function switchToFilesSection() {
|
|||||||
//reset files view + remove any error
|
//reset files view + remove any error
|
||||||
ui.resetFilesList();
|
ui.resetFilesList();
|
||||||
|
|
||||||
// Reset to home folder and update breadcrumb
|
// Reset to home folder and update breadcrumb. External users have no
|
||||||
app.currentPath = app.userHomeFolderId || '';
|
// home — leave `currentPath` as the caller set it (e.g. the magic-link
|
||||||
app.breadcrumbPath = [];
|
// 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();
|
ui.updateBreadcrumb();
|
||||||
if (batchToolbar) batchToolbar.clear();
|
if (batchToolbar) batchToolbar.clear();
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ export const app = {
|
|||||||
/** @type {string | null} */
|
/** @type {string | null} */
|
||||||
userHomeFolderName: 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}>} */
|
/** @type {Array<{id: string, name: string}>} */
|
||||||
breadcrumbPath: [], // Array of {id, name} tracking folder navigation hierarchy
|
breadcrumbPath: [], // Array of {id, name} tracking folder navigation hierarchy
|
||||||
|
|
||||||
|
|||||||
@@ -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 { groupDisplayName, groupIconClass, groupIconClassByVirtual } from './groupDisplay.js';
|
||||||
import { createGroupVignette } from './groupVignette.js';
|
import { createGroupVignette } from './groupVignette.js';
|
||||||
import { Modal } from './modal.js';
|
import { Modal } from './modal.js';
|
||||||
|
import { createPendingEmailVignette } from './pendingEmailVignette.js';
|
||||||
import { createUserVignette } from './userVignette.js';
|
import { createUserVignette } from './userVignette.js';
|
||||||
|
|
||||||
/** @import {FileItem, FolderItem, Grant, ContactItem, MemberEntry, LinkEntry, DraftLink, ShareRoleEnum} from '../core/types.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
|
* @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). */
|
/** Permissions that belong to each role (must mirror the Rust DTO). */
|
||||||
const ROLE_PERMISSIONS = {
|
const ROLE_PERMISSIONS = {
|
||||||
viewer: ['read'],
|
viewer: ['read'],
|
||||||
@@ -136,7 +167,7 @@ const shareModal = {
|
|||||||
/** @type {DraftLink[]} */
|
/** @type {DraftLink[]} */
|
||||||
_newLinks: [],
|
_newLinks: [],
|
||||||
|
|
||||||
/** @type {Array<ContactItem | GroupSuggestion>} */
|
/** @type {Array<ContactItem | GroupSuggestion | EmailSuggestion>} */
|
||||||
_stagedUsers: [],
|
_stagedUsers: [],
|
||||||
|
|
||||||
/** @type {ShareRoleEnum} */
|
/** @type {ShareRoleEnum} */
|
||||||
@@ -381,9 +412,27 @@ const shareModal = {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
const filtered = currentUserId ? contacts.filter((c) => c.id !== currentUserId) : contacts;
|
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),
|
// Groups first (they're a smaller, distinctively-iconed set),
|
||||||
// then contacts. Cap at 8 combined.
|
// then contacts, then the email-invite suggestion at the
|
||||||
const combined = [...groupItems, ...filtered].slice(0, 8);
|
// 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._renderSuggestions(dropdown, combined, (item) => {
|
||||||
this._stageUser(item, input, dropdown, addBtn);
|
this._stageUser(item, input, dropdown, addBtn);
|
||||||
});
|
});
|
||||||
@@ -416,9 +465,9 @@ const shareModal = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {HTMLElement} container
|
* @param {HTMLElement} container
|
||||||
* @param {Array<ContactItem | GroupSuggestion>} results
|
* @param {Array<ContactItem | GroupSuggestion | EmailSuggestion>} results
|
||||||
* @param {(c: ContactItem | GroupSuggestion) => void} onSelect
|
* @param {(c: ContactItem | GroupSuggestion | EmailSuggestion) => void} onSelect
|
||||||
*/
|
*/
|
||||||
_renderSuggestions(container, results, onSelect) {
|
_renderSuggestions(container, results, onSelect) {
|
||||||
container.replaceChildren();
|
container.replaceChildren();
|
||||||
@@ -434,6 +483,14 @@ const shareModal = {
|
|||||||
if (c._kind === 'group') {
|
if (c._kind === 'group') {
|
||||||
const g = /** @type {GroupSuggestion} */ (c);
|
const g = /** @type {GroupSuggestion} */ (c);
|
||||||
item.appendChild(createGroupVignette(groupDisplayName(g), 'sm', { icon: groupIconClass(g) }));
|
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 {
|
} else {
|
||||||
item.appendChild(createUserVignette(c.id, 'sm', { showEmail: true }));
|
item.appendChild(createUserVignette(c.id, 'sm', { showEmail: true }));
|
||||||
}
|
}
|
||||||
@@ -449,17 +506,25 @@ const shareModal = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {ContactItem | GroupSuggestion} contact
|
* @param {ContactItem | GroupSuggestion | EmailSuggestion} contact
|
||||||
* @param {HTMLInputElement} inputEl
|
* @param {HTMLInputElement} inputEl
|
||||||
* @param {HTMLElement} dropdown
|
* @param {HTMLElement} dropdown
|
||||||
* @param {HTMLButtonElement} addBtn
|
* @param {HTMLButtonElement} addBtn
|
||||||
*/
|
*/
|
||||||
_stageUser(contact, inputEl, dropdown, addBtn) {
|
_stageUser(contact, inputEl, dropdown, addBtn) {
|
||||||
// Idempotent: skip duplicates and already-existing members. Match on
|
// Idempotent: skip duplicates and already-existing members. Match on
|
||||||
// id *and* kind so a user and a group sharing a UUID collision (in
|
// id *and* kind so a user / group / email-invite sharing the same
|
||||||
// theory impossible; in practice harmless) wouldn't shadow each other.
|
// string value (unlikely but harmless) wouldn't shadow each other.
|
||||||
const kind = contact._kind === 'group' ? 'group' : 'user';
|
const kind = contact._kind === 'group' ? 'group' : contact._kind === 'email' ? 'email' : 'user';
|
||||||
const alreadyMember = this._localMembers.some((m) => m.grant.subject.id === contact.id && m.grant.subject.type === kind && m._op !== 'remove');
|
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);
|
const alreadyStaged = this._stagedUsers.some((u) => u.id === contact.id && (u._kind ?? 'user') === kind);
|
||||||
if (alreadyMember || alreadyStaged) return;
|
if (alreadyMember || alreadyStaged) return;
|
||||||
|
|
||||||
@@ -497,19 +562,22 @@ const shareModal = {
|
|||||||
const chip = document.createElement('div');
|
const chip = document.createElement('div');
|
||||||
chip.className = 'smd-chip';
|
chip.className = 'smd-chip';
|
||||||
|
|
||||||
const visual =
|
let visual;
|
||||||
c._kind === 'group'
|
if (c._kind === 'group') {
|
||||||
? (() => {
|
const g = /** @type {GroupSuggestion} */ (c);
|
||||||
const g = /** @type {GroupSuggestion} */ (c);
|
visual = createGroupVignette(groupDisplayName(g), 'xs', { icon: groupIconClass(g) });
|
||||||
return createGroupVignette(groupDisplayName(g), 'xs', { icon: groupIconClass(g) });
|
} else if (c._kind === 'email') {
|
||||||
})()
|
const e = /** @type {EmailSuggestion} */ (c);
|
||||||
: createUserVignette(c.id, 'xs');
|
visual = createPendingEmailVignette(e.email, 'xs');
|
||||||
|
} else {
|
||||||
|
visual = createUserVignette(c.id, 'xs');
|
||||||
|
}
|
||||||
|
|
||||||
const rm = document.createElement('button');
|
const rm = document.createElement('button');
|
||||||
rm.className = 'smd-chip-remove';
|
rm.className = 'smd-chip-remove';
|
||||||
rm.innerHTML = '×';
|
rm.innerHTML = '×';
|
||||||
rm.title = i18n.t('actions.remove', 'Remove');
|
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', () => {
|
rm.addEventListener('click', () => {
|
||||||
this._stagedUsers = this._stagedUsers.filter((u) => !(u.id === c.id && (u._kind ?? 'user') === kind));
|
this._stagedUsers = this._stagedUsers.filter((u) => !(u.id === c.id && (u._kind ?? 'user') === kind));
|
||||||
this._refreshChips();
|
this._refreshChips();
|
||||||
@@ -525,7 +593,13 @@ const shareModal = {
|
|||||||
|
|
||||||
_commitStagedUsers() {
|
_commitStagedUsers() {
|
||||||
for (const contact of this._stagedUsers) {
|
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} */
|
/** @type {Grant} */
|
||||||
const placeholderGrant = {
|
const placeholderGrant = {
|
||||||
id: '', // not yet persisted
|
id: '', // not yet persisted
|
||||||
@@ -541,7 +615,8 @@ const shareModal = {
|
|||||||
role: this._stagedRole,
|
role: this._stagedRole,
|
||||||
_op: 'new',
|
_op: 'new',
|
||||||
expires_at: this._stagedExpiry,
|
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 = [];
|
this._stagedUsers = [];
|
||||||
@@ -616,12 +691,20 @@ const shareModal = {
|
|||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'smd-member-row';
|
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 =
|
const vignette =
|
||||||
entry.grant.subject.type === 'group'
|
entry.grant.subject.type === 'group'
|
||||||
? createGroupVignette(entry._displayName ?? entry.grant.subject.id, 'md', {
|
? createGroupVignette(entry._displayName ?? entry.grant.subject.id, 'md', {
|
||||||
icon: groupIconClassByVirtual(entry._isVirtual)
|
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');
|
const roleSelect = document.createElement('select');
|
||||||
roleSelect.className = 'smd-member-role-select';
|
roleSelect.className = 'smd-member-role-select';
|
||||||
@@ -939,8 +1022,14 @@ const shareModal = {
|
|||||||
expires_at: expiresIso
|
expires_at: expiresIso
|
||||||
});
|
});
|
||||||
} else if (m._op === 'new') {
|
} 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({
|
await grants.createGrant({
|
||||||
subject: { type: m.grant.subject.type, id: m.grant.subject.id },
|
subject,
|
||||||
resource: { type: itemType, id: item.id },
|
resource: { type: itemType, id: item.id },
|
||||||
role: m.role,
|
role: m.role,
|
||||||
expires_at: expiresIso
|
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
|
* 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
|
* the name in a lighter style. Name and email are wrapped in a
|
||||||
* `.user-vignette__info` column. Has no effect when showName is false.
|
* `.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]
|
* @param {VignetteOptions} [options]
|
||||||
* @returns {HTMLElement}
|
* @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 colorIdx = _colorIndex(userId);
|
||||||
|
|
||||||
const wrapper = /** @type {HTMLElement} */ (document.createElement('span'));
|
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();
|
avatar.textContent = userId.slice(0, 2).toUpperCase();
|
||||||
wrapper.appendChild(avatar);
|
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} */
|
/** @type {HTMLElement | null} */
|
||||||
const nameEl = showName ? document.createElement('span') : 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.
|
// Resolve name, photo, email, and (when requested) is_external
|
||||||
Promise.all([systemUsers.getDisplayName(userId), systemUsers.getPhoto(userId), emailEl ? systemUsers.getEmail(userId) : Promise.resolve(null)]).then(
|
// asynchronously. All four go through the systemUsers cache so a
|
||||||
([name, photo, email]) => {
|
// single fetch back-fills every facet.
|
||||||
if (nameEl) nameEl.textContent = name;
|
Promise.all([
|
||||||
if (emailEl) emailEl.textContent = email ?? '';
|
systemUsers.getDisplayName(userId),
|
||||||
if (photo) {
|
systemUsers.getPhoto(userId),
|
||||||
_applyPhoto(avatar, photo, name);
|
emailEl ? systemUsers.getEmail(userId) : Promise.resolve(null),
|
||||||
} else {
|
originEl ? systemUsers.getIsExternal(userId) : Promise.resolve(false)
|
||||||
avatar.textContent = _initials(name);
|
]).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;
|
return wrapper;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ const OxiIcons = {
|
|||||||
576,
|
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'
|
'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: [
|
calendar: [
|
||||||
512,
|
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'
|
'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'
|
||||||
|
|||||||
+21
-4
@@ -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
|
* @typedef {Object} User
|
||||||
* @property {string} id
|
* @property {string} id
|
||||||
* @property {string} username
|
* @property {string} username
|
||||||
@@ -148,11 +154,16 @@
|
|||||||
* @property {string} role
|
* @property {string} role
|
||||||
* @property {number} storage_quota_bytes
|
* @property {number} storage_quota_bytes
|
||||||
* @property {number} storage_used_bytes
|
* @property {number} storage_used_bytes
|
||||||
* @property {number} created_at
|
* @property {string} created_at ISO 8601 timestamp
|
||||||
* @property {number} updated_at
|
* @property {string} updated_at ISO 8601 timestamp
|
||||||
* @property {number} last_login_at
|
* @property {string|null} [last_login_at] ISO 8601 timestamp; null until first login
|
||||||
* @property {boolean} active
|
* @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|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 {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 {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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
* returns false and `getDisplayName()` returns a shortened UUID.
|
* 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';
|
import { addressBook, SYSTEM_BOOK_ID } from './addressBook.js';
|
||||||
|
|
||||||
@@ -28,6 +28,16 @@ let _photoIndex = null;
|
|||||||
/** @type {Map<string, string | null> | null} userId → primary email (or null), built lazily */
|
/** @type {Map<string, string | null> | null} userId → primary email (or null), built lazily */
|
||||||
let _emailIndex = null;
|
let _emailIndex = null;
|
||||||
|
|
||||||
|
/** @type {Map<string, boolean> | 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<string, Promise<void>>} userId → in-flight fetch (de-dupe). */
|
||||||
|
const _inflight = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive the best human-readable name from a contact.
|
* Derive the best human-readable name from a contact.
|
||||||
* Priority: "First Last" → full_name → primary email → shortened id.
|
* Priority: "First Last" → full_name → primary email → shortened id.
|
||||||
@@ -61,12 +71,16 @@ async function _ensureIndex() {
|
|||||||
return [c.id, primary];
|
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
|
// Inject the current user if they are not already in the index
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem('oxicloud_user');
|
const raw = localStorage.getItem('oxicloud_user');
|
||||||
if (raw) {
|
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 (u?.id) {
|
||||||
if (!_index.has(u.id)) {
|
if (!_index.has(u.id)) {
|
||||||
const name = u.display_name || u.username || u.email || `${u.id.slice(0, 8)}…`;
|
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)) {
|
if (!_emailIndex.has(u.id)) {
|
||||||
_emailIndex.set(u.id, u.email ?? null);
|
_emailIndex.set(u.id, u.email ?? null);
|
||||||
}
|
}
|
||||||
|
if (!_externalIndex.has(u.id)) {
|
||||||
|
_externalIndex.set(u.id, u.is_external ?? false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} 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<void>}
|
||||||
|
*/
|
||||||
|
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 ────────────────────────────────────────────────────────────────
|
// ── Public API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,13 +168,17 @@ function getDisplayNameSync(userId) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a user UUID to a display name.
|
* 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
|
* @param {string} userId
|
||||||
* @returns {Promise<string>}
|
* @returns {Promise<string>}
|
||||||
*/
|
*/
|
||||||
async function getDisplayName(userId) {
|
async function getDisplayName(userId) {
|
||||||
await _ensureIndex();
|
await _ensureIndex();
|
||||||
|
if (!_index?.has(userId)) await _resolveMissing(userId);
|
||||||
return _index?.get(userId) ?? `${userId.slice(0, 8)}…`;
|
return _index?.get(userId) ?? `${userId.slice(0, 8)}…`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +191,7 @@ async function getDisplayName(userId) {
|
|||||||
*/
|
*/
|
||||||
async function getPhoto(userId) {
|
async function getPhoto(userId) {
|
||||||
await _ensureIndex();
|
await _ensureIndex();
|
||||||
|
if (!_index?.has(userId)) await _resolveMissing(userId);
|
||||||
return _photoIndex?.get(userId) ?? null;
|
return _photoIndex?.get(userId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,9 +204,26 @@ async function getPhoto(userId) {
|
|||||||
*/
|
*/
|
||||||
async function getEmail(userId) {
|
async function getEmail(userId) {
|
||||||
await _ensureIndex();
|
await _ensureIndex();
|
||||||
|
if (!_index?.has(userId)) await _resolveMissing(userId);
|
||||||
return _emailIndex?.get(userId) ?? null;
|
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<boolean>}
|
||||||
|
*/
|
||||||
|
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.
|
* 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
|
* Call this after saving a new avatar on the profile page so that existing
|
||||||
@@ -172,4 +251,13 @@ function isAvailable() {
|
|||||||
return addressBook.isSystemAvailable();
|
return addressBook.isSystemAvailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const systemUsers = { prefetch, getDisplayName, getDisplayNameSync, getPhoto, getEmail, refreshCurrentUserPhoto, isAvailable };
|
export const systemUsers = {
|
||||||
|
prefetch,
|
||||||
|
getDisplayName,
|
||||||
|
getDisplayNameSync,
|
||||||
|
getPhoto,
|
||||||
|
getEmail,
|
||||||
|
getIsExternal,
|
||||||
|
refreshCurrentUserPhoto,
|
||||||
|
isAvailable
|
||||||
|
};
|
||||||
|
|||||||
@@ -157,7 +157,8 @@
|
|||||||
"shareCopied": "Link copied to clipboard",
|
"shareCopied": "Link copied to clipboard",
|
||||||
"shareCreated": "Share link created successfully",
|
"shareCreated": "Share link created successfully",
|
||||||
"shareUpdated": "Share settings updated 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_dialogTitle": "Share Link",
|
||||||
"share_linkLabel": "Share Link:",
|
"share_linkLabel": "Share Link:",
|
||||||
|
|||||||
@@ -157,7 +157,8 @@
|
|||||||
"shareCopied": "Lien copié dans le presse-papiers",
|
"shareCopied": "Lien copié dans le presse-papiers",
|
||||||
"shareCreated": "Lien de partage créé avec succès",
|
"shareCreated": "Lien de partage créé avec succès",
|
||||||
"shareUpdated": "Paramètres de partage mis à jour",
|
"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_dialogTitle": "Lien de partage",
|
||||||
"share_linkLabel": "Lien partagé :",
|
"share_linkLabel": "Lien partagé :",
|
||||||
|
|||||||
Reference in New Issue
Block a user