Add logout, passkey management, and a standalone login
The "auth routes" open question in GOALS.md conflated four unrelated things once the upstream routes and controllers were checked: logout and the passkey settings routes sit behind the same token_auth middleware as everything else this crate wraps, login needs a credential exchange since there's no token yet, refresh/refresh_react turned out to be the bulk sync-dump endpoint already parked as a separate open question, and connected_account is Gmail OAuth linking that belongs with Integrations. Adds logout, list_passkeys, delete_passkey (passkey registration is left out — it needs a WebAuthn ceremony a browser has to drive), and InvoiceNinjaClient::login as an associated function returning a lean LoginResponse that types only is_owner/is_admin/token and leaves the server's much larger nested payload in extra. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Logout and passkey management: `logout`, `list_passkeys`, and
|
||||
`delete_passkey`. `logout` regenerates every system-issued token on the
|
||||
whole company, not just the caller's, so it ends every session sharing
|
||||
it. Passkey *registration* is not wrapped — it needs a WebAuthn ceremony
|
||||
only a browser can drive. Adds `Passkey`.
|
||||
- `InvoiceNinjaClient::login`, an associated function (not a method, since
|
||||
there is no token yet to build a client with) that exchanges an email and
|
||||
password for an API token, optionally with a one-time password for
|
||||
two-factor accounts. Adds `LoginResponse` and `LoginCompanyUser`; the
|
||||
nested company/user/account data the server also returns is left in
|
||||
`extra` rather than modelled. Does not cover passkey login, SSO, or
|
||||
`/login/precheck`.
|
||||
|
||||
## [0.2.0] - 2026-10-25
|
||||
|
||||
### Added
|
||||
|
||||
@@ -361,24 +361,28 @@ changes, doc tweaks) does not need an entry.
|
||||
## Coverage
|
||||
|
||||
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
|
||||
(which isn't one). 371 methods: 30 resources with CRUD + bulk, 3 with CRUD
|
||||
(which isn't one). 375 methods: 30 resources with CRUD + bulk, 3 with CRUD
|
||||
only, 4 with entity actions, 4 with invitation-key downloads, 16 with
|
||||
document uploads, and 107 hand-written.
|
||||
document uploads, and 111 hand-written.
|
||||
|
||||
Plus the non-CRUD surface: documents, entity actions and PDF downloads for
|
||||
the four action-capable resources, their invitation-key PDF and e-document
|
||||
downloads, `report`, `chart`, `search`, `statics`, `send_email`,
|
||||
`refund_payment`, `ping`, `health_check`, import/export, the 17 e-invoicing
|
||||
routes, and the QuickBooks, Stripe, calendar, Yodlee and GoCardless
|
||||
integrations.
|
||||
routes, the QuickBooks, Stripe, calendar, Yodlee and GoCardless
|
||||
integrations, `logout`, passkey list/delete, and a standalone `login`.
|
||||
|
||||
**`GOALS.md`'s roadmap is closed** — every route an API-token client can
|
||||
reach is wrapped. What is left out is left out for a reason, not pending:
|
||||
the auth routes (a different auth model, and an open question in `GOALS.md`),
|
||||
the client-portal family and the OAuth and provider-webhook routes (all
|
||||
outside `/api/v1`, so they don't fit the client's `base_url` or its token),
|
||||
`POST /quickbooks/settings` (a registered route with no controller method,
|
||||
so it 500s), and a few company and user extras.
|
||||
passkey registration and passkey login (WebAuthn ceremonies only a browser
|
||||
can drive), SSO (`oauth_login`, `oidc/*`) and `/login/precheck`,
|
||||
`/refresh`/`/refresh_react` (the bulk sync-dump shape parked as its own open
|
||||
question in `GOALS.md`, not an auth concept), `/connected_account` (Gmail
|
||||
OAuth linking — an integration, not login/logout), the client-portal family
|
||||
and the OAuth and provider-webhook routes (all outside `/api/v1`, so they
|
||||
don't fit the client's `base_url` or its token), `POST /quickbooks/settings`
|
||||
(a registered route with no controller method, so it 500s), and a few
|
||||
company and user extras.
|
||||
|
||||
**`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every
|
||||
gap, and the reason for each gap.
|
||||
|
||||
+61
-4
@@ -18,7 +18,7 @@ declares 34 `Route::resource` groups and 274 explicit route declarations
|
||||
> exists or what it returns.
|
||||
|
||||
**All 34 `Route::resource` groups are now wrapped**, plus `users` (which is
|
||||
not a resource group). 371 methods in total.
|
||||
not a resource group). 375 methods in total.
|
||||
|
||||
## Resources with full CRUD + bulk
|
||||
|
||||
@@ -434,6 +434,60 @@ established that a purged record reads back as a **400**, not a 404. A
|
||||
password containing a control character cannot be a header value and goes out
|
||||
as `X-API-PASSWORD-BASE64` instead.
|
||||
|
||||
## Logout, passkeys, and login
|
||||
|
||||
`/login`, `/logout`, and the passkey routes used to sit together under one
|
||||
"Auth" gap. They don't share a shape: `/logout` and the passkey *settings*
|
||||
routes are plain `token_auth` endpoints like everything else this crate
|
||||
wraps, while `/login` sits in a different, unauthenticated middleware group
|
||||
because the caller has no token yet to prove who they are.
|
||||
|
||||
| Endpoint | Method | Needs an existing client? |
|
||||
| --- | --- | --- |
|
||||
| `POST /logout` | `logout` | yes |
|
||||
| `GET /settings/passkeys` | `list_passkeys` | yes |
|
||||
| `DELETE /settings/passkeys/{id}` | `delete_passkey` | yes |
|
||||
| `POST /login` | `InvoiceNinjaClient::login` (associated fn, not a method) | **no** |
|
||||
|
||||
**`logout` has a bigger blast radius than its name suggests.** The
|
||||
controller doesn't revoke the caller's token — it regenerates *every*
|
||||
`is_system` token belonging to the whole company, logging out every user
|
||||
and device that shares it, including the one making the call.
|
||||
|
||||
**Passkey registration is not wrapped.** `POST /settings/passkeys/options`
|
||||
and `POST /settings/passkeys` exist to drive a WebAuthn ceremony that only a
|
||||
browser talking to a platform authenticator can complete — there is no
|
||||
attestation object for this crate to produce standalone. Listing and
|
||||
deleting an existing credential need no such ceremony, so those are the two
|
||||
operations offered.
|
||||
|
||||
**`login` returns the server's full "first load" payload** — nested
|
||||
company, clients, invoices, designs, and more under each `CompanyUser`
|
||||
entry — because it reuses the same transformer as a browser's initial page
|
||||
load. This crate does not model that nested tree; [`LoginCompanyUser`]
|
||||
types only `is_owner`, `is_admin`, and the `token` it exists to produce,
|
||||
and leaves the rest in `extra`, losslessly. [`LoginCompanyUser::token`]
|
||||
is what a caller actually needs — feed it to
|
||||
[`InvoiceNinjaClient::new`] or [`InvoiceNinjaClient::hosted`].
|
||||
|
||||
`login` optionally takes a one-time password for accounts with two-factor
|
||||
auth enabled. It does not support passkey login, SSO (`oauth_login`,
|
||||
`oidc/*`), `/login/precheck`, or a self-hosted instance's optional
|
||||
`X-API-SECRET` header — each needs a ceremony, a redirect, or a header this
|
||||
single email/password call does not send.
|
||||
|
||||
`/refresh` and `/refresh_react` were in the old "Auth" bucket too, but
|
||||
they're not an authentication concept: `BaseController::refreshResponse`
|
||||
is the same bulk, `updated_at`-filtered account dump as `/login`'s first
|
||||
load. That's the sync-engine shape [`GOALS.md`](GOALS.md) already parks as
|
||||
a separate open question, not something to bolt onto auth — not
|
||||
implemented, for the same reason.
|
||||
|
||||
`/connected_account` (linking a Gmail OAuth account) was also filed under
|
||||
"Auth". It's an integration-linking endpoint, not login/logout, so it
|
||||
belongs with Stripe/QuickBooks under [Integrations](#integrations) above —
|
||||
also not implemented.
|
||||
|
||||
## Per-resource extras
|
||||
|
||||
Routes that sit beside a resource's CRUD set, each with its own response
|
||||
@@ -491,8 +545,11 @@ check `routes/api.php`).
|
||||
- **User extras** (not password-protected, unlike the rest of `/users`) —
|
||||
`POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`,
|
||||
`POST /user/{id}/reconfirm`
|
||||
- **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`,
|
||||
passkey routes
|
||||
- **Auth** — see [Logout, passkeys, and login](#logout-passkeys-and-login)
|
||||
above for what's covered (`login`, `logout`, passkey list/delete) and
|
||||
what isn't: `/login/precheck`, `oauth_login`, `oidc/*`, passkey
|
||||
registration and passkey login, `/refresh`, `/refresh_react`, and
|
||||
`/connected_account`
|
||||
- **Migration** — `/migration/start`. Moves a company between instances and
|
||||
is driven by the v4 migration tool, not by an SDK caller.
|
||||
- **Invoice extras** — `delivery_note`, `payment_schedule`,
|
||||
@@ -526,7 +583,7 @@ check `routes/api.php`).
|
||||
`/claim_license`, `/check_license`, `/check_subdomain`, `/one_time_token`,
|
||||
`/last_error`, `/support/messages/send`, `/verify/*`, `/accounts/{id}`,
|
||||
`/feedback`, `/filters/{entity}`, `/scheduler`, `/mailer/check`,
|
||||
`/smtp/check`, `/password_timeout`, `/refresh_react`, `/webcron`,
|
||||
`/smtp/check`, `/password_timeout`, `/webcron`,
|
||||
`/token_hash_router`, `/reactivate_email/{bounce_id}`,
|
||||
`/protected_download/{hash}`, `/confirm_purchase`,
|
||||
`/exports/preview/{hash}`, `/templates/preview/{hash}`
|
||||
|
||||
@@ -228,6 +228,24 @@ last item, so **the roadmap is closed**: every route in `routes/api.php` that
|
||||
an API-token client can reach is now wrapped. It did land in pieces, as the
|
||||
item predicted — import/export first, then the integrations.
|
||||
|
||||
**Done:** the auth routes, once split apart — the single "auth routes"
|
||||
label had quietly merged four unrelated things. `logout` and the passkey
|
||||
list/delete routes sit behind the same `token_auth` middleware as every
|
||||
other wrapped endpoint, so they needed no new client type, just methods
|
||||
(and a documented warning that `logout` rotates every token on the whole
|
||||
company, not just the caller's). `login` *does* need a different entry
|
||||
point — there is no token yet to build an `InvoiceNinjaClient` with — but
|
||||
that turned out to be a standalone associated function, not a second
|
||||
client type: `InvoiceNinjaClient::login` trades an email and password for
|
||||
a `LoginResponse` carrying a token, which then goes to `new` or `hosted`
|
||||
as normal. Passkey *registration* and passkey *login* stay unwrapped
|
||||
because they're WebAuthn ceremonies a browser has to drive; `/refresh` and
|
||||
`/refresh_react` turned out to be the bulk `updated_at` sync dump already
|
||||
parked below, not an auth concept, so they stay out for that reason
|
||||
instead; and `/connected_account` (Gmail OAuth linking) was filed under
|
||||
the wrong heading — it belongs with the Integrations done item above. See
|
||||
`COVERAGE.md`'s "Logout, passkeys, and login" section for the detail.
|
||||
|
||||
As the item also predicted, it is the least SDK-shaped part of the API, and
|
||||
the typing reflects that: most of these return `serde_json::Value`, because
|
||||
their replies are pass-throughs from Storecove, Stripe, Yodlee or GoCardless,
|
||||
@@ -244,18 +262,17 @@ a registered route with no controller method behind it, so it 500s — there is
|
||||
nothing to wrap until upstream adds the handler.
|
||||
|
||||
What remains unwrapped is now only what each entry in `COVERAGE.md`'s gap
|
||||
list explains: the auth routes (an open question below), the client-portal
|
||||
family, the provider webhooks, and a few company and user extras. Keep that
|
||||
file current — it names exact counts, so it goes stale quietly.
|
||||
list explains: the passkey and SSO ceremonies and `/login/precheck`
|
||||
(each needs something — a browser, a redirect — this client doesn't have),
|
||||
`/refresh`/`/refresh_react` (the sync-engine shape parked below), the
|
||||
client-portal family, the provider webhooks, and a few company and user
|
||||
extras. Keep that file current — it names exact counts, so it goes stale
|
||||
quietly.
|
||||
|
||||
## Open questions
|
||||
|
||||
These are genuinely undecided. Deciding one is a change to this file.
|
||||
|
||||
- **Auth routes** (`/login`, `/logout`, `/refresh`, passkeys) are in the gap
|
||||
list, but they are a different authentication model from the API token
|
||||
this client is built around. Including them may mean a second client type
|
||||
rather than more methods on this one. Not obviously in scope.
|
||||
- **`chart` is still generic** over `T: DeserializeOwned`, because the
|
||||
eleven chart endpoints share no response shape. Per-chart structs would
|
||||
be eleven small models of unstable payloads; unclear whether that earns
|
||||
|
||||
@@ -100,7 +100,7 @@ IN_API_TOKEN=... cargo run --example list_clients
|
||||
|
||||
## Coverage
|
||||
|
||||
Every one of the API's 34 resource groups, plus users — 371 methods. Each
|
||||
Every one of the API's 34 resource groups, plus users — 375 methods. Each
|
||||
resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
|
||||
(where the API offers one) `bulk`:
|
||||
|
||||
@@ -116,7 +116,9 @@ resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
|
||||
|
||||
Plus documents, entity actions and PDF downloads — by record id, or by
|
||||
invitation key for the PDF and XML a given contact receives — reports,
|
||||
charts, search, statics, email sending, refunds, and health checks.
|
||||
charts, search, statics, email sending, refunds, health checks, logout,
|
||||
passkey management, and a standalone `login` to exchange credentials for
|
||||
a token.
|
||||
|
||||
**See [COVERAGE.md](COVERAGE.md) for the full endpoint-by-endpoint
|
||||
inventory**, including everything that is deliberately *not* wrapped and why.
|
||||
|
||||
+137
-23
@@ -10,10 +10,11 @@ use crate::models::{
|
||||
Activity, ActivityFeedEntry, BankIntegration, BankTransaction, BankTransactionRule,
|
||||
BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken,
|
||||
Credit, Design, Document, Expense, ExpenseCategory, ExportJob, GroupSetting, HealthCheck,
|
||||
ImportPreview, Invoice, ItemResponse, ListParams, ListResponse, Location, MessageResponse,
|
||||
Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote, RecurringExpense,
|
||||
RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription, SubscriptionStep,
|
||||
SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook,
|
||||
ImportPreview, Invoice, ItemResponse, ListParams, ListResponse, Location, LoginResponse,
|
||||
MessageResponse, Passkey, Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote,
|
||||
RecurringExpense, RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription,
|
||||
SubscriptionStep, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor,
|
||||
Webhook,
|
||||
};
|
||||
use crate::retry::{self, RetryPolicy};
|
||||
use crate::upload::FileUpload;
|
||||
@@ -63,6 +64,32 @@ fn seg(value: &str) -> Result<impl std::fmt::Display + '_> {
|
||||
Ok(utf8_percent_encode(value, PATH_SEGMENT))
|
||||
}
|
||||
|
||||
/// Validates a base URL and strips its trailing slash.
|
||||
///
|
||||
/// Shared by [`InvoiceNinjaClient::new`] and
|
||||
/// [`InvoiceNinjaClient::login`], the one other place a base URL arrives
|
||||
/// from a caller rather than from an existing client. Parsing up front
|
||||
/// means a malformed URL — most often a missing scheme, as in
|
||||
/// `invoicing.co/api/v1` — fails here instead of as a confusing per-request
|
||||
/// error.
|
||||
fn validate_base_url(base_url: String) -> Result<String> {
|
||||
let trimmed = base_url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(Error::InvalidBaseUrl(base_url));
|
||||
}
|
||||
|
||||
let parsed = reqwest::Url::parse(trimmed)
|
||||
.map_err(|e| Error::InvalidBaseUrl(format!("{trimmed}: {e}")))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return Err(Error::InvalidBaseUrl(format!(
|
||||
"{trimmed}: expected an http or https url, got scheme `{}`",
|
||||
parsed.scheme()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(trimmed.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
/// Standard base64 with padding. Only used for the one header that needs it,
|
||||
/// which is not worth a dependency.
|
||||
fn base64(bytes: &[u8]) -> String {
|
||||
@@ -135,25 +162,7 @@ pub struct InvoiceNinjaClient {
|
||||
|
||||
impl InvoiceNinjaClient {
|
||||
pub fn new(base_url: impl Into<String>, api_token: impl Into<String>) -> Result<Self> {
|
||||
let base_url = base_url.into();
|
||||
let trimmed = base_url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(Error::InvalidBaseUrl(base_url));
|
||||
}
|
||||
|
||||
// Parse up front rather than letting a malformed base URL surface as a
|
||||
// confusing per-request failure. A missing scheme is the common typo
|
||||
// (`invoicing.co/api/v1`), and it fails here instead of on every call.
|
||||
let parsed = reqwest::Url::parse(trimmed)
|
||||
.map_err(|e| Error::InvalidBaseUrl(format!("{trimmed}: {e}")))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return Err(Error::InvalidBaseUrl(format!(
|
||||
"{trimmed}: expected an http or https url, got scheme `{}`",
|
||||
parsed.scheme()
|
||||
)));
|
||||
}
|
||||
|
||||
let base_url = trimmed.trim_end_matches('/').to_string();
|
||||
let base_url = validate_base_url(base_url.into())?;
|
||||
|
||||
let mut headers = header::HeaderMap::new();
|
||||
let mut token_value = header::HeaderValue::from_str(&api_token.into())
|
||||
@@ -338,6 +347,76 @@ impl InvoiceNinjaClient {
|
||||
Self::new(HOSTED_BASE_URL, api_token)
|
||||
}
|
||||
|
||||
/// Exchanges an email and password for an API token, for the one case
|
||||
/// `new` cannot cover: there is no token yet to build a client with.
|
||||
///
|
||||
/// `POST /login` sits outside the `token_auth` middleware every other
|
||||
/// wrapped endpoint uses, so this is a standalone async function rather
|
||||
/// than a method — nothing in `self` could be used to make the request.
|
||||
/// It builds its own one-shot `reqwest::Client` and does not go through
|
||||
/// `req` or `execute`, and, like every other `POST` in this crate, is
|
||||
/// not retried on a 5xx or a timeout.
|
||||
///
|
||||
/// On success the server answers with one entry per company the account
|
||||
/// has access to, each carrying that company's token
|
||||
/// ([`LoginCompanyUser::token`](crate::models::LoginCompanyUser::token))
|
||||
/// — feed one into [`new`](Self::new) (or [`hosted`](Self::hosted)) to
|
||||
/// get a working client:
|
||||
///
|
||||
/// ```no_run
|
||||
/// # async fn run() -> invoiceninja_sdk::Result<()> {
|
||||
/// use invoiceninja_sdk::InvoiceNinjaClient;
|
||||
///
|
||||
/// let login = InvoiceNinjaClient::login(
|
||||
/// "https://invoicing.co/api/v1",
|
||||
/// "me@example.com",
|
||||
/// "correct-horse-battery-staple",
|
||||
/// None,
|
||||
/// )
|
||||
/// .await?;
|
||||
/// let token = &login.data[0].token.as_ref().expect("token include").token;
|
||||
/// let client = InvoiceNinjaClient::hosted(token)?;
|
||||
/// # let _ = client;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// `one_time_password` is only consulted when the account has
|
||||
/// two-factor authentication enabled; pass `None` otherwise. Passkey
|
||||
/// login, SSO (`oauth_login`, `oidc/*`), and a self-hosted instance's
|
||||
/// optional `X-API-SECRET` are not handled — each needs a ceremony or a
|
||||
/// header this function does not send, and none fits a single
|
||||
/// email/password call.
|
||||
pub async fn login(
|
||||
base_url: &str,
|
||||
email: &str,
|
||||
password: &str,
|
||||
one_time_password: Option<&str>,
|
||||
) -> Result<LoginResponse> {
|
||||
let base_url = validate_base_url(base_url.to_string())?;
|
||||
|
||||
let mut body = serde_json::json!({ "email": email, "password": password });
|
||||
if let Some(otp) = one_time_password {
|
||||
body["one_time_password"] = serde_json::Value::String(otp.to_string());
|
||||
}
|
||||
|
||||
let http = reqwest::Client::builder()
|
||||
.connect_timeout(CONNECT_TIMEOUT)
|
||||
.build()?;
|
||||
let response = http
|
||||
.post(format!("{base_url}/login"))
|
||||
.timeout(DEFAULT_TIMEOUT)
|
||||
.header(
|
||||
header::ACCEPT,
|
||||
header::HeaderValue::from_static("application/json"),
|
||||
)
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
Self::decode(response).await
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
|
||||
}
|
||||
@@ -1909,6 +1988,41 @@ impl InvoiceNinjaClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Logout and passkey management. Unlike the credential exchange in
|
||||
/// [`login`](Self::login), these sit behind the normal `token_auth`
|
||||
/// middleware — the same one every other wrapped endpoint uses — so they
|
||||
/// are plain methods here rather than a standalone function.
|
||||
impl InvoiceNinjaClient {
|
||||
/// Regenerates every system-issued API token on the **whole company**,
|
||||
/// not just the one this client is using.
|
||||
///
|
||||
/// `LogoutController::index` looks up the company behind the caller's
|
||||
/// token and replaces every `is_system` token belonging to it with a
|
||||
/// fresh random value — one call logs out every user and every device
|
||||
/// sharing that company. The token this client was built with is among
|
||||
/// them, so a subsequent call through it will fail; building a new
|
||||
/// client with a fresh token is the only way back in.
|
||||
pub async fn logout(&self) -> Result<MessageResponse> {
|
||||
self.post_unwrapped("logout", &serde_json::json!({})).await
|
||||
}
|
||||
|
||||
/// Lists the WebAuthn passkeys registered to the current user.
|
||||
pub async fn list_passkeys(&self) -> Result<Vec<Passkey>> {
|
||||
self.get_list("settings/passkeys", &ListParams::default())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Deletes a passkey credential. The server refuses with a 403 if
|
||||
/// `id` belongs to a different user than the one the token
|
||||
/// authenticates.
|
||||
///
|
||||
/// Registering a new passkey is not wrapped — see [`Passkey`] for why.
|
||||
pub async fn delete_passkey(&self, id: &str) -> Result<()> {
|
||||
self.delete_one(&format!("settings/passkeys/{}", seg(id)?))
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Activity log, email history, and document previews. Each has a response
|
||||
/// shape of its own (read from the upstream controllers, and for the activity
|
||||
/// routes confirmed against a 5.13.50 server), so none is macro-generated.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::CompanyToken;
|
||||
|
||||
/// Response of `POST /login` (via [`crate::InvoiceNinjaClient::login`]).
|
||||
///
|
||||
/// One entry per company the account can access. The full upstream payload
|
||||
/// also nests the whole company (clients, invoices, designs, ...), the
|
||||
/// user, and the account under each entry — this crate does not model that;
|
||||
/// it stays reachable, losslessly, through [`LoginCompanyUser::extra`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LoginResponse {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub data: Vec<LoginCompanyUser>,
|
||||
}
|
||||
|
||||
/// One company membership returned by a login, carrying that company's API
|
||||
/// token.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LoginCompanyUser {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_owner: bool,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_admin: bool,
|
||||
/// The token to pass to [`InvoiceNinjaClient::new`](crate::InvoiceNinjaClient::new)
|
||||
/// for this company. Only absent if the server ever omits the `token`
|
||||
/// include; every server-side `first_load` request requests it.
|
||||
#[serde(default)]
|
||||
pub token: Option<CompanyToken>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
@@ -17,6 +17,8 @@ mod health;
|
||||
mod import_export;
|
||||
mod invoice;
|
||||
mod location;
|
||||
mod login;
|
||||
mod passkey;
|
||||
mod payment;
|
||||
mod payment_term;
|
||||
mod product;
|
||||
@@ -58,6 +60,8 @@ pub use health::{HealthCheck, PhpVersion};
|
||||
pub use import_export::{ExportJob, ImportPreview};
|
||||
pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
|
||||
pub use location::Location;
|
||||
pub use login::{LoginCompanyUser, LoginResponse};
|
||||
pub use passkey::Passkey;
|
||||
pub use payment::{Payment, PaymentInvoice};
|
||||
pub use payment_term::PaymentTerm;
|
||||
pub use product::Product;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// A WebAuthn passkey credential registered against the current user.
|
||||
/// Served from `GET /settings/passkeys`.
|
||||
///
|
||||
/// Registering a new passkey is not wrapped: the server's `/settings/
|
||||
/// passkeys/options` and `/settings/passkeys` routes exist to drive a
|
||||
/// WebAuthn ceremony that only a browser, talking to its platform
|
||||
/// authenticator, can complete — there is no attestation for this crate to
|
||||
/// produce on its own. Listing and deleting an existing credential need no
|
||||
/// such ceremony, so those are the two passkey operations this crate
|
||||
/// offers.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Passkey {
|
||||
/// Hashed id — pass to [`crate::InvoiceNinjaClient::delete_passkey`] to
|
||||
/// remove this credential.
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub name: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub created_at: String,
|
||||
/// Unix timestamp of the credential's last successful authentication,
|
||||
/// or `None` if it has never been used to log in.
|
||||
#[serde(default)]
|
||||
pub last_used_at: Option<i64>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
@@ -2931,3 +2931,129 @@ extra_test!(
|
||||
|c| c.nordigen_institutions(),
|
||||
|got| assert_eq!(got[0]["id"], "BANK_X")
|
||||
);
|
||||
|
||||
// --- logout, passkeys, login --------------------------------------------
|
||||
|
||||
extra_test!(
|
||||
logout_posts_and_returns_the_message,
|
||||
"POST",
|
||||
"/logout",
|
||||
message(),
|
||||
|c| c.logout(),
|
||||
|got| assert_eq!(got.message, "ok")
|
||||
);
|
||||
|
||||
extra_test!(
|
||||
list_passkeys_decodes_the_data_envelope,
|
||||
"GET",
|
||||
"/settings/passkeys",
|
||||
json!({ "data": [
|
||||
{ "id": "p1", "name": "Work laptop", "created_at": "2026-01-01T00:00:00.000000Z", "last_used_at": 1700000000 },
|
||||
{ "id": "p2", "name": "Phone", "created_at": "2026-02-01T00:00:00.000000Z", "last_used_at": null },
|
||||
] }),
|
||||
|c| c.list_passkeys(),
|
||||
|got| {
|
||||
assert_eq!(got.len(), 2);
|
||||
assert_eq!(got[0].last_used_at, Some(1700000000));
|
||||
assert_eq!(got[1].last_used_at, None);
|
||||
}
|
||||
);
|
||||
|
||||
/// The server answers a delete with `{"message": ...}`, not an empty body,
|
||||
/// but `delete_passkey` discards it like every other `delete_*`.
|
||||
#[tokio::test]
|
||||
async fn delete_passkey_discards_the_message_body() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("DELETE"))
|
||||
.and(path("/settings/passkeys/p1"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(message()))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
client(&server).delete_passkey("p1").await.unwrap();
|
||||
}
|
||||
|
||||
/// `login` has no client yet, so it is an associated function taking the
|
||||
/// base url directly rather than a method.
|
||||
#[tokio::test]
|
||||
async fn login_posts_credentials_and_decodes_the_company_token() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/login"))
|
||||
.and(wiremock::matchers::body_json(json!({
|
||||
"email": "me@example.com",
|
||||
"password": "correct-horse-battery-staple",
|
||||
})))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": [
|
||||
{
|
||||
"is_owner": true,
|
||||
"is_admin": true,
|
||||
"token": { "id": "t1", "token": "new-api-token" },
|
||||
"company": { "id": "c1" },
|
||||
}
|
||||
]
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let login = InvoiceNinjaClient::login(
|
||||
&server.uri(),
|
||||
"me@example.com",
|
||||
"correct-horse-battery-staple",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("login should succeed");
|
||||
|
||||
assert_eq!(login.data.len(), 1);
|
||||
let token = login.data[0].token.as_ref().expect("token include present");
|
||||
assert_eq!(token.token, "new-api-token");
|
||||
// The nested company blob is not modelled, but it is not dropped either.
|
||||
assert!(login.data[0].extra.contains_key("company"));
|
||||
}
|
||||
|
||||
/// An optional one-time password goes out alongside the credentials when
|
||||
/// the caller supplies one, and is left out entirely otherwise.
|
||||
#[tokio::test]
|
||||
async fn login_sends_one_time_password_only_when_given() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/login"))
|
||||
.and(wiremock::matchers::body_json(json!({
|
||||
"email": "me@example.com",
|
||||
"password": "pw",
|
||||
"one_time_password": "123456",
|
||||
})))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": [] })))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
InvoiceNinjaClient::login(&server.uri(), "me@example.com", "pw", Some("123456"))
|
||||
.await
|
||||
.expect("login should succeed");
|
||||
}
|
||||
|
||||
/// A failed login surfaces the server's validation message like any other
|
||||
/// non-2xx response.
|
||||
#[tokio::test]
|
||||
async fn login_failure_is_an_api_error() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/login"))
|
||||
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
|
||||
"message": "These credentials do not match our records."
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = InvoiceNinjaClient::login(&server.uri(), "me@example.com", "wrong", None)
|
||||
.await
|
||||
.expect_err("400");
|
||||
assert!(
|
||||
matches!(&err, Error::Api { status, .. } if status.as_u16() == 400),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,9 @@ fn every_model_absorbs_a_null_or_missing_field() {
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
Location,
|
||||
LoginCompanyUser,
|
||||
LoginResponse,
|
||||
Passkey,
|
||||
Payment,
|
||||
PaymentInvoice,
|
||||
PaymentTerm,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use invoiceninja_sdk::models::{
|
||||
BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client, ClientGatewayToken,
|
||||
Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, ExpenseCategory,
|
||||
GroupSetting, HealthCheck, Invoice, ListParams, ListResponse, Location, PaymentTerm, Project,
|
||||
PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote, SearchResults,
|
||||
Statics, Subscription, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor,
|
||||
Webhook,
|
||||
GroupSetting, HealthCheck, Invoice, ListParams, ListResponse, Location, LoginResponse, Passkey,
|
||||
PaymentTerm, Project, PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote,
|
||||
SearchResults, Statics, Subscription, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate,
|
||||
User, Vendor, Webhook,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -763,3 +763,42 @@ fn statics_ids_accept_both_strings_and_numbers() {
|
||||
assert_eq!(statics.industries[0].id, "3");
|
||||
assert_eq!(statics.sizes[0].id, "", "a null id decodes as empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_response_types_the_token_and_preserves_the_rest() {
|
||||
let payload = json!({
|
||||
"data": [{
|
||||
"is_owner": true,
|
||||
"is_admin": true,
|
||||
"token": { "id": "t1", "token": "abc" },
|
||||
"company": { "id": "c1", "settings": {} },
|
||||
"user": { "id": "u1", "email": "me@example.com" }
|
||||
}]
|
||||
});
|
||||
|
||||
let login: LoginResponse = serde_json::from_value(payload).unwrap();
|
||||
assert!(login.data[0].is_owner);
|
||||
let token = login.data[0].token.as_ref().unwrap();
|
||||
assert_eq!(token.token, "abc");
|
||||
|
||||
let back = serde_json::to_value(&login).unwrap();
|
||||
assert_eq!(back["data"][0]["company"]["id"], "c1");
|
||||
assert_eq!(back["data"][0]["user"]["email"], "me@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passkey_round_trip_preserves_last_used_at() {
|
||||
let used = json!({
|
||||
"id": "p1", "name": "Laptop", "created_at": "2026-01-01T00:00:00.000000Z",
|
||||
"last_used_at": 1700000000
|
||||
});
|
||||
let never_used = json!({
|
||||
"id": "p2", "name": "Phone", "created_at": "2026-02-01T00:00:00.000000Z",
|
||||
"last_used_at": null
|
||||
});
|
||||
|
||||
let used: Passkey = serde_json::from_value(used).unwrap();
|
||||
let never_used: Passkey = serde_json::from_value(never_used).unwrap();
|
||||
assert_eq!(used.last_used_at, Some(1700000000));
|
||||
assert_eq!(never_used.last_used_at, None);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user