a shared link is a URL, not a key
Create, edit and revoke shared links from the album header, the selection bar and the Sharing panel. Password, expiry, and the three flags that decide what the person opening it can do. The panel showed a bare key because this plugin never learned the instance's public address. It does not need to store one: Immich has `externalDomain` in its own admin settings, `/server/config` already returns it, and it is the same field Immich's own copy-link button reads, so the two cannot disagree. Empty is a real state and the UI says so rather than inventing a host. Payload shapes came from the live instance's spec.json, not from memory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+152
-35
@@ -1,29 +1,77 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Copy, ImageOff, Link2 } from 'lucide-react';
|
||||
import { Copy, ExternalLink, ImageOff, Link2, MoreVertical, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { ALBUM_PARAM, formatShortDate, photosSectionPath, thumbUrl } from './shared';
|
||||
import { useAlbums, useSharedLinks } from './usePhotosData';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
import type { SharedLink } from './shared';
|
||||
import { ALBUM_PARAM, formatShortDate, photosSectionPath, shareUrl, thumbUrl } from './shared';
|
||||
import { ShareLinkDialog, type ShareTarget } from './ShareLinkDialog';
|
||||
import { useAlbums, useServerConfig, useSharedLinks } from './usePhotosData';
|
||||
import { useAssetActions } from './useAssetActions';
|
||||
|
||||
// Sharing: shared albums and shared links, read-mostly.
|
||||
// Sharing: shared albums, and the links that make them public.
|
||||
//
|
||||
// The link URL is Immich's own public URL, not an Officer route — a shared link is meant to be opened by
|
||||
// someone who has no Officer account, so it must point at the Immich instance directly. The proxy deliberately
|
||||
// never learns that public URL, so this copies the key and the owner pairs it with their own instance address.
|
||||
// The link column shows a full URL, not a key. The address is Immich's own `externalDomain` read from
|
||||
// `/server/config` — this plugin still stores no public address of its own, which is what the previous
|
||||
// version of this file was protecting when it copied a bare key instead. The protection was never the
|
||||
// point; the missing address was, and Immich already knows it.
|
||||
|
||||
export const SharingSection = () => {
|
||||
const { token } = useClient();
|
||||
const { data: links, isLoading } = useSharedLinks();
|
||||
const { data: albums } = useAlbums();
|
||||
const { data: config } = useServerConfig();
|
||||
const { deleteSharedLink } = useAssetActions();
|
||||
|
||||
const [target, setTarget] = useState<ShareTarget | null>(null);
|
||||
const [editing, setEditing] = useState<SharedLink | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const sharedAlbums = (albums ?? []).filter((album) => album.shared || album.hasSharedLink);
|
||||
|
||||
const share = (next: ShareTarget) => {
|
||||
setEditing(null);
|
||||
setTarget(next);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const edit = (link: SharedLink) => {
|
||||
setTarget(null);
|
||||
setEditing(link);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<h1 className="text-sm font-semibold">Sharing</h1>
|
||||
<div className="flex-1" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" disabled={(albums ?? []).length === 0}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
Share an album
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-80 overflow-y-auto">
|
||||
{albums?.map((album) => (
|
||||
<DropdownMenuItem
|
||||
key={album.id}
|
||||
onClick={() => share({ kind: 'album', albumId: album.id, albumName: album.albumName })}
|
||||
>
|
||||
{album.albumName}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto p-3">
|
||||
@@ -66,36 +114,105 @@ export const SharingSection = () => {
|
||||
<p className="text-sm text-muted-foreground">No shared links.</p>
|
||||
)}
|
||||
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{(links ?? []).map((link) => (
|
||||
<li key={link.id} className="flex items-center gap-3 px-3 py-2">
|
||||
<Link2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm">
|
||||
{link.album?.albumName ?? link.description ?? `${link.assets.length} items`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{link.expiresAt ? `Expires ${formatShortDate(link.expiresAt)}` : 'No expiry'}
|
||||
{link.allowDownload ? ' · download' : ''}
|
||||
{link.allowUpload ? ' · upload' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Copy share key — append it to your instance's /share/ URL"
|
||||
onClick={() => {
|
||||
void copyToClipboard(link.key);
|
||||
toast.success('Share key copied');
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!isLoading && (links ?? []).length > 0 && !config?.externalDomain && (
|
||||
/*
|
||||
Worth saying once, at the top, rather than per row: every link here works, and none of them can
|
||||
be written as a URL until Immich knows the address it answers on.
|
||||
*/
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Immich has no <span className="font-medium">External Domain</span> set, so these show as keys
|
||||
rather than URLs. Set it in Immich’s admin settings.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(links ?? []).length > 0 && (
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{(links ?? []).map((link) => (
|
||||
<LinkRow
|
||||
key={link.id}
|
||||
link={link}
|
||||
externalDomain={config?.externalDomain}
|
||||
onEdit={() => edit(link)}
|
||||
onRevoke={() => deleteSharedLink.mutate(link.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ShareLinkDialog open={open} onOpenChange={setOpen} target={target} link={editing} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LinkRow = ({
|
||||
link,
|
||||
externalDomain,
|
||||
onEdit,
|
||||
onRevoke,
|
||||
}: {
|
||||
link: SharedLink;
|
||||
externalDomain: string | undefined;
|
||||
onEdit: () => void;
|
||||
onRevoke: () => void;
|
||||
}) => {
|
||||
const url = shareUrl(externalDomain, link);
|
||||
const expired = !!link.expiresAt && new Date(link.expiresAt).getTime() < Date.now();
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-3 px-3 py-2">
|
||||
<Link2 className={`h-4 w-4 shrink-0 ${expired ? 'text-destructive' : 'text-muted-foreground'}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm">
|
||||
{link.album?.albumName ?? link.description ?? `${link.assets.length} items`}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{link.expiresAt
|
||||
? `${expired ? 'Expired' : 'Expires'} ${formatShortDate(link.expiresAt)}`
|
||||
: 'No expiry'}
|
||||
{link.password ? ' · password' : ''}
|
||||
{link.allowDownload ? ' · download' : ''}
|
||||
{link.allowUpload ? ' · upload' : ''}
|
||||
{link.showMetadata ? '' : ' · no metadata'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={url ? 'Copy link' : 'Copy share key — Immich has no public address set'}
|
||||
onClick={() => {
|
||||
void copyToClipboard(url ?? link.key);
|
||||
toast.success(url ? 'Link copied' : 'Share key copied');
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{url && (
|
||||
<Button variant="ghost" size="icon" title="Open" asChild>
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title="More">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onEdit}>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => window.confirm('Revoke this link? Anyone holding it loses access.') && onRevoke()}
|
||||
>
|
||||
Revoke
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user