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:
2026-09-02 18:16:02 +01:00
co-authored by Claude Opus 5
parent 470bb42fe8
commit 70fc2b08c6
7 changed files with 598 additions and 41 deletions
+152 -35
View File
@@ -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&rsquo;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>
);
};