Files
photos/web/ConnectionSection.tsx
pastilhasandClaude Opus 5 2dad1907f8 photos, extracted from the platform into its own repository
The fifth plugin out, and the cheapest so far — offscale's shape rather than
music's or vault's. Nothing stayed behind and nothing had to be redesigned,
for three structural reasons recorded in PLUGIN.md:

  • No reverse dependency. Nothing in src/ imported anything of photos'.
  • Configuration was already right. The URL and API key live in photos_config,
    a per-user registry encrypted at rest and driven from the app. Vault had to
    move VAULTWARDEN_URL out of .env and invent a settings panel; here the
    sidecar already read the database, and its own comment already said why
    nothing reads process.env.
  • The frontend was already panels. The screen was a WorkspaceView around
    exactly photos-nav and photos-view, so web/panels.ts and web/layout.ts are
    the screen's contents with the wrapper removed.

The router's prefix is now derived from mountPrefix rather than the literal
'/api/photos', which was correct only by coincidence — a third-party publisher
mounts at /api/p/<publisher>/photos and the literal would forward the wrong
path to the sidecar.

Palette debt paid down as the runbook requires: 6 classes (it says 47; someone
had already done most of it), all of them state colours wearing palette
numbers, now `success` and `warning` from the closed tone set so they follow
the theme.

Immich itself is still provisioned by the app store rather than by this plugin
— the `provisioned` half of its old catalogue entry has no home until Docker
provisioning for plugins exists. Point it at an instance you already run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:10:39 +00:00

368 lines
14 KiB
TypeScript

import type { PhotosAccount, PhotosCredential } from './usePhotosData';
import { useState } from 'react';
import { Check, CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { formatShortDate } from './shared';
import { photosErrorMessage, usePhotosAccountActions, usePhotosAccounts, usePhotosHealth } from './usePhotosData';
// Connecting Immich libraries to Officer, from the app.
//
// This screen is BOTH the setup wizard and the permanent settings page: PhotosView renders it in place of
// whatever section the URL asks for while nothing is connected, and /photos/settings renders it for good.
// One component, so a second account added later goes through exactly the code path that stored the first.
//
// It is a registry, not a single connection — two keys against the same instance (one per Immich user) is
// the ordinary case, which is why each account carries a LABEL and why the label, not the URL, is what has
// to be unique. The API key is never displayed, because Officer cannot display it: it is encrypted at rest
// and the sidecar's GET has no field that could carry it back.
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
/** Immich's own default — its compose file publishes 2283, and the sidecar dials from this machine. */
const DEFAULT_URL = 'http://localhost:2283';
const URL_HINT =
"The instance's base URL, without /api. Officer reaches it from the server, not from this browser — so " +
'localhost here means the machine Officer runs on, and a local instance needs no TLS.';
const KEY_HINT =
'Immich → Account Settings → API Keys. Grant all permissions unless you have a reason not to: Immich keys ' +
'are scoped, and a partial key looks like a broken feature rather than a rejected credential.';
const SIGN_IN_HINT =
'Your Immich login. It is used once, by the sidecar, to mint an all-permissions API key — the password is ' +
'never stored and the sign-in session is closed straight after, so no phantom device is left in Immich. ' +
'The key appears in Immich → Account Settings → API Keys as "Officer"; revoke it there.';
type FieldProps = {
label: string;
hint?: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
type?: string;
autoFocus?: boolean;
};
const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: FieldProps) => (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium">{label}</span>
<Input
value={value}
onChange={(ev) => onChange(ev.target.value)}
placeholder={placeholder}
type={type}
autoFocus={autoFocus}
autoComplete="off"
spellCheck={false}
/>
{hint && <span className={HINT}>{hint}</span>}
</label>
);
// ── credentials ──────────────────────────────────────────────────────────────────────────────────
//
// Two ways to prove the same thing, so they share one piece of state rather than two forms. Sign-in is the
// default: it is the only path that does not require the owner to go and read an API key out of Immich's own
// settings first, and what it stores is the same kind of key they would have pasted.
type CredentialMode = 'login' | 'key';
function useCredential() {
const [mode, setMode] = useState<CredentialMode>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [apiKey, setApiKey] = useState('');
const filled = mode === 'key' ? !!apiKey.trim() : !!email.trim() && !!password;
const payload: PhotosCredential = mode === 'key' ? { apiKey: apiKey.trim() } : { email: email.trim(), password };
const reset = () => {
setEmail('');
setPassword('');
setApiKey('');
};
return { mode, setMode, email, setEmail, password, setPassword, apiKey, setApiKey, filled, payload, reset };
}
type CredentialState = ReturnType<typeof useCredential>;
const TAB = 'rounded-md px-2 py-1 text-[11px] transition-colors';
const CredentialFields = ({ cred, autoFocus }: { cred: CredentialState; autoFocus?: boolean }) => (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-1">
<span className="text-xs font-medium">Credential</span>
<div className="ml-auto flex items-center gap-1 rounded-lg bg-muted p-0.5">
<button
type="button"
onClick={() => cred.setMode('login')}
className={`${TAB} ${cred.mode === 'login' ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
>
Sign in
</button>
<button
type="button"
onClick={() => cred.setMode('key')}
className={`${TAB} ${cred.mode === 'key' ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
>
API key
</button>
</div>
</div>
{cred.mode === 'login' ? (
<>
<Field
label="Email"
value={cred.email}
onChange={cred.setEmail}
placeholder="you@example.com"
type="email"
autoFocus={autoFocus}
/>
<Field
label="Password"
value={cred.password}
onChange={cred.setPassword}
placeholder="••••••••"
type="password"
hint={SIGN_IN_HINT}
/>
</>
) : (
<Field
label="API key"
value={cred.apiKey}
onChange={cred.setApiKey}
placeholder="••••••••••••"
type="password"
hint={KEY_HINT}
autoFocus={autoFocus}
/>
)}
</div>
);
type AccountRowProps = { account: PhotosAccount };
/**
* One stored account. The active one carries the live health line, because health only ever describes the
* account actually being used — showing a status next to the others would be inventing one.
*/
const AccountRow = ({ account }: AccountRowProps) => {
const { data: health, refetch: recheck, isFetching: checking } = usePhotosHealth();
const { edit, activate, remove } = usePhotosAccountActions();
const cred = useCredential();
const [error, setError] = useState<string | null>(null);
const [replacing, setReplacing] = useState(false);
// Removing an account throws away a key Officer can never show again, so the bin asks once.
const [confirming, setConfirming] = useState(false);
const run = async (action: Promise<unknown>) => {
setError(null);
try {
await action;
cred.reset();
setReplacing(false);
} catch (err) {
setError(photosErrorMessage(err));
}
};
return (
<div className={`flex flex-col gap-2 rounded-lg border p-4 text-xs ${account.isActive ? 'border-primary/40' : ''}`}>
<div className="flex items-center gap-2">
{account.isActive ? <Check className="h-4 w-4 shrink-0 text-primary" /> : <span className="h-4 w-4 shrink-0" />}
<span className="truncate font-medium">{account.label}</span>
{account.isActive && (
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">in use</span>
)}
<div className="ml-auto flex items-center gap-1">
{!account.isActive && (
<Button
variant="ghost"
size="sm"
className="h-7"
disabled={activate.isPending}
onClick={() => void run(activate.mutateAsync(account.id))}
>
Use
</Button>
)}
{account.isActive && (
<Button variant="ghost" size="sm" className="h-7" onClick={() => void recheck()}>
{checking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Test'}
</Button>
)}
<Button
variant="ghost"
size="sm"
className={`h-7 ${confirming ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}`}
disabled={remove.isPending}
onClick={() => {
if (!confirming) return setConfirming(true);
setConfirming(false);
void run(remove.mutateAsync(account.id));
}}
>
{confirming ? 'Remove?' : <Trash2 className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
<p className="truncate text-muted-foreground">{account.url}</p>
{account.isActive ? (
<div className="flex items-center gap-1.5 text-muted-foreground">
{health?.ok ? (
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
) : (
<TriangleAlert className="h-3.5 w-3.5 text-warning" />
)}
<span>
{health?.ok
? `Immich ${health.version ?? '?'}${health.user ? ` · ${health.user}` : ''}`
: (health?.error ?? 'not checked yet')}
</span>
</div>
) : (
<p className="text-muted-foreground">
{account.version ? `Immich ${account.version}` : 'version unknown'}
{account.lastSeenAt ? ` · last answered ${formatShortDate(account.lastSeenAt)}` : ''}
</p>
)}
{replacing ? (
<div className="flex flex-col gap-3 rounded-lg border p-3">
<CredentialFields cred={cred} autoFocus />
<div className="flex items-center gap-2">
<Button
size="sm"
className="h-8"
disabled={!cred.filled || edit.isPending}
onClick={() => void run(edit.mutateAsync({ id: account.id, ...cred.payload }))}
>
{edit.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
Save
</Button>
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => {
cred.reset();
setReplacing(false);
}}
>
Cancel
</Button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setReplacing(true)}
className="self-start text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
Replace credential
</button>
)}
{error && <p className="text-destructive">{error}</p>}
</div>
);
};
export const ConnectionSection = () => {
const { data, isLoading } = usePhotosAccounts();
const { add } = usePhotosAccountActions();
const cred = useCredential();
const [label, setLabel] = useState('');
const [url, setUrl] = useState(DEFAULT_URL);
const [error, setError] = useState<string | null>(null);
const accounts = data?.accounts ?? [];
const hasAccounts = accounts.length > 0;
const submit = async () => {
setError(null);
if (!url.trim()) return setError('The Immich URL is required');
if (!cred.filled) {
return setError(cred.mode === 'key' ? 'An API key is required' : 'An email and password are required');
}
try {
await add.mutateAsync({ label: label.trim(), url: url.trim(), ...cred.payload });
setLabel('');
setUrl(DEFAULT_URL);
cred.reset();
} catch (err) {
setError(photosErrorMessage(err));
}
};
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto flex max-w-xl flex-col gap-4 p-6">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-success/15 text-success">
<Plug className="h-5 w-5" />
</div>
<div>
<h2 className="text-sm font-semibold">{hasAccounts ? 'Immich accounts' : 'Connect Immich'}</h2>
<p className="text-xs text-muted-foreground">
{hasAccounts
? 'Officer stores each instance and its key encrypted, for this account only. One is in use at a time.'
: 'Photos needs an Immich instance and a login before it can show anything.'}
</p>
</div>
</div>
{!isLoading && accounts.map((account) => <AccountRow key={account.id} account={account} />)}
<form
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
className="flex flex-col gap-3 rounded-lg border p-4"
>
<p className="text-xs font-medium">{hasAccounts ? 'Add another account' : 'Add an account'}</p>
<Field
label="Label"
value={label}
onChange={setLabel}
placeholder="Optional — defaults to the Immich user"
hint="What the switcher calls this account. Two accounts cannot share a label; two can share a URL."
autoFocus={hasAccounts}
/>
<Field
label="Immich URL"
value={url}
onChange={setUrl}
placeholder={DEFAULT_URL}
hint={URL_HINT}
autoFocus={!hasAccounts}
/>
<CredentialFields cred={cred} />
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex items-center gap-2">
<Button type="submit" size="sm" disabled={add.isPending}>
{add.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
{add.isPending ? 'Verifying…' : hasAccounts ? 'Add account' : 'Connect'}
</Button>
{add.isPending && <span className={HINT}>Checking the instance and the key…</span>}
</div>
</form>
</div>
</div>
);
};