Ninth plugin out, third resurrection: switched off since 2026-08-13 with the permission reduced to api: [], so the server booted clean and every panel request 404'd. invoiceshelf/schema was also commented out of the drizzle barrel. Named `invoices`, not `invoiceshelf`. The appName drives the API prefix, the frontend route, the table prefix and the install key all at once, and before extraction those had drifted: the API was /api/invoiceshelf while the screen was /invoices. The URL is what a person sees and should name what the plugin shows rather than the daemon behind it — the permission key and label were already `invoices`, so the API prefix was the odd one out. Cost: three call sites, and renaming invoiceshelf_accounts to invoices_accounts to satisfy the table-prefix rule. That rename was free ONLY because the table existed on no server, which was checked before deciding. With rows in it, keeping the vendor name would have been the better trade: db:push sees a rename as a drop plus a create and this project has no migration mechanism. The sidecar keeps its Invoiceshelf-named functions deliberately — they name the upstream API it actually speaks. 91 palette classes converted, 52 of them in STATUS_TONE, which was already the single source of status colour but held raw palette strings. SENT and VIEWED now share `info`: the closed six-tone set has no second shade of "in flight, not yet actioned". Recorded in PLUGIN.md as a real loss.
450 lines
17 KiB
TypeScript
450 lines
17 KiB
TypeScript
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-success" />
|
|
) : (
|
|
<TriangleAlert className="h-3.5 w-3.5 text-warning" />
|
|
)}
|
|
<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-primary/15 text-primary">
|
|
<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>
|
|
);
|
|
};
|