invoiceshelf: accounts are configured from the ui, not the environment
The same registry photos got: any number of labelled instances stored encrypted in invoiceshelf_accounts, one selected, switchable from the nav. The token is write-only across the sidecar boundary — the list has no field that could carry it back — and nothing reads INVOICESHELF_URL/TOKEN/COMPANY_ID any more, so officer's own process.env no longer holds a credential only the sidecar can use. The company is pinned on the account row rather than resolved per request. InvoiceShelf's `company` header does not error on a wrong or missing value; it silently returns another company's books. So the choice is made once, at add time, and a token that can act for several answers 409 with the list instead of guessing. Both apps also take an email and password now, because neither service makes a key easy to get: InvoiceShelf 2.4.2 ships no screen that issues tokens at all (POST /auth/login is the only way), and Immich's is buried in account settings. The sidecar does the exchange — InvoiceShelf mints a Sanctum token, Immich logs in, creates an all-permissions API key and closes the session again — and stores only what comes back. The password is never persisted. Pasting a key still works. Verified against the live instances: InvoiceShelf 2.4.2 and Immich 3.1.0, routes and DTOs read from the running containers. The two sign-in paths are untested end to end — no second login to try them with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { Link } from 'react-router';
|
||||
import { Check, ChevronsUpDown, Loader2, Settings2 } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { invoicesSectionPath } from './shared';
|
||||
import { useInvoiceShelfAccountActions, useInvoiceShelfAccounts, useInvoiceShelfHealth } from './useInvoiceShelfData';
|
||||
|
||||
// Which books you are looking at, and how to change them.
|
||||
//
|
||||
// The nav header's subtitle was already the connection state, so the switcher takes that line rather than
|
||||
// adding a second control saying nearly the same thing. With one account it stays exactly what it was: a
|
||||
// line of text plus a way into the settings screen.
|
||||
//
|
||||
// Each entry shows its company under the label, because two accounts on the same instance differing only by
|
||||
// company is the normal case here — the label alone would not tell them apart.
|
||||
//
|
||||
// Switching invalidates the whole ['invoiceshelf'] key: it changes the answer to every query in the
|
||||
// workspace without changing any of their inputs.
|
||||
|
||||
export const AccountSwitcher = () => {
|
||||
const { data } = useInvoiceShelfAccounts();
|
||||
const { data: health } = useInvoiceShelfHealth();
|
||||
const { activate } = useInvoiceShelfAccountActions();
|
||||
|
||||
const accounts = data?.accounts ?? [];
|
||||
const active = accounts.find((account) => account.isActive) ?? null;
|
||||
|
||||
// Before the registry answers, fall back to health — it is the query that was already driving this line.
|
||||
const status = active?.label ?? (health?.ok ? (health.version ?? 'connected') : 'not connected');
|
||||
|
||||
if (accounts.length < 2) {
|
||||
return (
|
||||
<Link
|
||||
to={invoicesSectionPath('settings')}
|
||||
className="block truncate text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{status}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
|
||||
<span className="truncate">{status}</span>
|
||||
{activate.isPending ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<ChevronsUpDown className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
InvoiceShelf accounts
|
||||
</DropdownMenuLabel>
|
||||
{accounts.map((account) => (
|
||||
<DropdownMenuItem
|
||||
key={account.id}
|
||||
disabled={account.isActive || activate.isPending}
|
||||
onSelect={() => void activate.mutateAsync(account.id).catch(() => undefined)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Check className={`h-3.5 w-3.5 shrink-0 ${account.isActive ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{account.label}</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">
|
||||
{account.companyName ?? account.url}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={invoicesSectionPath('settings')} className="gap-2">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
Manage accounts
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,449 @@
|
||||
import type { CompanyOption, Credential, InvoiceShelfAccount } from './useInvoiceShelfData';
|
||||
import { useState } from 'react';
|
||||
import { Building2, Check, CheckCircle2, Loader2, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
parseAccountError,
|
||||
useAccountCompanies,
|
||||
useInvoiceShelfAccountActions,
|
||||
useInvoiceShelfAccounts,
|
||||
useInvoiceShelfHealth,
|
||||
} from './useInvoiceShelfData';
|
||||
|
||||
// Connecting InvoiceShelf instances to Officer, from the app.
|
||||
//
|
||||
// This screen is BOTH the setup wizard and the permanent settings page: InvoicesView renders it in place of
|
||||
// whatever section the URL asks for while nothing is connected, and /invoices/settings renders it for good.
|
||||
//
|
||||
// The company picker is the part that has no equivalent in the photos version, and it is not a nicety: the
|
||||
// `company` header scopes almost every InvoiceShelf route, and a wrong or missing value does NOT error — it
|
||||
// silently returns another company's books. So the account pins one, chosen once, visibly. Two accounts on
|
||||
// the same URL with the same token and different companies is an ordinary thing to have, which is why the
|
||||
// LABEL is what has to be unique.
|
||||
//
|
||||
// The credential is never displayed, because Officer cannot display it: the token is encrypted at rest and
|
||||
// the sidecar's GET has no field that could carry it back, and the password is not stored at all.
|
||||
|
||||
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
|
||||
|
||||
/** InvoiceShelf's own default — its production compose publishes 8090, and the sidecar dials from this machine. */
|
||||
const DEFAULT_URL = 'http://localhost:8090';
|
||||
|
||||
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 SIGN_IN_HINT =
|
||||
'Your InvoiceShelf login. It is used once, by the sidecar, to mint an API token — the password is never ' +
|
||||
'stored and never reaches the browser again. Each sign-in creates a new token in InvoiceShelf; revoke old ' +
|
||||
'ones there.';
|
||||
|
||||
const TOKEN_HINT =
|
||||
'A Sanctum token you already have. Paste it whole, including the leading "1|". It can read and write the ' +
|
||||
'entire books, so Officer stores it encrypted and never shows it again.';
|
||||
|
||||
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 because InvoiceShelf 2.4.2 ships no screen that issues tokens — before this, connecting Officer
|
||||
// meant running a curl command against /auth/login by hand, which is exactly what the sidecar now does.
|
||||
|
||||
type CredentialMode = 'login' | 'token';
|
||||
|
||||
function useCredential() {
|
||||
const [mode, setMode] = useState<CredentialMode>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [token, setToken] = useState('');
|
||||
|
||||
const filled = mode === 'token' ? !!token.trim() : !!email.trim() && !!password;
|
||||
const payload: Credential = mode === 'token' ? { token: token.trim() } : { email: email.trim(), password };
|
||||
|
||||
const reset = () => {
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
setToken('');
|
||||
};
|
||||
|
||||
return { mode, setMode, email, setEmail, password, setPassword, token, setToken, 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('token')}
|
||||
className={`${TAB} ${cred.mode === 'token' ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
|
||||
>
|
||||
API token
|
||||
</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 token"
|
||||
value={cred.token}
|
||||
onChange={cred.setToken}
|
||||
placeholder="1|xxxxxxxx"
|
||||
type="password"
|
||||
hint={TOKEN_HINT}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
type CompanyPickerProps = {
|
||||
companies: CompanyOption[];
|
||||
value: number | null;
|
||||
onChange: (id: number) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/** A plain select — the list is short, and a combobox would be machinery for four options. */
|
||||
const CompanyPicker = ({ companies, value, onChange, disabled }: CompanyPickerProps) => (
|
||||
<select
|
||||
value={value ?? ''}
|
||||
disabled={disabled}
|
||||
onChange={(ev) => onChange(Number(ev.target.value))}
|
||||
className="h-8 rounded-md border bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Choose a company…
|
||||
</option>
|
||||
{companies.map((company) => (
|
||||
<option key={company.id} value={company.id}>
|
||||
{company.name ?? `Company ${company.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
type AccountRowProps = { account: InvoiceShelfAccount };
|
||||
|
||||
/**
|
||||
* 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 } = useInvoiceShelfHealth();
|
||||
const { edit, activate, remove } = useInvoiceShelfAccountActions();
|
||||
|
||||
const cred = useCredential();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [replacing, setReplacing] = useState(false);
|
||||
const [changingCompany, setChangingCompany] = useState(false);
|
||||
// Removing an account throws away a token Officer can never show again, so the bin asks once.
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const companies = useAccountCompanies(changingCompany ? account.id : null);
|
||||
|
||||
const run = async (action: Promise<unknown>) => {
|
||||
setError(null);
|
||||
try {
|
||||
await action;
|
||||
cred.reset();
|
||||
setReplacing(false);
|
||||
setChangingCompany(false);
|
||||
} catch (err) {
|
||||
setError(parseAccountError(err).message);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Building2 className="h-3.5 w-3.5 shrink-0" />
|
||||
{changingCompany ? (
|
||||
<>
|
||||
{companies.isLoading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<CompanyPicker
|
||||
companies={companies.data?.companies ?? []}
|
||||
value={account.companyId}
|
||||
disabled={edit.isPending}
|
||||
onChange={(companyId) => void run(edit.mutateAsync({ id: account.id, companyId }))}
|
||||
/>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" className="h-7" onClick={() => setChangingCompany(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="truncate">{account.companyName ?? `Company ${account.companyId ?? '?'}`}</span>
|
||||
<Button variant="ghost" size="sm" className="h-7" onClick={() => setChangingCompany(true)}>
|
||||
Change
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{account.isActive && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
{health?.ok ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
|
||||
) : (
|
||||
<TriangleAlert className="h-3.5 w-3.5 text-amber-500" />
|
||||
)}
|
||||
<span>{health?.ok ? `InvoiceShelf ${health.version ?? '?'}` : (health?.error ?? 'not checked yet')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 } = useInvoiceShelfAccounts();
|
||||
const { add } = useInvoiceShelfAccountActions();
|
||||
|
||||
const cred = useCredential();
|
||||
const [label, setLabel] = useState('');
|
||||
const [url, setUrl] = useState(DEFAULT_URL);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Populated only when the instance answers with more than one company — see parseAccountError.
|
||||
const [choices, setChoices] = useState<CompanyOption[]>([]);
|
||||
const [companyId, setCompanyId] = useState<number | null>(null);
|
||||
|
||||
const accounts = data?.accounts ?? [];
|
||||
const hasAccounts = accounts.length > 0;
|
||||
|
||||
const submit = async (withCompany: number | null) => {
|
||||
setError(null);
|
||||
if (!url.trim()) return setError('The InvoiceShelf URL is required');
|
||||
if (!cred.filled) {
|
||||
return setError(cred.mode === 'token' ? 'An API token is required' : 'An email and password are required');
|
||||
}
|
||||
|
||||
try {
|
||||
await add.mutateAsync({ label: label.trim(), url: url.trim(), companyId: withCompany, ...cred.payload });
|
||||
setLabel('');
|
||||
setUrl(DEFAULT_URL);
|
||||
cred.reset();
|
||||
setChoices([]);
|
||||
setCompanyId(null);
|
||||
} catch (err) {
|
||||
const parsed = parseAccountError(err);
|
||||
// A 409 is not a failure — the credential can act for several companies and one has to be picked. Keep
|
||||
// the form filled in and show the list rather than making them type it all again.
|
||||
if (parsed.needsChoice && parsed.companies.length) {
|
||||
setChoices(parsed.companies);
|
||||
setCompanyId(parsed.companies[0]?.id ?? null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
setError(parsed.message);
|
||||
}
|
||||
};
|
||||
|
||||
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-sky-500/15 text-sky-500">
|
||||
<Building2 className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">{hasAccounts ? 'InvoiceShelf accounts' : 'Connect InvoiceShelf'}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{hasAccounts
|
||||
? 'Officer stores each instance and its token encrypted, for this account only. One is in use at a time.'
|
||||
: 'Invoices needs an InvoiceShelf 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(companyId);
|
||||
}}
|
||||
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 company name"
|
||||
hint="What the switcher calls this account. Two accounts cannot share a label; two can share a URL, a login and everything but the company."
|
||||
autoFocus={hasAccounts}
|
||||
/>
|
||||
<Field
|
||||
label="InvoiceShelf URL"
|
||||
value={url}
|
||||
onChange={setUrl}
|
||||
placeholder={DEFAULT_URL}
|
||||
hint={URL_HINT}
|
||||
autoFocus={!hasAccounts}
|
||||
/>
|
||||
<CredentialFields cred={cred} />
|
||||
|
||||
{choices.length > 0 && (
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium">Company</span>
|
||||
<CompanyPicker companies={choices} value={companyId} onChange={setCompanyId} disabled={add.isPending} />
|
||||
<span className={HINT}>
|
||||
This login can act for more than one company. Whichever you pick is pinned to this account — add a
|
||||
second account for the other one.
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" size="sm" disabled={add.isPending || (choices.length > 0 && companyId == null)}>
|
||||
{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 credential…</span>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,19 +7,25 @@ import {
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
PieChart,
|
||||
Plug,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Users,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
import { INVOICES_SECTIONS, invoicesSectionPath, type InvoicesSectionId } from './shared';
|
||||
import { AccountSwitcher } from './AccountSwitcher';
|
||||
import { formatMoney } from './format';
|
||||
import { useSummary } from './useInvoiceShelfData';
|
||||
|
||||
// Left panel of /invoices: the company it is pointed at, the amount outstanding, then the sections.
|
||||
// Left panel of /invoices: the company it is pointed at, which account that is, then the sections.
|
||||
//
|
||||
// Sections are real links so cmd-click, back and reload behave. Counts come from the dashboard totals that
|
||||
// useSummary already holds — no section fetches a count of its own just to render a badge.
|
||||
//
|
||||
// The subtitle is the account switcher rather than the amount outstanding, because with several sets of
|
||||
// books on screen "whose books are these" is the question the header has to answer. Outstanding moved into
|
||||
// the totals at the bottom, where it sits with the rest of the money.
|
||||
|
||||
const ICONS: Record<InvoicesSectionId, LucideIcon> = {
|
||||
dashboard: LayoutDashboard,
|
||||
@@ -31,12 +37,13 @@ const ICONS: Record<InvoicesSectionId, LucideIcon> = {
|
||||
customers: Users,
|
||||
items: Package,
|
||||
reports: PieChart,
|
||||
settings: Plug,
|
||||
};
|
||||
|
||||
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
|
||||
|
||||
export const InvoicesNav = () => {
|
||||
const { company, currency, dashboard, isLoading, error } = useSummary();
|
||||
const { company, currency, dashboard, isLoading } = useSummary();
|
||||
|
||||
const counts: Partial<Record<InvoicesSectionId, number>> = {
|
||||
invoices: dashboard?.total_invoice_count,
|
||||
@@ -54,15 +61,7 @@ export const InvoicesNav = () => {
|
||||
<div className="truncate text-sm font-semibold leading-tight" title={company?.name ?? undefined}>
|
||||
{company?.name ?? (isLoading ? 'Loading…' : 'InvoiceShelf')}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{error ? (
|
||||
<span className="text-red-500">unreachable</span>
|
||||
) : dashboard ? (
|
||||
<>{formatMoney(dashboard.total_amount_due, currency)} outstanding</>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</div>
|
||||
<AccountSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -103,6 +102,7 @@ export const InvoicesNav = () => {
|
||||
|
||||
{dashboard && (
|
||||
<div className="mt-auto space-y-1.5 border-t border-border/60 px-4 py-3 text-[11px]">
|
||||
<NavRowStat label="Outstanding" value={formatMoney(dashboard.total_amount_due, currency)} />
|
||||
<NavRowStat label="Sales" value={formatMoney(dashboard.total_sales, currency)} />
|
||||
<NavRowStat label="Received" value={formatMoney(dashboard.total_receipts, currency)} />
|
||||
<NavRowStat label="Expenses" value={formatMoney(dashboard.total_expenses, currency)} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router';
|
||||
import { ConnectionSection } from './ConnectionSection';
|
||||
import { CustomersListView } from './CustomersListView';
|
||||
import { DashboardView } from './DashboardView';
|
||||
import { DocumentEditor } from './DocumentEditor';
|
||||
@@ -11,6 +12,8 @@ import { PaymentsListView } from './PaymentsListView';
|
||||
import { RecurringListView } from './RecurringListView';
|
||||
import { ReportsView } from './ReportsView';
|
||||
import { CustomerEditor, ExpenseEditor, ItemEditor, PaymentEditor } from './RecordEditors';
|
||||
import { invoicesSectionPath } from './shared';
|
||||
import { useInvoiceShelfHealth } from './useInvoiceShelfData';
|
||||
import { useInvoicesSection } from './useInvoicesSection';
|
||||
|
||||
// Right panel of the /invoices workspace: renders the section named by the URL, and owns the one piece of
|
||||
@@ -31,6 +34,7 @@ const parseEdit = (raw: string | null): EditTarget => {
|
||||
|
||||
export const InvoicesView = () => {
|
||||
const section = useInvoicesSection();
|
||||
const { data: health, isLoading: healthLoading } = useInvoiceShelfHealth();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -65,6 +69,28 @@ export const InvoicesView = () => {
|
||||
[setParams],
|
||||
);
|
||||
|
||||
// Health gates every section, and its two failure modes are answered differently: nothing configured yet is
|
||||
// the setup form, whatever section was asked for, because there is nothing else useful to show; a stored
|
||||
// instance that is failing keeps its own message and a way back, since replacing a working token by
|
||||
// accident is worse than a wall of text.
|
||||
if (section === 'settings') return <ConnectionSection />;
|
||||
|
||||
if (!healthLoading && health && !health.ok) {
|
||||
if (health.configured === false) return <ConnectionSection />;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
|
||||
<p className="text-sm font-medium">InvoiceShelf is not answering</p>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
{health.error ?? 'The invoiceshelf sidecar could not reach the configured instance.'}
|
||||
</p>
|
||||
<Link to={invoicesSectionPath('settings')} className="mt-2 text-xs font-medium text-primary hover:underline">
|
||||
Check the connection
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Documents get a full-height editor rather than a dialog — a line-item table does not fit in one, and
|
||||
// upstream gives them a whole page too.
|
||||
if (edit != null && (section === 'invoices' || section === 'estimates' || section === 'recurring')) {
|
||||
|
||||
@@ -56,7 +56,7 @@ export const ErrorState = ({ error, hint }: { error: unknown; hint?: string }) =
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-10 text-center">
|
||||
<div className="text-sm font-medium text-red-500">Could not reach InvoiceShelf</div>
|
||||
<div className="max-w-md text-xs text-muted-foreground">
|
||||
{hint ?? 'Check that officer-invoiceshelf is running and INVOICESHELF_URL / INVOICESHELF_TOKEN are set.'}
|
||||
{hint ?? 'Check the connection under Invoices → Connection, and that officer-invoiceshelf is running.'}
|
||||
</div>
|
||||
{error != null && (
|
||||
<pre className="max-w-md overflow-hidden text-ellipsis text-[10px] text-muted-foreground/70">
|
||||
|
||||
@@ -22,6 +22,7 @@ export const INVOICES_SECTIONS = [
|
||||
{ id: 'customers', label: 'Customers' },
|
||||
{ id: 'items', label: 'Items' },
|
||||
{ id: 'reports', label: 'Reports' },
|
||||
{ id: 'settings', label: 'Connection' },
|
||||
] as const;
|
||||
|
||||
export type InvoicesSectionId = (typeof INVOICES_SECTIONS)[number]['id'];
|
||||
|
||||
@@ -411,3 +411,164 @@ export function downloadPdf(url: string, filename: string) {
|
||||
}
|
||||
|
||||
export { errorMessage };
|
||||
|
||||
// ── accounts ─────────────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The InvoiceShelf URL, its Sanctum token and the company it acts for are the owner's to set from
|
||||
// /invoices/settings; nothing reads them from the environment any more. It is a REGISTRY — any number of
|
||||
// labelled accounts, one of them selected — and two accounts differing only by company is the normal case,
|
||||
// because the `company` header silently returns another company's books rather than erroring.
|
||||
//
|
||||
// The token is WRITE-ONLY across this boundary: an account carries its label, URL and pinned company, and
|
||||
// has no field that could carry the token back to the browser.
|
||||
|
||||
const CONFIG = '/invoiceshelf/_config';
|
||||
|
||||
export type InvoiceShelfHealth = {
|
||||
ok: boolean;
|
||||
/** False only when no account is stored. It separates "set this up" from "this used to work". */
|
||||
configured?: boolean;
|
||||
/** Label of the selected account, so a failure names which books did not answer. */
|
||||
account?: string;
|
||||
version?: string | null;
|
||||
company?: number | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type InvoiceShelfAccount = {
|
||||
id: number;
|
||||
label: string;
|
||||
url: string;
|
||||
companyId: number | null;
|
||||
companyName: string | null;
|
||||
version: string | null;
|
||||
isActive: boolean;
|
||||
lastSeenAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type InvoiceShelfAccounts = { configured: boolean; activeId: number | null; accounts: InvoiceShelfAccount[] };
|
||||
|
||||
export type CompanyOption = { id: number; name: string | null };
|
||||
|
||||
/**
|
||||
* Unwrap a thrown client error into the shape the connection form needs.
|
||||
*
|
||||
* `needsChoice` is the interesting one: adding an account whose token can see several companies is a 409
|
||||
* carrying the list, because nothing is wrong with what was sent — it just is not enough to decide. The form
|
||||
* turns that into a picker rather than an error.
|
||||
*/
|
||||
export function parseAccountError(err: unknown): { message: string; needsChoice: boolean; companies: CompanyOption[] } {
|
||||
const raw = (err as { message?: unknown } | null)?.message;
|
||||
const fallback = { message: 'Something went wrong', needsChoice: false, companies: [] as CompanyOption[] };
|
||||
if (typeof raw !== 'string' || !raw) return fallback;
|
||||
try {
|
||||
const body = JSON.parse(raw) as { error?: string; needsChoice?: boolean; companies?: CompanyOption[] };
|
||||
return {
|
||||
message: body.error || raw.slice(0, 300),
|
||||
needsChoice: !!body.needsChoice,
|
||||
companies: body.companies ?? [],
|
||||
};
|
||||
} catch {
|
||||
return { ...fallback, message: raw.slice(0, 300) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health, including its failure bodies.
|
||||
*
|
||||
* `get` throws on any status >= 400, so a plain query would leave `data` undefined for exactly the two cases
|
||||
* the UI most needs to tell apart — 503 not connected and 502 connected-but-broken. Both carry a JSON body,
|
||||
* so the throw is turned back into the answer rather than an error state.
|
||||
*/
|
||||
export function useInvoiceShelfHealth() {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [ROOT, 'health'] as const,
|
||||
queryFn: async (): Promise<InvoiceShelfHealth> => {
|
||||
try {
|
||||
return await get<InvoiceShelfHealth>('/invoiceshelf/_health');
|
||||
} catch (err) {
|
||||
const raw = (err as { message?: unknown } | null)?.message;
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
const body = JSON.parse(raw) as InvoiceShelfHealth;
|
||||
if (body && body.ok === false) return body;
|
||||
} catch {
|
||||
/* not the sidecar's body */
|
||||
}
|
||||
}
|
||||
// Anything else — the platform proxy, auth, the sidecar being down — is a configured instance that is
|
||||
// failing, not an unconfigured one. Never offer the setup form on a guess.
|
||||
return { ok: false, configured: true, error: parseAccountError(err).message };
|
||||
}
|
||||
},
|
||||
staleTime: STALE_MS,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInvoiceShelfAccounts() {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [ROOT, 'accounts'] as const,
|
||||
queryFn: () => get<InvoiceShelfAccounts>(CONFIG),
|
||||
staleTime: STALE_MS,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** The companies a stored account's token can act for — what the company picker is populated from. */
|
||||
export function useAccountCompanies(id: number | null) {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [ROOT, 'accounts', id, 'companies'] as const,
|
||||
queryFn: () => get<{ companies: CompanyOption[]; companyId: number | null }>(`${CONFIG}/${id}/companies`),
|
||||
enabled: id != null,
|
||||
staleTime: STALE_MS,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Either a pasted Sanctum token or a sign-in the sidecar trades for one.
|
||||
*
|
||||
* The password goes to the sidecar, which uses it for the single `/auth/login` call and stores only the token
|
||||
* it gets back. It exists because InvoiceShelf 2.4.2 has no screen that issues tokens at all.
|
||||
*/
|
||||
export type Credential = { token: string } | { email: string; password: string };
|
||||
|
||||
export type AddAccountInput = { label: string; url: string; companyId?: number | null } & Credential;
|
||||
export type EditAccountInput = { id: number; label?: string; url?: string; companyId?: number | null } & Partial<
|
||||
Record<'token' | 'email' | 'password', string>
|
||||
>;
|
||||
|
||||
export function useInvoiceShelfAccountActions() {
|
||||
const { post, patch, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
// Adding, editing, switching and removing all change what every other query here can even answer — a
|
||||
// switch in particular changes the answer to all of them without changing any of their inputs.
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: [ROOT] });
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: (input: AddAccountInput) => post<{ account: InvoiceShelfAccount }>(CONFIG, input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const edit = useMutation({
|
||||
mutationFn: ({ id, ...rest }: EditAccountInput) => patch<{ account: InvoiceShelfAccount }>(`${CONFIG}/${id}`, rest),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: (id: number) => post<InvoiceShelfAccounts>(`${CONFIG}/${id}/activate`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => del<InvoiceShelfAccounts>(`${CONFIG}/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { add, edit, activate, remove };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PhotosAccount } from './usePhotosData';
|
||||
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';
|
||||
@@ -30,6 +30,11 @@ 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;
|
||||
@@ -56,6 +61,91 @@ const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: F
|
||||
</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 };
|
||||
|
||||
/**
|
||||
@@ -66,8 +156,9 @@ const AccountRow = ({ account }: AccountRowProps) => {
|
||||
const { data: health, refetch: recheck, isFetching: checking } = usePhotosHealth();
|
||||
const { edit, activate, remove } = usePhotosAccountActions();
|
||||
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
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);
|
||||
|
||||
@@ -75,7 +166,8 @@ const AccountRow = ({ account }: AccountRowProps) => {
|
||||
setError(null);
|
||||
try {
|
||||
await action;
|
||||
setApiKey('');
|
||||
cred.reset();
|
||||
setReplacing(false);
|
||||
} catch (err) {
|
||||
setError(photosErrorMessage(err));
|
||||
}
|
||||
@@ -144,26 +236,41 @@ const AccountRow = ({ account }: AccountRowProps) => {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={apiKey}
|
||||
onChange={(ev) => setApiKey(ev.target.value)}
|
||||
placeholder="Replace API key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="h-8"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
disabled={!apiKey.trim() || edit.isPending}
|
||||
onClick={() => void run(edit.mutateAsync({ id: account.id, apiKey: apiKey.trim() }))}
|
||||
{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"
|
||||
>
|
||||
{edit.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
Replace credential
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && <p className="text-destructive">{error}</p>}
|
||||
</div>
|
||||
@@ -174,9 +281,9 @@ export const ConnectionSection = () => {
|
||||
const { data, isLoading } = usePhotosAccounts();
|
||||
const { add } = usePhotosAccountActions();
|
||||
|
||||
const cred = useCredential();
|
||||
const [label, setLabel] = useState('');
|
||||
const [url, setUrl] = useState(DEFAULT_URL);
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const accounts = data?.accounts ?? [];
|
||||
@@ -185,13 +292,15 @@ export const ConnectionSection = () => {
|
||||
const submit = async () => {
|
||||
setError(null);
|
||||
if (!url.trim()) return setError('The Immich URL is required');
|
||||
if (!apiKey.trim()) return setError('An API key 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(), apiKey: apiKey.trim() });
|
||||
await add.mutateAsync({ label: label.trim(), url: url.trim(), ...cred.payload });
|
||||
setLabel('');
|
||||
setApiKey('');
|
||||
setUrl(DEFAULT_URL);
|
||||
cred.reset();
|
||||
} catch (err) {
|
||||
setError(photosErrorMessage(err));
|
||||
}
|
||||
@@ -209,7 +318,7 @@ export const ConnectionSection = () => {
|
||||
<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 an API key before it can show anything.'}
|
||||
: 'Photos needs an Immich instance and a login before it can show anything.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,14 +349,7 @@ export const ConnectionSection = () => {
|
||||
hint={URL_HINT}
|
||||
autoFocus={!hasAccounts}
|
||||
/>
|
||||
<Field
|
||||
label="API key"
|
||||
value={apiKey}
|
||||
onChange={setApiKey}
|
||||
placeholder="••••••••••••"
|
||||
type="password"
|
||||
hint={KEY_HINT}
|
||||
/>
|
||||
<CredentialFields cred={cred} />
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
|
||||
@@ -323,8 +323,18 @@ export function usePhotosAccounts() {
|
||||
});
|
||||
}
|
||||
|
||||
export type AddPhotosAccountInput = { label: string; url: string; apiKey: string };
|
||||
export type EditPhotosAccountInput = { id: number; label?: string; url?: string; apiKey?: string };
|
||||
/**
|
||||
* Either a pasted API key or a sign-in the sidecar trades for one.
|
||||
*
|
||||
* The password goes to the sidecar, which logs in, mints an all-permissions key, closes the session again and
|
||||
* stores only the key. It never reaches Postgres and never comes back to the browser.
|
||||
*/
|
||||
export type PhotosCredential = { apiKey: string } | { email: string; password: string };
|
||||
|
||||
export type AddPhotosAccountInput = { label: string; url: string } & PhotosCredential;
|
||||
export type EditPhotosAccountInput = { id: number; label?: string; url?: string } & Partial<
|
||||
Record<'apiKey' | 'email' | 'password', string>
|
||||
>;
|
||||
|
||||
export function usePhotosAccountActions() {
|
||||
const { post, patch, delete: del } = useClient();
|
||||
|
||||
Reference in New Issue
Block a user