invoiceshelf: accounts are configured from the ui, not the environment
The same registry photos got: any number of labelled instances stored encrypted in invoiceshelf_accounts, one selected, switchable from the nav. The token is write-only across the sidecar boundary — the list has no field that could carry it back — and nothing reads INVOICESHELF_URL/TOKEN/COMPANY_ID any more, so officer's own process.env no longer holds a credential only the sidecar can use. The company is pinned on the account row rather than resolved per request. InvoiceShelf's `company` header does not error on a wrong or missing value; it silently returns another company's books. So the choice is made once, at add time, and a token that can act for several answers 409 with the list instead of guessing. Both apps also take an email and password now, because neither service makes a key easy to get: InvoiceShelf 2.4.2 ships no screen that issues tokens at all (POST /auth/login is the only way), and Immich's is buried in account settings. The sidecar does the exchange — InvoiceShelf mints a Sanctum token, Immich logs in, creates an all-permissions API key and closes the session again — and stores only what comes back. The password is never persisted. Pasting a key still works. Verified against the live instances: InvoiceShelf 2.4.2 and Immich 3.1.0, routes and DTOs read from the running containers. The two sign-in paths are untested end to end — no second login to try them with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+5
-16
@@ -28,16 +28,9 @@ TRANSMISSION_USER=
|
|||||||
TRANSMISSION_PASS=
|
TRANSMISSION_PASS=
|
||||||
# TRANSMISSION_RPC_PATH=/transmission/rpc
|
# TRANSMISSION_RPC_PATH=/transmission/rpc
|
||||||
|
|
||||||
# InvoiceShelf (officer-invoiceshelf). The token is a Sanctum personal access token — mint one with
|
# InvoiceShelf (officer-invoiceshelf) is configured from the app, not from here — Invoices → Connection.
|
||||||
# POST /api/v1/auth/login {username: <email>, password, device_name} and copy the `token` field. It has
|
# Instances, their Sanctum tokens and the company each one is pinned to live encrypted in
|
||||||
# full abilities and never expires, so treat it as a password.
|
# `invoiceshelf_accounts`, so nothing outside the sidecar can read a token.
|
||||||
# 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="<sanctum api token, e.g. 1|xxxxxxxx>"
|
|
||||||
# INVOICESHELF_COMPANY_ID=1
|
|
||||||
|
|
||||||
# slskd (officer-slskd). The key is injected as X-API-Key on every forwarded request.
|
# slskd (officer-slskd). The key is injected as X-API-Key on every forwarded request.
|
||||||
SLSKD_URL=http://127.0.0.1:5030
|
SLSKD_URL=http://127.0.0.1:5030
|
||||||
@@ -78,9 +71,5 @@ VAULT_STORE_KEY="<generate with: openssl rand -base64 32>"
|
|||||||
# seals it. Both are required to spend. If you lose VAULT_STORE_KEY, every stored seed is
|
# seals it. Both are required to spend. If you lose VAULT_STORE_KEY, every stored seed is
|
||||||
# unrecoverable — back up the mnemonics separately, offline.
|
# 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
|
# Immich (officer-photos) is configured from the app, not from here — Photos → Connection. Instances and
|
||||||
# sees it. Create it in Immich: Account Settings → API Keys → New API Key. Immich keys are SCOPED — grant
|
# their API keys live encrypted in `photos_config`, so the platform never sees a key.
|
||||||
# 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 api key>"
|
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ module.exports = {
|
|||||||
args: 'run src/servers/sidecar/transmission/index.ts',
|
args: 'run src/servers/sidecar/transmission/index.ts',
|
||||||
watch: false,
|
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',
|
name: 'officer-invoiceshelf',
|
||||||
script: 'bun',
|
script: 'bun',
|
||||||
|
|||||||
@@ -141,6 +141,17 @@ export {
|
|||||||
recordHeadscaleProbe,
|
recordHeadscaleProbe,
|
||||||
} from './queries/headscale';
|
} from './queries/headscale';
|
||||||
export type { HeadscaleServer, HeadscaleServerCredentials } 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 {
|
export {
|
||||||
listPhotosAccounts,
|
listPhotosAccounts,
|
||||||
getActivePhotosCredentials,
|
getActivePhotosCredentials,
|
||||||
|
|||||||
@@ -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<InvoiceshelfAccount[]> {
|
||||||
|
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<InvoiceshelfCredentials | null> {
|
||||||
|
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<InvoiceshelfCredentials | null> {
|
||||||
|
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<InvoiceshelfAccount> {
|
||||||
|
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<InvoiceshelfAccount | null> {
|
||||||
|
const set: Record<string, unknown> = { 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<InvoiceshelfAccount | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
await db
|
||||||
|
.update(invoiceshelfAccounts)
|
||||||
|
.set({ version, lastSeenAt: new Date() })
|
||||||
|
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id)));
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export * from './chat-events';
|
|||||||
export * from './dashboards';
|
export * from './dashboards';
|
||||||
export * from './email';
|
export * from './email';
|
||||||
export * from './headscale';
|
export * from './headscale';
|
||||||
|
export * from './invoiceshelf';
|
||||||
export * from './music';
|
export * from './music';
|
||||||
export * from './notify';
|
export * from './notify';
|
||||||
export * from './operations';
|
export * from './operations';
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
],
|
||||||
|
);
|
||||||
@@ -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<ProbeResult> {
|
||||||
|
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<Response> {
|
||||||
|
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<void> {
|
||||||
|
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<AccountBody> =>
|
||||||
|
((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<string> {
|
||||||
|
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<Response> {
|
||||||
|
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<Response> {
|
||||||
|
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<Response> {
|
||||||
|
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 '/<id>', '/<id>/activate', '/<id>/companies'. */
|
||||||
|
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||||
import { createSidecarConnector } from '../connect';
|
import { createSidecarConnector } from '../connect';
|
||||||
|
import { handleConfigRoute, noteProbe, probe } from './config';
|
||||||
import { handleOfficerRoute } from './routes';
|
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
|
// 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
|
// 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.
|
// 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
|
// 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
|
// _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
|
// 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.
|
// 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 /_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/summary me + current company + dashboard totals, one call
|
||||||
// GET /_officer/lookups customers/items/units/tax-types/categories/payment-methods/currencies
|
// GET /_officer/lookups customers/items/units/tax-types/categories/payment-methods/currencies
|
||||||
// GET /_officer/bootstrap the upstream's own bootstrap blob
|
// GET /_officer/bootstrap the upstream's own bootstrap blob
|
||||||
@@ -59,28 +71,51 @@ const server = Bun.serve({
|
|||||||
maxRequestBodySize: 64 * 1024 * 1024,
|
maxRequestBodySize: 64 * 1024 * 1024,
|
||||||
async fetch(req) {
|
async fetch(req) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const cfg = getConfig();
|
|
||||||
|
|
||||||
if (url.pathname === '/_health') {
|
const officerUser = req.headers.get('X-Officer-User');
|
||||||
if (!cfg) return Response.json({ ok: false, error: 'INVOICESHELF_URL/TOKEN not configured' }, { status: 503 });
|
const userId = Number(officerUser);
|
||||||
const started = Date.now();
|
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 {
|
try {
|
||||||
const [versionRes, company] = await Promise.all([
|
return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length));
|
||||||
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 });
|
|
||||||
} catch (err) {
|
} 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 (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 {
|
try {
|
||||||
const res = await handleOfficerRoute(cfg, req, url);
|
const res = await handleOfficerRoute(cfg, req, url);
|
||||||
if (res) return res;
|
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;
|
type ReplyFn = (msg: SidecarEvent) => void;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
// behalf of — lives here, mirroring officer-transmission/officer-slskd: the platform API is a thin
|
// behalf of — lives here, mirroring officer-transmission/officer-slskd: the platform API is a thin
|
||||||
// auth+forward proxy and holds NO InvoiceShelf credentials.
|
// 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:
|
// 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
|
// 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
|
// 419. Bun's fetch adds neither on its own; the platform proxy in api/invoiceshelf/router.ts
|
||||||
// deliberately forwards neither. Don't add them.
|
// deliberately forwards neither. Don't add them.
|
||||||
// 3. The `company` header (lowercase, a bare numeric id) scopes almost every route — and a wrong or
|
// 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
|
// missing value does NOT error. It silently falls back to another company's data. That is why the
|
||||||
// resolveCompanyId() pins one explicitly and logs it, rather than relying on the fallback.
|
// 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 };
|
/** 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 };
|
||||||
let warnedUnset = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The configured instance, or null when unconfigured — the sidecar then answers 503 rather than pretending
|
* A stored account, which is where every real call goes.
|
||||||
* to work. Warns once so a misconfigured deployment is obvious in the logs without flooding them.
|
*
|
||||||
|
* `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 {
|
export type UpstreamConfig = UpstreamTarget & { id: number; label: string };
|
||||||
const base = INVOICESHELF_URL?.trim().replace(/\/+$/, '');
|
|
||||||
const token = INVOICESHELF_TOKEN?.trim();
|
/** Trailing slashes off, so `${base}/api/v1/...` never doubles the separator. */
|
||||||
if (!base || !token) {
|
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
|
||||||
if (!warnedUnset) {
|
|
||||||
const missing = [!base && 'INVOICESHELF_URL', !token && 'INVOICESHELF_TOKEN'].filter(Boolean).join(' and ');
|
// A list view is a burst of requests and each one needs the token, so the row is cached rather than re-read
|
||||||
console.warn(`[invoiceshelf] ${missing} unset — the sidecar will respond 503 until set`);
|
// per request. Writes invalidate immediately; the TTL only covers someone editing the row in psql, which
|
||||||
warnedUnset = true;
|
// then takes effect within a minute instead of needing a restart.
|
||||||
}
|
const TTL_MS = 60_000;
|
||||||
return null;
|
const cache = new Map<number, { cfg: UpstreamConfig | null; at: number }>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<UpstreamConfig | null> {
|
||||||
|
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<number> | 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
|
* InvoiceShelf 2.4.2 has no screen that issues personal access tokens — `POST /api/v1/auth/login` (the
|
||||||
* token can see — which is right for a single-company install and, crucially, is LOGGED. The silent-fallback
|
* controller upstream files under "Mobile") is the only way to get one, which meant connecting Officer
|
||||||
* behaviour is the sharpest edge in this API; making the choice explicit and visible is the whole point.
|
* 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
|
* Each call mints a NEW token upstream; the previous one is deliberately left alone, because Officer cannot
|
||||||
* so the next request retries instead of latching a transient network error forever.
|
* 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<number> {
|
export async function mintToken(base: string, email: string, password: string): Promise<string> {
|
||||||
const pinned = Number(INVOICESHELF_COMPANY_ID?.trim());
|
const res = await fetch(`${base}/api/v1/auth/login`, {
|
||||||
if (Number.isInteger(pinned) && pinned > 0) return pinned;
|
method: 'POST',
|
||||||
|
// No Origin or Referer — see the header note. `device_name` names the token in InvoiceShelf's own list.
|
||||||
companyPromise ??= (async () => {
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
const res = await fetch(`${cfg.base}/api/v1/companies`, {
|
body: JSON.stringify({ username: email, password, device_name: 'officer' }),
|
||||||
headers: { Authorization: `Bearer ${cfg.token}`, Accept: 'application/json' },
|
redirect: 'manual',
|
||||||
});
|
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new UpstreamError(`sign-in failed with ${res.status}`, res.status);
|
||||||
return companyPromise;
|
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<string> | 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<Company[]> {
|
||||||
|
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<number, Promise<string>>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The pinned company's `unique_hash`. The report PDFs live on web routes keyed by it
|
* 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.
|
* (`/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<string> {
|
export async function resolveCompanyHash(cfg: UpstreamConfig): Promise<string> {
|
||||||
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' });
|
const res = await callUpstream(cfg, { path: '/api/v1/current-company' });
|
||||||
if (!res.ok) throw new Error(`current-company lookup failed with ${res.status}`);
|
if (!res.ok) throw new Error(`current-company lookup failed with ${res.status}`);
|
||||||
const payload = (await res.json()) as { data?: { unique_hash?: string } };
|
const payload = (await res.json()) as { data?: { unique_hash?: string } };
|
||||||
@@ -92,11 +152,13 @@ export async function resolveCompanyHash(cfg: UpstreamConfig): Promise<string> {
|
|||||||
if (!hash) throw new Error('current-company returned no unique_hash');
|
if (!hash) throw new Error('current-company returned no unique_hash');
|
||||||
return hash;
|
return hash;
|
||||||
})().catch((err) => {
|
})().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;
|
throw err;
|
||||||
});
|
});
|
||||||
|
|
||||||
return hashPromise;
|
hashCache.set(cfg.id, pending);
|
||||||
|
return pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CallOptions = {
|
type CallOptions = {
|
||||||
@@ -114,13 +176,13 @@ type CallOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** The single door to InvoiceShelf. Everything the sidecar fetches goes through here. */
|
/** The single door to InvoiceShelf. Everything the sidecar fetches goes through here. */
|
||||||
export async function callUpstream(cfg: UpstreamConfig, opts: CallOptions): Promise<Response> {
|
export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise<Response> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
Authorization: `Bearer ${cfg.token}`,
|
Authorization: `Bearer ${cfg.token}`,
|
||||||
Accept: opts.accept ?? 'application/json',
|
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;
|
if (opts.contentType) headers['Content-Type'] = opts.contentType;
|
||||||
|
|
||||||
const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, {
|
const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
setActivePhotosAccount,
|
setActivePhotosAccount,
|
||||||
updatePhotosAccount,
|
updatePhotosAccount,
|
||||||
} from 'officerdb';
|
} 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.
|
// `/_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
|
// 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.
|
// 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
|
// 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
|
// 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
|
// 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 });
|
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<AccountBody> =>
|
const readBody = async (req: Request): Promise<AccountBody> =>
|
||||||
((await req.json().catch(() => null)) as AccountBody | null) ?? {};
|
((await req.json().catch(() => null)) as AccountBody | null) ?? {};
|
||||||
@@ -88,21 +92,52 @@ const readBody = async (req: Request): Promise<AccountBody> =>
|
|||||||
const readLabel = (body: AccountBody): string => (typeof body.label === 'string' ? body.label.trim() : '');
|
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 readUrl = (body: AccountBody): string => (typeof body.url === 'string' ? normalizeBase(body.url) : '');
|
||||||
const readKey = (body: AccountBody): string => (typeof body.apiKey === 'string' ? body.apiKey.trim() : '');
|
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);
|
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<string> {
|
||||||
|
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');
|
const duplicateLabel = (err: unknown): boolean => String(err).includes('uq_photos_config_user_label');
|
||||||
|
|
||||||
/** Add an account: validated against the live instance, then stored encrypted. */
|
/** Add an account: validated against the live instance, then stored encrypted. */
|
||||||
async function addAccount(req: Request, userId: number): Promise<Response> {
|
async function addAccount(req: Request, userId: number): Promise<Response> {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
const url = readUrl(body);
|
const url = readUrl(body);
|
||||||
const apiKey = readKey(body);
|
|
||||||
let label = readLabel(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://');
|
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 });
|
const result = await probe({ base: url, key: apiKey });
|
||||||
if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 });
|
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<Re
|
|||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
const label = readLabel(body);
|
const label = readLabel(body);
|
||||||
const url = readUrl(body);
|
const url = readUrl(body);
|
||||||
const apiKey = readKey(body);
|
|
||||||
|
|
||||||
const current = await getPhotosCredentials(userId, id);
|
const current = await getPhotosCredentials(userId, id);
|
||||||
if (!current) return bad('no such account', 404);
|
if (!current) return bad('no such account', 404);
|
||||||
if (url && !isHttpUrl(url)) return bad('url must start with http:// or https://');
|
if (url && !isHttpUrl(url)) return bad('url must start with http:// or https://');
|
||||||
|
|
||||||
|
let apiKey: string;
|
||||||
|
try {
|
||||||
|
apiKey = await resolveKey(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.
|
// Re-validate whenever what we would talk to changes. A rename on its own never touches the instance.
|
||||||
let version: string | null | undefined;
|
let version: string | null | undefined;
|
||||||
if ((url && url !== current.url) || apiKey) {
|
if ((url && url !== current.url) || apiKey) {
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ import { getConfig } from './upstream';
|
|||||||
//
|
//
|
||||||
// GET /_health ours. Confirms the key is live; reports the Immich version and who the key is.
|
// GET /_health ours. Confirms the key is live; reports the Immich version and who the key is.
|
||||||
// GET /_config the account registry MINUS every key: { configured, activeId, accounts[] }
|
// GET /_config the account registry MINUS every key: { configured, activeId, accounts[] }
|
||||||
// POST /_config { label?, url, apiKey } — validated against the instance, then stored encrypted
|
// POST /_config { label?, url, and EITHER apiKey OR email+password } — the password mints a
|
||||||
// PATCH /_config/:id { label?, url?, apiKey? } — a blank key keeps the stored one
|
// key and is never stored. Validated against the instance, then stored encrypted
|
||||||
|
// PATCH /_config/:id same fields, all optional — no credential keeps the stored key
|
||||||
// POST /_config/:id/activate switch to that account
|
// POST /_config/:id/activate switch to that account
|
||||||
// DEL /_config/:id remove it; the newest survivor is promoted if it was the active one
|
// DEL /_config/:id remove it; the newest survivor is promoted if it was the active one
|
||||||
// * /_officer/<path> forwarded to <active url>/api/<path>, first-segment allow-list (routes.ts)
|
// * /_officer/<path> forwarded to <active url>/api/<path>, first-segment allow-list (routes.ts)
|
||||||
|
|||||||
@@ -61,6 +61,80 @@ export function invalidateConfig(userId: number): void {
|
|||||||
cache.delete(userId);
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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 = {
|
type CallOptions = {
|
||||||
/** Absolute path on the Immich host, e.g. `/api/albums`. */
|
/** Absolute path on the Immich host, e.g. `/api/albums`. */
|
||||||
path: string;
|
path: string;
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
|
import { Check, ChevronsUpDown, Loader2, Settings2 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { invoicesSectionPath } from './shared';
|
||||||
|
import { useInvoiceShelfAccountActions, useInvoiceShelfAccounts, useInvoiceShelfHealth } from './useInvoiceShelfData';
|
||||||
|
|
||||||
|
// Which books you are looking at, and how to change them.
|
||||||
|
//
|
||||||
|
// The nav header's subtitle was already the connection state, so the switcher takes that line rather than
|
||||||
|
// adding a second control saying nearly the same thing. With one account it stays exactly what it was: a
|
||||||
|
// line of text plus a way into the settings screen.
|
||||||
|
//
|
||||||
|
// Each entry shows its company under the label, because two accounts on the same instance differing only by
|
||||||
|
// company is the normal case here — the label alone would not tell them apart.
|
||||||
|
//
|
||||||
|
// Switching invalidates the whole ['invoiceshelf'] key: it changes the answer to every query in the
|
||||||
|
// workspace without changing any of their inputs.
|
||||||
|
|
||||||
|
export const AccountSwitcher = () => {
|
||||||
|
const { data } = useInvoiceShelfAccounts();
|
||||||
|
const { data: health } = useInvoiceShelfHealth();
|
||||||
|
const { activate } = useInvoiceShelfAccountActions();
|
||||||
|
|
||||||
|
const accounts = data?.accounts ?? [];
|
||||||
|
const active = accounts.find((account) => account.isActive) ?? null;
|
||||||
|
|
||||||
|
// Before the registry answers, fall back to health — it is the query that was already driving this line.
|
||||||
|
const status = active?.label ?? (health?.ok ? (health.version ?? 'connected') : 'not connected');
|
||||||
|
|
||||||
|
if (accounts.length < 2) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={invoicesSectionPath('settings')}
|
||||||
|
className="block truncate text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
|
||||||
|
<span className="truncate">{status}</span>
|
||||||
|
{activate.isPending ? (
|
||||||
|
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ChevronsUpDown className="h-3 w-3 shrink-0" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-64">
|
||||||
|
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||||
|
InvoiceShelf accounts
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={account.id}
|
||||||
|
disabled={account.isActive || activate.isPending}
|
||||||
|
onSelect={() => void activate.mutateAsync(account.id).catch(() => undefined)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Check className={`h-3.5 w-3.5 shrink-0 ${account.isActive ? 'opacity-100' : 'opacity-0'}`} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate">{account.label}</span>
|
||||||
|
<span className="block truncate text-[11px] text-muted-foreground">
|
||||||
|
{account.companyName ?? account.url}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to={invoicesSectionPath('settings')} className="gap-2">
|
||||||
|
<Settings2 className="h-3.5 w-3.5" />
|
||||||
|
Manage accounts
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
import type { CompanyOption, Credential, InvoiceShelfAccount } from './useInvoiceShelfData';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Building2, Check, CheckCircle2, Loader2, Trash2, TriangleAlert } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
parseAccountError,
|
||||||
|
useAccountCompanies,
|
||||||
|
useInvoiceShelfAccountActions,
|
||||||
|
useInvoiceShelfAccounts,
|
||||||
|
useInvoiceShelfHealth,
|
||||||
|
} from './useInvoiceShelfData';
|
||||||
|
|
||||||
|
// Connecting InvoiceShelf instances to Officer, from the app.
|
||||||
|
//
|
||||||
|
// This screen is BOTH the setup wizard and the permanent settings page: InvoicesView renders it in place of
|
||||||
|
// whatever section the URL asks for while nothing is connected, and /invoices/settings renders it for good.
|
||||||
|
//
|
||||||
|
// The company picker is the part that has no equivalent in the photos version, and it is not a nicety: the
|
||||||
|
// `company` header scopes almost every InvoiceShelf route, and a wrong or missing value does NOT error — it
|
||||||
|
// silently returns another company's books. So the account pins one, chosen once, visibly. Two accounts on
|
||||||
|
// the same URL with the same token and different companies is an ordinary thing to have, which is why the
|
||||||
|
// LABEL is what has to be unique.
|
||||||
|
//
|
||||||
|
// The credential is never displayed, because Officer cannot display it: the token is encrypted at rest and
|
||||||
|
// the sidecar's GET has no field that could carry it back, and the password is not stored at all.
|
||||||
|
|
||||||
|
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
|
||||||
|
|
||||||
|
/** InvoiceShelf's own default — its production compose publishes 8090, and the sidecar dials from this machine. */
|
||||||
|
const DEFAULT_URL = 'http://localhost:8090';
|
||||||
|
|
||||||
|
const URL_HINT =
|
||||||
|
"The instance's base URL, without /api. Officer reaches it from the server, not from this browser — so " +
|
||||||
|
'localhost here means the machine Officer runs on, and a local instance needs no TLS.';
|
||||||
|
|
||||||
|
const SIGN_IN_HINT =
|
||||||
|
'Your InvoiceShelf login. It is used once, by the sidecar, to mint an API token — the password is never ' +
|
||||||
|
'stored and never reaches the browser again. Each sign-in creates a new token in InvoiceShelf; revoke old ' +
|
||||||
|
'ones there.';
|
||||||
|
|
||||||
|
const TOKEN_HINT =
|
||||||
|
'A Sanctum token you already have. Paste it whole, including the leading "1|". It can read and write the ' +
|
||||||
|
'entire books, so Officer stores it encrypted and never shows it again.';
|
||||||
|
|
||||||
|
type FieldProps = {
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
type?: string;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: FieldProps) => (
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs font-medium">{label}</span>
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(ev) => onChange(ev.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
type={type}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
{hint && <span className={HINT}>{hint}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── credentials ──────────────────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Two ways to prove the same thing, so they share one piece of state rather than two forms. Sign-in is the
|
||||||
|
// default because InvoiceShelf 2.4.2 ships no screen that issues tokens — before this, connecting Officer
|
||||||
|
// meant running a curl command against /auth/login by hand, which is exactly what the sidecar now does.
|
||||||
|
|
||||||
|
type CredentialMode = 'login' | 'token';
|
||||||
|
|
||||||
|
function useCredential() {
|
||||||
|
const [mode, setMode] = useState<CredentialMode>('login');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
|
||||||
|
const filled = mode === 'token' ? !!token.trim() : !!email.trim() && !!password;
|
||||||
|
const payload: Credential = mode === 'token' ? { token: token.trim() } : { email: email.trim(), password };
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setEmail('');
|
||||||
|
setPassword('');
|
||||||
|
setToken('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return { mode, setMode, email, setEmail, password, setPassword, token, setToken, filled, payload, reset };
|
||||||
|
}
|
||||||
|
|
||||||
|
type CredentialState = ReturnType<typeof useCredential>;
|
||||||
|
|
||||||
|
const TAB = 'rounded-md px-2 py-1 text-[11px] transition-colors';
|
||||||
|
|
||||||
|
const CredentialFields = ({ cred, autoFocus }: { cred: CredentialState; autoFocus?: boolean }) => (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs font-medium">Credential</span>
|
||||||
|
<div className="ml-auto flex items-center gap-1 rounded-lg bg-muted p-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => cred.setMode('login')}
|
||||||
|
className={`${TAB} ${cred.mode === 'login' ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => cred.setMode('token')}
|
||||||
|
className={`${TAB} ${cred.mode === 'token' ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
|
||||||
|
>
|
||||||
|
API token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cred.mode === 'login' ? (
|
||||||
|
<>
|
||||||
|
<Field
|
||||||
|
label="Email"
|
||||||
|
value={cred.email}
|
||||||
|
onChange={cred.setEmail}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
type="email"
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Password"
|
||||||
|
value={cred.password}
|
||||||
|
onChange={cred.setPassword}
|
||||||
|
placeholder="••••••••"
|
||||||
|
type="password"
|
||||||
|
hint={SIGN_IN_HINT}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Field
|
||||||
|
label="API token"
|
||||||
|
value={cred.token}
|
||||||
|
onChange={cred.setToken}
|
||||||
|
placeholder="1|xxxxxxxx"
|
||||||
|
type="password"
|
||||||
|
hint={TOKEN_HINT}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
type CompanyPickerProps = {
|
||||||
|
companies: CompanyOption[];
|
||||||
|
value: number | null;
|
||||||
|
onChange: (id: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A plain select — the list is short, and a combobox would be machinery for four options. */
|
||||||
|
const CompanyPicker = ({ companies, value, onChange, disabled }: CompanyPickerProps) => (
|
||||||
|
<select
|
||||||
|
value={value ?? ''}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(ev) => onChange(Number(ev.target.value))}
|
||||||
|
className="h-8 rounded-md border bg-background px-2 text-xs"
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Choose a company…
|
||||||
|
</option>
|
||||||
|
{companies.map((company) => (
|
||||||
|
<option key={company.id} value={company.id}>
|
||||||
|
{company.name ?? `Company ${company.id}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
|
||||||
|
type AccountRowProps = { account: InvoiceShelfAccount };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One stored account. The active one carries the live health line, because health only ever describes the
|
||||||
|
* account actually being used — showing a status next to the others would be inventing one.
|
||||||
|
*/
|
||||||
|
const AccountRow = ({ account }: AccountRowProps) => {
|
||||||
|
const { data: health, refetch: recheck, isFetching: checking } = useInvoiceShelfHealth();
|
||||||
|
const { edit, activate, remove } = useInvoiceShelfAccountActions();
|
||||||
|
|
||||||
|
const cred = useCredential();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [replacing, setReplacing] = useState(false);
|
||||||
|
const [changingCompany, setChangingCompany] = useState(false);
|
||||||
|
// Removing an account throws away a token Officer can never show again, so the bin asks once.
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
|
||||||
|
const companies = useAccountCompanies(changingCompany ? account.id : null);
|
||||||
|
|
||||||
|
const run = async (action: Promise<unknown>) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await action;
|
||||||
|
cred.reset();
|
||||||
|
setReplacing(false);
|
||||||
|
setChangingCompany(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(parseAccountError(err).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-2 rounded-lg border p-4 text-xs ${account.isActive ? 'border-primary/40' : ''}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{account.isActive ? <Check className="h-4 w-4 shrink-0 text-primary" /> : <span className="h-4 w-4 shrink-0" />}
|
||||||
|
<span className="truncate font-medium">{account.label}</span>
|
||||||
|
{account.isActive && (
|
||||||
|
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">in use</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
{!account.isActive && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7"
|
||||||
|
disabled={activate.isPending}
|
||||||
|
onClick={() => void run(activate.mutateAsync(account.id))}
|
||||||
|
>
|
||||||
|
Use
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{account.isActive && (
|
||||||
|
<Button variant="ghost" size="sm" className="h-7" onClick={() => void recheck()}>
|
||||||
|
{checking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Test'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={`h-7 ${confirming ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}`}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!confirming) return setConfirming(true);
|
||||||
|
setConfirming(false);
|
||||||
|
void run(remove.mutateAsync(account.id));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirming ? 'Remove?' : <Trash2 className="h-3.5 w-3.5" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="truncate text-muted-foreground">{account.url}</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Building2 className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
{changingCompany ? (
|
||||||
|
<>
|
||||||
|
{companies.isLoading ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CompanyPicker
|
||||||
|
companies={companies.data?.companies ?? []}
|
||||||
|
value={account.companyId}
|
||||||
|
disabled={edit.isPending}
|
||||||
|
onChange={(companyId) => void run(edit.mutateAsync({ id: account.id, companyId }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" className="h-7" onClick={() => setChangingCompany(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="truncate">{account.companyName ?? `Company ${account.companyId ?? '?'}`}</span>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7" onClick={() => setChangingCompany(true)}>
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{account.isActive && (
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
{health?.ok ? (
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<TriangleAlert className="h-3.5 w-3.5 text-amber-500" />
|
||||||
|
)}
|
||||||
|
<span>{health?.ok ? `InvoiceShelf ${health.version ?? '?'}` : (health?.error ?? 'not checked yet')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{replacing ? (
|
||||||
|
<div className="flex flex-col gap-3 rounded-lg border p-3">
|
||||||
|
<CredentialFields cred={cred} autoFocus />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
disabled={!cred.filled || edit.isPending}
|
||||||
|
onClick={() => void run(edit.mutateAsync({ id: account.id, ...cred.payload }))}
|
||||||
|
>
|
||||||
|
{edit.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => {
|
||||||
|
cred.reset();
|
||||||
|
setReplacing(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReplacing(true)}
|
||||||
|
className="self-start text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
Replace credential
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConnectionSection = () => {
|
||||||
|
const { data, isLoading } = useInvoiceShelfAccounts();
|
||||||
|
const { add } = useInvoiceShelfAccountActions();
|
||||||
|
|
||||||
|
const cred = useCredential();
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const [url, setUrl] = useState(DEFAULT_URL);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// Populated only when the instance answers with more than one company — see parseAccountError.
|
||||||
|
const [choices, setChoices] = useState<CompanyOption[]>([]);
|
||||||
|
const [companyId, setCompanyId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const accounts = data?.accounts ?? [];
|
||||||
|
const hasAccounts = accounts.length > 0;
|
||||||
|
|
||||||
|
const submit = async (withCompany: number | null) => {
|
||||||
|
setError(null);
|
||||||
|
if (!url.trim()) return setError('The InvoiceShelf URL is required');
|
||||||
|
if (!cred.filled) {
|
||||||
|
return setError(cred.mode === 'token' ? 'An API token is required' : 'An email and password are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await add.mutateAsync({ label: label.trim(), url: url.trim(), companyId: withCompany, ...cred.payload });
|
||||||
|
setLabel('');
|
||||||
|
setUrl(DEFAULT_URL);
|
||||||
|
cred.reset();
|
||||||
|
setChoices([]);
|
||||||
|
setCompanyId(null);
|
||||||
|
} catch (err) {
|
||||||
|
const parsed = parseAccountError(err);
|
||||||
|
// A 409 is not a failure — the credential can act for several companies and one has to be picked. Keep
|
||||||
|
// the form filled in and show the list rather than making them type it all again.
|
||||||
|
if (parsed.needsChoice && parsed.companies.length) {
|
||||||
|
setChoices(parsed.companies);
|
||||||
|
setCompanyId(parsed.companies[0]?.id ?? null);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(parsed.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
<div className="mx-auto flex max-w-xl flex-col gap-4 p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-sky-500/15 text-sky-500">
|
||||||
|
<Building2 className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold">{hasAccounts ? 'InvoiceShelf accounts' : 'Connect InvoiceShelf'}</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{hasAccounts
|
||||||
|
? 'Officer stores each instance and its token encrypted, for this account only. One is in use at a time.'
|
||||||
|
: 'Invoices needs an InvoiceShelf instance and a login before it can show anything.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isLoading && accounts.map((account) => <AccountRow key={account.id} account={account} />)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
void submit(companyId);
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-3 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<p className="text-xs font-medium">{hasAccounts ? 'Add another account' : 'Add an account'}</p>
|
||||||
|
<Field
|
||||||
|
label="Label"
|
||||||
|
value={label}
|
||||||
|
onChange={setLabel}
|
||||||
|
placeholder="Optional — defaults to the company name"
|
||||||
|
hint="What the switcher calls this account. Two accounts cannot share a label; two can share a URL, a login and everything but the company."
|
||||||
|
autoFocus={hasAccounts}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="InvoiceShelf URL"
|
||||||
|
value={url}
|
||||||
|
onChange={setUrl}
|
||||||
|
placeholder={DEFAULT_URL}
|
||||||
|
hint={URL_HINT}
|
||||||
|
autoFocus={!hasAccounts}
|
||||||
|
/>
|
||||||
|
<CredentialFields cred={cred} />
|
||||||
|
|
||||||
|
{choices.length > 0 && (
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs font-medium">Company</span>
|
||||||
|
<CompanyPicker companies={choices} value={companyId} onChange={setCompanyId} disabled={add.isPending} />
|
||||||
|
<span className={HINT}>
|
||||||
|
This login can act for more than one company. Whichever you pick is pinned to this account — add a
|
||||||
|
second account for the other one.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="submit" size="sm" disabled={add.isPending || (choices.length > 0 && companyId == null)}>
|
||||||
|
{add.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||||
|
{add.isPending ? 'Verifying…' : hasAccounts ? 'Add account' : 'Connect'}
|
||||||
|
</Button>
|
||||||
|
{add.isPending && <span className={HINT}>Checking the instance and the credential…</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,19 +7,25 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Package,
|
Package,
|
||||||
PieChart,
|
PieChart,
|
||||||
|
Plug,
|
||||||
Receipt,
|
Receipt,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Users,
|
Users,
|
||||||
Wallet,
|
Wallet,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { INVOICES_SECTIONS, invoicesSectionPath, type InvoicesSectionId } from './shared';
|
import { INVOICES_SECTIONS, invoicesSectionPath, type InvoicesSectionId } from './shared';
|
||||||
|
import { AccountSwitcher } from './AccountSwitcher';
|
||||||
import { formatMoney } from './format';
|
import { formatMoney } from './format';
|
||||||
import { useSummary } from './useInvoiceShelfData';
|
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
|
// 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.
|
// useSummary already holds — no section fetches a count of its own just to render a badge.
|
||||||
|
//
|
||||||
|
// The subtitle is the account switcher rather than the amount outstanding, because with several sets of
|
||||||
|
// books on screen "whose books are these" is the question the header has to answer. Outstanding moved into
|
||||||
|
// the totals at the bottom, where it sits with the rest of the money.
|
||||||
|
|
||||||
const ICONS: Record<InvoicesSectionId, LucideIcon> = {
|
const ICONS: Record<InvoicesSectionId, LucideIcon> = {
|
||||||
dashboard: LayoutDashboard,
|
dashboard: LayoutDashboard,
|
||||||
@@ -31,12 +37,13 @@ const ICONS: Record<InvoicesSectionId, LucideIcon> = {
|
|||||||
customers: Users,
|
customers: Users,
|
||||||
items: Package,
|
items: Package,
|
||||||
reports: PieChart,
|
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';
|
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
|
||||||
|
|
||||||
export const InvoicesNav = () => {
|
export const InvoicesNav = () => {
|
||||||
const { company, currency, dashboard, isLoading, error } = useSummary();
|
const { company, currency, dashboard, isLoading } = useSummary();
|
||||||
|
|
||||||
const counts: Partial<Record<InvoicesSectionId, number>> = {
|
const counts: Partial<Record<InvoicesSectionId, number>> = {
|
||||||
invoices: dashboard?.total_invoice_count,
|
invoices: dashboard?.total_invoice_count,
|
||||||
@@ -54,15 +61,7 @@ export const InvoicesNav = () => {
|
|||||||
<div className="truncate text-sm font-semibold leading-tight" title={company?.name ?? undefined}>
|
<div className="truncate text-sm font-semibold leading-tight" title={company?.name ?? undefined}>
|
||||||
{company?.name ?? (isLoading ? 'Loading…' : 'InvoiceShelf')}
|
{company?.name ?? (isLoading ? 'Loading…' : 'InvoiceShelf')}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-xs text-muted-foreground">
|
<AccountSwitcher />
|
||||||
{error ? (
|
|
||||||
<span className="text-red-500">unreachable</span>
|
|
||||||
) : dashboard ? (
|
|
||||||
<>{formatMoney(dashboard.total_amount_due, currency)} outstanding</>
|
|
||||||
) : (
|
|
||||||
'—'
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -103,6 +102,7 @@ export const InvoicesNav = () => {
|
|||||||
|
|
||||||
{dashboard && (
|
{dashboard && (
|
||||||
<div className="mt-auto space-y-1.5 border-t border-border/60 px-4 py-3 text-[11px]">
|
<div className="mt-auto space-y-1.5 border-t border-border/60 px-4 py-3 text-[11px]">
|
||||||
|
<NavRowStat label="Outstanding" value={formatMoney(dashboard.total_amount_due, currency)} />
|
||||||
<NavRowStat label="Sales" value={formatMoney(dashboard.total_sales, currency)} />
|
<NavRowStat label="Sales" value={formatMoney(dashboard.total_sales, currency)} />
|
||||||
<NavRowStat label="Received" value={formatMoney(dashboard.total_receipts, currency)} />
|
<NavRowStat label="Received" value={formatMoney(dashboard.total_receipts, currency)} />
|
||||||
<NavRowStat label="Expenses" value={formatMoney(dashboard.total_expenses, currency)} />
|
<NavRowStat label="Expenses" value={formatMoney(dashboard.total_expenses, currency)} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback } from 'react';
|
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 { CustomersListView } from './CustomersListView';
|
||||||
import { DashboardView } from './DashboardView';
|
import { DashboardView } from './DashboardView';
|
||||||
import { DocumentEditor } from './DocumentEditor';
|
import { DocumentEditor } from './DocumentEditor';
|
||||||
@@ -11,6 +12,8 @@ import { PaymentsListView } from './PaymentsListView';
|
|||||||
import { RecurringListView } from './RecurringListView';
|
import { RecurringListView } from './RecurringListView';
|
||||||
import { ReportsView } from './ReportsView';
|
import { ReportsView } from './ReportsView';
|
||||||
import { CustomerEditor, ExpenseEditor, ItemEditor, PaymentEditor } from './RecordEditors';
|
import { CustomerEditor, ExpenseEditor, ItemEditor, PaymentEditor } from './RecordEditors';
|
||||||
|
import { invoicesSectionPath } from './shared';
|
||||||
|
import { useInvoiceShelfHealth } from './useInvoiceShelfData';
|
||||||
import { useInvoicesSection } from './useInvoicesSection';
|
import { useInvoicesSection } from './useInvoicesSection';
|
||||||
|
|
||||||
// Right panel of the /invoices workspace: renders the section named by the URL, and owns the one piece of
|
// 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 = () => {
|
export const InvoicesView = () => {
|
||||||
const section = useInvoicesSection();
|
const section = useInvoicesSection();
|
||||||
|
const { data: health, isLoading: healthLoading } = useInvoiceShelfHealth();
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -65,6 +69,28 @@ export const InvoicesView = () => {
|
|||||||
[setParams],
|
[setParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Health gates every section, and its two failure modes are answered differently: nothing configured yet is
|
||||||
|
// the setup form, whatever section was asked for, because there is nothing else useful to show; a stored
|
||||||
|
// instance that is failing keeps its own message and a way back, since replacing a working token by
|
||||||
|
// accident is worse than a wall of text.
|
||||||
|
if (section === 'settings') return <ConnectionSection />;
|
||||||
|
|
||||||
|
if (!healthLoading && health && !health.ok) {
|
||||||
|
if (health.configured === false) return <ConnectionSection />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
|
||||||
|
<p className="text-sm font-medium">InvoiceShelf is not answering</p>
|
||||||
|
<p className="max-w-sm text-xs text-muted-foreground">
|
||||||
|
{health.error ?? 'The invoiceshelf sidecar could not reach the configured instance.'}
|
||||||
|
</p>
|
||||||
|
<Link to={invoicesSectionPath('settings')} className="mt-2 text-xs font-medium text-primary hover:underline">
|
||||||
|
Check the connection
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Documents get a full-height editor rather than a dialog — a line-item table does not fit in one, and
|
// 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.
|
// upstream gives them a whole page too.
|
||||||
if (edit != null && (section === 'invoices' || section === 'estimates' || section === 'recurring')) {
|
if (edit != null && (section === 'invoices' || section === 'estimates' || section === 'recurring')) {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export const ErrorState = ({ error, hint }: { error: unknown; hint?: string }) =
|
|||||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-10 text-center">
|
<div className="flex h-full flex-col items-center justify-center gap-2 p-10 text-center">
|
||||||
<div className="text-sm font-medium text-red-500">Could not reach InvoiceShelf</div>
|
<div className="text-sm font-medium text-red-500">Could not reach InvoiceShelf</div>
|
||||||
<div className="max-w-md text-xs text-muted-foreground">
|
<div className="max-w-md text-xs text-muted-foreground">
|
||||||
{hint ?? 'Check that officer-invoiceshelf is running and INVOICESHELF_URL / INVOICESHELF_TOKEN are set.'}
|
{hint ?? 'Check the connection under Invoices → Connection, and that officer-invoiceshelf is running.'}
|
||||||
</div>
|
</div>
|
||||||
{error != null && (
|
{error != null && (
|
||||||
<pre className="max-w-md overflow-hidden text-ellipsis text-[10px] text-muted-foreground/70">
|
<pre className="max-w-md overflow-hidden text-ellipsis text-[10px] text-muted-foreground/70">
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const INVOICES_SECTIONS = [
|
|||||||
{ id: 'customers', label: 'Customers' },
|
{ id: 'customers', label: 'Customers' },
|
||||||
{ id: 'items', label: 'Items' },
|
{ id: 'items', label: 'Items' },
|
||||||
{ id: 'reports', label: 'Reports' },
|
{ id: 'reports', label: 'Reports' },
|
||||||
|
{ id: 'settings', label: 'Connection' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type InvoicesSectionId = (typeof INVOICES_SECTIONS)[number]['id'];
|
export type InvoicesSectionId = (typeof INVOICES_SECTIONS)[number]['id'];
|
||||||
|
|||||||
@@ -411,3 +411,164 @@ export function downloadPdf(url: string, filename: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { errorMessage };
|
export { errorMessage };
|
||||||
|
|
||||||
|
// ── accounts ─────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The InvoiceShelf URL, its Sanctum token and the company it acts for are the owner's to set from
|
||||||
|
// /invoices/settings; nothing reads them from the environment any more. It is a REGISTRY — any number of
|
||||||
|
// labelled accounts, one of them selected — and two accounts differing only by company is the normal case,
|
||||||
|
// because the `company` header silently returns another company's books rather than erroring.
|
||||||
|
//
|
||||||
|
// The token is WRITE-ONLY across this boundary: an account carries its label, URL and pinned company, and
|
||||||
|
// has no field that could carry the token back to the browser.
|
||||||
|
|
||||||
|
const CONFIG = '/invoiceshelf/_config';
|
||||||
|
|
||||||
|
export type InvoiceShelfHealth = {
|
||||||
|
ok: boolean;
|
||||||
|
/** False only when no account is stored. It separates "set this up" from "this used to work". */
|
||||||
|
configured?: boolean;
|
||||||
|
/** Label of the selected account, so a failure names which books did not answer. */
|
||||||
|
account?: string;
|
||||||
|
version?: string | null;
|
||||||
|
company?: number | null;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InvoiceShelfAccount = {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
companyId: number | null;
|
||||||
|
companyName: string | null;
|
||||||
|
version: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InvoiceShelfAccounts = { configured: boolean; activeId: number | null; accounts: InvoiceShelfAccount[] };
|
||||||
|
|
||||||
|
export type CompanyOption = { id: number; name: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unwrap a thrown client error into the shape the connection form needs.
|
||||||
|
*
|
||||||
|
* `needsChoice` is the interesting one: adding an account whose token can see several companies is a 409
|
||||||
|
* carrying the list, because nothing is wrong with what was sent — it just is not enough to decide. The form
|
||||||
|
* turns that into a picker rather than an error.
|
||||||
|
*/
|
||||||
|
export function parseAccountError(err: unknown): { message: string; needsChoice: boolean; companies: CompanyOption[] } {
|
||||||
|
const raw = (err as { message?: unknown } | null)?.message;
|
||||||
|
const fallback = { message: 'Something went wrong', needsChoice: false, companies: [] as CompanyOption[] };
|
||||||
|
if (typeof raw !== 'string' || !raw) return fallback;
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(raw) as { error?: string; needsChoice?: boolean; companies?: CompanyOption[] };
|
||||||
|
return {
|
||||||
|
message: body.error || raw.slice(0, 300),
|
||||||
|
needsChoice: !!body.needsChoice,
|
||||||
|
companies: body.companies ?? [],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { ...fallback, message: raw.slice(0, 300) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health, including its failure bodies.
|
||||||
|
*
|
||||||
|
* `get` throws on any status >= 400, so a plain query would leave `data` undefined for exactly the two cases
|
||||||
|
* the UI most needs to tell apart — 503 not connected and 502 connected-but-broken. Both carry a JSON body,
|
||||||
|
* so the throw is turned back into the answer rather than an error state.
|
||||||
|
*/
|
||||||
|
export function useInvoiceShelfHealth() {
|
||||||
|
const { get } = useClient();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [ROOT, 'health'] as const,
|
||||||
|
queryFn: async (): Promise<InvoiceShelfHealth> => {
|
||||||
|
try {
|
||||||
|
return await get<InvoiceShelfHealth>('/invoiceshelf/_health');
|
||||||
|
} catch (err) {
|
||||||
|
const raw = (err as { message?: unknown } | null)?.message;
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(raw) as InvoiceShelfHealth;
|
||||||
|
if (body && body.ok === false) return body;
|
||||||
|
} catch {
|
||||||
|
/* not the sidecar's body */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Anything else — the platform proxy, auth, the sidecar being down — is a configured instance that is
|
||||||
|
// failing, not an unconfigured one. Never offer the setup form on a guess.
|
||||||
|
return { ok: false, configured: true, error: parseAccountError(err).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
staleTime: STALE_MS,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInvoiceShelfAccounts() {
|
||||||
|
const { get } = useClient();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [ROOT, 'accounts'] as const,
|
||||||
|
queryFn: () => get<InvoiceShelfAccounts>(CONFIG),
|
||||||
|
staleTime: STALE_MS,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The companies a stored account's token can act for — what the company picker is populated from. */
|
||||||
|
export function useAccountCompanies(id: number | null) {
|
||||||
|
const { get } = useClient();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [ROOT, 'accounts', id, 'companies'] as const,
|
||||||
|
queryFn: () => get<{ companies: CompanyOption[]; companyId: number | null }>(`${CONFIG}/${id}/companies`),
|
||||||
|
enabled: id != null,
|
||||||
|
staleTime: STALE_MS,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Either a pasted Sanctum token or a sign-in the sidecar trades for one.
|
||||||
|
*
|
||||||
|
* The password goes to the sidecar, which uses it for the single `/auth/login` call and stores only the token
|
||||||
|
* it gets back. It exists because InvoiceShelf 2.4.2 has no screen that issues tokens at all.
|
||||||
|
*/
|
||||||
|
export type Credential = { token: string } | { email: string; password: string };
|
||||||
|
|
||||||
|
export type AddAccountInput = { label: string; url: string; companyId?: number | null } & Credential;
|
||||||
|
export type EditAccountInput = { id: number; label?: string; url?: string; companyId?: number | null } & Partial<
|
||||||
|
Record<'token' | 'email' | 'password', string>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function useInvoiceShelfAccountActions() {
|
||||||
|
const { post, patch, delete: del } = useClient();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
// Adding, editing, switching and removing all change what every other query here can even answer — a
|
||||||
|
// switch in particular changes the answer to all of them without changing any of their inputs.
|
||||||
|
const invalidate = () => qc.invalidateQueries({ queryKey: [ROOT] });
|
||||||
|
|
||||||
|
const add = useMutation({
|
||||||
|
mutationFn: (input: AddAccountInput) => post<{ account: InvoiceShelfAccount }>(CONFIG, input),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const edit = useMutation({
|
||||||
|
mutationFn: ({ id, ...rest }: EditAccountInput) => patch<{ account: InvoiceShelfAccount }>(`${CONFIG}/${id}`, rest),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activate = useMutation({
|
||||||
|
mutationFn: (id: number) => post<InvoiceShelfAccounts>(`${CONFIG}/${id}/activate`, {}),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: number) => del<InvoiceShelfAccounts>(`${CONFIG}/${id}`),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { add, edit, activate, remove };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PhotosAccount } from './usePhotosData';
|
import type { PhotosAccount, PhotosCredential } from './usePhotosData';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Check, CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react';
|
import { Check, CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
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 ' +
|
'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.';
|
'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 = {
|
type FieldProps = {
|
||||||
label: string;
|
label: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
@@ -56,6 +61,91 @@ const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: F
|
|||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── credentials ──────────────────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Two ways to prove the same thing, so they share one piece of state rather than two forms. Sign-in is the
|
||||||
|
// default: it is the only path that does not require the owner to go and read an API key out of Immich's own
|
||||||
|
// settings first, and what it stores is the same kind of key they would have pasted.
|
||||||
|
|
||||||
|
type CredentialMode = 'login' | 'key';
|
||||||
|
|
||||||
|
function useCredential() {
|
||||||
|
const [mode, setMode] = useState<CredentialMode>('login');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
|
||||||
|
const filled = mode === 'key' ? !!apiKey.trim() : !!email.trim() && !!password;
|
||||||
|
const payload: PhotosCredential = mode === 'key' ? { apiKey: apiKey.trim() } : { email: email.trim(), password };
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setEmail('');
|
||||||
|
setPassword('');
|
||||||
|
setApiKey('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return { mode, setMode, email, setEmail, password, setPassword, apiKey, setApiKey, filled, payload, reset };
|
||||||
|
}
|
||||||
|
|
||||||
|
type CredentialState = ReturnType<typeof useCredential>;
|
||||||
|
|
||||||
|
const TAB = 'rounded-md px-2 py-1 text-[11px] transition-colors';
|
||||||
|
|
||||||
|
const CredentialFields = ({ cred, autoFocus }: { cred: CredentialState; autoFocus?: boolean }) => (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs font-medium">Credential</span>
|
||||||
|
<div className="ml-auto flex items-center gap-1 rounded-lg bg-muted p-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => cred.setMode('login')}
|
||||||
|
className={`${TAB} ${cred.mode === 'login' ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => cred.setMode('key')}
|
||||||
|
className={`${TAB} ${cred.mode === 'key' ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
|
||||||
|
>
|
||||||
|
API key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cred.mode === 'login' ? (
|
||||||
|
<>
|
||||||
|
<Field
|
||||||
|
label="Email"
|
||||||
|
value={cred.email}
|
||||||
|
onChange={cred.setEmail}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
type="email"
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Password"
|
||||||
|
value={cred.password}
|
||||||
|
onChange={cred.setPassword}
|
||||||
|
placeholder="••••••••"
|
||||||
|
type="password"
|
||||||
|
hint={SIGN_IN_HINT}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Field
|
||||||
|
label="API key"
|
||||||
|
value={cred.apiKey}
|
||||||
|
onChange={cred.setApiKey}
|
||||||
|
placeholder="••••••••••••"
|
||||||
|
type="password"
|
||||||
|
hint={KEY_HINT}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
type AccountRowProps = { account: PhotosAccount };
|
type AccountRowProps = { account: PhotosAccount };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,8 +156,9 @@ const AccountRow = ({ account }: AccountRowProps) => {
|
|||||||
const { data: health, refetch: recheck, isFetching: checking } = usePhotosHealth();
|
const { data: health, refetch: recheck, isFetching: checking } = usePhotosHealth();
|
||||||
const { edit, activate, remove } = usePhotosAccountActions();
|
const { edit, activate, remove } = usePhotosAccountActions();
|
||||||
|
|
||||||
const [apiKey, setApiKey] = useState('');
|
const cred = useCredential();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [replacing, setReplacing] = useState(false);
|
||||||
// Removing an account throws away a key Officer can never show again, so the bin asks once.
|
// Removing an account throws away a key Officer can never show again, so the bin asks once.
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
|
||||||
@@ -75,7 +166,8 @@ const AccountRow = ({ account }: AccountRowProps) => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await action;
|
await action;
|
||||||
setApiKey('');
|
cred.reset();
|
||||||
|
setReplacing(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(photosErrorMessage(err));
|
setError(photosErrorMessage(err));
|
||||||
}
|
}
|
||||||
@@ -144,26 +236,41 @@ const AccountRow = ({ account }: AccountRowProps) => {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{replacing ? (
|
||||||
<Input
|
<div className="flex flex-col gap-3 rounded-lg border p-3">
|
||||||
value={apiKey}
|
<CredentialFields cred={cred} autoFocus />
|
||||||
onChange={(ev) => setApiKey(ev.target.value)}
|
<div className="flex items-center gap-2">
|
||||||
placeholder="Replace API key"
|
<Button
|
||||||
type="password"
|
size="sm"
|
||||||
autoComplete="off"
|
className="h-8"
|
||||||
spellCheck={false}
|
disabled={!cred.filled || edit.isPending}
|
||||||
className="h-8"
|
onClick={() => void run(edit.mutateAsync({ id: account.id, ...cred.payload }))}
|
||||||
/>
|
>
|
||||||
<Button
|
{edit.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||||
variant="secondary"
|
Save
|
||||||
size="sm"
|
</Button>
|
||||||
className="h-8 shrink-0"
|
<Button
|
||||||
disabled={!apiKey.trim() || edit.isPending}
|
variant="ghost"
|
||||||
onClick={() => void run(edit.mutateAsync({ id: account.id, apiKey: apiKey.trim() }))}
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => {
|
||||||
|
cred.reset();
|
||||||
|
setReplacing(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReplacing(true)}
|
||||||
|
className="self-start text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
{edit.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
|
Replace credential
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{error && <p className="text-destructive">{error}</p>}
|
{error && <p className="text-destructive">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -174,9 +281,9 @@ export const ConnectionSection = () => {
|
|||||||
const { data, isLoading } = usePhotosAccounts();
|
const { data, isLoading } = usePhotosAccounts();
|
||||||
const { add } = usePhotosAccountActions();
|
const { add } = usePhotosAccountActions();
|
||||||
|
|
||||||
|
const cred = useCredential();
|
||||||
const [label, setLabel] = useState('');
|
const [label, setLabel] = useState('');
|
||||||
const [url, setUrl] = useState(DEFAULT_URL);
|
const [url, setUrl] = useState(DEFAULT_URL);
|
||||||
const [apiKey, setApiKey] = useState('');
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const accounts = data?.accounts ?? [];
|
const accounts = data?.accounts ?? [];
|
||||||
@@ -185,13 +292,15 @@ export const ConnectionSection = () => {
|
|||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (!url.trim()) return setError('The Immich URL is required');
|
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 {
|
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('');
|
setLabel('');
|
||||||
setApiKey('');
|
|
||||||
setUrl(DEFAULT_URL);
|
setUrl(DEFAULT_URL);
|
||||||
|
cred.reset();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(photosErrorMessage(err));
|
setError(photosErrorMessage(err));
|
||||||
}
|
}
|
||||||
@@ -209,7 +318,7 @@ export const ConnectionSection = () => {
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{hasAccounts
|
{hasAccounts
|
||||||
? 'Officer stores each instance and its key encrypted, for this account only. One is in use at a time.'
|
? 'Officer stores each instance and its key encrypted, for this account only. One is in use at a time.'
|
||||||
: 'Photos needs an Immich instance and an API key before it can show anything.'}
|
: 'Photos needs an Immich instance and a login before it can show anything.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,14 +349,7 @@ export const ConnectionSection = () => {
|
|||||||
hint={URL_HINT}
|
hint={URL_HINT}
|
||||||
autoFocus={!hasAccounts}
|
autoFocus={!hasAccounts}
|
||||||
/>
|
/>
|
||||||
<Field
|
<CredentialFields cred={cred} />
|
||||||
label="API key"
|
|
||||||
value={apiKey}
|
|
||||||
onChange={setApiKey}
|
|
||||||
placeholder="••••••••••••"
|
|
||||||
type="password"
|
|
||||||
hint={KEY_HINT}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
|||||||
@@ -323,8 +323,18 @@ export function usePhotosAccounts() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AddPhotosAccountInput = { label: string; url: string; apiKey: string };
|
/**
|
||||||
export type EditPhotosAccountInput = { id: number; label?: string; url?: string; apiKey?: string };
|
* Either a pasted API key or a sign-in the sidecar trades for one.
|
||||||
|
*
|
||||||
|
* The password goes to the sidecar, which logs in, mints an all-permissions key, closes the session again and
|
||||||
|
* stores only the key. It never reaches Postgres and never comes back to the browser.
|
||||||
|
*/
|
||||||
|
export type PhotosCredential = { apiKey: string } | { email: string; password: string };
|
||||||
|
|
||||||
|
export type AddPhotosAccountInput = { label: string; url: string } & PhotosCredential;
|
||||||
|
export type EditPhotosAccountInput = { id: number; label?: string; url?: string } & Partial<
|
||||||
|
Record<'apiKey' | 'email' | 'password', string>
|
||||||
|
>;
|
||||||
|
|
||||||
export function usePhotosAccountActions() {
|
export function usePhotosAccountActions() {
|
||||||
const { post, patch, delete: del } = useClient();
|
const { post, patch, delete: del } = useClient();
|
||||||
|
|||||||
Reference in New Issue
Block a user