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) => ( ); // ── 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('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; const TAB = 'rounded-md px-2 py-1 text-[11px] transition-colors'; const CredentialFields = ({ cred, autoFocus }: { cred: CredentialState; autoFocus?: boolean }) => (
Credential
{cred.mode === 'login' ? ( <> ) : ( )}
); 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(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) => { setError(null); try { await action; cred.reset(); setReplacing(false); } catch (err) { setError(photosErrorMessage(err)); } }; return (
{account.isActive ? : } {account.label} {account.isActive && ( in use )}
{!account.isActive && ( )} {account.isActive && ( )}

{account.url}

{account.isActive ? (
{health?.ok ? ( ) : ( )} {health?.ok ? `Immich ${health.version ?? '?'}${health.user ? ` · ${health.user}` : ''}` : (health?.error ?? 'not checked yet')}
) : (

{account.version ? `Immich ${account.version}` : 'version unknown'} {account.lastSeenAt ? ` · last answered ${formatShortDate(account.lastSeenAt)}` : ''}

)} {replacing ? (
) : ( )} {error &&

{error}

}
); }; 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(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 (

{hasAccounts ? 'Immich accounts' : 'Connect Immich'}

{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.'}

{!isLoading && accounts.map((account) => )}
{ ev.preventDefault(); void submit(); }} className="flex flex-col gap-3 rounded-lg border p-4" >

{hasAccounts ? 'Add another account' : 'Add an account'}

{error &&

{error}

}
{add.isPending && Checking the instance and the key…}
); };