From f20d4a300e6139d106e63eb43b9338975335ca5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 3 Aug 2026 17:44:28 +0000 Subject: [PATCH] invoiceshelf: accounts are configured from the ui, not the environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 21 +- ecosystem.config.cjs | 4 + src/databases/officer_db/src/index.ts | 11 + .../officer_db/src/queries/invoiceshelf.ts | 200 ++++++++ src/databases/officer_db/src/schema/index.ts | 1 + .../officer_db/src/schema/invoiceshelf.ts | 58 +++ src/servers/sidecar/invoiceshelf/config.ts | 326 +++++++++++++ src/servers/sidecar/invoiceshelf/index.ts | 71 ++- src/servers/sidecar/invoiceshelf/upstream.ts | 170 ++++--- src/servers/sidecar/photos/config.ts | 51 +- src/servers/sidecar/photos/index.ts | 5 +- src/servers/sidecar/photos/upstream.ts | 74 +++ .../src/apps/Invoices/AccountSwitcher.tsx | 88 ++++ .../src/apps/Invoices/ConnectionSection.tsx | 449 ++++++++++++++++++ .../src/apps/Invoices/InvoicesNav.tsx | 22 +- .../src/apps/Invoices/InvoicesView.tsx | 28 +- .../src/apps/Invoices/components.tsx | 2 +- .../officerdev/src/apps/Invoices/shared.ts | 1 + .../src/apps/Invoices/useInvoiceShelfData.ts | 161 +++++++ .../src/apps/Photos/ConnectionSection.tsx | 172 +++++-- .../src/apps/Photos/usePhotosData.ts | 14 +- 21 files changed, 1785 insertions(+), 144 deletions(-) create mode 100644 src/databases/officer_db/src/queries/invoiceshelf.ts create mode 100644 src/databases/officer_db/src/schema/invoiceshelf.ts create mode 100644 src/servers/sidecar/invoiceshelf/config.ts create mode 100644 src/workspaces/officerdev/src/apps/Invoices/AccountSwitcher.tsx create mode 100644 src/workspaces/officerdev/src/apps/Invoices/ConnectionSection.tsx diff --git a/.env.example b/.env.example index 44f4639f..82c21b0d 100644 --- a/.env.example +++ b/.env.example @@ -28,16 +28,9 @@ TRANSMISSION_USER= TRANSMISSION_PASS= # TRANSMISSION_RPC_PATH=/transmission/rpc -# InvoiceShelf (officer-invoiceshelf). The token is a Sanctum personal access token — mint one with -# POST /api/v1/auth/login {username: , password, device_name} and copy the `token` field. It has -# full abilities and never expires, so treat it as a password. -# INVOICESHELF_COMPANY_ID pins which company every request is scoped to. Leave it unset on a -# single-company install and the sidecar resolves it once at boot and LOGS the choice — worth setting -# explicitly if you have more than one, because InvoiceShelf does not error on a wrong company header, -# it silently returns the other company's data. -INVOICESHELF_URL=https://invoice.example.com -INVOICESHELF_TOKEN="" -# INVOICESHELF_COMPANY_ID=1 +# InvoiceShelf (officer-invoiceshelf) is configured from the app, not from here — Invoices → Connection. +# Instances, their Sanctum tokens and the company each one is pinned to live encrypted in +# `invoiceshelf_accounts`, so nothing outside the sidecar can read a token. # slskd (officer-slskd). The key is injected as X-API-Key on every forwarded request. SLSKD_URL=http://127.0.0.1:5030 @@ -78,9 +71,5 @@ VAULT_STORE_KEY="" # seals it. Both are required to spend. If you lose VAULT_STORE_KEY, every stored seed is # unrecoverable — back up the mnemonics separately, offline. -# Immich (officer-photos). The key is injected as x-api-key on every forwarded request; the platform never -# sees it. Create it in Immich: Account Settings → API Keys → New API Key. Immich keys are SCOPED — grant -# all permissions unless you want a read-only library, because a missing permission answers 403 on that one -# route and looks like a broken feature rather than a bad credential. -IMMICH_URL=http://127.0.0.1:2283 -IMMICH_API_KEY="" +# Immich (officer-photos) is configured from the app, not from here — Photos → Connection. Instances and +# their API keys live encrypted in `photos_config`, so the platform never sees a key. diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 0e6df484..35f270b0 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -82,6 +82,10 @@ module.exports = { args: 'run src/servers/sidecar/transmission/index.ts', watch: false, }, + // The books. Wraps a self-hosted InvoiceShelf. Instances, their Sanctum tokens and the company each one + // is pinned to are set by the owner from /invoices/settings and stored encrypted in + // `invoiceshelf_accounts` — read here, never from the environment, because Bun auto-loads `.env` into + // every process in this directory and `officer` would hold the token too. { name: 'officer-invoiceshelf', script: 'bun', diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 72d969f8..52b421ce 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -141,6 +141,17 @@ export { recordHeadscaleProbe, } from './queries/headscale'; export type { HeadscaleServer, HeadscaleServerCredentials } from './queries/headscale'; +export { + listInvoiceshelfAccounts, + getActiveInvoiceshelfCredentials, + getInvoiceshelfCredentials, + createInvoiceshelfAccount, + updateInvoiceshelfAccount, + setActiveInvoiceshelfAccount, + deleteInvoiceshelfAccount, + recordInvoiceshelfProbe, +} from './queries/invoiceshelf'; +export type { InvoiceshelfAccount, InvoiceshelfCredentials } from './queries/invoiceshelf'; export { listPhotosAccounts, getActivePhotosCredentials, diff --git a/src/databases/officer_db/src/queries/invoiceshelf.ts b/src/databases/officer_db/src/queries/invoiceshelf.ts new file mode 100644 index 00000000..6c07d100 --- /dev/null +++ b/src/databases/officer_db/src/queries/invoiceshelf.ts @@ -0,0 +1,200 @@ +import { eq, and, desc } from 'drizzle-orm'; +import { db } from '../db'; +import { invoiceshelfAccounts } from '../schema'; +import { encryptSecret, decryptSecret } from '../crypto'; + +// InvoiceShelf account registry for the officer-invoiceshelf sidecar. Callers deal in PLAINTEXT — encryption +// to and from at-rest ciphertext happens here. See ../crypto.ts and ../schema/invoiceshelf.ts. +// +// Two return types, and the split is the safety property: +// InvoiceshelfAccount — safe to serialize to the browser. Has NO token field at all, not even masked. +// InvoiceshelfCredentials — url + decrypted token + company, for the sidecar's own upstream calls. +// `accountCols` is what enforces it: a bare `select()` would put the ciphertext column into every list +// response the moment someone forgot to strip it. + +export type InvoiceshelfAccount = { + id: number; + label: string; + url: string; + companyId: number | null; + companyName: string | null; + version: string | null; + isActive: boolean; + lastSeenAt: Date | null; + createdAt: Date; +}; + +export type InvoiceshelfCredentials = { + id: number; + label: string; + url: string; + token: string; + companyId: number | null; +}; + +const accountCols = { + id: invoiceshelfAccounts.id, + label: invoiceshelfAccounts.label, + url: invoiceshelfAccounts.url, + companyId: invoiceshelfAccounts.companyId, + companyName: invoiceshelfAccounts.companyName, + version: invoiceshelfAccounts.version, + isActive: invoiceshelfAccounts.isActive, + lastSeenAt: invoiceshelfAccounts.lastSeenAt, + createdAt: invoiceshelfAccounts.createdAt, +}; + +/** Every account the owner has added, active first then newest. Never includes the token. */ +export async function listInvoiceshelfAccounts(userId: number): Promise { + return db + .select(accountCols) + .from(invoiceshelfAccounts) + .where(eq(invoiceshelfAccounts.userId, userId)) + .orderBy(desc(invoiceshelfAccounts.isActive), desc(invoiceshelfAccounts.createdAt)); +} + +/** The selected account with its token decrypted, or null when none is added. */ +export async function getActiveInvoiceshelfCredentials(userId: number): Promise { + const [row] = await db + .select() + .from(invoiceshelfAccounts) + .where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true))); + if (!row) return null; + return { id: row.id, label: row.label, url: row.url, token: decryptSecret(row.token), companyId: row.companyId }; +} + +/** One account's credentials by id — for probing a specific account rather than the active one. */ +export async function getInvoiceshelfCredentials(userId: number, id: number): Promise { + const [row] = await db + .select() + .from(invoiceshelfAccounts) + .where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id))); + if (!row) return null; + return { id: row.id, label: row.label, url: row.url, token: decryptSecret(row.token), companyId: row.companyId }; +} + +type CreateInvoiceshelfAccountParams = { + userId: number; + label: string; + url: string; + token: string; + companyId: number | null; + companyName: string | null; + version: string | null; + /** Select it. True for the first account, so the UI is never left with accounts added but none chosen. */ + activate: boolean; +}; + +/** Add an account. The token is encrypted before write; the returned row carries no token. */ +export async function createInvoiceshelfAccount(params: CreateInvoiceshelfAccountParams): Promise { + const { userId, label, url, token, companyId, companyName, version, activate } = params; + return db.transaction(async (tx) => { + if (activate) { + await tx + .update(invoiceshelfAccounts) + .set({ isActive: false, updatedAt: new Date() }) + .where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true))); + } + const [row] = await tx + .insert(invoiceshelfAccounts) + .values({ + userId, + label, + url, + token: encryptSecret(token), + companyId, + companyName, + version, + isActive: activate, + lastSeenAt: version ? new Date() : null, + }) + .returning(accountCols); + return row!; + }); +} + +type UpdateInvoiceshelfAccountParams = { + label?: string; + url?: string; + token?: string; + companyId?: number | null; + companyName?: string | null; + version?: string | null; +}; + +/** Edit an account. Omitted fields are left alone; a supplied token is re-encrypted. */ +export async function updateInvoiceshelfAccount( + userId: number, + id: number, + params: UpdateInvoiceshelfAccountParams, +): Promise { + const set: Record = { updatedAt: new Date() }; + if (params.label !== undefined) set.label = params.label; + if (params.url !== undefined) set.url = params.url; + if (params.token !== undefined) set.token = encryptSecret(params.token); + if (params.companyId !== undefined) set.companyId = params.companyId; + if (params.companyName !== undefined) set.companyName = params.companyName; + if (params.version !== undefined) set.version = params.version; + + const [row] = await db + .update(invoiceshelfAccounts) + .set(set) + .where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id))) + .returning(accountCols); + return row ?? null; +} + +/** Switch accounts. Clearing the others first keeps the one-active partial index satisfied. */ +export async function setActiveInvoiceshelfAccount(userId: number, id: number): Promise { + return db.transaction(async (tx) => { + await tx + .update(invoiceshelfAccounts) + .set({ isActive: false, updatedAt: new Date() }) + .where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true))); + const [row] = await tx + .update(invoiceshelfAccounts) + .set({ isActive: true, updatedAt: new Date() }) + .where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id))) + .returning(accountCols); + return row ?? null; + }); +} + +/** + * Remove an account. If it was the active one the newest survivor is promoted — otherwise removing the + * account in use would leave the owner with accounts added but none selected, which reads as "not connected" + * and is a confusing place to land. + */ +export async function deleteInvoiceshelfAccount(userId: number, id: number): Promise { + return db.transaction(async (tx) => { + const [deleted] = await tx + .delete(invoiceshelfAccounts) + .where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id))) + .returning({ id: invoiceshelfAccounts.id, wasActive: invoiceshelfAccounts.isActive }); + if (!deleted) return false; + + if (deleted.wasActive) { + const [next] = await tx + .select({ id: invoiceshelfAccounts.id }) + .from(invoiceshelfAccounts) + .where(eq(invoiceshelfAccounts.userId, userId)) + .orderBy(desc(invoiceshelfAccounts.createdAt)) + .limit(1); + if (next) { + await tx + .update(invoiceshelfAccounts) + .set({ isActive: true, updatedAt: new Date() }) + .where(eq(invoiceshelfAccounts.id, next.id)); + } + } + return true; + }); +} + +/** Stamp a successful probe, so the UI can tell "never reached" from "was reachable, now isn't". */ +export async function recordInvoiceshelfProbe(userId: number, id: number, version: string | null): Promise { + await db + .update(invoiceshelfAccounts) + .set({ version, lastSeenAt: new Date() }) + .where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id))); +} diff --git a/src/databases/officer_db/src/schema/index.ts b/src/databases/officer_db/src/schema/index.ts index 1d2fb928..21638d2c 100644 --- a/src/databases/officer_db/src/schema/index.ts +++ b/src/databases/officer_db/src/schema/index.ts @@ -3,6 +3,7 @@ export * from './chat-events'; export * from './dashboards'; export * from './email'; export * from './headscale'; +export * from './invoiceshelf'; export * from './music'; export * from './notify'; export * from './operations'; diff --git a/src/databases/officer_db/src/schema/invoiceshelf.ts b/src/databases/officer_db/src/schema/invoiceshelf.ts new file mode 100644 index 00000000..58702917 --- /dev/null +++ b/src/databases/officer_db/src/schema/invoiceshelf.ts @@ -0,0 +1,58 @@ +import { pgTable, serial, integer, text, boolean, timestamp, unique, uniqueIndex } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { users } from './auth'; + +// The InvoiceShelf accounts behind /invoices, for the officer-invoiceshelf sidecar. +// +// This used to be INVOICESHELF_URL + INVOICESHELF_TOKEN + INVOICESHELF_COMPANY_ID in the platform-wide +// `.env`, which was wrong twice over: Bun auto-loads `.env` into EVERY process started in the platform +// directory, so `officer` itself held a Sanctum token it has no code to use — and connecting a books +// instance was a shell task on the server rather than something the owner could do from the app. +// +// It is a REGISTRY, not a single row: the owner adds any number of accounts and switches between them, the +// same shape headscale_servers and photos_config use. Uniqueness is on the label rather than the URL, +// because the same instance with a different company is a different account here, and two of those share +// a URL AND a token. +// +// `token` is encrypted at rest via ../crypto.ts. A Sanctum token can read and write the entire books — every +// invoice, customer and payment — so a DB dump must not hand it over. Encryption is confined to +// queries/invoiceshelf.ts; nothing outside that file sees ciphertext, and no route ever returns the token. +export const invoiceshelfAccounts = pgTable( + 'invoiceshelf_accounts', + { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + /** What the owner calls this account. The switcher shows nothing else, so it has to be theirs to set. */ + label: text('label').notNull(), + // Normalized without a trailing slash before write, so `${url}/api/v1/...` never doubles the separator. + url: text('url').notNull(), + token: text('token').notNull(), // encrypted + /** + * The company every request is scoped to, pinned explicitly. + * + * This is the sharpest edge in InvoiceShelf's API: the `company` header scopes almost every route, and a + * wrong or missing value does NOT error — it silently returns another company's books. Storing the + * choice means it is made once, visibly, instead of falling back per request. + */ + companyId: integer('company_id'), + /** Shown in the switcher next to the label, so two companies on one token are told apart at a glance. */ + companyName: text('company_name'), + /** InvoiceShelf version seen at the last successful probe — shown in the UI, never used for behaviour. */ + version: text('version'), + isActive: boolean('is_active').notNull().default(false), + lastSeenAt: timestamp('last_seen_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + unique('uq_invoiceshelf_accounts_user_label').on(t.userId, t.label), + // At most one active account per owner, enforced by the DB rather than by convention: a partial unique + // index over the active rows only. setActiveInvoiceshelfAccount still clears the others in a transaction, + // but a bug there fails loudly here instead of silently leaving two active and the UI picking one. + uniqueIndex('uq_invoiceshelf_accounts_one_active') + .on(t.userId) + .where(sql`${t.isActive}`), + ], +); diff --git a/src/servers/sidecar/invoiceshelf/config.ts b/src/servers/sidecar/invoiceshelf/config.ts new file mode 100644 index 00000000..8f7df921 --- /dev/null +++ b/src/servers/sidecar/invoiceshelf/config.ts @@ -0,0 +1,326 @@ +import type { Company, UpstreamTarget } from './upstream'; +import { + createInvoiceshelfAccount, + deleteInvoiceshelfAccount, + getInvoiceshelfCredentials, + listInvoiceshelfAccounts, + recordInvoiceshelfProbe, + setActiveInvoiceshelfAccount, + updateInvoiceshelfAccount, +} from 'officerdb'; +import { UpstreamError, callUpstream, invalidateConfig, listCompanies, mintToken, normalizeBase } from './upstream'; + +// `/_config` — the InvoiceShelf account registry, driven from the app rather than from a shell on the server. +// +// The Sanctum token is WRITE-ONLY across this boundary. The list reports each account's label, URL, pinned +// company and whether it is selected; it has no field that could carry a token, masked or otherwise. The +// only way to change one is to send a new one, which is the same shape photos and headscale use. +// +// A save is validated before it is stored, and the validation does something the photos one does not: it +// lists the companies the token can act for and PINS one. The `company` header is the sharpest edge in this +// API — a wrong or missing value silently returns another company's books instead of erroring — so the +// choice is made once, here, and stored, rather than falling back per request. +// +// Two accounts on the same URL with the same token but different companies is therefore an ordinary thing +// to have, and is exactly why the label is what has to be unique. + +export type ProbeResult = + | { ok: true; version: string | null; companies: Company[] } + | { ok: false; version: string | null; error: string }; + +/** 401/419, or a 3xx that is Laravel redirecting to its HTML login — all three mean the token was refused. */ +const rejected = (status: number): boolean => status === 401 || status === 419 || (status >= 300 && status < 400); + +const REJECTED_MESSAGE = + 'the API token was rejected — mint a new one and paste the whole value, including the leading "1|"'; + +/** + * Ask an instance whether it is really there and whether the token works. + * + * Two calls, because they answer different questions, but NOT the two you would expect: `/app/version` is an + * UNAUTHENTICATED route on 2.4.2, so it answers 200 to a revoked, truncated or empty token. It proves only + * that the URL points at an InvoiceShelf. The companies lookup is the first call that exercises the token at + * all, which is why its 401 has to be reported as a credential problem and not as "companies lookup failed". + */ +export async function probe(cfg: UpstreamTarget): Promise { + let version: string | null = null; + + try { + const res = await callUpstream(cfg, { path: '/api/v1/app/version', withCompany: false }); + if (!res.ok) { + return { ok: false, version: null, error: `that URL does not look like an InvoiceShelf (${res.status})` }; + } + const payload = (await res.json()) as { version?: string }; + version = payload.version ?? null; + } catch (err) { + return { ok: false, version: null, error: `could not reach the instance (${String(err)})` }; + } + + try { + const companies = await listCompanies(cfg); + if (!companies.length) return { ok: false, version, error: 'no company is visible to this token' }; + return { ok: true, version, companies }; + } catch (err) { + const status = err instanceof UpstreamError ? err.status : 0; + if (rejected(status)) return { ok: false, version, error: REJECTED_MESSAGE }; + return { ok: false, version, error: String(err) }; + } +} + +/** What the browser is allowed to know about the registry. Never includes a token. */ +async function accountList(userId: number): Promise { + const accounts = await listInvoiceshelfAccounts(userId); + const active = accounts.find((account) => account.isActive) ?? null; + return Response.json({ configured: !!active, activeId: active?.id ?? null, accounts }); +} + +/** Record that the instance answered, so the UI can tell "never connected" from "was working, now isn't". */ +export async function noteProbe(userId: number, id: number, version: string | null): Promise { + await recordInvoiceshelfProbe(userId, id, version).catch(() => { + /* a stale lastSeenAt is not worth failing a request over */ + }); +} + +const bad = (error: string, status = 400) => Response.json({ error }, { status }); + +type AccountBody = { + label?: unknown; + url?: unknown; + token?: unknown; + companyId?: unknown; + email?: unknown; + password?: unknown; +}; + +const readBody = async (req: Request): Promise => + ((await req.json().catch(() => null)) as AccountBody | null) ?? {}; + +const readLabel = (body: AccountBody): string => (typeof body.label === 'string' ? body.label.trim() : ''); +const readUrl = (body: AccountBody): string => (typeof body.url === 'string' ? normalizeBase(body.url) : ''); +const readToken = (body: AccountBody): string => (typeof body.token === 'string' ? body.token.trim() : ''); + +const readCompanyId = (body: AccountBody): number | null => { + const n = Number(body.companyId); + return Number.isInteger(n) && n > 0 ? n : null; +}; + +const readEmail = (body: AccountBody): string => (typeof body.email === 'string' ? body.email.trim() : ''); +const readPassword = (body: AccountBody): string => (typeof body.password === 'string' ? body.password : ''); + +const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url); + +/** + * The token to store, however the owner chose to supply it. + * + * A pasted token wins; otherwise an email and password are traded for a fresh one and the password is + * discarded with this function's stack frame. Returns '' when neither was given, which for an edit means + * "keep what is stored" and for an add is an error. + */ +async function resolveToken(body: AccountBody, base: string): Promise { + const token = readToken(body); + if (token) return token; + + const email = readEmail(body); + const password = readPassword(body); + if (!email || !password) return ''; + + return mintToken(base, email, password); +} + +/** Sign-in failures, in the words of what the owner actually did. 422 is Laravel's validation refusal. */ +function signInError(err: unknown): string { + if (!(err instanceof UpstreamError)) return `could not reach the instance (${String(err)})`; + if (err.status === 422 || err.status === 401) return 'that email and password were rejected by InvoiceShelf'; + if (err.status === 429) return 'too many sign-in attempts — wait a moment and try again'; + return err.message; +} + +const duplicateLabel = (err: unknown): boolean => String(err).includes('uq_invoiceshelf_accounts_user_label'); + +/** + * Pick the company to pin. + * + * An explicit choice is honoured only if the token can actually see it — pinning an invisible company is the + * silent-wrong-books failure this whole mechanism exists to prevent. Otherwise the sole company is taken + * without asking, and with several the caller must choose: `needsChoice` is the 409 that makes the UI show + * the picker instead of guessing. + */ +function pickCompany(companies: Company[], requested: number | null): Company | null { + if (requested != null) return companies.find((c) => c.id === requested) ?? null; + return companies.length === 1 ? companies[0]! : null; +} + +/** Add an account: validated against the live instance, company pinned, then stored encrypted. */ +async function addAccount(req: Request, userId: number): Promise { + const body = await readBody(req); + const url = readUrl(body); + const requested = readCompanyId(body); + let label = readLabel(body); + + if (!url) return bad('url is required'); + if (!isHttpUrl(url)) return bad('url must start with http:// or https://'); + + let token: string; + try { + token = await resolveToken(body, url); + } catch (err) { + return bad(signInError(err)); + } + if (!token) return bad('an API token, or an email and password to mint one, is required'); + + const result = await probe({ base: url, token, companyId: null }); + if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 }); + + const company = pickCompany(result.companies, requested); + if (!company) { + // 409, not 400: nothing is wrong with what was sent, it is just not enough to decide. The UI answers + // this by showing the returned companies and asking, then posting again with companyId. + return Response.json( + { + error: requested ? 'that company is not visible to this token' : 'choose which company to use', + needsChoice: true, + companies: result.companies, + version: result.version, + }, + { status: 409 }, + ); + } + + // An unlabelled account takes the company's own name, which is nearly always what the owner would type. + // Falling back to the host keeps the switcher readable when the company is unnamed. + if (!label) label = company.name ?? new URL(url).host; + + // The first account wins the selection: a registry with rows but nothing selected reads as "not connected". + const existing = await listInvoiceshelfAccounts(userId); + const activate = existing.length === 0; + + try { + const account = await createInvoiceshelfAccount({ + userId, + label, + url, + token, + companyId: company.id, + companyName: company.name, + version: result.version, + activate, + }); + invalidateConfig(userId); + return Response.json({ account }); + } catch (err) { + if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`); + throw err; + } +} + +/** Edit one account. No credential means "keep the stored one", so a rename never needs one re-typed. */ +async function editAccount(req: Request, userId: number, id: number): Promise { + const body = await readBody(req); + const label = readLabel(body); + const url = readUrl(body); + const requested = readCompanyId(body); + + const current = await getInvoiceshelfCredentials(userId, id); + if (!current) return bad('no such account', 404); + if (url && !isHttpUrl(url)) return bad('url must start with http:// or https://'); + + let token: string; + try { + token = await resolveToken(body, url || current.url); + } catch (err) { + return bad(signInError(err)); + } + + // Re-validate whenever what we would talk to changes. A rename on its own never touches the instance. + let version: string | null | undefined; + let companyId: number | undefined; + let companyName: string | null | undefined; + + if ((url && url !== current.url) || token || requested != null) { + const target = { base: url || current.url, token: token || current.token, companyId: null }; + const result = await probe(target); + if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 }); + version = result.version; + + // Keep the pinned company across a token rotation, but only if the new token can still see it. + const company = pickCompany(result.companies, requested ?? current.companyId); + if (!company) { + return Response.json( + { error: 'choose which company to use', needsChoice: true, companies: result.companies }, + { status: 409 }, + ); + } + companyId = company.id; + companyName = company.name; + } + + try { + const account = await updateInvoiceshelfAccount(userId, id, { + label: label || undefined, + url: url || undefined, + token: token || undefined, + companyId, + companyName, + version, + }); + if (!account) return bad('no such account', 404); + invalidateConfig(userId); + return Response.json({ account }); + } catch (err) { + if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`); + throw err; + } +} + +/** The companies a stored account's token can act for — what the company picker is populated from. */ +async function accountCompanies(userId: number, id: number): Promise { + const creds = await getInvoiceshelfCredentials(userId, id); + if (!creds) return bad('no such account', 404); + try { + const companies = await listCompanies({ base: normalizeBase(creds.url), token: creds.token, companyId: null }); + return Response.json({ companies, companyId: creds.companyId }); + } catch (err) { + const status = err instanceof UpstreamError ? err.status : 0; + return Response.json({ error: rejected(status) ? REJECTED_MESSAGE : String(err) }, { status: 502 }); + } +} + +/** `subpath` is '' for /_config, or '/', '//activate', '//companies'. */ +export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise { + const [, rawId, action] = subpath.split('/'); + + if (!rawId) { + if (req.method === 'GET') return accountList(userId); + if (req.method === 'POST' || req.method === 'PUT') return addAccount(req, userId); + return bad('method not allowed', 405); + } + + const id = Number(rawId); + if (!Number.isInteger(id) || id <= 0) return bad('invalid account id', 404); + + if (action === 'activate') { + if (req.method !== 'POST') return bad('method not allowed', 405); + const account = await setActiveInvoiceshelfAccount(userId, id); + if (!account) return bad('no such account', 404); + invalidateConfig(userId); + return accountList(userId); + } + + if (action === 'companies') { + if (req.method !== 'GET') return bad('method not allowed', 405); + return accountCompanies(userId, id); + } + + if (action) return bad('not found', 404); + + if (req.method === 'PATCH' || req.method === 'PUT') return editAccount(req, userId, id); + + if (req.method === 'DELETE') { + const removed = await deleteInvoiceshelfAccount(userId, id); + if (!removed) return bad('no such account', 404); + invalidateConfig(userId); + return accountList(userId); + } + + return bad('method not allowed', 405); +} diff --git a/src/servers/sidecar/invoiceshelf/index.ts b/src/servers/sidecar/invoiceshelf/index.ts index f00d05d8..d5a3d15c 100644 --- a/src/servers/sidecar/invoiceshelf/index.ts +++ b/src/servers/sidecar/invoiceshelf/index.ts @@ -1,12 +1,16 @@ import type { SidecarCommand, SidecarEvent } from '../protocol'; import { createSidecarConnector } from '../connect'; +import { handleConfigRoute, noteProbe, probe } from './config'; import { handleOfficerRoute } from './routes'; -import { callUpstream, getConfig, resolveCompanyId } from './upstream'; +import { getConfig } from './upstream'; // The officer-invoiceshelf sidecar. Owns the whole InvoiceShelf contract for Officer: the instance URL, the // Sanctum API token, and the `company` header that scopes every request. The platform API is a thin // auth-gated forwarder (src/servers/api/invoiceshelf/router.ts) holding no InvoiceShelf credentials. // +// The connection is the OWNER'S to set, from the UI — accounts are stored encrypted in +// `invoiceshelf_accounts` and no longer read from the environment. See upstream.ts for why that mattered. +// // Built and verified against the LIVE instance, which runs 2.4.2 — NOT against the 3.0.0-alpha.1 checkout in // _references/InvoiceShelf. The two differ in ways that matter (2.4.2 has no invoices/{id}/convert-to-estimate // and no installation/is-installed). The route allow-list in routes.ts came from `artisan route:list` on the @@ -16,6 +20,14 @@ import { callUpstream, getConfig, resolveCompanyId } from './upstream'; // HTTP CONTRACT — the platform strips its /api/invoiceshelf mount prefix before forwarding. // // GET /_health ours. Confirms the token is live and reports the pinned company. +// GET /_config the account registry MINUS every token +// POST /_config { label?, url, companyId?, and EITHER token OR email+password } +// — the password mints a token and is never stored. +// 409 { needsChoice, companies } when the company is ambiguous. +// PATCH /_config/:id same fields, all optional — no credential keeps the stored token +// GET /_config/:id/companies the companies that account's token can act for +// POST /_config/:id/activate switch to that account +// DEL /_config/:id remove it; the newest survivor is promoted if it was active // GET /_officer/summary me + current company + dashboard totals, one call // GET /_officer/lookups customers/items/units/tax-types/categories/payment-methods/currencies // GET /_officer/bootstrap the upstream's own bootstrap blob @@ -59,28 +71,51 @@ const server = Bun.serve({ maxRequestBodySize: 64 * 1024 * 1024, async fetch(req) { const url = new URL(req.url); - const cfg = getConfig(); - if (url.pathname === '/_health') { - if (!cfg) return Response.json({ ok: false, error: 'INVOICESHELF_URL/TOKEN not configured' }, { status: 503 }); - const started = Date.now(); + const officerUser = req.headers.get('X-Officer-User'); + const userId = Number(officerUser); + if (!officerUser || !Number.isInteger(userId) || userId <= 0) { + return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 }); + } + + if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) { try { - const [versionRes, company] = await Promise.all([ - callUpstream(cfg, { path: '/api/v1/app/version', withCompany: false }), - resolveCompanyId(cfg), - ]); - if (!versionRes.ok) { - return Response.json({ ok: false, error: `upstream returned ${versionRes.status}` }, { status: 502 }); - } - const version = (await versionRes.json()) as { version?: string }; - return Response.json({ ok: true, version: version.version ?? null, company, ms: Date.now() - started }); + return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length)); } catch (err) { - return Response.json({ ok: false, error: String(err), ms: Date.now() - started }, { status: 502 }); + console.error(`[invoiceshelf] ${req.method} ${url.pathname} failed`, err); + return Response.json({ error: 'internal error' }, { status: 500 }); } } + const cfg = await getConfig(userId); + + // 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a + // configured-but-broken instance is the whole reason the flag is on the response. + if (url.pathname === '/_health') { + if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 }); + const started = Date.now(); + const result = await probe(cfg); + const ms = Date.now() - started; + + if (!result.ok) { + return Response.json( + { ok: false, configured: true, account: cfg.label, version: result.version, error: result.error, ms }, + { status: 502 }, + ); + } + await noteProbe(userId, cfg.id, result.version); + return Response.json({ + ok: true, + configured: true, + account: cfg.label, + version: result.version, + company: cfg.companyId, + ms, + }); + } + if (url.pathname.startsWith('/_officer/')) { - if (!cfg) return Response.json({ error: 'invoiceshelf not configured' }, { status: 503 }); + if (!cfg) return Response.json({ error: 'invoiceshelf not connected', configured: false }, { status: 503 }); try { const res = await handleOfficerRoute(cfg, req, url); if (res) return res; @@ -95,7 +130,9 @@ const server = Bun.serve({ }, }); -console.log(`[invoiceshelf] listening on 127.0.0.1:${port} -> ${getConfig()?.base ?? '(INVOICESHELF_URL unset)'}`); +console.log( + `[invoiceshelf] listening on 127.0.0.1:${port} (instance configured from the UI, stored in invoiceshelf_accounts)`, +); type ReplyFn = (msg: SidecarEvent) => void; diff --git a/src/servers/sidecar/invoiceshelf/upstream.ts b/src/servers/sidecar/invoiceshelf/upstream.ts index af9f2f34..323d6ece 100644 --- a/src/servers/sidecar/invoiceshelf/upstream.ts +++ b/src/servers/sidecar/invoiceshelf/upstream.ts @@ -4,6 +4,12 @@ // behalf of — lives here, mirroring officer-transmission/officer-slskd: the platform API is a thin // auth+forward proxy and holds NO InvoiceShelf credentials. // +// The instance is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `invoiceshelf_accounts` (see +// databases/officer_db/src/queries/invoiceshelf.ts). It is deliberately no longer read from the environment: +// Bun auto-loads `.env` into every process started in the platform directory, so an `INVOICESHELF_TOKEN` +// there was also sitting in `officer`'s own `process.env` — a credential held by the one process that has no +// code to use it and the largest attack surface in the system. Nothing in this file reads process.env. +// // Three things about InvoiceShelf's API are load-bearing and easy to get wrong: // // 1. `Accept: application/json` is MANDATORY. Without it Laravel's Authenticate middleware answers an @@ -14,77 +20,131 @@ // 419. Bun's fetch adds neither on its own; the platform proxy in api/invoiceshelf/router.ts // deliberately forwards neither. Don't add them. // 3. The `company` header (lowercase, a bare numeric id) scopes almost every route — and a wrong or -// missing value does NOT error. It silently falls back to another company's data. That is the reason -// resolveCompanyId() pins one explicitly and logs it, rather than relying on the fallback. +// missing value does NOT error. It silently falls back to another company's data. That is why the +// company is PINNED ON THE ACCOUNT ROW and chosen once when the account is added, rather than resolved +// per request from whatever the token happens to see first. -const { INVOICESHELF_URL, INVOICESHELF_TOKEN, INVOICESHELF_COMPANY_ID } = process.env; +import { getActiveInvoiceshelfCredentials } from 'officerdb'; -export type UpstreamConfig = { base: string; token: string }; - -let warnedUnset = false; +/** Everything needed to make one call. A candidate being validated has this and nothing else yet. */ +export type UpstreamTarget = { base: string; token: string; companyId: number | null }; /** - * The configured instance, or null when unconfigured — the sidecar then answers 503 rather than pretending - * to work. Warns once so a misconfigured deployment is obvious in the logs without flooding them. + * A stored account, which is where every real call goes. + * + * `id` and `label` ride along because the owner can have several accounts registered and only one selected: + * a probe has to be recorded against the row it actually reached, and a log line saying which books answered + * is the difference between "invoices is broken" and "you are looking at the other company". */ -export function getConfig(): UpstreamConfig | null { - const base = INVOICESHELF_URL?.trim().replace(/\/+$/, ''); - const token = INVOICESHELF_TOKEN?.trim(); - if (!base || !token) { - if (!warnedUnset) { - const missing = [!base && 'INVOICESHELF_URL', !token && 'INVOICESHELF_TOKEN'].filter(Boolean).join(' and '); - console.warn(`[invoiceshelf] ${missing} unset — the sidecar will respond 503 until set`); - warnedUnset = true; - } - return null; +export type UpstreamConfig = UpstreamTarget & { id: number; label: string }; + +/** Trailing slashes off, so `${base}/api/v1/...` never doubles the separator. */ +export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, ''); + +// A list view is a burst of requests and each one needs the token, so the row is cached rather than re-read +// per request. Writes invalidate immediately; the TTL only covers someone editing the row in psql, which +// then takes effect within a minute instead of needing a restart. +const TTL_MS = 60_000; +const cache = new Map(); + +/** + * The owner's SELECTED account, or null when /invoices has no account yet — the sidecar then answers 503, + * and the UI turns that into the setup form rather than a screen of empty tables. + */ +export async function getConfig(userId: number): Promise { + const hit = cache.get(userId); + if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg; + + const creds = await getActiveInvoiceshelfCredentials(userId); + const cfg = creds + ? { + id: creds.id, + label: creds.label, + base: normalizeBase(creds.url), + token: creds.token, + companyId: creds.companyId, + } + : null; + cache.set(userId, { cfg, at: Date.now() }); + return cfg; +} + +/** Drop the cached row — called by the config routes after any add, edit, switch or removal. */ +export function invalidateConfig(userId: number): void { + cache.delete(userId); + hashCache.clear(); +} + +export type Company = { id: number; name: string | null; unique_hash?: string | null }; + +/** Carries the upstream status so callers can tell a rejected token from an unreachable instance. */ +export class UpstreamError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); } - return { base, token }; } -let companyPromise: Promise | null = null; - /** - * The company id every scoped request is pinned to. + * Trade an email and password for a Sanctum token. * - * `INVOICESHELF_COMPANY_ID` wins when set. Otherwise we ask the instance once and take the first company the - * token can see — which is right for a single-company install and, crucially, is LOGGED. The silent-fallback - * behaviour is the sharpest edge in this API; making the choice explicit and visible is the whole point. + * InvoiceShelf 2.4.2 has no screen that issues personal access tokens — `POST /api/v1/auth/login` (the + * controller upstream files under "Mobile") is the only way to get one, which meant connecting Officer + * required a curl command. Doing it here instead is the whole point: the password is used for this one call + * and never stored, and only the token it returns is persisted. * - * The resolved promise is cached, so concurrent first requests share one lookup. A failure clears the cache - * so the next request retries instead of latching a transient network error forever. + * Each call mints a NEW token upstream; the previous one is deliberately left alone, because Officer cannot + * know whether the owner also uses it somewhere else. Old ones are revoked from InvoiceShelf, not from here. */ -export async function resolveCompanyId(cfg: UpstreamConfig): Promise { - const pinned = Number(INVOICESHELF_COMPANY_ID?.trim()); - if (Number.isInteger(pinned) && pinned > 0) return pinned; - - companyPromise ??= (async () => { - const res = await fetch(`${cfg.base}/api/v1/companies`, { - headers: { Authorization: `Bearer ${cfg.token}`, Accept: 'application/json' }, - }); - if (!res.ok) throw new Error(`companies lookup failed with ${res.status}`); - const payload = (await res.json()) as { data?: Array<{ id?: number; name?: string }> }; - const first = payload.data?.[0]; - if (typeof first?.id !== 'number') throw new Error('no company visible to this token'); - console.log( - `[invoiceshelf] pinned to company ${first.id} (${first.name ?? 'unnamed'}) — set INVOICESHELF_COMPANY_ID to override`, - ); - return first.id; - })().catch((err) => { - companyPromise = null; - throw err; +export async function mintToken(base: string, email: string, password: string): Promise { + const res = await fetch(`${base}/api/v1/auth/login`, { + method: 'POST', + // No Origin or Referer — see the header note. `device_name` names the token in InvoiceShelf's own list. + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: email, password, device_name: 'officer' }), + redirect: 'manual', }); - - return companyPromise; + if (!res.ok) throw new UpstreamError(`sign-in failed with ${res.status}`, res.status); + const payload = (await res.json()) as { token?: unknown }; + if (typeof payload.token !== 'string' || !payload.token) { + throw new UpstreamError('sign-in returned no token', res.status); + } + return payload.token; } -let hashPromise: Promise | null = null; +/** + * Every company this token can act for. Asked once when an account is added, so the owner picks explicitly + * instead of inheriting whichever one the API happens to list first. + * + * This is also the FIRST call that actually exercises the token: `/app/version` is an unauthenticated route, + * so it answers 200 to a wrong token, an empty one or a revoked one. A 401 here means the token, not the URL. + */ +export async function listCompanies(cfg: UpstreamTarget): Promise { + const res = await fetch(`${cfg.base}/api/v1/companies`, { + headers: { Authorization: `Bearer ${cfg.token}`, Accept: 'application/json' }, + redirect: 'manual', + }); + if (!res.ok) throw new UpstreamError(`companies lookup failed with ${res.status}`, res.status); + const payload = (await res.json()) as { data?: Array<{ id?: number; name?: string }> }; + return (payload.data ?? []) + .filter((c): c is { id: number; name?: string } => typeof c.id === 'number') + .map((c) => ({ id: c.id, name: c.name ?? null })); +} + +// Keyed by account id, because two accounts can be two companies on one instance and the hash is per company. +const hashCache = new Map>(); /** * The pinned company's `unique_hash`. The report PDFs live on web routes keyed by it * (`/reports/sales/customers/{hash}`), not by company id, so it has to be looked up and is worth caching. */ export async function resolveCompanyHash(cfg: UpstreamConfig): Promise { - hashPromise ??= (async () => { + const cached = hashCache.get(cfg.id); + if (cached) return cached; + + const pending = (async () => { const res = await callUpstream(cfg, { path: '/api/v1/current-company' }); if (!res.ok) throw new Error(`current-company lookup failed with ${res.status}`); const payload = (await res.json()) as { data?: { unique_hash?: string } }; @@ -92,11 +152,13 @@ export async function resolveCompanyHash(cfg: UpstreamConfig): Promise { if (!hash) throw new Error('current-company returned no unique_hash'); return hash; })().catch((err) => { - hashPromise = null; + // Clear on failure so the next request retries instead of latching a transient network error forever. + hashCache.delete(cfg.id); throw err; }); - return hashPromise; + hashCache.set(cfg.id, pending); + return pending; } type CallOptions = { @@ -114,13 +176,13 @@ type CallOptions = { }; /** The single door to InvoiceShelf. Everything the sidecar fetches goes through here. */ -export async function callUpstream(cfg: UpstreamConfig, opts: CallOptions): Promise { +export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise { const headers: Record = { Authorization: `Bearer ${cfg.token}`, Accept: opts.accept ?? 'application/json', }; - if (opts.withCompany !== false) headers.company = String(await resolveCompanyId(cfg)); + if (opts.withCompany !== false && cfg.companyId != null) headers.company = String(cfg.companyId); if (opts.contentType) headers['Content-Type'] = opts.contentType; const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, { diff --git a/src/servers/sidecar/photos/config.ts b/src/servers/sidecar/photos/config.ts index b80cb7cd..98ce8eb8 100644 --- a/src/servers/sidecar/photos/config.ts +++ b/src/servers/sidecar/photos/config.ts @@ -8,7 +8,7 @@ import { setActivePhotosAccount, updatePhotosAccount, } from 'officerdb'; -import { callUpstream, invalidateConfig, normalizeBase } from './upstream'; +import { UpstreamError, callUpstream, invalidateConfig, mintApiKey, normalizeBase } from './upstream'; // `/_config` — the Immich account registry, driven from the app rather than from a shell on the server. // @@ -16,6 +16,10 @@ import { callUpstream, invalidateConfig, normalizeBase } from './upstream'; // is selected; it has no field that could carry a key, masked or otherwise. The only way to change one is to // send a new one, which is the same shape headscale's server registry uses. // +// "A new one" has two forms: a key the owner pasted from Immich's own settings screen, or an email and +// password the sidecar trades for one (see mintApiKey). The password is used for that exchange and never +// stored — only the key it yields is. +// // A save is validated before it is stored: an Immich key that is missing, wrong or under-scoped is a 400 // with the reason, not a saved row that makes every later screen fail mysteriously. That matters more here // than usual — Immich keys are SCOPED, and a key created without the right permissions returns 403 on @@ -80,7 +84,7 @@ export async function noteProbe(userId: number, id: number, version: string | nu const bad = (error: string, status = 400) => Response.json({ error }, { status }); -type AccountBody = { label?: unknown; url?: unknown; apiKey?: unknown }; +type AccountBody = { label?: unknown; url?: unknown; apiKey?: unknown; email?: unknown; password?: unknown }; const readBody = async (req: Request): Promise => ((await req.json().catch(() => null)) as AccountBody | null) ?? {}; @@ -88,21 +92,52 @@ const readBody = async (req: Request): Promise => const readLabel = (body: AccountBody): string => (typeof body.label === 'string' ? body.label.trim() : ''); const readUrl = (body: AccountBody): string => (typeof body.url === 'string' ? normalizeBase(body.url) : ''); const readKey = (body: AccountBody): string => (typeof body.apiKey === 'string' ? body.apiKey.trim() : ''); +const readEmail = (body: AccountBody): string => (typeof body.email === 'string' ? body.email.trim() : ''); +const readPassword = (body: AccountBody): string => (typeof body.password === 'string' ? body.password : ''); const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url); +/** + * The key to store, however the owner chose to supply it. + * + * A pasted key wins; otherwise an email and password are traded for a fresh one and the password is discarded + * with this function's stack frame. Returns '' when neither was given, which for an edit means "keep what is + * stored" and for an add is an error. + */ +async function resolveKey(body: AccountBody, base: string): Promise { + const apiKey = readKey(body); + if (apiKey) return apiKey; + + const email = readEmail(body); + const password = readPassword(body); + if (!email || !password) return ''; + + return mintApiKey(base, email, password); +} + +/** A refused sign-in already carries Immich's own words; anything else never reached Immich at all. */ +const signInError = (err: unknown): string => + err instanceof UpstreamError ? err.message : `could not reach the instance (${String(err)})`; + const duplicateLabel = (err: unknown): boolean => String(err).includes('uq_photos_config_user_label'); /** Add an account: validated against the live instance, then stored encrypted. */ async function addAccount(req: Request, userId: number): Promise { const body = await readBody(req); const url = readUrl(body); - const apiKey = readKey(body); let label = readLabel(body); - if (!url || !apiKey) return bad('url and apiKey are required'); + if (!url) return bad('url is required'); if (!isHttpUrl(url)) return bad('url must start with http:// or https://'); + let apiKey: string; + try { + apiKey = await resolveKey(body, url); + } catch (err) { + return bad(signInError(err)); + } + if (!apiKey) return bad('an API key, or an email and password to mint one, is required'); + const result = await probe({ base: url, key: apiKey }); if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 }); @@ -129,12 +164,18 @@ async function editAccount(req: Request, userId: number, id: number): Promise forwarded to /api/, first-segment allow-list (routes.ts) diff --git a/src/servers/sidecar/photos/upstream.ts b/src/servers/sidecar/photos/upstream.ts index e2aee035..9d92f39e 100644 --- a/src/servers/sidecar/photos/upstream.ts +++ b/src/servers/sidecar/photos/upstream.ts @@ -61,6 +61,80 @@ export function invalidateConfig(userId: number): void { cache.delete(userId); } +/** Carries the upstream status so callers can tell a rejected credential from an unreachable instance. */ +export class UpstreamError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + } +} + +/** Immich errors as `{ message }`; fall back to the status when the body is not that (a proxy's HTML, say). */ +async function upstreamMessage(res: Response, fallback: string): Promise { + const body = (await res.json().catch(() => null)) as { message?: unknown } | null; + const message = typeof body?.message === 'string' ? body.message : ''; + return message ? `${message} (${res.status})` : `${fallback} with ${res.status}`; +} + +/** + * Trade an email and password for an API key, so connecting Officer does not mean a trip to Immich's own + * settings screen. + * + * Three calls, and the shape of them is the point: + * + * 1. `/auth/login` returns a SESSION token, not an API key. Storing that would be wrong — sessions are + * listed as devices, can be revoked from a phone, and authenticate with `Authorization: Bearer` rather + * than the `x-api-key` header everything else here uses. + * 2. `/api-keys` mints the durable credential. `permissions` is required and NOT optional; `all` is only + * grantable because a session has every permission — an API key can never mint a key stronger than + * itself (`Cannot grant permissions you do not have`). Asking for `all` here is deliberate: Immich keys + * are scoped, and an under-scoped one returns 403 per route, which reads as a broken feature. + * 3. `/auth/logout` ends the session we only needed to hold the key. Skipping it would leave a phantom + * "device" in the owner's Immich account for every connection made from Officer. + * + * The password is used here and nowhere else; only the key survives this function. + */ +export async function mintApiKey(base: string, email: string, password: string): Promise { + const login = await fetch(`${base}/api/auth/login`, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + redirect: 'manual', + }); + // Immich says why in the body, and its two 401s mean different things — "Incorrect email or password" versus + // "Password login has been disabled" on an OAuth-only instance. Guessing from the status would flatten them. + if (!login.ok) throw new UpstreamError(await upstreamMessage(login, 'sign-in failed'), login.status); + + const session = (await login.json()) as { accessToken?: unknown }; + if (typeof session.accessToken !== 'string' || !session.accessToken) { + throw new UpstreamError('sign-in returned no access token', login.status); + } + const bearer = { Authorization: `Bearer ${session.accessToken}` }; + + try { + const created = await fetch(`${base}/api/api-keys`, { + method: 'POST', + headers: { ...bearer, Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Officer', permissions: ['all'] }), + redirect: 'manual', + }); + if (!created.ok) { + throw new UpstreamError(await upstreamMessage(created, 'could not create an API key'), created.status); + } + + const payload = (await created.json()) as { secret?: unknown }; + if (typeof payload.secret !== 'string' || !payload.secret) { + throw new UpstreamError('Immich created a key but returned no secret', created.status); + } + return payload.secret; + } finally { + // Best effort: a session we cannot close is untidy, not a failure of the thing the owner asked for. + await fetch(`${base}/api/auth/logout`, { method: 'POST', headers: bearer, redirect: 'manual' }).catch(() => {}); + } +} + type CallOptions = { /** Absolute path on the Immich host, e.g. `/api/albums`. */ path: string; diff --git a/src/workspaces/officerdev/src/apps/Invoices/AccountSwitcher.tsx b/src/workspaces/officerdev/src/apps/Invoices/AccountSwitcher.tsx new file mode 100644 index 00000000..8cba76ba --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Invoices/AccountSwitcher.tsx @@ -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 ( + + {status} + + ); + } + + return ( + + + {status} + {activate.isPending ? ( + + ) : ( + + )} + + + + InvoiceShelf accounts + + {accounts.map((account) => ( + void activate.mutateAsync(account.id).catch(() => undefined)} + className="gap-2" + > + + + {account.label} + + {account.companyName ?? account.url} + + + + ))} + + + + + Manage accounts + + + + + ); +}; diff --git a/src/workspaces/officerdev/src/apps/Invoices/ConnectionSection.tsx b/src/workspaces/officerdev/src/apps/Invoices/ConnectionSection.tsx new file mode 100644 index 00000000..766db6fa --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Invoices/ConnectionSection.tsx @@ -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) => ( + +); + +// ── 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…} +
+ +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Invoices/InvoicesNav.tsx b/src/workspaces/officerdev/src/apps/Invoices/InvoicesNav.tsx index 00c17e4d..55a7185a 100644 --- a/src/workspaces/officerdev/src/apps/Invoices/InvoicesNav.tsx +++ b/src/workspaces/officerdev/src/apps/Invoices/InvoicesNav.tsx @@ -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 = { dashboard: LayoutDashboard, @@ -31,12 +37,13 @@ const ICONS: Record = { 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> = { invoices: dashboard?.total_invoice_count, @@ -54,15 +61,7 @@ export const InvoicesNav = () => {
{company?.name ?? (isLoading ? 'Loading…' : 'InvoiceShelf')}
-
- {error ? ( - unreachable - ) : dashboard ? ( - <>{formatMoney(dashboard.total_amount_due, currency)} outstanding - ) : ( - '—' - )} -
+ @@ -103,6 +102,7 @@ export const InvoicesNav = () => { {dashboard && (
+ diff --git a/src/workspaces/officerdev/src/apps/Invoices/InvoicesView.tsx b/src/workspaces/officerdev/src/apps/Invoices/InvoicesView.tsx index a2689f69..23fc7c74 100644 --- a/src/workspaces/officerdev/src/apps/Invoices/InvoicesView.tsx +++ b/src/workspaces/officerdev/src/apps/Invoices/InvoicesView.tsx @@ -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 ; + + if (!healthLoading && health && !health.ok) { + if (health.configured === false) return ; + + return ( +
+

InvoiceShelf is not answering

+

+ {health.error ?? 'The invoiceshelf sidecar could not reach the configured instance.'} +

+ + Check the connection + +
+ ); + } + // 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')) { diff --git a/src/workspaces/officerdev/src/apps/Invoices/components.tsx b/src/workspaces/officerdev/src/apps/Invoices/components.tsx index d9fc3349..2a47185f 100644 --- a/src/workspaces/officerdev/src/apps/Invoices/components.tsx +++ b/src/workspaces/officerdev/src/apps/Invoices/components.tsx @@ -56,7 +56,7 @@ export const ErrorState = ({ error, hint }: { error: unknown; hint?: string }) =
Could not reach InvoiceShelf
- {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.'}
{error != null && (
diff --git a/src/workspaces/officerdev/src/apps/Invoices/shared.ts b/src/workspaces/officerdev/src/apps/Invoices/shared.ts
index f75a9275..6a383ef0 100644
--- a/src/workspaces/officerdev/src/apps/Invoices/shared.ts
+++ b/src/workspaces/officerdev/src/apps/Invoices/shared.ts
@@ -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'];
diff --git a/src/workspaces/officerdev/src/apps/Invoices/useInvoiceShelfData.ts b/src/workspaces/officerdev/src/apps/Invoices/useInvoiceShelfData.ts
index 42631784..6c575419 100644
--- a/src/workspaces/officerdev/src/apps/Invoices/useInvoiceShelfData.ts
+++ b/src/workspaces/officerdev/src/apps/Invoices/useInvoiceShelfData.ts
@@ -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 => {
+      try {
+        return await get('/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(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(`${CONFIG}/${id}/activate`, {}),
+    onSuccess: invalidate,
+  });
+
+  const remove = useMutation({
+    mutationFn: (id: number) => del(`${CONFIG}/${id}`),
+    onSuccess: invalidate,
+  });
+
+  return { add, edit, activate, remove };
+}
diff --git a/src/workspaces/officerdev/src/apps/Photos/ConnectionSection.tsx b/src/workspaces/officerdev/src/apps/Photos/ConnectionSection.tsx
index 210a605a..e3084cf3 100644
--- a/src/workspaces/officerdev/src/apps/Photos/ConnectionSection.tsx
+++ b/src/workspaces/officerdev/src/apps/Photos/ConnectionSection.tsx
@@ -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
   
 );
 
+// ── 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 }; /** @@ -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(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) => {

)} -
- setApiKey(ev.target.value)} - placeholder="Replace API key" - type="password" - autoComplete="off" - spellCheck={false} - className="h-8" - /> - + +
+
+ ) : ( + -
+ Replace credential + + )} {error &&

{error}

} @@ -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(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 = () => {

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

@@ -240,14 +349,7 @@ export const ConnectionSection = () => { hint={URL_HINT} autoFocus={!hasAccounts} /> - + {error &&

{error}

} diff --git a/src/workspaces/officerdev/src/apps/Photos/usePhotosData.ts b/src/workspaces/officerdev/src/apps/Photos/usePhotosData.ts index 5fc69fcf..5155e029 100644 --- a/src/workspaces/officerdev/src/apps/Photos/usePhotosData.ts +++ b/src/workspaces/officerdev/src/apps/Photos/usePhotosData.ts @@ -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();