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>
368 lines
14 KiB
TypeScript
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>
|
|
);
|
|
};
|