From 9be5c8a3c32aad57d5656e543d3611bb7fcb12cf Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 23 Oct 2026 19:53:03 +0200 Subject: [PATCH] feat: give API refusals stable codes and actionable messages (attempt_check_unavailable and code_lookup_limited with Retry-After, account and workspace standing codes, session_ended, reset_link_invalid, sessions_not_ended, api_credentials_revoke_unsupported, invitation_expired, image_unreadable and the other image upload codes, own-domain redirects naming the allowed domains, Pipedrive reload guidance, duplicate parameters, structured forms service errors), and show them in the dashboard, admin panel and warmbly CLI --- admin/src/lib/api/client.ts | 3 +- cmd/cli/device.go | 6 +- cmd/cli/main.go | 4 + docs/content/docs/api/error-codes.mdx | 121 ++++++++++++++++-- docs/content/docs/guides/forms.mdx | 2 +- internal/api/handler/admin_tester.go | 2 +- internal/api/handler/avatar.go | 16 ++- internal/api/handler/crm_provider.go | 13 +- internal/api/handler/email_image.go | 14 +- internal/api/handler/form.go | 9 +- internal/api/handler/image_upload.go | 48 +++++++ internal/api/handler/oauth.go | 2 +- internal/api/handler/oauth_logo.go | 9 +- internal/api/handler/organization.go | 2 +- internal/api/middleware/code_budget.go | 7 +- internal/api/middleware/request_params.go | 12 +- internal/app/auth/cache.go | 2 +- internal/app/auth/login.go | 2 +- internal/app/auth/reset_password.go | 34 ++++- internal/app/emailsend/service.go | 4 +- internal/app/organization/service.go | 8 +- internal/app/owndomain/owndomain.go | 6 +- internal/app/passkey/login.go | 2 +- internal/app/sendingdomain/service.go | 2 +- internal/app/token/refresh.go | 2 +- internal/app/token/verify.go | 2 +- internal/app/twofa/login.go | 2 +- internal/app/twofa/service.go | 2 +- internal/cli/api/client.go | 23 +++- internal/formserver/server.go | 26 ++-- .../security/ChangePasswordDialog.tsx | 4 +- .../auth/hooks/useResetPasswordConfirmForm.ts | 3 + web/src/app/auth/login/page.tsx | 6 +- .../app/auth/reset-password/confirm/page.tsx | 4 +- .../emails/domains/SendingDomainDrawer.tsx | 2 +- .../components/app/emails/domains/rules.ts | 6 +- .../components/app/forms/SettingsPanel.tsx | 10 +- .../app/modals/PermissionDeniedModal.tsx | 16 ++- web/src/components/app/modals/ReauthModal.tsx | 3 +- web/src/lib/auth.ts | 28 +++- web/src/lib/errors/auth.ts | 2 +- web/src/lib/helper/buildError.ts | 3 + 42 files changed, 373 insertions(+), 101 deletions(-) create mode 100644 internal/api/handler/image_upload.go diff --git a/admin/src/lib/api/client.ts b/admin/src/lib/api/client.ts index 3f06147bd..8c6bc6c3d 100644 --- a/admin/src/lib/api/client.ts +++ b/admin/src/lib/api/client.ts @@ -178,7 +178,8 @@ export async function Request(config: AuthRequestConfig): Promise { return retry.data; } const failure = new APIError( - body.error || body.message || err.message || "Request failed", + // message is the sentence written for people; error is only the HTTP title. + body.message || (typeof body.error === "string" ? body.error : "") || err.message || "Request failed", status, err.response?.data, ); diff --git a/cmd/cli/device.go b/cmd/cli/device.go index eec70b548..75bcc7231 100644 --- a/cmd/cli/device.go +++ b/cmd/cli/device.go @@ -103,9 +103,9 @@ func pollDeviceFlow(ctx context.Context, client *api.Client, start *deviceStart) if api.StatusOf(err) == http.StatusNotFound { return nil, fmt.Errorf("the code %s is no longer valid. Run `warmbly auth login` again.", start.UserCode) } - // A rate limit or a blip must not end a sign-in someone is - // standing at; back off and keep waiting. - if api.StatusOf(err) == http.StatusTooManyRequests || api.StatusOf(err) == 0 { + // A rate limit, a brief outage or a blip must not end a sign-in + // someone is standing at; back off and keep waiting. + if st := api.StatusOf(err); st == http.StatusTooManyRequests || st == http.StatusServiceUnavailable || st == 0 { interval += time.Second continue } diff --git a/cmd/cli/main.go b/cmd/cli/main.go index d1539c837..3959a222f 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -83,6 +83,10 @@ func reportError(io *iostreams.IOStreams, err error) int { case apiErr.IsUnauthorized(): fmt.Fprintln(io.ErrOut, io.Gray("The credential was rejected. Run `warmbly auth status` to see which one was used, or `warmbly auth login` to replace it.")) return 4 + case apiErr.Code == "account_suspended" || apiErr.Code == "account_sending_suspended" || + apiErr.Code == "account_workspace_create_suspended" || apiErr.Code == "workspace_sending_suspended": + // A standing on the account or workspace, not a missing scope; the message says what to do. + return 4 case apiErr.Status == 403: fmt.Fprintln(io.ErrOut, io.Gray("The key is missing a scope for this call. `warmbly auth refresh` re-runs the sign-in and can ask for more.")) return 4 diff --git a/docs/content/docs/api/error-codes.mdx b/docs/content/docs/api/error-codes.mdx index 5d5a37e35..2208390dd 100644 --- a/docs/content/docs/api/error-codes.mdx +++ b/docs/content/docs/api/error-codes.mdx @@ -18,7 +18,7 @@ All errors follow this structure: } ``` -`error` and `message` are for people. Client logic should use `code`, HTTP status, and endpoint-specific fields such as `retry_after`. Include `request_id` when contacting support. +`error` and `message` are for people. Client logic should use `code`, HTTP status, and endpoint-specific fields such as `retry_after`. Include `request_id` when contacting support. A few codes also carry an optional `reason`, a finer key for when the same code has more than one next step; each is listed with its code, and a client should treat an unknown or missing `reason` as the plain code. ## HTTP status codes @@ -96,11 +96,27 @@ Fields are named by their JSON key, with nested fields as a dotted path (`inner. Versioned API routes check every query parameter name and value before the endpoint runs, including endpoints that read filters directly. The legacy `/invitations/lookup` alias applies the same validation. NUL characters, invalid UTF-8 and malformed query encoding return `400` rather than sending unsupported text to the database. For example, `?q=%00` returns `Query parameter "q[0]" contains a NUL character, which is not supported.` Valid Unicode text and properly encoded punctuation remain unchanged. -Every route takes each query parameter at most once. A parameter repeated in the query string returns `400` with `code: "duplicate_query_parameter"` and a `request_id`, whatever the values. The parameters documented as lists accept repeats: `status` on `GET /v1/advisor/recommendations`, and `campaign_ids` and `folder_ids` on `GET /v1/analytics/dashboard`. Send any other list as one comma-separated value where the endpoint documents one. +Every route takes each query parameter at most once. A parameter repeated in the query string returns `400` with `code: "duplicate_query_parameter"` and a `request_id`, whatever the values, and the `message` names it: `The query parameter "status" appears more than once. Send it once per request.` The parameters documented as lists accept repeats: `status` on `GET /v1/advisor/recommendations`, and `campaign_ids` and `folder_ids` on `GET /v1/analytics/dashboard`. Send any other list as one comma-separated value where the endpoint documents one. -A request carrying more than one `Authorization`, `X-Warmbly-Token`, `Idempotency-Key` or `Origin` header line returns `400` with `code: "duplicate_header"`. Send each of them once. Repeated `X-Forwarded-For` lines are read as one list in arrival order, and for `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Forwarded-Port` and `X-Real-IP` the last line is used, the one the nearest proxy wrote. Forwarded headers are believed only from [`TRUSTED_PROXIES`](/development/configuration/#network-and-proxy). +A request carrying more than one `Authorization`, `X-Warmbly-Token`, `Idempotency-Key` or `Origin` header line returns `400` with `code: "duplicate_header"`, and the `message` names the header. Send each of them once. Repeated `X-Forwarded-For` lines are read as one list in arrival order, and for `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Forwarded-Port` and `X-Real-IP` the last line is used, the one the nearest proxy wrote. Forwarded headers are believed only from [`TRUSTED_PROXIES`](/development/configuration/#network-and-proxy). -The public forms service answers a repeated query parameter or `X-Warmbly-Render` header on its `/api/forms/` routes with `400` and `{"error": "duplicate_query_parameter"}` or `{"error": "duplicate_header"}`. +The public forms service answers its `/api/forms/` routes with `error` and `code` holding the same slug, and a `message` to show: + +| `code` | Status | Meaning | +|--------|--------|---------| +| `duplicate_query_parameter`, `duplicate_header` | 400 | A query parameter or the `X-Warmbly-Render` header was sent more than once. The `message` names it | +| `stale_page` | 403 | The page was open long enough for its submission token to lapse. Reload it and submit again | +| `not_found` | 404 | The form does not exist or is not published, or no forms API route matches the path | +| `unavailable` | 503 | The forms service could not reach the backend. Try again in a few minutes | +| `form_submit_failed` | 400, 413, 502 | The submission was refused (the `message` names the field or rule), was too large, or could not be passed on. Fix and submit again, or retry a `502` | + +```json +{ + "error": "stale_page", + "message": "This page has been open too long. Reload it and try again.", + "code": "stale_page" +} +``` Resource IDs on email, campaign and contact routes must be UUIDs. Email `tags` and campaign `email_tags` and `folders` arrays also require UUID values. Malformed values return `400` with `code: "bad_request"` and a `request_id` before a database write. Valid UUIDs are normalized and repeated relation IDs are deduplicated; workspace ownership checks still apply. @@ -148,12 +164,43 @@ Resource IDs on email, campaign and contact routes must be UUIDs. Email `tags` a | `recipient_suppressed` | `POST /emails/:id/send`, `POST /unibox/reply` or `POST /unibox/compose` named a `to`, `cc` or `bcc` address on the workspace [suppression list](/guides/unsubscribe/#the-suppression-list), directly or through its domain. The `message` names the address and the entry. Nothing was queued. A forward (`POST /unibox/reply` with `forward_message_id`) is not checked against the list | | `invalid_message_id` | `POST /emails/:id/send` or `POST /unibox/reply` was given an `in_reply_to` entry that is not one Message-ID: printable characters with no spaces or line breaks, with or without the surrounding `<` and `>` | +#### Image upload refusals + +Avatars, OAuth app logos, form logos, covers and backgrounds, and images placed in an email are checked by what the file contains, not by its name or the type the browser sends. + +| `code` | Status | Meaning | +|--------|--------|---------| +| `image_unreadable` | 400 | The file is empty or does not decode as an image: it is damaged, or it is a different format than its name suggests | +| `image_format_not_allowed` | 400 | The file is an image, but not a format this upload takes | +| `image_too_large` | 400 | The file is over the size limit | +| `image_dimensions_too_large` | 400 | The image is wider or taller than the limit | +| `image_too_small` | 400 | An OAuth app logo is under 32px on a side | + +| Upload | Formats | Size | Each side | +|--------|---------|------|-----------| +| Avatar, OAuth app logo | PNG or JPG | 2 MB | 1024px | +| Form logo | PNG or JPG | 1 MB | 1024px | +| Form cover or background | PNG or JPG | 4 MB | 2560px | +| Email image | PNG, JPG, GIF or WebP | 5 MB | 4000px | + +```json +{ + "error": "Bad Request", + "message": "The avatar could not be read as an image. The file looks damaged, or it is a different format than its name suggests. Upload a PNG or JPG file up to 2 MB and 1024px on each side.", + "code": "image_unreadable", + "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e" +} +``` + #### Password refusals | `code` | Status | Meaning | |--------|--------|---------| | `password_breached` | 400 | The password appears in a public list of breached passwords and was refused. Choose one that does not | | `sso_link_expired` | 400 | The pending token from a `link_required` sign-in is unknown, expired (ten minutes), already used, or has had three wrong passwords. Start the provider sign-in again | +| `reset_link_invalid` | 401 | `POST /auth/reset-password/session` or `/confirm` was given a reset link or reset session that expired, was already used, or was issued before the password last changed. Request a new link | +| `api_credentials_revoke_unsupported` | 400 | `POST /auth/reset-password/confirm` or `POST /auth/me/password` asked to end connected apps and API keys (`revoke_api_credentials`) on a server that cannot. The password was not changed. Send it again without that option, then remove apps and keys from Settings | +| `sessions_not_ended` | 503 | The same two routes could not sign out every device on the account, so the password was not changed. The response carries `Retry-After`; retry after it | A password must be 8 to 128 characters. There is no composition rule, but it is checked against the 100,000 most commonly breached passwords published by the UK National Cyber Security Centre, case-insensitively. @@ -252,7 +299,7 @@ Hosts are compared in lowercase, without a trailing dot and in their ASCII form, ```json { "error": "Bad Request", - "message": "The redirect has to go to a page on one of this workspace's own domains: a domain it has a mailbox on, its verified forms domain, or a subdomain of one of them.", + "message": "The redirect has to go to a page on one of this workspace's own domains: a domain it has a mailbox on (not a shared provider such as gmail.com), its verified tracking domain, its verified forms domain, or a subdomain of one of them. Use an address on one of those, or connect a mailbox or verify a domain first.", "code": "redirect_target_not_own_domain", "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e" } @@ -503,7 +550,18 @@ Returned when authentication fails. - Check if your key has expired or been revoked - Generate a new key if necessary -Two `401` variants are not about API keys at all. `setup_token_invalid` is returned by the first-run claim endpoint when the setup link is invalid, already used or expired. Print a new one with `warmblyctl setup-link`, described in [first run](/development/first-run/). +`session_ended` means the dashboard session behind the token was signed out, revoked, or ended by a password change. Refreshing will not help; sign in again. + +```json +{ + "error": "Unauthorized", + "message": "Your session has ended. Sign in again to continue.", + "code": "session_ended", + "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e" +} +``` + +Two more `401` variants are not about API keys at all. `setup_token_invalid` is returned by the first-run claim endpoint when the setup link is invalid, already used or expired. Print a new one with `warmblyctl setup-link`, described in [first run](/development/first-run/). `sso_wrong_browser` is returned by `POST /auth/sso/exchange` when the handoff code is collected without the binding secret that `POST /auth//begin` handed the browser that started the sign-in. The handoff is deliberately non-transferable: a sign-in link that was forwarded, or opened in another browser, cannot sign the recipient in. Start the sign-in again in the browser you want to use. @@ -579,6 +637,9 @@ Signup and invitation refusals carry their own `code`, so a client can branch on | `registration_invite_only` | 403 | The server runs `DISABLE_REGISTRATION=invite_only`. Creating an account requires an invitation link, which carries the token that permits the signup | | `registration_closed` | 403 | The server runs `DISABLE_REGISTRATION=true`. Signups are off and invitations do not override it | | `invitation_invalid` | 403 | The invitation is expired, cancelled, already used, or was issued for a different email address | +| `invitation_expired` | 400 | `POST /invitations/accept` on an invitation older than 48 hours. It stays on the team page, where it can be sent again | +| `invitation_used_or_expired` | 400 | `POST /invitations/accept` on an invitation that was accepted or expired while the request was in flight. Ask for it to be sent again | +| `invitation_roles_removed` | 400 | `POST /invitations/accept` on an invitation whose roles were all deleted since it was sent. Ask for a new invitation | | `setup_already_complete` | 403 | The first-run claim was attempted on an instance that already has an account | ```json @@ -592,6 +653,26 @@ Signup and invitation refusals carry their own `code`, so a client can branch on **How to fix:** on a self-hosted deployment these are configuration, not faults. See [accounts and access](/development/accounts-and-access/#registration-modes). +#### Account and workspace standing + +An account or workspace that support or the instance's administrators have restricted answers with its own `code`. These are not permission problems, and a role change does not lift them. + +| `code` | Status | Meaning | +|--------|--------|---------| +| `account_suspended` | 403 | The account is suspended and cannot sign in, by password, passkey or two-factor confirmation | +| `account_sending_suspended` | 403 | The account may not send mail. A manual send (`POST /emails/:id/send`, `POST /unibox/*`) is refused; nothing else is affected | +| `account_workspace_create_suspended` | 403 | The account may not create workspaces. It can still join one it is invited to | +| `workspace_sending_suspended` | 403 | Sending is paused for the whole workspace while it is under review. Contact support to resolve the review | + +```json +{ + "error": "Forbidden", + "message": "Sending is paused for this workspace while it is under review. Contact support to resolve the review; nothing else in the workspace is affected.", + "code": "workspace_sending_suspended", + "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e" +} +``` + #### Confirmation required | `code` | Status | Meaning | @@ -811,7 +892,18 @@ On a self-hosted deployment that means `MAIL_TRANSPORT` and the `SMTP_` variable Returned when the service is temporarily unavailable. -Every endpoint that checks a password, an emailed or two-factor code, or a sign-in link charges an attempt to its budget before it compares anything. When that budget cannot be read, the request is refused with this code and nothing is compared, so a sign-in can briefly fail during a cache outage. Retry it. +Every endpoint that checks a password, an emailed or two-factor code, or a sign-in link charges an attempt to its budget before it compares anything. When that budget cannot be read, the request is refused with `code: "attempt_check_unavailable"` and a `Retry-After` header, nothing is compared and the attempt is not counted, so a sign-in can briefly fail during a cache outage. Retry after the header. Looking up a CLI or Warmbly Cloud pairing code (`/auth/cli/codes/:code`, `/pool-link/codes/:code`) answers the same code when its lookup budget cannot be read. + +```json +{ + "error": "Service Unavailable", + "message": "We couldn't check this attempt right now, so it was not counted. Wait a few seconds and try again.", + "code": "attempt_check_unavailable", + "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e" +} +``` + +Other temporary failures answer the generic form: **Example:** @@ -959,7 +1051,7 @@ A `400` whose `code` is `mailbox_auth_refused` comes from the SMTP and IMAP conn #### `mailbox_unreachable` -A `400` whose `code` is `mailbox_unreachable` comes from the same endpoints. The worker could not open a connection to the server at all: the name did not resolve, the port refused, or a firewall dropped the packets. The message says which server and which of those it was. Nothing was saved. +A `400` whose `code` is `mailbox_unreachable` comes from the same endpoints. The worker could not open a connection to the server at all: the name did not resolve, the port refused, a firewall dropped the packets, or the host name points to a private network address. The message says which server and which of those it was. Nothing was saved. ```json { @@ -973,6 +1065,7 @@ A `400` whose `code` is `mailbox_unreachable` comes from the same endpoints. The **How to fix:** - Check the host and port for typos - The connection leaves from the worker, not from your browser or from the API host: the worker's network has to reach the server on that port. Some hosting providers block outbound mail ports until asked +- A host name that points to a private network address (such as `10.x`, `192.168.x` or a loopback address) is refused, because Warmbly only connects mailboxes to public addresses. Use the host name your provider gives for connecting from outside your network. On a self-hosted instance, the message also names `MAIL_ALLOW_PRIVATE_HOSTS=true`, which the operator can set to connect mail servers on a private network (see [configuration](/development/configuration/)) #### `mailbox_tls_failed` @@ -981,13 +1074,13 @@ A `400` whose `code` is `mailbox_tls_failed` comes from the same endpoints. The ```json { "error": "Bad Request", - "message": "IMAP (mail.example.com:143) did not complete a secure connection: the server offers no STARTTLS on this port. Nothing was saved.", + "message": "IMAP (mail.example.com:143) did not complete a secure connection: the server offers no STARTTLS on this port, and Warmbly only signs in over an encrypted connection. Use the server's SSL/TLS port (usually 993) or a port that offers STARTTLS (usually 143). Nothing was saved.", "code": "mailbox_tls_failed", "request_id": "6f3c0a1e-2d47-4c6b-9a1f-2b7c5f0e8d31" } ``` -When the certificate is what failed, the message also names the certificate the server presented: who it is for, who issued it (or that it is self-signed) and the SHA-256 fingerprint of its public key. +When the certificate is what failed, the message says why (issued for a different host name, expired, or not vouched for by a recognised certificate authority) and names the certificate the server presented: who it is for, who issued it (or that it is self-signed) and the SHA-256 fingerprint of its public key. When the server can be trusted on its own certificate instead, the message ends by saying so and the response carries `"reason": "certificate_untrusted"`; the dashboard's connect and reconnect forms then offer **Trust this server's own certificate**. **How to fix:** - Match the security setting to the port: `tls` for 465 and 993, `starttls` for 587 and 143, unless the provider documents otherwise @@ -1008,10 +1101,11 @@ Both are warnings: the mailbox is not deactivated, its credentials are not marke **How to fix:** - Check with the server's owner that the change is expected - Open **Settings > Server certificates** on the mailbox and choose **Re-confirm**, or call `POST /emails/onboarding/smtp-imap/:id/certificates/check` and then `.../certificates/confirm` with the fingerprints it reported. The error clears once the confirmed certificates connect +- If the confirm call answers `mailbox_tls_failed` saying the server did not present the certificate with the fingerprint you confirmed, a fingerprint was mistyped or the certificate changed after the check. Nothing was changed; run the check again and confirm what it reports #### `mailbox_server_declined` -A `400` whose `code` is `mailbox_server_declined` comes from the same endpoints. The sign-in could not be completed for a reason other than the credentials: the server asked for a retry later (a `4xx` reply, often a login rate limit), it offers no sign-in method Warmbly implements, it answered with a `5xx` that is not about the password (a mechanism it does not support, a STARTTLS it insists on), the conversation broke, or the probe itself failed before a reply arrived. The message carries the server's reply when there is one. Nothing was saved. +A `400` whose `code` is `mailbox_server_declined` comes from the same endpoints. The sign-in could not be completed for a reason other than the credentials: the server asked for a retry later (a `4xx` reply, often a login rate limit), it offers no sign-in method Warmbly implements, it answered with a `5xx` that is not about the password (a mechanism it does not support, a STARTTLS it insists on), it only offers sign-in without encryption, the conversation broke, or the probe itself failed before a reply arrived. The message carries the server's reply when there is one. Nothing was saved. ```json { @@ -1024,7 +1118,8 @@ A `400` whose `code` is `mailbox_server_declined` comes from the same endpoints. **How to fix:** - For a `4xx` reply, wait a few minutes and retry -- For a server that offers only mechanisms Warmbly does not implement (NTLM, GSSAPI), use the provider's documented SMTP submission host, which normally offers `LOGIN` or `PLAIN`; a different password does not add a mechanism +- For a server that offers only mechanisms Warmbly does not implement (NTLM, GSSAPI, XOAUTH2), use the provider's documented SMTP submission host, which normally offers `LOGIN` or `PLAIN`; a different password does not add a mechanism. The message lists what the server offered +- A server that only offers sign-in over an unencrypted connection is refused before any password is sent. Use its SSL/TLS port (usually 465) or a port that offers STARTTLS (usually 587) #### `mailbox_worker_unreachable` @@ -1189,6 +1284,8 @@ Retry-After: 60 Use the `Retry-After` header to determine when to retry. +Looking up a CLI or Warmbly Cloud pairing code is budgeted per signed-in person, and past it answers `429` with `code: "code_lookup_limited"` and `Retry-After: 60`. Check the code shown on the other device before trying again. + ## Admin device request errors The [admin device flow](/api/authentication/#platform-admin-device-sessions) diff --git a/docs/content/docs/guides/forms.mdx b/docs/content/docs/guides/forms.mdx index 2113d7e65..5f90f3f08 100644 --- a/docs/content/docs/guides/forms.mdx +++ b/docs/content/docs/guides/forms.mdx @@ -115,7 +115,7 @@ The **Design** tab styles the form while the canvas updates live. The defaults a The **Settings** tab controls what a submission does: -- **Success message** is shown after submitting, or set a **redirect URL** to send the visitor to your own thank-you page instead. The page has to be on one of your workspace's own domains: a domain you have a mailbox on, your verified [custom forms domain](#custom-domain), or a subdomain of either. Any other address is refused with `redirect_target_not_own_domain`. If a saved page stops qualifying, for example after the last mailbox on its domain is removed, the setting is kept and marked **Not in use**, and visitors see the success message until you change it. +- **Success message** is shown after submitting, or set a **redirect URL** to send the visitor to your own thank-you page instead. The page has to be on one of your workspace's own domains: a domain you have a mailbox on (not a shared provider such as `gmail.com`), a verified tracking domain, your verified [custom forms domain](#custom-domain), or a subdomain of one of them. Any other address is refused with `redirect_target_not_own_domain`. If a saved page stops qualifying, for example after the last mailbox on its domain is removed, the setting is kept and marked **Not in use**, and visitors see the success message until you change it. - **Add to labels** puts the labels you pick on every submitted contact, for example "Website leads". - **Add to campaign** enrolls new contacts as leads in the campaign you pick. Sending still follows the campaign's own schedule, limits and windows; a form never causes immediate mail. Picking a campaign turns on its **Keep running for new leads** setting, so it waits between submissions instead of finishing; a campaign that had already finished restarts when a lead arrives, through the usual launch checks, and a refused restart is noted in its activity log while the lead waits. - **Spam protection** and **allowed embed domains** are covered below. diff --git a/internal/api/handler/admin_tester.go b/internal/api/handler/admin_tester.go index 0d4f42a7e..e9637bbdf 100644 --- a/internal/api/handler/admin_tester.go +++ b/internal/api/handler/admin_tester.go @@ -62,7 +62,7 @@ func testerPasswordLifetime(req adminCreateTesterRequest) (time.Duration, *errx. hours = testerPasswordDefaultHours } if hours < 1 || hours > testerPasswordMaxHours { - return 0, errx.New(errx.BadRequest, "the password can be valid for 1 to 48 hours") + return 0, errx.New(errx.BadRequest, "The initial password can be valid for 1 to 48 hours. Set password_hours to a whole number from 1 to 48.") } return time.Duration(hours) * time.Hour, nil } diff --git a/internal/api/handler/avatar.go b/internal/api/handler/avatar.go index 65afd05b4..c40b505f0 100644 --- a/internal/api/handler/avatar.go +++ b/internal/api/handler/avatar.go @@ -280,6 +280,11 @@ func (h *Handler) requireOrgOwner(c *gin.Context, orgID, userID uuid.UUID) *errx // readAvatarUpload pulls the "file" field out of the request, enforces // the size cap, decides the format from the bytes, and returns them ready for S3. func readAvatarUpload(c *gin.Context) ([]byte, string, string, *errx.Error) { + return readImageUpload(c, avatarLimits) +} + +// readImageUpload is readAvatarUpload with the refusals worded for lim. +func readImageUpload(c *gin.Context, lim imageLimits) ([]byte, string, string, *errx.Error) { // Cap the request body before parsing so a 50MB upload doesn't // pin a gin worker. c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, avatarMaxBytes+1024) @@ -289,7 +294,7 @@ func readAvatarUpload(c *gin.Context) ([]byte, string, string, *errx.Error) { return nil, "", "", errx.New(errx.BadRequest, "file is required") } if fh.Size > avatarMaxBytes { - return nil, "", "", errx.New(errx.BadRequest, "avatar must be smaller than 2 MB") + return nil, "", "", lim.tooLarge() } src, err := fh.Open() @@ -308,10 +313,10 @@ func readAvatarUpload(c *gin.Context) ([]byte, string, string, *errx.Error) { // format's own decoder have to agree. format, cfg, err := imagetype.Detect(body, avatarFormats...) if errors.Is(err, imagetype.ErrUnreadable) { - return nil, "", "", errx.New(errx.BadRequest, "image could not be parsed") + return nil, "", "", lim.unreadable() } if err != nil { - return nil, "", "", errx.New(errx.BadRequest, "avatar must be a PNG or JPG") + return nil, "", "", lim.wrongFormat() } mime, ext := format.MIME, format.Ext @@ -319,10 +324,7 @@ func readAvatarUpload(c *gin.Context) ([]byte, string, string, *errx.Error) { // this is a backstop against a raw camera dump or a bypass of the // JS resizer. if cfg.Width > avatarMaxDimension || cfg.Height > avatarMaxDimension { - return nil, "", "", errx.New( - errx.BadRequest, - fmt.Sprintf("avatar dimensions must be %dpx or smaller — please resize before uploading", avatarMaxDimension), - ) + return nil, "", "", lim.tooWide() } return body, mime, ext, nil } diff --git a/internal/api/handler/crm_provider.go b/internal/api/handler/crm_provider.go index 6dc306d72..35d359645 100644 --- a/internal/api/handler/crm_provider.go +++ b/internal/api/handler/crm_provider.go @@ -664,7 +664,7 @@ func (h *Handler) pipedriveAppCall(c *gin.Context) (pipedrive.AppCall, bool) { q := c.Request.URL.Query() for _, k := range []string{"userId", "companyId", "selectedIds", "token"} { if len(q[k]) > 1 { - c.Status(http.StatusBadRequest) + pipedriveRefuse(c, http.StatusBadRequest, pipedriveReopenMessage) return pipedrive.AppCall{}, false } } @@ -675,12 +675,21 @@ func (h *Handler) pipedriveAppCall(c *gin.Context) (pipedrive.AppCall, bool) { RecordID: strings.TrimSpace(q.Get("selectedIds")), } if !h.Pipedrive.VerifyAppToken(q.Get("token"), call) { - c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"message": "Pipedrive did not sign this request."}}) + pipedriveRefuse(c, http.StatusUnauthorized, pipedriveReopenMessage) return pipedrive.AppCall{}, false } return call, true } +// pipedriveReopenMessage answers a request whose Pipedrive signature is +// missing, malformed, too old, or not Pipedrive's. +const pipedriveReopenMessage = "Warmbly could not confirm this request came from Pipedrive, or the page has been open too long. Reload the Pipedrive page and open the Warmbly panel again." + +// pipedriveRefuse answers in the shape both Pipedrive panels and modals display. +func pipedriveRefuse(c *gin.Context, status int, message string) { + c.JSON(status, gin.H{"error": gin.H{"title": "Warmbly", "subtitle": message, "message": message}}) +} + // PipedrivePanel answers the Warmbly JSON panel on Pipedrive person and deal // pages. A refusal is shown in the panel with its reason. func (h *Handler) PipedrivePanel(c *gin.Context) { diff --git a/internal/api/handler/email_image.go b/internal/api/handler/email_image.go index 0db286e55..987cd73ed 100644 --- a/internal/api/handler/email_image.go +++ b/internal/api/handler/email_image.go @@ -71,8 +71,12 @@ func (h *Handler) UploadEmailImage(c *gin.Context) { errx.JSON(c, errx.New(errx.BadRequest, "file is required")) return } - if fh.Size <= 0 || fh.Size > emailImageMaxBytes { - errx.JSON(c, errx.New(errx.BadRequest, fmt.Sprintf("the image must be between 1 byte and %d MB", mb(emailImageMaxBytes)))) + if fh.Size <= 0 { + errx.JSON(c, emailImageLimits.unreadable()) + return + } + if fh.Size > emailImageMaxBytes { + errx.JSON(c, emailImageLimits.tooLarge()) return } @@ -93,16 +97,16 @@ func (h *Handler) UploadEmailImage(c *gin.Context) { // back from our own public origin. format, cfg, err := imagetype.Detect(body, allowedEmailImageFormats...) if errors.Is(err, imagetype.ErrUnreadable) { - errx.JSON(c, errx.New(errx.BadRequest, "the image could not be parsed")) + errx.JSON(c, emailImageLimits.unreadable()) return } if err != nil { - errx.JSON(c, errx.New(errx.BadRequest, "the image must be a PNG, JPG, GIF or WebP")) + errx.JSON(c, emailImageLimits.wrongFormat()) return } mimeType, ext := format.MIME, format.Ext if cfg.Width > emailImageMaxDimension || cfg.Height > emailImageMaxDimension { - errx.JSON(c, errx.New(errx.BadRequest, fmt.Sprintf("the image must be %dpx or smaller on each side", emailImageMaxDimension))) + errx.JSON(c, emailImageLimits.tooWide()) return } diff --git a/internal/api/handler/form.go b/internal/api/handler/form.go index e9a4aa4bc..c439571fe 100644 --- a/internal/api/handler/form.go +++ b/internal/api/handler/form.go @@ -276,13 +276,14 @@ func readFormImageUpload(c *gin.Context, kind string) (body []byte, mime, ext st if kind == "cover" || kind == "background" { maxBytes, maxDim = formCoverMaxBytes, formCoverMaxDim } + lim := imageLimits{label: kind, formats: "PNG or JPG", maxBytes: maxBytes, maxDim: maxDim} c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes+1024) fh, err := c.FormFile("file") if err != nil { return nil, "", "", errx.New(errx.BadRequest, "file is required") } if fh.Size > maxBytes { - return nil, "", "", errx.New(errx.BadRequest, fmt.Sprintf("the %s must be smaller than %d MB", kind, maxBytes>>20)) + return nil, "", "", lim.tooLarge() } src, err := fh.Open() if err != nil { @@ -296,14 +297,14 @@ func readFormImageUpload(c *gin.Context, kind string) (body []byte, mime, ext st body = buf.Bytes() format, cfg, err := imagetype.Detect(body, avatarFormats...) if errors.Is(err, imagetype.ErrUnreadable) { - return nil, "", "", errx.New(errx.BadRequest, "image could not be parsed") + return nil, "", "", lim.unreadable() } if err != nil { - return nil, "", "", errx.New(errx.BadRequest, fmt.Sprintf("the %s must be a PNG or JPG", kind)) + return nil, "", "", lim.wrongFormat() } mime, ext = format.MIME, format.Ext if cfg.Width > maxDim || cfg.Height > maxDim { - return nil, "", "", errx.New(errx.BadRequest, fmt.Sprintf("the %s must be %dpx or smaller", kind, maxDim)) + return nil, "", "", lim.tooWide() } return body, mime, ext, nil } diff --git a/internal/api/handler/image_upload.go b/internal/api/handler/image_upload.go new file mode 100644 index 000000000..09c7cf11f --- /dev/null +++ b/internal/api/handler/image_upload.go @@ -0,0 +1,48 @@ +package handler + +import ( + "fmt" + + "github.com/warmbly/warmbly/internal/errx" +) + +// imageLimits names an upload and its caps, so every image refusal says the +// same thing in the same words and tells the person what would be accepted. +type imageLimits struct { + label string // "avatar", "logo", "cover", "image" + formats string // "PNG or JPG" + maxBytes int64 + maxDim int +} + +var ( + avatarLimits = imageLimits{label: "avatar", formats: "PNG or JPG", maxBytes: avatarMaxBytes, maxDim: avatarMaxDimension} + appLogoLimits = imageLimits{label: "logo", formats: "PNG or JPG", maxBytes: avatarMaxBytes, maxDim: avatarMaxDimension} + emailImageLimits = imageLimits{label: "image", formats: "PNG, JPG, GIF or WebP", maxBytes: emailImageMaxBytes, maxDim: emailImageMaxDimension} +) + +func (l imageLimits) accepted() string { + return fmt.Sprintf("Upload a %s file up to %d MB and %dpx on each side.", l.formats, mb(l.maxBytes), l.maxDim) +} + +// unreadable: the bytes do not decode as any allowed format. +func (l imageLimits) unreadable() *errx.Error { + return errx.NewWithIdentifier(errx.BadRequest, "image_unreadable", + fmt.Sprintf("The %s could not be read as an image. The file looks damaged, or it is a different format than its name suggests. %s", l.label, l.accepted())) +} + +// wrongFormat: the bytes are an image, but not one this upload takes. +func (l imageLimits) wrongFormat() *errx.Error { + return errx.NewWithIdentifier(errx.BadRequest, "image_format_not_allowed", + fmt.Sprintf("The %s must be a %s file, and this file is a different format whatever its name says. %s", l.label, l.formats, l.accepted())) +} + +func (l imageLimits) tooLarge() *errx.Error { + return errx.NewWithIdentifier(errx.BadRequest, "image_too_large", + fmt.Sprintf("The %s must be %d MB or smaller. Compress or resize it and try again.", l.label, mb(l.maxBytes))) +} + +func (l imageLimits) tooWide() *errx.Error { + return errx.NewWithIdentifier(errx.BadRequest, "image_dimensions_too_large", + fmt.Sprintf("The %s must be %dpx or smaller on each side. Resize it and try again.", l.label, l.maxDim)) +} diff --git a/internal/api/handler/oauth.go b/internal/api/handler/oauth.go index 87afe9e98..b9a782d50 100644 --- a/internal/api/handler/oauth.go +++ b/internal/api/handler/oauth.go @@ -314,7 +314,7 @@ func (h *Handler) UploadOAuthAppLogo(c *gin.Context) { errx.JSON(c, oauthAppWriteError(err)) return } - raw, _, _, xerr := readAvatarUpload(c) + raw, _, _, xerr := readImageUpload(c, appLogoLimits) if xerr != nil { errx.JSON(c, xerr) return diff --git a/internal/api/handler/oauth_logo.go b/internal/api/handler/oauth_logo.go index 6fb788780..0b77878cd 100644 --- a/internal/api/handler/oauth_logo.go +++ b/internal/api/handler/oauth_logo.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "encoding/hex" "errors" + "fmt" "image" "image/jpeg" "image/png" @@ -39,11 +40,11 @@ const ( func reencodeLogo(body []byte) ([]byte, string, string, *errx.Error) { img, format, err := image.Decode(bytes.NewReader(body)) if err != nil { - return nil, "", "", errx.New(errx.BadRequest, "image could not be parsed") + return nil, "", "", appLogoLimits.unreadable() } b := img.Bounds() if b.Dx() < appLogoMinSide || b.Dy() < appLogoMinSide { - return nil, "", "", errx.New(errx.BadRequest, "the logo must be at least 32px on each side") + return nil, "", "", errx.NewWithIdentifier(errx.BadRequest, "image_too_small", fmt.Sprintf("The logo must be at least %dpx on each side. Upload a larger image.", appLogoMinSide)) } var out bytes.Buffer switch format { @@ -58,7 +59,7 @@ func reencodeLogo(body []byte) ([]byte, string, string, *errx.Error) { } return out.Bytes(), "image/jpeg", ".jpg", nil } - return nil, "", "", errx.New(errx.BadRequest, "the logo must be a PNG or JPG") + return nil, "", "", appLogoLimits.wrongFormat() } // appOwnLogoPrefix is the key prefix of images stored for one app. Only those @@ -189,7 +190,7 @@ func (h *Handler) UploadOAuthApplicationLogo(c *gin.Context) { errx.JSON(c, oauthAppWriteError(err)) return } - raw, _, _, xerr := readAvatarUpload(c) + raw, _, _, xerr := readImageUpload(c, appLogoLimits) if xerr != nil { errx.JSON(c, xerr) return diff --git a/internal/api/handler/organization.go b/internal/api/handler/organization.go index 03405c765..be0e553cc 100644 --- a/internal/api/handler/organization.go +++ b/internal/api/handler/organization.go @@ -417,7 +417,7 @@ func (h *Handler) GetPendingInvitations(c *gin.Context) { case "true": includeExpired = true default: - errx.JSON(c, errx.New(errx.BadRequest, "include_expired must be true or false")) + errx.JSON(c, errx.New(errx.BadRequest, "include_expired must be true or false. Leave it out to list only invitations that can still be accepted.")) return } diff --git a/internal/api/middleware/code_budget.go b/internal/api/middleware/code_budget.go index 9ac652821..016cdb7d7 100644 --- a/internal/api/middleware/code_budget.go +++ b/internal/api/middleware/code_budget.go @@ -13,7 +13,8 @@ const ( codeLookupWindow = time.Minute ) -var errCodeLookupLimited = errx.New(errx.TooManyRequests, "Too many pairing code lookups. Wait a minute and check the code.") +var errCodeLookupLimited = &errx.Error{Code: errx.TooManyRequests, Identifier: "code_lookup_limited", RetryAfter: 60, + Message: "Too many pairing code lookups. Wait a minute, check the code, and try again."} // CodeLookupBudget charges each pairing-code lookup to the signed-in user // before the code is read. A cache error refuses with a retryable 503. @@ -21,14 +22,14 @@ func (h *Handler) CodeLookupBudget(name string) gin.HandlerFunc { return func(c *gin.Context) { userID := GetUserID(c) if h.Cache == nil || userID == "" { - errx.Handle(c, errx.ErrServiceDown) + errx.Handle(c, errx.ErrCodeLookupUnavailable) c.Abort() return } ok, err := h.Cache.ReserveAttempt(c.Request.Context(), "code_lookup:"+name+":"+userID, codeLookupLimit, codeLookupWindow) if err != nil { errs.CaptureException(err) - errx.Handle(c, errx.ErrServiceDown) + errx.Handle(c, errx.ErrCodeLookupUnavailable) c.Abort() return } diff --git a/internal/api/middleware/request_params.go b/internal/api/middleware/request_params.go index 1ff2100c4..121729107 100644 --- a/internal/api/middleware/request_params.go +++ b/internal/api/middleware/request_params.go @@ -26,7 +26,7 @@ func SingleValuedQuery() gin.HandlerFunc { return func(c *gin.Context) { if name, dup := DuplicateQueryParam(c.Request.URL.RawQuery, RepeatableQuery[c.FullPath()]); dup { c.Abort() - errx.JSON(c, errx.NewWithIdentifier(errx.BadRequest, "duplicate_query_parameter", duplicateMessage("query parameter", name))) + errx.JSON(c, errx.NewWithIdentifier(errx.BadRequest, "duplicate_query_parameter", DuplicateMessage("query parameter", name))) return } c.Next() @@ -70,7 +70,7 @@ func RequestHeaders() gin.HandlerFunc { for _, name := range singleValuedHeaders { if len(h.Values(name)) > 1 { c.Abort() - errx.JSON(c, errx.NewWithIdentifier(errx.BadRequest, "duplicate_header", duplicateMessage("header", name))) + errx.JSON(c, errx.NewWithIdentifier(errx.BadRequest, "duplicate_header", DuplicateMessage("header", name))) return } } @@ -100,9 +100,11 @@ func normalizeForwardedHeaders(h http.Header) { var plainParamName = regexp.MustCompile(`^[A-Za-z0-9_.\[\]-]{1,64}$`) -func duplicateMessage(kind, name string) string { +// DuplicateMessage names the repeated parameter or header when its name is +// plain enough to echo back, and says how to fix the request. +func DuplicateMessage(kind, name string) string { if plainParamName.MatchString(name) { - return kind + " " + name + " may appear only once" + return "The " + kind + " " + name + " appears more than once. Send it once per request." } - return "a " + kind + " may appear only once" + return "A " + kind + " appears more than once. Send each one once per request." } diff --git a/internal/app/auth/cache.go b/internal/app/auth/cache.go index b2ba0ecee..302855bc4 100644 --- a/internal/app/auth/cache.go +++ b/internal/app/auth/cache.go @@ -272,7 +272,7 @@ func (s *authService) reserveAttempt(ctx context.Context, key string, limit int6 ok, err := s.cache.ReserveAttempt(ctx, key, limit, window) if err != nil { errs.CaptureException(err) - return false, errx.ErrServiceDown + return false, errx.ErrAttemptCheckUnavailable } return ok, nil } diff --git a/internal/app/auth/login.go b/internal/app/auth/login.go index c66d8377e..9a51e4f54 100644 --- a/internal/app/auth/login.go +++ b/internal/app/auth/login.go @@ -251,7 +251,7 @@ func (s *authService) refuseSuspended(ctx context.Context, userID uuid.UUID) *er return errx.InternalError() } if models.BanScope(scope).Has(models.BanScopeLogin) { - return errx.New(errx.Forbidden, "this account has been suspended") + return errx.ErrAccountSuspended } return nil } diff --git a/internal/app/auth/reset_password.go b/internal/app/auth/reset_password.go index adf9c58d9..0a5ffe545 100644 --- a/internal/app/auth/reset_password.go +++ b/internal/app/auth/reset_password.go @@ -138,8 +138,31 @@ func (s *authService) sendResetEmailWithRetry(ctx context.Context, to, subject, return s.sendAuthEmail(ctx, to, subject, message) } +// ErrResetLinkInvalid answers a reset link or reset session that is expired, +// already used, or older than the account's current password. +var ErrResetLinkInvalid = errx.NewWithIdentifier(errx.Unauthorized, "reset_link_invalid", + "This password reset link has expired or was already used. Request a new one with Forgot password on the sign-in page.") + +// ErrSessionsNotEnded refuses a password write whose other sessions could not +// all be ended; the old password stays in place and the request can be retried. +var ErrSessionsNotEnded = &errx.Error{Code: errx.ServiceUnavailable, Identifier: "sessions_not_ended", RetryAfter: 5, + Message: "Your password was not changed because the devices signed in to this account could not all be signed out. Wait a few seconds and try again."} + +// ErrCredentialRevokeUnsupported refuses a password write that asked to end app +// grants and API keys on an instance that has no way to end them. +var ErrCredentialRevokeUnsupported = errx.NewWithIdentifier(errx.BadRequest, "api_credentials_revoke_unsupported", + "This server cannot sign out connected apps and API keys, so your password was not changed. Untick that option and try again, then remove apps and keys from Settings.") + // verifyResetLink checks an emailed reset link token without spending it. func (s *authService) verifyResetLink(ctx context.Context, linkToken string) (*tokenpkg.TokenClaims, *errx.Error) { + sess, err := s.verifyResetLinkToken(ctx, linkToken) + if err != nil && err.Code == errx.Unauthorized { + return nil, ErrResetLinkInvalid + } + return sess, err +} + +func (s *authService) verifyResetLinkToken(ctx context.Context, linkToken string) (*tokenpkg.TokenClaims, *errx.Error) { sess, err := s.tokenService.VerifyTokenFor(tokenpkg.PurposePasswordReset, linkToken) if err != nil { return nil, err @@ -222,6 +245,9 @@ func (s *authService) ResetPasswordConfirm(ctx context.Context, data *ResetPassw handle = session var err *errx.Error if linkToken, err = s.getResetHandle(ctx, handle); err != nil { + if err.Code == errx.Unauthorized { + return ErrResetLinkInvalid + } return err } } @@ -239,7 +265,8 @@ func (s *authService) ResetPasswordConfirm(ctx context.Context, data *ResetPassw // failure refuses the reset while the link is still good for a retry. if s.tokenService != nil { if err := s.tokenService.RevokeOtherSessions(ctx, sess.UserID, uuid.Nil); err != nil { - return err + errs.CaptureException(err) + return ErrSessionsNotEnded } } if data.RevokeAPICredentials { @@ -281,7 +308,7 @@ func (s *authService) ResetPasswordConfirm(ctx context.Context, data *ResetPassw // they created; any failure refuses the password write that asked for it. func (s *authService) revokeAPICredentials(ctx context.Context, userID uuid.UUID) *errx.Error { if len(s.credentialRevokers) == 0 { - return errx.New(errx.BadRequest, "connected apps and API keys cannot be signed out on this instance") + return ErrCredentialRevokeUnsupported } for _, r := range s.credentialRevokers { if err := r.RevokeUserCredentials(ctx, userID); err != nil { @@ -368,7 +395,8 @@ func (s *authService) ChangePassword(ctx context.Context, userID uuid.UUID, curr keep = current.ID } if err := s.tokenService.RevokeOtherSessions(ctx, userID, keep); err != nil { - return nil, err + errs.CaptureException(err) + return nil, ErrSessionsNotEnded } } if data.RevokeAPICredentials { diff --git a/internal/app/emailsend/service.go b/internal/app/emailsend/service.go index b5feb191f..9531ba9a0 100644 --- a/internal/app/emailsend/service.go +++ b/internal/app/emailsend/service.go @@ -169,7 +169,7 @@ func (s *emailSendService) SendEmail(ctx context.Context, userID, orgID, account return nil, errx.InternalError() } if models.BanScope(scope).Has(models.BanScopeSend) { - return nil, errx.New(errx.Forbidden, "this account cannot send email") + return nil, errx.ErrAccountSendingSuspended } } @@ -183,7 +183,7 @@ func (s *emailSendService) SendEmail(ctx context.Context, userID, orgID, account return nil, errx.InternalError() } if states[orgID].BlocksSending() { - return nil, errx.New(errx.Forbidden, "this workspace is suspended from sending while it is under review") + return nil, errx.ErrWorkspaceSendingSuspended } } diff --git a/internal/app/organization/service.go b/internal/app/organization/service.go index 5a70ce651..d9e0e41fa 100644 --- a/internal/app/organization/service.go +++ b/internal/app/organization/service.go @@ -310,7 +310,7 @@ func (s *organizationService) Create(ctx context.Context, userID uuid.UUID, name return nil, errx.InternalError() } if models.BanScope(scope).Has(models.BanScopeOrgCreate) { - return nil, errx.New(errx.Forbidden, "this account cannot create new workspaces") + return nil, errx.ErrAccountWorkspaceCreateSuspended } // Daily creation throttle — caps "new workspaces per owner per @@ -951,7 +951,7 @@ func (s *organizationService) acceptResolved(ctx context.Context, inv *models.Or // An expired invitation stays listed so the team page can send it again. if inv.IsExpired() { - return nil, errx.New(errx.BadRequest, "invitation has expired") + return nil, errx.NewWithIdentifier(errx.BadRequest, "invitation_expired", "This invitation has expired. Invitations last 48 hours; ask the person who invited you to send it again from the Team page.") } // Check if already a member @@ -992,7 +992,7 @@ func (s *organizationService) acceptResolved(ctx context.Context, inv *models.Or if err := s.orgRepo.DeleteInvitation(ctx, inv.ID); err != nil { errs.CaptureException(err) } - return nil, errx.New(errx.BadRequest, "the roles for this invitation no longer exist — ask for a new invite") + return nil, errx.NewWithIdentifier(errx.BadRequest, "invitation_roles_removed", "The roles this invitation granted no longer exist. Ask the person who invited you to send a new invitation.") } // Add the membership row, then assign the full role set (which recomputes @@ -1013,7 +1013,7 @@ func (s *organizationService) acceptResolved(ctx context.Context, inv *models.Or } if err := s.orgRepo.AcceptInvitation(ctx, inv.ID, member, liveRoleIDs); err != nil { if errors.Is(err, repository.ErrInvitationInvalid) { - return nil, errx.New(errx.BadRequest, "this invitation has already been used or has expired") + return nil, errx.NewWithIdentifier(errx.BadRequest, "invitation_used_or_expired", "This invitation has already been used or has expired. Ask the person who invited you to send it again from the Team page.") } errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to add member") diff --git a/internal/app/owndomain/owndomain.go b/internal/app/owndomain/owndomain.go index 5faa08885..c3fa8fdb1 100644 --- a/internal/app/owndomain/owndomain.go +++ b/internal/app/owndomain/owndomain.go @@ -20,10 +20,12 @@ const ErrIDNotOwn = "redirect_target_not_own_domain" // Refusal is the save-time error for a destination outside the workspace's own domains. func Refusal() *errx.Error { - return errx.NewWithIdentifier(errx.BadRequest, ErrIDNotOwn, - "The redirect has to go to a page on one of this workspace's own domains: a domain it has a mailbox on, its verified forms domain, or a subdomain of one of them.") + return errx.NewWithIdentifier(errx.BadRequest, ErrIDNotOwn, RefusalMessage) } +// RefusalMessage names the kinds of domain a redirect may point at. +const RefusalMessage = "The redirect has to go to a page on one of this workspace's own domains: a domain it has a mailbox on (not a shared provider such as gmail.com), its verified tracking domain, its verified forms domain, or a subdomain of one of them. Use an address on one of those, or connect a mailbox or verify a domain first." + // Mailboxes lists the domains the workspace has a mailbox on and its verified tracking hosts. type Mailboxes interface { OwnedDomains(ctx context.Context, orgID uuid.UUID) ([]string, *errx.Error) diff --git a/internal/app/passkey/login.go b/internal/app/passkey/login.go index fcdf2d94d..22e5dee91 100644 --- a/internal/app/passkey/login.go +++ b/internal/app/passkey/login.go @@ -87,7 +87,7 @@ func (s *service) FinishLogin(ctx context.Context, session string, credential [] return nil, errx.InternalError() } if models.BanScope(scope).Has(models.BanScopeLogin) { - return nil, errx.New(errx.Forbidden, "this account has been suspended") + return nil, errx.ErrAccountSuspended } // No TOTP step: a user-verified passkey is possession plus a PIN or diff --git a/internal/app/sendingdomain/service.go b/internal/app/sendingdomain/service.go index fa6aaf79e..eee58d3ab 100644 --- a/internal/app/sendingdomain/service.go +++ b/internal/app/sendingdomain/service.go @@ -127,7 +127,7 @@ func markTarget(r *models.DomainRedirect, own []string) { } } -const targetInactiveMessage = "Not in use: the destination is not on one of this workspace's own domains, so visitors get the domain's default page instead. Point it at a page on a domain the workspace has a mailbox on." +const targetInactiveMessage = "Not in use: the destination is no longer on one of this workspace's own domains, so visitors get the domain's default page instead. This happens when the mailbox or verified domain it relied on was removed. Point the redirect at a page on a domain the workspace has a mailbox on, its verified tracking domain, or its verified forms domain, or reconnect that domain." func normalizeDomain(d string) string { return mailhost.NormalizeDomain(strings.TrimPrefix(strings.ToLower(strings.TrimSpace(d)), "www.")) diff --git a/internal/app/token/refresh.go b/internal/app/token/refresh.go index 79c4fd0a9..60d8c3a15 100644 --- a/internal/app/token/refresh.go +++ b/internal/app/token/refresh.go @@ -29,7 +29,7 @@ func (s *tokenService) RefreshToken(ctx context.Context, refreshToken string) (* // Neither a revoked session nor one past its absolute expiry refreshes back to life. if !sess.Live(time.Now()) { - return nil, errx.ErrToken + return nil, errx.ErrSessionEnded } if sess.RefreshNonce != t.Nonce { diff --git a/internal/app/token/verify.go b/internal/app/token/verify.go index d6268e30e..79c09ca8e 100644 --- a/internal/app/token/verify.go +++ b/internal/app/token/verify.go @@ -58,7 +58,7 @@ func (s *tokenService) ValidateAccessToken(ctx context.Context, accessToken stri // stops working without waiting for the cache TTL or a refresh. A session // past its expires_at is dead the same way. if !session.Live(time.Now()) { - return nil, errx.ErrToken + return nil, errx.ErrSessionEnded } if session.AccessNonce != t.Nonce || !sameTokenIssueTime(session.LastRefreshedAt, t.IssuedAt.Time) { diff --git a/internal/app/twofa/login.go b/internal/app/twofa/login.go index 448542507..b5951af24 100644 --- a/internal/app/twofa/login.go +++ b/internal/app/twofa/login.go @@ -106,7 +106,7 @@ func (s *service) VerifyLogin(ctx context.Context, pendingToken, code, ipaddr, u return nil, errx.InternalError() } if models.BanScope(scope).Has(models.BanScopeLogin) { - return nil, errx.New(errx.Forbidden, "this account has been suspended") + return nil, errx.ErrAccountSuspended } } // A carried identity links only here, once both factors have passed. diff --git a/internal/app/twofa/service.go b/internal/app/twofa/service.go index 395386a12..f6613189a 100644 --- a/internal/app/twofa/service.go +++ b/internal/app/twofa/service.go @@ -264,7 +264,7 @@ func (s *service) reserveAttempt(ctx context.Context, key string, limit int64, w ok, err := s.cache.ReserveAttempt(ctx, key, limit, window) if err != nil { errs.CaptureException(err) - return false, errx.ErrServiceDown + return false, errx.ErrAttemptCheckUnavailable } return ok, nil } diff --git a/internal/cli/api/client.go b/internal/cli/api/client.go index f025ba416..043ac0d52 100644 --- a/internal/cli/api/client.go +++ b/internal/cli/api/client.go @@ -69,8 +69,13 @@ func (e *Error) Error() string { if e.RequestID != "" { out += " (request " + e.RequestID + ")" } - if e.Status == http.StatusTooManyRequests && e.RetryAfter != "" { - out += ". Rate limited; retry after " + e.RetryAfter + "s." + if e.RetryAfter != "" { + switch e.Status { + case http.StatusTooManyRequests: + out += ". Rate limited; retry after " + e.RetryAfter + "s." + case http.StatusServiceUnavailable: + out += ". Temporarily unavailable; retry after " + e.RetryAfter + "s." + } } return out } @@ -321,12 +326,18 @@ func (c *Client) Paginate(ctx context.Context, req Request, maxPages int) ([]byt return json.Marshal(envelope) } -// retryAfter reports how long a rate-limited response asked the caller to -// wait. The wait is capped so a hostile or misconfigured Retry-After cannot -// park the CLI for an hour. +// retryAfter reports how long a rate-limited or briefly unavailable (503 with +// Retry-After) response asked the caller to wait. The wait is capped so a +// hostile or misconfigured Retry-After cannot park the CLI for an hour. func retryAfter(err error) (time.Duration, bool) { var apiErr *Error - if !errors.As(err, &apiErr) || apiErr.Status != http.StatusTooManyRequests { + if !errors.As(err, &apiErr) { + return 0, false + } + switch { + case apiErr.Status == http.StatusTooManyRequests: + case apiErr.Status == http.StatusServiceUnavailable && apiErr.RetryAfter != "": + default: return 0, false } seconds, perr := strconv.Atoi(strings.TrimSpace(apiErr.RetryAfter)) diff --git a/internal/formserver/server.go b/internal/formserver/server.go index 077a546e8..dfd1dd740 100644 --- a/internal/formserver/server.go +++ b/internal/formserver/server.go @@ -32,6 +32,9 @@ import ( // is dozens of short answers. const formMaxSubmitBytes = 64 << 10 +// formNotFoundMessage answers a form that does not exist or is not published. +const formNotFoundMessage = "This form does not exist or is not published." + const ( submitWindow = 10 * time.Minute submitDefaultLimit = 30 @@ -160,7 +163,7 @@ func (s *Server) Router(trustedProxies []string) (*gin.Engine, error) { // page. r.NoRoute(func(c *gin.Context) { if strings.HasPrefix(c.Request.URL.Path, "/api/") { - c.JSON(http.StatusNotFound, gin.H{"error": "not_found"}) + abortJSON(c, http.StatusNotFound, "not_found", "No forms API route matches this path.") return } notFoundPage(c) @@ -183,24 +186,29 @@ func (s *Server) Router(trustedProxies []string) (*gin.Engine, error) { // singleValued refuses a repeated query parameter or render token; no forms route reads a list. func singleValued(c *gin.Context) { - if _, dup := middleware.DuplicateQueryParam(c.Request.URL.RawQuery, nil); dup { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "duplicate_query_parameter"}) + if name, dup := middleware.DuplicateQueryParam(c.Request.URL.RawQuery, nil); dup { + abortJSON(c, http.StatusBadRequest, "duplicate_query_parameter", middleware.DuplicateMessage("query parameter", name)) return } if len(c.Request.Header.Values("X-Warmbly-Render")) > 1 { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "duplicate_header"}) + abortJSON(c, http.StatusBadRequest, "duplicate_header", middleware.DuplicateMessage("header", "X-Warmbly-Render")) return } c.Next() } +// abortJSON answers with the same {error, message, code} shape as the API. +func abortJSON(c *gin.Context, status int, code, message string) { + c.AbortWithStatusJSON(status, gin.H{"error": code, "message": message, "code": code}) +} + // requireRenderToken rejects API calls whose X-Warmbly-Render token is // missing, forged, expired or minted for another form. The app treats the // stale_page error as "refresh this tab". func (s *Server) requireRenderToken(c *gin.Context) { token := c.GetHeader("X-Warmbly-Render") if token == "" || !verifyRenderToken(s.renderKey, c.Param("publicID"), token, time.Now()) { - c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "stale_page"}) + abortJSON(c, http.StatusForbidden, "stale_page", "This page has been open too long. Reload it and try again.") return } c.Next() @@ -282,11 +290,11 @@ func (s *Server) GetPublicForm(c *gin.Context) { f, err = s.fetchForm(c) } if errors.Is(err, errFormNotFound) { - c.JSON(http.StatusNotFound, gin.H{"error": "not_found"}) + abortJSON(c, http.StatusNotFound, "not_found", formNotFoundMessage) return } if err != nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "unavailable"}) + abortJSON(c, http.StatusServiceUnavailable, "unavailable", "This form is temporarily unavailable. Try again in a few minutes.") return } c.Header("Cache-Control", "no-store") @@ -382,7 +390,7 @@ type submitBody struct { // Same-origin with the app, so there is no CORS surface. func (s *Server) SubmitForm(c *gin.Context) { fail := func(status int, msg string) { - c.JSON(status, gin.H{"error": "form_submit_failed", "message": msg}) + c.JSON(status, gin.H{"error": "form_submit_failed", "message": msg, "code": "form_submit_failed"}) } if ip := c.ClientIP(); ip != "" && !s.limits.Allow(ip) { @@ -433,7 +441,7 @@ func (s *Server) SubmitForm(c *gin.Context) { } switch { case out.NotFound: - c.JSON(http.StatusNotFound, gin.H{"error": "not_found"}) + abortJSON(c, http.StatusNotFound, "not_found", formNotFoundMessage) return case out.Rejected != "": fail(http.StatusBadRequest, out.Rejected) diff --git a/web/src/app/app/settings/security/ChangePasswordDialog.tsx b/web/src/app/app/settings/security/ChangePasswordDialog.tsx index 0dfbfc662..e08cfc889 100644 --- a/web/src/app/app/settings/security/ChangePasswordDialog.tsx +++ b/web/src/app/app/settings/security/ChangePasswordDialog.tsx @@ -64,9 +64,11 @@ export default function ChangePasswordDialog({ open, onClose }: { open: boolean; if (err.code === "password_changed_sign_in_again") { toast.error(err.message); onClose(); - endSession(); + endSession({ notice: false }); return; } + // Only the option can be refused here, so clear it for the retry. + if (err.code === "api_credentials_revoke_unsupported") setRevokeApi(false); toast.error(buildError(err)); } finally { setPending(false); diff --git a/web/src/app/auth/hooks/useResetPasswordConfirmForm.ts b/web/src/app/auth/hooks/useResetPasswordConfirmForm.ts index e57ca3802..3c4653aad 100644 --- a/web/src/app/auth/hooks/useResetPasswordConfirmForm.ts +++ b/web/src/app/auth/hooks/useResetPasswordConfirmForm.ts @@ -68,6 +68,9 @@ export function useResetPasswordConfirmForm() { { loading: "Loading...", success: "Password successfully changed", error: (err: AppError) => buildError(err) } ); navigate({ to: "/auth/login", search: { action: "1" } }); + } catch (err) { + // The link died meanwhile (used, expired, or the password changed): show the request-a-new-one screen. + if ((err as AppError)?.code === "reset_link_invalid") setLinkState("invalid"); } finally { setPending(false); } }; diff --git a/web/src/app/auth/login/page.tsx b/web/src/app/auth/login/page.tsx index c23221223..8f7e39267 100644 --- a/web/src/app/auth/login/page.tsx +++ b/web/src/app/auth/login/page.tsx @@ -20,7 +20,7 @@ import { useTwoFactorVerify } from "@/lib/api/hooks/auth/useTwoFactor"; import useLinkSSO from "@/lib/api/hooks/auth/useLinkSSO"; import useRegister from "@/lib/api/hooks/auth/useRegister"; import useRegisterConfirm from "@/lib/api/hooks/auth/useRegisterConfirm"; -import { saveTokens } from "@/lib/auth"; +import { saveTokens, takeSessionEndedNotice, SESSION_ENDED_MESSAGE } from "@/lib/auth"; import getUser from "@/lib/api/client/auth/getUser"; import { TURNSTILE_KEY, API_URL } from "@/lib/information"; import useBrand from "@/hooks/useBrand"; @@ -192,6 +192,10 @@ export default function LoginPage() { const reason = new URLSearchParams(location.searchStr).get("sso_error"); if (reason) toast.error(reason); }, [location.searchStr]); + // Landing here because the server ended the session gets one plain line. + useEffect(() => { + if (takeSessionEndedNotice()) toast(SESSION_ENDED_MESSAGE, { id: "session-ended" }); + }, []); const captchaRequired = authConfig.captcha; const turnstileBypassToken = import.meta.env.DEV ? (import.meta.env.VITE_TURNSTILE_BYPASS_TOKEN?.trim() || "") diff --git a/web/src/app/auth/reset-password/confirm/page.tsx b/web/src/app/auth/reset-password/confirm/page.tsx index cae191efd..cc30163ff 100644 --- a/web/src/app/auth/reset-password/confirm/page.tsx +++ b/web/src/app/auth/reset-password/confirm/page.tsx @@ -26,7 +26,9 @@ export default function ResetPasswordConfirmPage() {

Link expired

-

This password reset link is no longer valid.

+

+ This password reset link has expired or was already used. Each link works once, for a limited time, and stops working when the password changes. Request a new one to continue. +

{redirect.target_inactive_reason || - "Not in use: the destination is not on one of this workspace's own domains, so visitors get the domain's default page instead."} + "Not in use: the destination is no longer on one of this workspace's own domains, so visitors get the domain's default page instead. This happens when the mailbox or verified domain it relied on was removed; point it at one of your own domains or reconnect that domain."}

) : null}

{OWN_DOMAIN_HINT}

diff --git a/web/src/components/app/emails/domains/rules.ts b/web/src/components/app/emails/domains/rules.ts index 849e1b7bd..79e37eb12 100644 --- a/web/src/components/app/emails/domains/rules.ts +++ b/web/src/components/app/emails/domains/rules.ts @@ -158,14 +158,16 @@ export const REDIRECT_NOT_OWN_DOMAIN = "redirect_target_not_own_domain"; /** Where a redirect may point, shown under every destination field. */ export const OWN_DOMAIN_HINT = - "Must be a page on one of your workspace's own domains: a domain you have a mailbox on, your verified forms domain, or a subdomain of one."; + "Must be a page on one of your workspace's own domains: a domain you have a mailbox on (not gmail.com or another shared provider), your verified tracking domain, your verified forms domain, or a subdomain of one."; export function redirectPill(d: SendingDomain): PillInfo { if (d.redirect && d.redirect.target_active === false) { return { tone: "amber", label: "Not in use", - title: d.redirect.target_inactive_reason || `${d.redirect.target_url} is not on one of this workspace's own domains.`, + title: + d.redirect.target_inactive_reason || + `${d.redirect.target_url} is no longer on one of this workspace's own domains, so visitors get the domain's default page. Point it at one of your own domains.`, }; } switch (redirectState(d)) { diff --git a/web/src/components/app/forms/SettingsPanel.tsx b/web/src/components/app/forms/SettingsPanel.tsx index e630809bc..d75c0699a 100644 --- a/web/src/components/app/forms/SettingsPanel.tsx +++ b/web/src/components/app/forms/SettingsPanel.tsx @@ -91,13 +91,15 @@ export default function SettingsPanel({

) : redirectInactive ? (

- Not in use: this page is not on one of your workspace's own domains, so visitors see the - success message instead. + Not in use: this page is no longer on one of your workspace's own domains, so visitors see + the success message instead. This happens when the mailbox or verified domain it relied on + was removed. Point it at one of your own domains, or reconnect that domain.

) : null}

- Must be a page on one of your workspace's own domains: a domain you have a mailbox on, your - verified forms domain, or a subdomain of one. Leave empty to show the success message instead. + Must be a page on one of your workspace's own domains: a domain you have a mailbox on (not + gmail.com or another shared provider), your verified tracking domain, your verified forms + domain, or a subdomain of one. Leave empty to show the success message instead.

diff --git a/web/src/components/app/modals/PermissionDeniedModal.tsx b/web/src/components/app/modals/PermissionDeniedModal.tsx index 53c148e7f..3e451857e 100644 --- a/web/src/components/app/modals/PermissionDeniedModal.tsx +++ b/web/src/components/app/modals/PermissionDeniedModal.tsx @@ -13,6 +13,13 @@ interface DeniedDetail { code?: string; } +const STANDING_TITLES: Record = { + account_suspended: "Account suspended", + account_sending_suspended: "Sending is turned off for your account", + account_workspace_create_suspended: "You can't create workspaces", + workspace_sending_suspended: "Sending is paused for this workspace", +}; + export default function PermissionDeniedModal() { const [open, setOpen] = React.useState(false); const [message, setMessage] = React.useState(""); @@ -35,7 +42,10 @@ export default function PermissionDeniedModal() { // is fixed under Settings > Security by the person themselves, so the // "ask an admin" advice would be wrong; link them there instead. const isMFA = code === "admin_mfa_required"; - const isPlan = !isMFA && /\b(plan|upgrade|trial|subscription|paid)\b/i.test(message); + // A restriction on the account or workspace is lifted by support, not by + // a role, so the server's own message is the whole explanation. + const standing = code ? STANDING_TITLES[code] : undefined; + const isPlan = !isMFA && !standing && /\b(plan|upgrade|trial|subscription|paid)\b/i.test(message); const close = () => setOpen(false); return ( @@ -88,11 +98,11 @@ export default function PermissionDeniedModal() { ? "Upgrade required" : isMFA ? "Two-factor authentication required" - : "You don't have permission"} + : (standing ?? "You don't have permission")}

{message} - {!isPlan && !isMFA && ( + {!isPlan && !isMFA && !standing && ( <> {" "} Ask a workspace admin or the owner to grant you access from{" "} diff --git a/web/src/components/app/modals/ReauthModal.tsx b/web/src/components/app/modals/ReauthModal.tsx index 4b3ed88b4..8d5ed3cfb 100644 --- a/web/src/components/app/modals/ReauthModal.tsx +++ b/web/src/components/app/modals/ReauthModal.tsx @@ -150,7 +150,8 @@ export default function ReauthModal() { } catch (err) { if (attempt.current !== mine) return; const { code: errCode, message } = errorOf(err); - if (errCode === "reauth_no_factor" || errCode === "reauth_limited") { + // These say nothing about which factor matched, so their own words are safe to show. + if (errCode === "reauth_no_factor" || errCode === "reauth_limited" || errCode === "attempt_check_unavailable") { setError(message ?? "That did not work. Try again."); } else { // One message for both factors: which one matched is not something diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts index 02cd929c3..5ad3b476e 100644 --- a/web/src/lib/auth.ts +++ b/web/src/lib/auth.ts @@ -113,10 +113,34 @@ export const announceSessionEnded = () => { } }; +// The sign-in page reads this once to say why the person landed there. +const SESSION_ENDED_NOTICE_KEY = "warmbly:session-ended-notice"; +export const SESSION_ENDED_MESSAGE = "Your session ended. Sign in again to continue."; + +// takeSessionEndedNotice reports, once, whether the last session was ended by +// the server rather than by signing out. +export const takeSessionEndedNotice = (): boolean => { + try { + const seen = sessionStorage.getItem(SESSION_ENDED_NOTICE_KEY) !== null; + sessionStorage.removeItem(SESSION_ENDED_NOTICE_KEY); + return seen; + } catch { + return false; + } +}; + // endSession ends the session and says so. Use it wherever the server has // refused the credentials; clearTokens alone is for a sign-out that already -// owns the navigation. -export const endSession = () => { +// owns the navigation. notice=false is for a caller that already told the +// person why. +export const endSession = ({ notice = true }: { notice?: boolean } = {}) => { clearTokens(); + if (notice) { + try { + sessionStorage.setItem(SESSION_ENDED_NOTICE_KEY, "1"); + } catch { + /* storage unavailable: the redirect still happens */ + } + } announceSessionEnded(); }; diff --git a/web/src/lib/errors/auth.ts b/web/src/lib/errors/auth.ts index b47c95f98..76264e36f 100644 --- a/web/src/lib/errors/auth.ts +++ b/web/src/lib/errors/auth.ts @@ -8,5 +8,5 @@ export class AuthError extends Error { // Built per throw, not shared. A module-level instance captures its stack once, // at module evaluation, so every report pointed at "module code" instead of the // call that failed, and one mutable Error was shared across concurrent requests. -export const sessionExpired = () => new AuthError("Session expired") +export const sessionExpired = () => new AuthError("Your session ended. Sign in again to continue.") export const noToken = () => new AuthError("No authorization token") diff --git a/web/src/lib/helper/buildError.ts b/web/src/lib/helper/buildError.ts index 74d206629..314d1da56 100644 --- a/web/src/lib/helper/buildError.ts +++ b/web/src/lib/helper/buildError.ts @@ -1,8 +1,11 @@ import type { AppError } from "../api/client/normalizeError"; +import { AuthError } from "../errors/auth"; // A network failure has no status, and `status && ...` printed the literal // string "undefined" in front of every one of those messages. export default function buildError(err: AppError): string { + // An ended session is not a failure of the action; the sign-in page explains it. + if ((err as unknown) instanceof AuthError) return err.message; // Raw Error instances reach this too (a thrown AuthError has no `error`). const label = err.error || "Error"; const prefix = err.status ? `${err.status} ${label}` : label;