Files
Oxicloud/frontend/src/lib/components/ShareDialog.svelte
T

859 lines
22 KiB
Svelte
Raw Normal View History

2026-06-17 22:07:18 -06:00
<script lang="ts">
2026-06-17 22:30:56 -06:00
import { errorToast } from '$lib/utils/errors';
2026-06-17 22:07:18 -06:00
import {
copyShareLink,
createShare,
deleteShare,
listSharesForItem,
updateShare
} from '$lib/api/endpoints/shares';
import {
createGrant,
expiryToIso,
displayRole,
2026-06-17 22:07:18 -06:00
fetchGrantsForResource,
notifyGrantRecipient,
revokeGrant,
updateGrantRole,
type Grant,
type GrantSubject,
type GrantSubjectInput,
type NotifyOutcome,
type ShareRole
} from '$lib/api/endpoints/grants';
import {
ensureResolvers,
isDirectoryAvailable,
resolveRecipient,
searchRecipients,
type Recipient
} from '$lib/api/endpoints/recipients';
import type { ItemType, ShareItem } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import Modal from '$lib/components/Modal.svelte';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
interface Target {
id: string;
name: string;
kind: ItemType;
}
interface Props {
open: boolean;
item: Target | null;
/** Fired with the item id when an outgoing share (grant or link) is created. */
onshared?: (id: string) => void;
2026-06-17 22:07:18 -06:00
}
let { open = $bindable(false), item, onshared }: Props = $props();
2026-06-17 22:07:18 -06:00
let tab = $state<'people' | 'link'>('people');
let directoryAvailable = $state(true);
const ROLES: { v: ShareRole; l: string; icon: string }[] = [
{ v: 'owner', l: t('share.role.canManage', 'Can manage'), icon: 'crown' },
2026-06-17 22:07:18 -06:00
{ v: 'editor', l: t('share.role.canEdit', 'Can edit'), icon: 'pencil-alt' },
{ v: 'viewer', l: t('share.role.canView', 'Can view'), icon: 'eye' }
];
const ROLE_ORDER: ShareRole[] = ['owner', 'editor', 'viewer'];
2026-06-17 22:07:18 -06:00
function roleLabel(r: ShareRole): string {
return ROLES.find((x) => x.v === r)?.l ?? r;
}
function roleIcon(r: ShareRole): string {
return ROLES.find((x) => x.v === r)?.icon ?? 'eye';
}
// ── People / grants ──────────────────────────────────────────────────────
interface Member {
subject: GrantSubject;
recipient: Recipient;
role: ShareRole;
grantIds: string[];
/** Representative grant id for notify (any grant on this subject). */
notifyGrantId?: string;
expiry: string | null; // YYYY-MM-DD or null
isExternal: boolean;
}
let members = $state<Member[]>([]);
let grantsLoading = $state(false);
let query = $state('');
let results = $state<Recipient[]>([]);
let newRole = $state<ShareRole>('viewer');
let newExpiry = $state<string | null>(null);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
function isoToDate(iso: string | null | undefined): string | null {
return iso ? String(iso).slice(0, 10) : null;
}
function groupGrants(grants: Grant[]): Member[] {
const bySubject = new Map<
string,
{ subject: GrantSubject; role: ShareRole; ids: string[]; expiry: string | null }
2026-06-17 22:07:18 -06:00
>();
for (const g of grants) {
if (g.subject.type === 'token') continue;
const key = `${g.subject.type}:${g.subject.id}`;
const entry = bySubject.get(key) ?? {
subject: g.subject,
role: 'viewer' as ShareRole,
ids: [],
expiry: null
};
// Role-grants emit one row per (subject, resource), so the row's role
// is the subject's role directly.
entry.role = displayRole(g.role);
2026-06-17 22:07:18 -06:00
entry.ids.push(g.id);
if (g.expires_at && !entry.expiry) entry.expiry = isoToDate(g.expires_at);
bySubject.set(key, entry);
}
return [...bySubject.values()].map((e) => ({
subject: e.subject,
recipient: resolveRecipient(e.subject.type as 'user' | 'group', e.subject.id),
role: e.role,
2026-06-17 22:07:18 -06:00
grantIds: e.ids,
notifyGrantId: e.ids[0],
expiry: e.expiry,
isExternal: false
}));
}
async function loadGrants() {
if (!item) return;
grantsLoading = true;
try {
await ensureResolvers();
directoryAvailable = isDirectoryAvailable();
members = groupGrants(await fetchGrantsForResource(item.kind, item.id));
} catch (e) {
2026-06-17 22:30:56 -06:00
errorToast(e);
2026-06-17 22:07:18 -06:00
} finally {
grantsLoading = false;
}
}
function onQueryInput() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(async () => {
const existing = new Set(members.map((m) => `${m.subject.type}:${m.subject.id}`));
results = (await searchRecipients(query)).filter(
(r) => !existing.has(`${r.type === 'email' ? 'user' : r.type}:${r.id}`)
);
}, 200);
}
function subjectInput(r: Recipient): GrantSubjectInput {
if (r.type === 'email') return { type: 'email', email: r.id };
return { type: r.type, id: r.id };
}
async function addRecipient(r: Recipient) {
if (!item) return;
try {
const res = await createGrant(
subjectInput(r),
{ type: item.kind, id: item.id },
newRole,
expiryToIso(newExpiry)
);
query = '';
results = [];
summarizeNotifications(res.notification.outcomes);
onshared?.(item.id);
2026-06-17 22:07:18 -06:00
await loadGrants();
} catch (e) {
2026-06-17 22:30:56 -06:00
errorToast(e);
2026-06-17 22:07:18 -06:00
}
}
async function changeRole(m: Member, role: ShareRole) {
if (!item || role === m.role) return;
try {
await updateGrantRole(
m.subject,
{ type: item.kind, id: item.id },
role,
expiryToIso(m.expiry)
);
await loadGrants();
} catch (e) {
2026-06-17 22:30:56 -06:00
errorToast(e);
2026-06-17 22:07:18 -06:00
}
}
async function changeMemberExpiry(m: Member, expiry: string | null) {
if (!item) return;
try {
await updateGrantRole(
m.subject,
{ type: item.kind, id: item.id },
m.role,
expiryToIso(expiry)
);
await loadGrants();
} catch (e) {
2026-06-17 22:30:56 -06:00
errorToast(e);
2026-06-17 22:07:18 -06:00
}
}
async function removeMember(m: Member) {
try {
for (const id of m.grantIds) await revokeGrant(id);
await loadGrants();
} catch (e) {
2026-06-17 22:30:56 -06:00
errorToast(e);
2026-06-17 22:07:18 -06:00
}
}
async function notifyMember(m: Member) {
if (!m.notifyGrantId) return;
try {
const set = await notifyGrantRecipient(m.notifyGrantId);
summarizeNotifications(set.outcomes);
} catch (e) {
2026-06-17 22:30:56 -06:00
errorToast(e);
2026-06-17 22:07:18 -06:00
}
}
/** Aggregate notification outcomes into a single toast (mirrors OLD _surfaceNotifySummary). */
function summarizeNotifications(outcomes: NotifyOutcome[]) {
if (!outcomes || outcomes.length === 0) return;
const sent = outcomes.filter((o) => o.kind === 'sent').length;
const coalesced = outcomes.filter((o) => o.kind === 'coalesced').length;
const rateLimited = outcomes.filter((o) => o.kind === 'rate_limited').length;
const skipped = outcomes.filter((o) => o.kind === 'not_applicable').length;
const lines: string[] = [];
if (sent > 0) lines.push(t('share.notify.sent', { n: sent }, '{{n}} notified by email.'));
if (coalesced > 0)
lines.push(t('share.notify.coalesced', { n: coalesced }, '{{n}} already notified recently.'));
if (rateLimited > 0)
lines.push(
t('share.notify.rateLimited', { n: rateLimited }, '{{n}} hit the rate limit — try later.')
);
if (skipped > 0)
lines.push(
t('share.notify.skipped', { n: skipped }, '{{n}} skipped (no email / opted out).')
);
if (lines.length === 0) return;
const onlySent = coalesced === 0 && rateLimited === 0 && skipped === 0;
ui.notify(lines.join(' '), onlySent ? 'success' : 'info');
}
// Members grouped by role, highest privilege first.
const memberGroups = $derived(
ROLE_ORDER.map((role) => ({
role,
members: members.filter((m) => m.role === role)
})).filter((g) => g.members.length > 0)
);
// ── Public link ──────────────────────────────────────────────────────────
let shares = $state<ShareItem[]>([]);
let linkLoading = $state(false);
let creating = $state(false);
let newLinkName = $state('');
let password = $state('');
let expiresAt = $state<string | null>(null);
async function loadShares() {
if (!item) return;
linkLoading = true;
try {
shares = await listSharesForItem(item.id, item.kind);
} catch (e) {
2026-06-17 22:30:56 -06:00
errorToast(e);
2026-06-17 22:07:18 -06:00
} finally {
linkLoading = false;
}
}
async function createLink() {
if (!item) return;
creating = true;
try {
await createShare({
itemId: item.id,
itemName: newLinkName.trim() || item.name,
itemType: item.kind,
password: password || null,
expiresAt: expiresAt || null
});
newLinkName = '';
password = '';
expiresAt = null;
onshared?.(item.id);
2026-06-17 22:07:18 -06:00
await loadShares();
ui.notify(t('share.created', 'Public link created'), 'success');
} catch (e) {
2026-06-17 22:30:56 -06:00
errorToast(e);
2026-06-17 22:07:18 -06:00
} finally {
creating = false;
}
}
async function editLinkExpiry(share: ShareItem, expiry: string | null) {
try {
await updateShare(share.id, { expiresAt: expiry });
await loadShares();
} catch (e) {
2026-06-17 22:30:56 -06:00
errorToast(e);
2026-06-17 22:07:18 -06:00
}
}
async function editLinkPassword(share: ShareItem, pw: string | null) {
try {
await updateShare(share.id, { password: pw });
await loadShares();
ui.notify(
pw
? t('share.password_set', 'Password updated')
: t('share.password_cleared', 'Password removed'),
'success'
);
} catch (e) {
2026-06-17 22:30:56 -06:00
errorToast(e);
2026-06-17 22:07:18 -06:00
}
}
async function removeLink(share: ShareItem) {
try {
await deleteShare(share.id);
shares = shares.filter((s) => s.id !== share.id);
} catch (e) {
2026-06-17 22:30:56 -06:00
errorToast(e);
2026-06-17 22:07:18 -06:00
}
}
async function copy(url: string) {
if (await copyShareLink(url)) ui.notify(t('share.copied', 'Link copied'), 'success');
else ui.notify(t('share.copy_failed', 'Could not copy link'), 'error');
}
function shareExpiryIso(s: ShareItem): string | null {
return s.expires_at ? new Date(s.expires_at * 1000).toISOString().slice(0, 10) : null;
}
$effect(() => {
if (open && item) {
void loadGrants();
void loadShares();
}
});
</script>
<!-- ── Reusable expiry chip ─────────────────────────────────────────────── -->
{#snippet expiryChip(value: string | null, onchange: (v: string | null) => void)}
<span class="chip-edit">
{#if value}
<input
class="chip-edit__date"
type="date"
value={value ?? ''}
onchange={(e) => onchange((e.currentTarget as HTMLInputElement).value || null)}
aria-label={t('share.expiry', 'Expiry')}
/>
<button
class="chip-edit__clear"
title={t('actions.clear', 'Clear')}
onclick={() => onchange(null)}
aria-label={t('actions.clear', 'Clear')}>×</button
>
{:else}
<label class="chip chip--ghost">
<Icon name="infinity" />
<span>{t('share.noExpiry', 'No expiry')}</span>
<input
class="chip-edit__date chip-edit__date--hidden"
type="date"
onchange={(e) => onchange((e.currentTarget as HTMLInputElement).value || null)}
aria-label={t('share.set_expiry', 'Set expiry')}
/>
</label>
{/if}
</span>
{/snippet}
<Modal bind:open title={t('share.dialog_title', { name: item?.name ?? '' }, 'Share “{{name}}”')}>
<div class="tabs" role="tablist">
<button role="tab" aria-selected={tab === 'people'} onclick={() => (tab = 'people')}>
{t('share.people', 'People')}
</button>
<button role="tab" aria-selected={tab === 'link'} onclick={() => (tab = 'link')}>
{t('share.public_link', 'Public link')}
</button>
</div>
{#if tab === 'people'}
{#if !directoryAvailable && !grantsLoading}
<p class="status status--note">
{t('share.directoryUnavailable', 'User directory unavailable')}
</p>
{:else}
<div class="add-row">
<div class="search">
<input
placeholder={t('share.add_people', 'Add people, groups, or email…')}
bind:value={query}
oninput={onQueryInput}
autocomplete="off"
/>
{#if results.length > 0}
<ul class="results">
{#each results as r (r.type + r.id)}
<li>
<button class="result" onclick={() => addRecipient(r)}>
<Icon
name={r.type === 'group'
? 'user-group'
: r.type === 'email'
? 'envelope'
: 'user'}
/>
<span class="result__label">{r.label}</span>
{#if r.type === 'email'}
<span class="result__sub">{t('share.inviteByEmail', 'Invite by email')}</span>
{:else if r.sublabel}
<span class="result__sub">{r.sublabel}</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
2026-06-17 22:52:06 -06:00
<select class="role-select" bind:value={newRole} aria-label={t('share.role_label', 'Role')}>
2026-06-17 22:07:18 -06:00
{#each ROLES as r (r.v)}<option value={r.v}>{r.l}</option>{/each}
</select>
{@render expiryChip(newExpiry, (v) => (newExpiry = v))}
</div>
{/if}
{#if grantsLoading}
<div class="skeleton" aria-hidden="true">
<div class="skeleton__line skeleton__line--short"></div>
<div class="skeleton__line skeleton__line--medium"></div>
<div class="skeleton__line"></div>
</div>
{:else if members.length === 0}
<p class="status">{t('share.no_people', 'Not shared with anyone yet.')}</p>
{:else}
{#each memberGroups as group (group.role)}
<div class="member-group">
<div class="member-group__header">
<Icon name={roleIcon(group.role)} />
<span>{roleLabel(group.role)}</span>
<span class="member-group__badge">{group.members.length}</span>
</div>
<ul class="members">
{#each group.members as m (m.subject.type + m.subject.id)}
<li
class="member"
class:member--expired={m.expiry && new Date(m.expiry) < new Date()}
>
<Icon name={m.subject.type === 'group' ? 'user-group' : 'user'} />
<span class="member__label">
{m.recipient.label}
{#if m.recipient.sublabel}<span class="member__sub">{m.recipient.sublabel}</span
>{/if}
</span>
{@render expiryChip(m.expiry, (v) => changeMemberExpiry(m, v))}
<select
class="role-select"
value={m.role}
onchange={(e) => changeRole(m, e.currentTarget.value as ShareRole)}
>
{#each ROLES as r (r.v)}<option value={r.v}>{r.l}</option>{/each}
</select>
<button
class="btn-action"
title={t('share.notifyByEmail', 'Notify by email')}
onclick={() => notifyMember(m)}><Icon name="paper-plane" /></button
>
<button
class="btn-action btn-action--delete"
title={t('share.revoke', 'Remove')}
onclick={() => removeMember(m)}><Icon name="user-xmark" /></button
>
</li>
{/each}
</ul>
</div>
{/each}
{/if}
{:else}
<section class="sh-create">
<div class="sh-fields">
<label>
<span>{t('share.link_name', 'Link name (optional)')}</span>
<input type="text" bind:value={newLinkName} autocomplete="off" />
</label>
<label>
<span>{t('share.password_optional', 'Password (optional)')}</span>
<input type="text" bind:value={password} autocomplete="off" />
</label>
<label>
<span>{t('share.expires_optional', 'Expires (optional)')}</span>
<input
type="date"
value={expiresAt ?? ''}
onchange={(e) => (expiresAt = e.currentTarget.value || null)}
/>
</label>
</div>
<button class="btn btn-primary" disabled={creating} onclick={createLink}>
{t('share.create_link', 'Create link')}
</button>
</section>
{#if linkLoading}
<div class="skeleton" aria-hidden="true">
<div class="skeleton__line skeleton__line--medium"></div>
<div class="skeleton__line"></div>
</div>
{:else if shares.length === 0}
<p class="status">{t('share.none', 'No public links yet.')}</p>
{:else}
<ul class="links">
{#each shares as s (s.id)}
<li class="link-row">
<span class="link-row__title">
<Icon name={s.has_password ? 'lock' : 'link'} />
<span class="link-row__name"
>{s.item_name || t('share.sharedLink', 'Shared link')}</span
>
</span>
{@render expiryChip(shareExpiryIso(s), (v) => editLinkExpiry(s, v))}
<button
class="btn-action"
class:btn-action--on={s.has_password}
title={s.has_password
? t('share.changePassword', 'Change password')
: t('share.addPassword', 'Add password')}
onclick={() => {
const pw = window.prompt(
s.has_password
? t('share.passwordPrompt_clear', 'New password (blank to remove):')
: t('share.passwordPrompt', 'Set a password:')
);
if (pw !== null) editLinkPassword(s, pw || null);
}}><Icon name={s.has_password ? 'lock' : 'lock-open'} /></button
>
<button class="btn-action" title={t('share.copy', 'Copy')} onclick={() => copy(s.url)}>
<Icon name="copy" />
</button>
<button
class="btn-action btn-action--delete"
title={t('common.delete', 'Delete')}
onclick={() => removeLink(s)}><Icon name="trash" /></button
>
</li>
{/each}
</ul>
{/if}
{/if}
{#snippet footer()}
<button class="btn btn-secondary" onclick={() => (open = false)}>
{t('common.close', 'Close')}
</button>
{/snippet}
</Modal>
<style>
.tabs {
display: flex;
gap: var(--space-1);
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-4);
}
.tabs button {
padding: var(--space-2) var(--space-3);
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tabs button[aria-selected='true'] {
color: var(--color-text);
border-bottom-color: var(--color-accent);
}
.add-row {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.search {
position: relative;
flex: 1;
min-width: 12rem;
}
.search input,
.role-select,
.sh-fields input {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
.search input {
width: 100%;
}
.results {
position: absolute;
left: 0;
right: 0;
top: 100%;
z-index: 10;
list-style: none;
margin: var(--space-1) 0 0;
padding: var(--space-1);
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
max-height: 14rem;
overflow: auto;
}
.result {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-2);
border: none;
background: none;
color: var(--color-text);
cursor: pointer;
border-radius: var(--radius-sm);
text-align: left;
}
.result:hover {
background: var(--color-bg-hover);
}
.result__label {
flex: 1;
}
.result__sub {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.member-group {
margin-bottom: var(--space-3);
}
.member-group__header {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
font-weight: var(--weight-semibold, 600);
color: var(--color-text-muted);
margin-bottom: var(--space-2);
}
.member-group__badge {
min-width: 1.25rem;
text-align: center;
padding: 0 var(--space-1);
border-radius: var(--radius-pill, 999px);
background: var(--color-bg-muted);
color: var(--color-text-muted);
font-size: var(--text-xs, 0.75rem);
}
.members,
.links {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.member {
display: flex;
align-items: center;
gap: var(--space-2);
}
.member--expired {
opacity: 0.6;
}
.member__label {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.member__sub {
color: var(--color-text-muted);
font-size: var(--text-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sh-fields {
display: flex;
gap: var(--space-3);
margin-bottom: var(--space-3);
flex-wrap: wrap;
}
.sh-fields label {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 8rem;
font-size: var(--text-sm);
}
.link-row {
display: flex;
align-items: center;
gap: var(--space-2);
}
.link-row__title {
display: flex;
align-items: center;
gap: var(--space-2);
flex: 1;
overflow: hidden;
}
.link-row__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status {
color: var(--color-text-muted);
padding: var(--space-3) 0;
}
.status--note {
font-style: italic;
}
.btn-action--delete:hover {
color: var(--color-danger-text);
}
.btn-action--on {
color: var(--color-accent);
}
/* ── Expiry chip ─────────────────────────────────────────────────────── */
.chip-edit {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-pill, 999px);
border: 1px solid var(--color-border);
font-size: var(--text-sm);
color: var(--color-text);
cursor: pointer;
position: relative;
}
.chip--ghost {
border-style: dashed;
color: var(--color-text-muted);
}
.chip-edit__date {
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
font-size: var(--text-sm);
}
.chip-edit__date--hidden {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.chip-edit__clear {
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: var(--text-md, 1rem);
line-height: 1;
}
/* ── Loading skeleton ────────────────────────────────────────────────── */
.skeleton {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3) 0;
}
.skeleton__line {
height: 1rem;
border-radius: var(--radius-sm);
background: linear-gradient(
90deg,
var(--color-bg-muted) 25%,
var(--color-bg-hover) 37%,
var(--color-bg-muted) 63%
);
background-size: 400% 100%;
animation: shimmer 1.4s ease infinite;
}
.skeleton__line--short {
width: 40%;
}
.skeleton__line--medium {
width: 65%;
}
@keyframes shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: 0 0;
}
}
</style>