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;
|
||||
}
|
||||
|
||||
/* "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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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('/');
|
||||
|
||||
|
||||
@@ -299,9 +299,15 @@ function switchToFilesSection() {
|
||||
//reset files view + remove any error
|
||||
ui.resetFilesList();
|
||||
|
||||
// Reset to home folder and update breadcrumb
|
||||
// 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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -417,8 +466,8 @@ const shareModal = {
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
* @param {Array<ContactItem | GroupSuggestion>} results
|
||||
* @param {(c: ContactItem | GroupSuggestion) => void} onSelect
|
||||
* @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 {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'
|
||||
? (() => {
|
||||
let visual;
|
||||
if (c._kind === 'group') {
|
||||
const g = /** @type {GroupSuggestion} */ (c);
|
||||
return createGroupVignette(groupDisplayName(g), 'xs', { icon: groupIconClass(g) });
|
||||
})()
|
||||
: createUserVignette(c.id, 'xs');
|
||||
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,11 +691,19 @@ 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)
|
||||
})
|
||||
: entry._invitedEmail
|
||||
? createPendingEmailVignette(entry._invitedEmail, 'md')
|
||||
: createUserVignette(entry.grant.subject.id, 'md');
|
||||
|
||||
const roleSelect = document.createElement('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,9 +154,15 @@ 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]) => {
|
||||
// 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) {
|
||||
@@ -146,8 +170,17 @@ export function createUserVignette(userId, size = 'sm', { showName = true, showE
|
||||
} 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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
+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
|
||||
* @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.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, string | null> | null} userId → primary email (or null), built lazily */
|
||||
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.
|
||||
* 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<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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -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<string>}
|
||||
*/
|
||||
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<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.
|
||||
* 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
|
||||
};
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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é :",
|
||||
|
||||
Reference in New Issue
Block a user