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
)}
);
};
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
? '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.'}