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) => ( ); // ── 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('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; 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 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) => ( ); 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(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) => { setError(null); try { await action; cred.reset(); setReplacing(false); setChangingCompany(false); } catch (err) { setError(parseAccountError(err).message); } }; return (
{account.isActive ? : } {account.label} {account.isActive && ( in use )}
{!account.isActive && ( )} {account.isActive && ( )}

{account.url}

{changingCompany ? ( <> {companies.isLoading ? ( ) : ( void run(edit.mutateAsync({ id: account.id, companyId }))} /> )} ) : ( <> {account.companyName ?? `Company ${account.companyId ?? '?'}`} )}
{account.isActive && (
{health?.ok ? ( ) : ( )} {health?.ok ? `InvoiceShelf ${health.version ?? '?'}` : (health?.error ?? 'not checked yet')}
)} {replacing ? (
) : ( )} {error &&

{error}

}
); }; 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(null); // Populated only when the instance answers with more than one company — see parseAccountError. const [choices, setChoices] = useState([]); const [companyId, setCompanyId] = useState(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 (

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

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

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

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

{choices.length > 0 && ( )} {error &&

{error}

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