760 Commits

Author SHA1 Message Date
vernu
c5e5932168 Merge pull request #248 from vernu/dev
Dev
2026-07-22 17:34:38 +03:00
vernu
8964df5911 Merge pull request #247 from vernu/fix/dialog-long-text-overflow
fix(web): keep long message text inside the details dialog
2026-07-22 17:18:51 +03:00
vernu
0b45933b26 Merge pull request #246 from vernu/perf/dashboard-nav-speed
perf(web): make dashboard navigation fast
2026-07-22 17:18:42 +03:00
isra el
bb58cb3ba8 fix(web): keep long message text inside the details dialog
DialogContent is a CSS grid, and an implicit grid track has a min-content
floor. `break-words` does not reduce min-content size, so a long
unbreakable URL in any child pushed every child past the card's
max-width instead of wrapping. In message history that left the message
box, the delivery error panel and the action button rendered hundreds of
pixels outside the dialog, off the side of the screen.

An explicit minmax(0,1fr) column removes that floor, after which
break-words can do its job. This fixes every dialog in the app, not just
this one: any long URL, API key or gateway ID hit the same track.

Also makes the details dialog the single scroll container, matching
api-keys and the webhook payload modal, instead of capping the message
body at 224px behind a scrollbar nested in a scrollbar. And the list row
preview now wraps, so a URL-heavy message fills both clamped lines
rather than being cut off partway through the first.

Covered by an e2e regression test at 375px and 1280px that asserts the
dialog does not scroll sideways and that the action button stays inside
it. Verified failing before this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 15:47:18 +03:00
isra el
83d0c3803a fix(web): stop the SupportHQ widget rebuilding itself on every session refetch
The effect depended on the whole session object. SessionProvider returns a
new object on every refetch, and React compares deps by reference, so an
identical session still ran the cleanup and the body again: the widget was
destroyed and its script re-injected even though nothing about the user
had changed, closing any chat the user had open.

It now depends on the individual strings the effect actually reads, which
compare by value, so it only re-initialises when the metadata genuinely
changes.

Two related leaks fixed at the same time:

- cleanup called destroy() but left the script tag in the document, so
  every re-run added another one for the life of the page. It is now
  removed.
- a script whose src is already cached can finish loading after cleanup
  ran, and initialising then left a widget behind that nothing would
  destroy. That late load is now ignored.

Tests cover all four behaviours and fail against the previous version.

Also gives the API guide page focus before the clipboard test: writeText
rejects on an unfocused document, which is where a page sits while other
workers run, and granting clipboard permissions does not cover it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 14:45:40 +03:00
isra el
3eba8d3444 perf(web): register only the Prism languages used, scope Google OAuth
The API guide imported react-syntax-highlighter's default entry, which is
the highlight.js build with every language compiled in. It also applied
Prism themes to it, and the two use different class names, so the samples
were largely uncoloured on top of being heavy. Switch to PrismLight and
register the six languages the guide actually renders.

GoogleOAuthProvider wrapped the whole app but only the login and register
pages use it, so the Google Identity SDK initialised on every dashboard
page. Move it inside LoginWithGoogle, which is where both consumers go
through.

Total client chunks drop from 3.1M to 2.3M.

Also fixes four e2e tests that were relying on page.goto returning a
fully loaded page. Adding loading.tsx introduced a short skeleton state
that did not exist before, and each test measured or interacted during
it. They now wait on real signals instead of timing:

- bulk send waits for the dropzone row cap, which comes from
  useSubscription inside that page's own hook. The previous wait on the
  devices response was wrong: the dashboard layout requests the same
  query key, so it can resolve before the page hydrates.
- the mobile overflow sweep and the API guide replace networkidle, which
  needs a 500ms window with zero connections that link prefetching keeps
  pushing out of reach.
- the footer/tab-bar check re-scrolls as the page grows, instead of
  scrolling once while the skeleton is still short.
- the overflow sweep measures the billing tab rather than
  /dashboard/account, a redirect stub with no layout of its own whose
  client-side redirect tore down the measurement.

A new test asserts the samples carry Prism token markup in all five
languages, so a revert to the highlight.js entry fails instead of
silently dropping the colours.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 14:36:34 +03:00
isra el
ab96e731a6 test(web): wait for hydration before typing in messaging e2e specs
The messaging pages now stream behind a loading.tsx boundary, so their
HTML can paint before React hydrates. Tests that filled the To field or
dropped a CSV immediately after page.goto raced that window: the input
was there, the handlers were not, and the interaction was silently lost.
Under parallel workers the race lost often enough to fail runs at random.

Both specs now navigate through a helper that waits for the client-side
devices fetch, which only fires after hydration effects run, so the form
is proven interactive before the test touches it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 14:02:13 +03:00
isra el
18e7d1b3d8 perf(web): make dashboard navigation fast and cut session fetches
- prefetch route-tab links so tab clicks reuse a cached payload instead
  of paying an uncached server round trip each time
- add loading.tsx boundaries (dashboard root and each section) so clicks
  paint within a frame; section-level files keep the header and tabs
  mounted while only the content area swaps
- link the sidebar Account item and the checkout page straight to
  /dashboard/account/billing. The /dashboard/account redirect stub stays
  for old links, but no internal link pays the extra hop anymore. The
  stub also dropped query params, which silently ate the plan-change
  success toast.
- set QueryClient defaults (staleTime 60s, no focus refetch, retry 1);
  mutations already invalidate their keys, so the user's own changes
  stay instant. Device messages and webhook deliveries get a 15s
  staleTime since they change from outside the tab.
- replace the axios getCachedSession TTL cache with a token seeded from
  the server session in Providers and kept in sync by a session bridge.
  Requests attach the token synchronously; /api/auth/session is only a
  deduped fallback, instead of a refetch every 2 minutes with a
  thundering herd on expiry.
- swap the billing card's 16px loading spinner for a card-shaped
  skeleton so the tab no longer looks blank while loading

Tests: interceptor seeding/dedupe/signed-out behavior, provider
defaults and token seeding, nav active-state matching, tab prefetch,
billing loading state, plus e2e coverage for direct-to-billing
navigation and an at-most-one-session-call budget guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 13:49:14 +03:00
vernu
c36cfc12a6 Merge pull request #245 from vernu/fix/checkout-plan-params-next16
fix(billing): send the plan name from route params under Next 16
2026-07-20 22:12:25 +03:00
isra el
0bd5d4c900 fix(billing): send the plan name from route params under Next 16
/checkout/pro and /checkout/scale failed with "Plan cannot be purchased"
for every user since the Next 16 upgrade (ea1b00c).

The checkout page still typed `params` as a plain object and read
`params.planName` synchronously. Next 16 removed the sync compat shim, so
the value was undefined, axios dropped the key, and the API ran
findOne({ name: undefined }). Mongoose 9 returns null for that where
older versions stripped the key, so the guard threw a message about a
misconfigured plan for a request that never named one. Plan data and
Polar product ids were correct throughout.

- unwrap params with React's use() and drop the `as string` cast that
  suppressed the type error
- split the conflated guard into PLAN_NAME_REQUIRED, PLAN_NOT_FOUND and
  the existing unpurchasable case, each logged server side
- stop retrying a rejected checkout twice: a 400 is not transient
- mark the Polar product id indexes sparse, they have been failing to
  build with E11000 on every boot because most plans leave them unset

Checkout now defaults to yearly. A URL that names an interval still
redirects straight through, so the marketing funnel is unchanged; the
in-app CTAs that name none get an interval chooser with yearly
preselected instead of a silent monthly default.

Also redesigns the checkout states, which were the only place in the
dashboard hardcoding gray-100/white and so rendering a light slab in dark
mode, and the plan picker, which painted the accent on every tier.

Tests: a checkout e2e spec asserting the request body carries planName,
which is what would have caught this, plus API cases pinning the three
guard failures apart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:47:17 +03:00
vernu
48607239bd Merge pull request #244 from vernu/dev
Dev
2026-07-19 21:07:08 +03:00
isra el
ede855e15a Merge branch 'main' into dev
Resolve auth.service.spec.ts by keeping dev's suite and porting the
password-reset lockout coverage from #238 into it.

Harden the lockout from #238 while merging:
- claim each attempt with an atomic findOneAndUpdate $inc, so concurrent
  guesses cannot all read the same count and slip past the cap
- match records predating the counter, which have no attempts field
- lift MAX_PASSWORD_RESET_ATTEMPTS to module scope and trim comments

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 20:53:52 +03:00
vernu
e9107c180f Merge pull request #238 from sebastiondev/fix/cwe640-auth-service-password-ed0b
fix(auth): lock out password-reset OTP after 5 failed attempts
2026-07-19 20:42:44 +03:00
vernu
381e8987b8 Merge pull request #243 from vernu/feat/api-hardening
test(api): lock security-critical behaviour, then small guarded fixes
2026-07-19 20:35:37 +03:00
vernu
e46b832e5a Merge pull request #242 from vernu/fix/auth-form-errors
fix(web): show auth errors instead of silently reloading
2026-07-19 20:35:11 +03:00
isra el
8b635a2344 fix(api): keep push token and serial out of the device list
getDevicesForUser did find({ user }) with no projection, so the device
list endpoint returned the full document, including the fcmToken push
credential and the hardware serial, straight to the browser. Neither is
used by the dashboard or the Android client.

The query now projects both fields out with '-fcmToken -serial'. Only the
user-facing list is narrowed; getDeviceById, which feeds the guards and
internal logic, is untouched.

Guarded by the existing getDevicesForUser test, rewritten to assert the
projection and seen to fail against the pre-change single-argument call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 18:37:40 +03:00
isra el
e58412fa14 fix(api): escape the api-key lookup regex
findActiveApiKeyByClientKey builds a fallback lookup as
new RegExp(`^${prefix}`) from the client-supplied key prefix and runs it
as a Mongo $regex. A prefix containing regex metacharacters compiles to
the wrong pattern or, for something like "((((", throws SyntaxError and
fails the request. On the authentication hot path that is a denial and a
correctness hole.

The prefix is now escaped with escapeRegExp before it reaches the RegExp.
escapeRegExp already existed under gateway with its own spec; it is moved
to src/common so auth and gateway share one copy, and the gateway import
is updated. No behaviour change for the gateway caller.

Guarded by the existing lock (a legitimate key still resolves via the
masked hit and the prefix fallback) plus a new case, written first and
seen to fail against the pre-fix code (it threw "Unterminated group"):
a key with metacharacters now compiles to a literal pattern and resolves
to no match instead of throwing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 18:35:36 +03:00
isra el
6b66160f7f fix(api): actually enforce email and password validation
validateEmail and validatePassword are async and signal failure by
throwing (a rejected promise). Three callers invoked them without await:
register (both) and changePassword. An unawaited rejected promise is
dropped, so execution continued past the check: registration accepted a
malformed email or an out-of-range password, and a password change
accepted a too-short new password. The validators were effectively dead.

Adding await makes the existing rules take effect at their intended
point. Legitimate clients are unaffected: the web signup and
change-password forms already enforce a valid email and the length
bounds, so this only rejects input the service was always meant to
reject, with a clean 400 instead of persisting bad data.

Guards were written first and seen to fail against the pre-fix code
(register with a bad email or short password still created the user;
changePassword with a short password still saved), then pass after the
await is added. The valid-input paths are asserted to still succeed.

This is the dependency-free half of what a global ValidationPipe would
have covered. The pipe itself is deferred: class-transformer is not
installed, so enabling it now would add a dependency and risk crashing
once any DTO gains a decorator. Recommended as a separate follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 18:33:57 +03:00
isra el
b514891cad test(api): lock support.service flows
support.service had no tests. These lock both flows against current code:

- createSupportMessage: the 24h rate limit rejects a fourth request
  without persisting or emailing; the success path saves the message,
  strips the turnstile token before persistence, emails the requester,
  and returns success.
- requestAccountDeletion: a missing or invalid user id and an unknown
  user are both NotFound; a second request is a Conflict; the success
  path records accountDeletionRequestedAt with the reason and emails the
  user.

6 tests, all passing against current code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 18:27:32 +03:00
isra el
78fb040c21 test(api): lock webhook.service security-critical units
webhook.service was 1039 lines with zero tests. These specs pin the
parts a regression would hurt most, without changing any code:

- validateDeliveryUrl: accepts normal https; rejects non-http(s) and
  malformed URLs; rejects loopback and private hosts including the cloud
  metadata IP. This locks the SSRF guard so it cannot silently regress.
- Signing secret validation: a secret under 20 characters is rejected on
  both create and update.
- attemptWebhookDelivery signing: the X-Signature header equals an
  independently computed HMAC-SHA256 of the JSON payload under the
  subscription secret, and the signature changes with the secret.
- Delivery abort: when the subscription is inactive or soft-deleted, the
  attempt is marked aborted and saved and no HTTP request is made.

axios is mocked so no request leaves the process; private methods are
exercised through the instance.

16 tests, all passing against current code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 18:26:24 +03:00
isra el
9b7a8bcc55 test(api): lock auth.service core flows
auth.service was 521 lines with only the withoutPassword helper covered.
These specs pin the flows a regression would hurt most, all against the
current unchanged code:

- validateEmail / validatePassword: boundary and malformed-input cases,
  including the 6 and 128 character password edges.
- findActiveApiKeyByClientKey: the exact-masked hit path and the legacy
  prefix-regex fallback, asserting the revoked-key exclusion is applied on
  both lookups. This is the behaviour lock for the upcoming regex-escape
  fix: it captures how a legitimate key resolves today.
- generateApiKey: the raw key is returned once, and only a masked value
  plus a bcrypt hash of the key are persisted (never the raw key).
- changePassword: a wrong old password is rejected without saving; the
  success path replaces the stored hash.
- resetPassword: a missing or non-matching OTP is rejected without saving;
  the success path updates the password and closes the reset window.

Note (to be fixed under the refactor part, not here): changePassword
calls validatePassword without await, so the length rule does not
actually block a weak password on change. Left as-is in this behaviour
-lock commit and tracked for a guarded fix.

16 tests, all passing against current code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 18:24:31 +03:00
isra el
68e72d8bdc test(api): lock the three authorization guards
The guards that prevent cross-tenant access had zero tests, which is the
highest-risk gap in the backend: a silent regression here would let one
account reach another account's devices or api keys.

These specs pin the current authz contract without changing any code:

- AuthGuard: valid bearer resolves the user; invalid/expired bearer is
  401; a valid x-api-key resolves via findActiveApiKeyByClientKey plus a
  matching bcrypt hash and attaches request.apiKey; a non-matching hash,
  an unknown key, and no credentials are all rejected; a token that
  resolves an id for a user that no longer exists is 401.
- CanModifyDevice / CanModifyApiKey: owner passes; non-owner is rejected
  (the cross-tenant case); admin passes regardless; an invalid ObjectId
  is 400 before any lookup; a missing record is rejected.

Guards are plain constructor-injected classes, so they are instantiated
directly with mocked services and a minimal ExecutionContext, which is
lighter than a testing module and equally faithful.

17 tests, all passing against the current unchanged guards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 18:21:38 +03:00
isra el
5f16ac8883 fix: show auth errors instead of silently reloading
Login showed no message on wrong credentials and just reloaded. The
handler called signIn with redirect:true, which makes next-auth navigate
on both success and failure, so the browser reloaded to /login?error=
and the `if (result?.error)` branch never ran: the error handling was
dead code. Switched to redirect:false, so the result is read here, the
message renders, and success navigates via router.push + refresh.

The password-reset (OTP) form had two related bugs, both found while
verifying the "check forgot-password too" ask:
- It set form error under `root.serverError` but the JSX rendered
  `errors.root.message`, which a nested key leaves undefined, so a failed
  reset showed an empty paragraph and no text.
- The catch swallowed the error, so react-hook-form's isSubmitSuccessful
  stayed true and the "Password reset successful" alert rendered even on
  failure. The success alert is now gated on the absence of a root error,
  and errors are cleared at the start of each submit.

register-form was already correct (redirect:false); added a regression
test so the login bug cannot reappear there unnoticed.

Tests: component specs for all three forms under vitest + RTL, with
useTurnstile and the network boundary mocked. Each new failing case was
confirmed to fail against the pre-change component before the fix. The
login signIn mock reflects real redirect semantics (redirect:true yields
no readable result), so the "shows an error" test is a faithful guard.

Verified: typecheck clean, build clean, 0 lint errors (20 warnings,
unchanged), 164 unit tests (from 155).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 18:17:47 +03:00
vernu
c8358de156 Merge pull request #241 from vernu/feat/web-refactor-ui
Dashboard refactor: UI rebuild, typed data layer, strict TS, CI test gate
2026-07-19 09:49:15 +03:00
isra el
670d0819b8 chore: drop an over-specific test comment
The comment named endpoints and described prior behaviour, which is more
detail than a public repo needs. The assertions are unchanged and still
enforce the same guarantee.

The remaining comments in this area state forward-looking rules ("never
return this to a client") rather than describing what used to happen,
which is what a contributor actually needs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 09:46:53 +03:00
isra el
dcb12c6b59 chore: delete two unused components
black-friday-modal and community-alert were imported nowhere. Both were
left in place during the feature-folder reorg rather than deleted
unsupervised; removing them now on request.

Confirmed unreferenced by name and by path across app, components, lib,
e2e and test before removal. Both were self-contained presentational
components with no side effects, so nothing else changes.

Verified: typecheck clean, build clean, 0 lint errors (20 warnings, down
from 21 since one of the deleted files carried one), 155 unit tests, 78
e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 09:35:10 +03:00
isra el
bd808333b3 refactor: split bulk-send into a state hook and per-step components
index.tsx was 732 lines, the largest file in the app by a wide margin,
holding all wizard state, the CSV parsing callbacks, the send mutation
and four steps of JSX in one component.

State and behaviour move to use-bulk-send, matching the useOnboarding
pattern the get-started folder already uses. The four steps and the
success panel become presentation components. index.tsx is now 23 lines
of composition.

Each step destructures what it needs from the hook result at the top,
which leaves the moved JSX byte-identical to what it replaced. That was
a deliberate second attempt: the first pass rewrote every reference to
bulk.<name> inline, which corrupted a string literal (an input id became
"message-bulk.template") and produced 22 false "cannot access refs
during render" warnings in the one step that receives the ref. Warnings
are back to the 21-warning baseline.

Verified: typecheck clean, build clean, 0 lint errors, 155 unit tests,
78 e2e. The seven bulk-send e2e tests walk upload, mapping, compose and
a full send, which is the evidence that behaviour did not move with the
code. No test needed editing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 09:16:05 +03:00
isra el
9c0826016a refactor: group dashboard components into feature folders
The (components) directory held 28 loose files alongside six feature
folders, so the convention existed but was only half applied. Every new
feature added to the pile rather than to a place.

Grouped into account, billing, devices, api-keys, alerts, search and
community. Only nav-items and overview stay at the root, since both
compose across features rather than belonging to one.

Also merged the duplicate webhooks component trees. Components lived in
both dashboard/(components)/webhooks and dashboard/webhooks/(components),
and the second tree was reached by a cross-tree import from
webhooks-history. All three of its files are delivery-history specific
and had no other consumer, so they moved into webhooks-history and that
tree is gone. webhook-table also became deliveries-table, matching what
it renders.

Moves only, no logic changes: git records all 25 as renames, and no test
needed editing, which is the check that behaviour did not move with them.

Two files turned out to be dead code and are deliberately left in place
rather than deleted: black-friday-modal (seasonal, clearly parked) and
community-alert. Nothing imports either.

Verified: typecheck clean, build clean, 0 lint errors (21 warnings,
unchanged), 155 unit tests, 78 e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 09:08:17 +03:00
isra el
82973444c2 refactor: move mutations behind the shared data hooks
Ten components hand-rolled useMutation against httpBrowserClient, each
repeating its own endpoint, error shape and cache invalidations. The four
webhook mutations wrote the same ['webhooks'] key four times, and the two
API-key generators wrote the same three invalidations twice, which is how
one of them ended up with the ['apiKeys', 'stats'] key that matched
nothing.

Invalidations now live once beside each hook, so a component cannot
forget one or spell it differently. Component-specific behaviour (toasts,
dialog closing, form resets) stays at the call site via per-call
mutate(vars, { onSuccess, onError }) callbacks.

For the two hooks that take options, the caller's onSuccess is composed
with the hook's rather than spread over it, so passing a callback cannot
silently drop the invalidation the hook exists to guarantee.

Also normalised useRevokeApiKey, useDeleteApiKey and useRenameApiKey onto
queryKeys.apiKeysAll. They previously invalidated either a bare
['apiKeys'] literal or only the 'active' list, so revoking a key left the
revoked and all lists stale.

Two behaviour details preserved deliberately: the inline register panel's
failure toast, which moved to the call site rather than being dropped,
and webhook-card's toggle, which was a raw try/catch with its own loading
state and is now the shared mutation's isPending.

The typed generate hook surfaced that the copy-key handler could pass
undefined to clipboard.writeText, now guarded.

Verified: typecheck clean, build clean, 0 lint errors (21 warnings,
unchanged), 155 unit tests, 78 e2e including the webhook create, edit,
delete and toggle paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 04:56:13 +03:00
isra el
290149c8fb fix(security): stop sending the password hash to the browser
The user schema had no projection on `password` and no select: false, so
every read of a user document loaded the bcrypt hash. Three paths then
sent it to the client:

- GET /auth/who-am-i returns req.user, which the auth guard populates
  with an unprojected usersService.findOne. Every session check.
- The login response returns the whole user document.
- The register response returns the freshly created document.

The hash is bcrypt, so it is not directly usable, and this is not a
plaintext leak. It still matters: it hands an attacker unlimited offline
guessing with no rate limit, no lockout and no audit trail, against
whatever password strength the user chose. It also lands anywhere a
response goes, which for this app includes Microsoft Clarity session
recording, plus devtools, browser extensions, error reporting and any
XSS. Password reuse makes a cracked hash a problem beyond this app.

Fixed in two layers. The schema marks the hash select: false, so nothing
loads it by default and any future read is safe by construction. The two
flows that genuinely need it, login and change-password, ask for it
explicitly via usersService.findOneWithPassword.

select: false does not cover a document held in memory, so the login and
register responses also strip it through withoutPassword before
returning. Those were the two paths that had it loaded on purpose or had
just written it.

The schema guard was confirmed to fail against the pre-change schema
before being trusted.

Verified: 61 API tests pass (from 53). Typecheck introduces no new
errors (2 pre-existing before this change, 1 after).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 04:50:06 +03:00
isra el
06295ae7f1 refactor: read the shared data hooks instead of refetching by hand
Nine components each hand-rolled a useQuery against endpoints the typed
hooks in lib/api already cover: the current user in seven places, the
subscription in four, the device list in two, and stats in one. Each
copy repeated the endpoint, the envelope unwrapping and the cache key,
which is where both of the invalidation bugs fixed in c1942a4 came from.

They now call useCurrentUser, useSubscription, useDevices and
useGatewayStats. The polling behaviour in use-onboarding is preserved by
passing refetchInterval through the hooks' options, with the difference
that its polling now refreshes the same cache entries the rest of the
dashboard reads rather than maintaining a parallel copy of them.

update-app-modal and update-app-notification-bar previously destructured
the raw { data } envelope; useDevices unwraps it, so those consumers
read the array directly now.

Added createdAt and onboarding to the User type. Both are real fields
that components were already reading, and adding them lets the shared
hook feed the onboarding checklist, which had been the reason for a
separate query in the first place.

Verified: typecheck clean, build clean, 0 lint errors (21 warnings,
unchanged), 155 unit tests, 78 e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 04:31:07 +03:00
isra el
5bae4ffd9a ci: run the web test suites instead of only building
The web job ran `pnpm install && pnpm run build` and nothing else, while
the API job right above it ran its tests. So the 155 unit tests and 78
e2e tests built up over previous rounds had never gated a merge, and a
regression in either would have reached main unremarked.

Lint, typecheck and unit tests now block. All three were confirmed to
exit 0 against the current tree first, so this does not wedge the merge
queue on day one. Typecheck earns its place here: next build does not
check test files, so type errors in them were invisible.

E2e runs but is non-blocking for now. It is fully mocked and never
contacts a backend, but it drives a real browser, so it reports for a
few merges before it is made blocking. The continue-on-error carries a
TODO saying so, so it does not quietly become permanent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 04:21:01 +03:00
isra el
3e4597294c refactor: turn on strict TypeScript and fix the 63 errors it found
My earlier estimate of 34 errors was wrong, and the reason is worth
recording: tsconfig carried "strictNullChecks": false as a duplicate key
AFTER the strict flag, and an explicit option beats the strict umbrella
even when --strict is passed on the CLI. So the measurement that produced
34 had silently excluded every null-safety error. The real number was 63.
Both duplicate keys are gone; strict: true now stands alone.

Most were implicit-any, but strict caught several genuine type lies:

- SendSmsPayload.deviceId and WebhookData._id were optional while both
  are interpolated into request paths, so an absent value would have hit
  /gateway/devices/undefined/send-sms or PATCH /webhooks/undefined.
- webhook-table's deviceName was typed string while buildDeviceLabel
  returns string | string[] and the Device cell already renders the
  array case. The type never described what the code produced.
- webhooks-section read `webhooks?.data?.length > 0`, comparing
  undefined against 0 while the query was still in flight.
- app-header declared a non-null Session while its own body guarded with
  session?.user throughout. Making the type honest surfaced four
  genuinely unguarded accesses.
- api-keys kept a local ApiKeyRow duplicating the shared ApiKey type, so
  the list callback annotated rows as one type while the hook returned
  the other. ApiKeyRow is now an alias and the two extra fields moved
  onto ApiKey.
- The notifications envelope typed its rows as unknown[], so the
  deliveries table's row type went entirely unchecked. Now a real
  WebhookNotification type.

The react-hook-form cluster (20 of the 63) was one root cause: zod's
.default() makes the input and output types differ, so z.infer (the
output) is not what the resolver takes. Fixed by typing the forms with
z.input and z.output separately, which changes nothing at runtime.

Also bumped target es5 to ES2017, which fixes the Set-iteration error
that made tsc --noEmit fail before any of this. Next compiles browser
output via SWC and its own browserslist, so bundle targeting is
unaffected. Added @types/papaparse and @types/react-syntax-highlighter,
and a typecheck script, since next build does not check test files.

No @ts-expect-error and no new any were used.

Verified: typecheck clean, build clean, 0 lint errors (21 warnings,
unchanged), 155 unit tests, 78 e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 04:19:35 +03:00
isra el
c1942a4ba3 fix: repair two cache invalidations that silently matched nothing
Both bugs were hand-written query keys. lib/api/query-keys.ts exists to
prevent exactly this and neither site used it.

1. /auth/who-am-i was cached under two keys. Four call sites used
   ['whoAmI'] and seven used ['currentUser'], including the typed
   useCurrentUser hook. So the endpoint was fetched twice on any page
   using both, and invalidating one never invalidated the other:
   edit-profile-form refreshed ['currentUser'] and left the email
   verification banner reading stale data under ['whoAmI'], while
   use-onboarding had the mirror-image bug.

   verify-email/page.tsx additionally cached the whole axios response
   where the others cached the unwrapped user, so unifying the key meant
   normalising that shape too. One key demands one shape.

2. generate-api-key invalidated ['apiKeys', 'stats'], which no query
   uses. react-query matches by prefix, so it matched neither the key
   list (['apiKeys', status]) nor the dashboard stats (['stats']).
   Generating an API key refreshed nothing at all. It is now three
   invalidations against queryKeys, using a new apiKeysAll prefix so
   every status filter refreshes rather than just 'active'.

Also added accountDeletionRequestedAt to the User type. It is a real
field on the user schema and the deletion banner already reads it; only
the type did not know about it.

Both bugs are invisible on inspection, which is how they shipped, so the
regression tests were each confirmed to fail against the pre-change code
before being trusted.

Verified: build clean, 0 lint errors (21 warnings, unchanged), 155 unit
tests (from 153), 78 e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 04:02:30 +03:00
isra el
366a47913b feat: redesign the billing page and stop calling free users "Unknown"
/billing/current-subscription answers in two shapes. A subscriber gets the
Subscription document, which always carries a status because the schema
requires one. A user with no subscription gets a synthesised
{ plan, isActive, usage } with no status, no amount and no dates.

The page read that second shape as a subscription with missing fields, so
every free user was told their subscription status was "Unknown" and shown
two "N/A" billing dates. Absence of a status is not an unknown status.

lib/billing.ts interprets the payload instead of guessing at it. A paid plan
arriving without a status is still genuinely unknown and is still reported
that way, so the earlier fix for fabricated "Active" is preserved.

The redesign splits one flat card into plan identity and usage. The old
inner panels were bg-card with shadow-sm sitting on a bg-card parent, so the
nesting was invisible; they are bordered muted panels now. Free accounts
drop the status pill, the always-N/A dates and the portal link they have
nothing to manage with.

CTAs are Buttons rather than hand-rolled Links with background utilities,
and the upgrade target comes from position on the plan ladder, so Scale
subscribers are no longer sold a tier above Scale and bespoke plans are not
pushed onto the self-serve ladder at all. Added a "Compare all plans" link
to textbee.dev/pricing, matching the label the onboarding plan picker
already uses for the same destination.

Also corrected the Pro fixture from 1900 to 999 cents, a price we do not
charge, and gave the portal link noopener noreferrer.

Verified: build clean, 0 lint errors (21 warnings, unchanged), 153 unit
tests (from 121), 78 e2e (from 75). The three free-account guards and the
pricing link guard were each confirmed to fail against the pre-change
component before being trusted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 03:23:00 +03:00
isra el
469aa0025c fix: regrade the logo to the brand orange and crop its dead space
Copying the marketing logo did not fix the colour, because the marketing
artwork does not match --primary either. Measured, the mark held two
orange families: one already near brand at hsl(19,93%,52%) and an amber
cluster at hsl(28,98%,48%). The amber is what read as the old colour.

Regraded only the saturated orange fill onto hsl(21,90%,48%), preserving
shading, and left the tan body and dark figure alone. The rendered mark
now samples at hsl(22,89%,48%) against the text-primary "bee" beside it
at hsl(21,90%,48%).

The bigger problem was not colour. The source was a 500x500 canvas whose
artwork occupied a 387x319 box and only 24.7% of the pixels, drawn at
24px in the header. Roughly three quarters of that box rendered pure
white, so the mark was a faint smudge and whatever colour it carried was
barely visible. Cropped to the content on a square canvas.

The favicon carries the same regrade across all three of its frames.

Also fixes a latent flake in the RelativeTime component tests. They fed
the component dates derived from a hardcoded NOW while the component
reads the real clock, so "7 days ago" only held on 2026-07-18 and broke
the moment the date rolled over.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 03:07:42 +03:00
isra el
5d43866847 fix: show every plan in onboarding and let finished steps be reopened
The plan step hardcoded Free and Pro inline, so Scale never appeared no
matter what the pricing page offered. Tiers now come from lib/plans,
which mirrors the marketing pricing section, with a test pinning the
values so the two cannot drift silently.

Kept static rather than fetched from /billing/plans. That endpoint
returns Plan documents (limits and cents), not customer-facing copy, and
an environment with no plans rows left the step showing "plans could not
be loaded" with nothing to choose.

Reopening a completed step showed an empty row. Two things caused it:
the body was gated on the step not being done, and an effect cleared the
selection whenever the selected step was done, so clicking a finished
step deselected it on the next render. That effect exists to advance you
when the step you are sitting on completes underneath you, so it now
fires only on that transition. Both paths are covered: reopening the API
key step offers "Generate another API key", and a step completing while
selected still moves the selection on.

Skip is hidden on an already-finished step, where it would mean nothing.

Also corrects the Plan type, which declared amount, currency and
recurringInterval. The plans endpoint has never sent those; they belong
to Subscription. Anything reading plan.amount saw undefined and rendered
a paid plan as free. The fixture encoded the same wrong shape, so tests
would have passed while production broke.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:48:12 +03:00
isra el
e75dbc7a67 feat: compact the API key rows and align the logo with the brand orange
Each key rendered a Card stripped of its border and shadow inside the
outer Card, buying markup and padding for no visual result. It is now a
plain row: the key glyph drops from 24px to 16px and reads as muted
rather than competing with the key name, the tinted code chip hugs the
key instead of stretching into a full-width bar, and the timestamps
share one line.

Removed the per-row "Active" badge. This list only ever contains active
keys, revoked ones live behind their own dialog, so the badge appeared
on every row and distinguished nothing. Say if you would rather keep it.

The loading skeleton now mirrors the loaded row, so the list no longer
resizes as it settles.

The logo and favicon were still the older lighter amber artwork while
the marketing site had been regraded to the deeper brand orange that
--primary already uses here. Same artwork, so both files are copied
across rather than regenerated, which keeps the two properties
byte-identical instead of merely similar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:31:36 +03:00
isra el
b2acba4c2c fix: tidy the community page and close mobile layout gaps
community-links was the only file in the app still wrapping a Button in
a Link, four times, producing an anchor around a button: invalid markup
and a nested interactive control that assistive tech announces twice.
Everywhere else already uses Button asChild. Two whole Card blocks sat
commented out, with an icon imported solely for that dead code.

community/page.tsx was the only dashboard section that never got the
mobile pass, keeping p-6 with no p-4 step and an unconditional text-3xl.
It was also the only section missing from the 375px overflow guard,
which is presumably how it was missed. Extracting the shared PageHeader,
which the messaging, webhooks and account layouts all repeated by hand,
fixes that outlier by construction.

Both billing limit grids and the promo modal were locked to two columns
at every width, so the meter captions had no room on a phone.

window.open kept a live opener handle back to the app in four places.
Browsers imply noopener for anchor targets but not for window.open.

The share dialog grid moves to 3 columns then 7. Worth stating plainly:
this is not an overflow fix. I expected 7 icons at grid-cols-4 to
overflow at 375px and the extended guard proved they do not. It was
only an awkward 4 + 3 split.

The overflow guard now covers 12 routes instead of 6 and opens a dialog,
since the densest layouts in the app only exist inside modals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:20:39 +03:00
isra el
2b766e8019 fix: drop the phantom device status and share one error state
The Device type declared a `status` field that the schema does not have
and the API never sends, so `device.status === 'online'` was always
false. The badge took its colour from that phantom field and its text
from `enabled`, so an enabled, working device was styled exactly like a
disabled one. Both now come from `enabled`.

Devices, API keys and webhooks each rendered a bare unstyled
"Error: {error.message}" with no way to recover, putting raw transport
strings like "Request failed with status code 500" in front of users.
formatError already handles axios rejections and rate limits and was
used by none of them.

Adds components/shared/error-state.tsx as the counterpart to the
existing EmptyState, routing all three through formatError and offering
a retry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:10:33 +03:00
isra el
022a6bd2a6 fix: repair form labelling and navigation landmarks
None of the three password fields declared autoComplete, so password
managers could not tell them apart and would fill the saved password
into the wrong box. They now declare current-password and new-password.

The delete-account dialog, the most destructive action in the app, had
a label whose htmlFor pointed at an id the textarea never had, and an
email confirmation field with no label at all. Both are now wired, and
the controls moved out of DialogDescription, which Radix targets with
aria-describedby: opening the dialog previously read the labels and
placeholders back as one long description string.

Validation errors are now tied to their inputs via aria-describedby and
announced with role=alert instead of appearing silently.

confirmPassword required 4 characters while newPassword required 8, so
a five-character confirmation reported "Passwords must match" rather
than the actual length problem.

Five icon-only buttons had no accessible name: copy API key, copy
device ID, the API key menu, and both promo code copy buttons.

Four of the five nav landmarks were unlabelled, leaving them
indistinguishable in a screen reader's landmark list. Added a
skip-to-content link, since keyboard users otherwise tab the whole
sidebar on every page.

The delete-account label guard was confirmed to fail against the
previous markup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:00:11 +03:00
isra el
ad7bcd99a5 fix: stop the billing page asserting things it does not know
The status pill hardcoded a check mark and varied only its colour, so a
past_due or canceled subscriber was shown a tick beside the bad news.
It also fell back to the label "Active" when the payload carried no
status at all, inventing a healthy state on a billing screen. The icon
now comes from the status, and an absent status reads "Unknown".

formatPrice returned "Free" whenever the currency was missing, even
with a real amount. Since the badge only renders when amount > 0, the
one case it could produce was a paying customer being shown
"Free / monthly". Default the currency instead.

The page also derived its usage numbers inline instead of calling
deriveUsage, which exists precisely so billing and the dashboard cannot
disagree. The inline copy skipped the helper's 0-100 clamp, so the
invariant was already broken.

Monthly is a rolling 30-day window server-side, not a calendar month,
which the dashboard already labels correctly. Billing said "this
month", so a user capping out late in the month would wait for a reset
that never arrives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 01:50:30 +03:00
isra el
f65af5003b fix: give every webhook its own signing secret
The create dialog is mounted for the whole session, so its
defaultValues, and the uuid inside them, were evaluated exactly once.
A bare form.reset() then restored that same object, so every webhook
created without reloading the page shared one signing secret.

The secret is what proves a delivery genuinely came from us, so sharing
it across endpoints means compromising any one of them compromises the
rest.

Build the defaults fresh on each use, and reset with a new secret both
when the dialog opens and after a successful create.

The e2e guard was confirmed to fail against the previous code, with
both creates submitting an identical secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 01:42:17 +03:00
isra el
6da80ac4e7 fix: correct five CSV handling defects in bulk send
The bulk send rebuild shipped several parsing bugs that silently
produced wrong results rather than failing loudly.

Columns were read from the first parsed row instead of the header.
papaparse only assigns keys for values actually present in a row, so a
short first row hid every column after it: the phone column could not
be selected and the file was unusable. Read results.meta.fields.

Row-level parse errors were discarded. Only fatal stream failures were
reported, so a mis-delimited file parsed into one mangled column and
looked fine. Surface them as a warning.

The plan row cap was checked once at drop time and skipped entirely
while the subscription was still loading, so an over-cap file passed on
a limited plan and nothing revalidated it. The cap is now derived, so
it applies whenever the limit resolves.

Rejected files returned silently, leaving a dropped .xlsx with no
feedback. Report the rejection.

The preview index was left pointing into the old recipient list when
the phone column changed, hiding the preview and disabling its own Next
button. It is now clamped and reset.

Also count segments from the rendered message rather than falling back
to the raw template, which counted the placeholder literals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 01:37:50 +03:00
isra el
05143b8752 fix: give the dashboard Add device button the guided flow
The header quick action opened the API key modal directly, so a first-time
user who clicked "Add device" was handed a generated key with no mention of
installing the app or granting SMS permissions. The Registered Devices card
already had the right flow: explain the steps, then Continue into the key and
QR code.

That flow lived as local state inside device-list, so it could not be reused.
Extracted to add-device-dialog with the same imperative open() handle the
existing GenerateApiKey uses, and wired into both entry points.

"New API key" deliberately still goes straight to key generation. That button
asks for a key, so the device prerequisites would be noise there. The two
actions now do genuinely different things instead of both opening the same
modal under different labels.

E2e covers both entry points reaching the instructions first and Continue
still reaching key generation, plus New API key skipping the instructions.
Verified the guard fails against the old wiring rather than assuming it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:49:19 +03:00
isra el
1349134107 fix: give every messaging tab the same content column
Switching tabs shifted the layout. The cause was broader than History being
full width: all four subroutes set their own width, so nothing lined up.

  Send    max-w-xl   576px
  Bulk    max-w-3xl  768px
  API     max-w-4xl  896px
  History none       full bleed

The header and tab strip were also full width while the view beneath them was
constrained, so on Send the tabs ran roughly 200px past the form.

The column is now defined once in the messaging layout and wraps the header,
the tabs and the children together. The per-page wrappers are gone, so a new
subroute inherits the width instead of inventing one.

Guard: an e2e visits all four tabs, measures the view rendered beneath the tab
strip, and asserts the widths match and that the column is constrained rather
than stretched.

Getting that guard to actually work took three attempts, each a real hole:
- Comparing the column against the viewport passed even with the constraint
  removed, because the sidebar and padding already made it narrower.
- Comparing against the parent's border box passed too, since that box
  includes the padding the column sits inside. It now subtracts the padding.
- Measuring the layout column could not detect a page re-constraining itself,
  because that column is identical on every tab by construction. It now
  measures the rendered view instead.
Verified by reintroducing both regressions separately and watching it fail
each one, with the offending tab named in the message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:34:46 +03:00
isra el
628e5b09d1 feat: rework the SMS detail modal around chips and per-field copy
The bottom of the modal was a label/value table, and most of it repeated the
header: Direction restated the To/From already shown with its arrow icon, and
Number restated the number in the title. What remained was two facts wrapped
in table furniture.

Those facts are now inline chips: status, device, and the gateway ID in a
mono chip. The redundant rows are gone.

Copy moved from a single footer button to one button per field, next to the
thing it copies: the number in the header, the message body, and the gateway
ID. A lone "Copy text" button left the target to be inferred, and it could
only ever copy one of the three.

Reused the existing shared CopyButton rather than adding another, which also
brought its copied-state feedback and toast. It had no accessible name
though, so every icon-only copy button was announced identically. It now
names itself after what it copies, which is what lets the new test address
them individually.

Also overrode the dialog primitive's mobile text centring in this header: it
left the title on the left and the timestamp centred beneath it.

E2e reads the clipboard after each button and asserts all three return their
own value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:17:16 +03:00
isra el
1999593e07 fix: day header overlap, mobile footer alignment, modal animation
Day headers in message history could cover message rows. Two distinct causes,
both reproduced before fixing:
- On mobile the header was pinned to top-14, the same sticky band the mobile
  search bar already occupies, so it detached and landed on top of rows.
  Being bg-muted/70 with a backdrop blur, row text bled through it, which is
  the distortion that was reported.
- On desktop it was pinned to top-0, behind the app header, and it covered
  rows scrolled beneath it and swallowed their clicks. Playwright surfaced
  this second one: a row click failed with "h3 intercepts pointer events".

Day headers are no longer sticky. A page holds 20 messages, so groups are
short and pinning bought little in exchange for those failures.

Footer links are left-aligned on mobile. Centred links in a single column had
no common edge to scan down.

Modal animation is now a quick fade. Dialogs slid in from the left and top
while zooming, which is the movement that read badly; the slide and zoom are
gone and the duration drops from 200ms to 150ms, applied to both the dialog
and alert-dialog primitives so every modal matches. Sheets still slide, since
that is what a drawer should do.

Guards: the overlap check runs at both mobile and desktop widths and also
asserts a row is still clickable after scrolling. Verified it fails against
the old markup rather than assuming a green test means coverage.

Fixture dates now anchor to local midnight instead of "N hours ago". A
2-hour-old message falls on the previous day when the suite runs shortly
after midnight, which made the Today/Yesterday assertions depend on the wall
clock. It failed exactly that way during this session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:07:29 +03:00
isra el
9913b7cbe7 feat: rebuild the send page with recipient chips and segment counting
Brings the send screen up to the standard of the rebuilt bulk send flow.

Fixes real defects:
- The single-device auto-select never worked. It was computed in
  defaultValues from devices?.data?.length === 1, but react-hook-form reads
  defaultValues once on mount, while the devices query is still pending, so
  the condition was always false. It now preselects once devices resolve.
- Submitting before devices loaded showed "Required" on the device field,
  which then auto-filled a moment later, leaving a stale error beside a
  populated field. Submit is disabled until devices load.
- Moved off the legacy raw useQuery(['devices']) plus devices.data, the shared
  cache key with a different unwrapped shape that caused the devices?.filter
  crash previously.
- Every field used a placeholder as its label. Placeholders vanish on focus
  and are not reliably announced, so all three now have real labels.

Recipients are chips instead of a growing stack of inputs. They commit on
Enter, comma or blur, so a number typed and left uncommitted is not silently
dropped at send time. Pasting a list adds several at once.

Splitting a pasted list needed care, and an e2e caught it: splitting on
whitespace shredded "+1 (415) 555-0101" into three fragments. Only unambiguous
separators split first; whitespace splitting is a fallback for tokens that
cannot be a single number.

Also adds a segment counter, inline alerts for success and failure instead of
bare text, and a form reset after sending that keeps the device selected.

Phone helpers moved to lib/sms.ts alongside the segment helpers, so the send
page and bulk send share one tested implementation.

One test correction: the desktop footer assertion required links to sit on
exactly one row, but the row layout wraps by design, so it failed whenever
font metrics pushed a link to a second line. It now asserts the property the
design actually guarantees, that links are not one-per-row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:04:35 +03:00
isra el
8c818351d6 feat: redesign message history around a scannable list
The screen opened with a tall filter card, so messages started below the fold,
and each message was a bordered card capped at max-w-sm, fitting three or four
per mobile screen.

List:
- Messages are now rows grouped under day headings (Today, Yesterday, then the
  date). Roughly triples what fits on a phone, and scanning by time is how
  people actually look for a message.
- Rows are buttons, so the list is keyboard navigable, with relative times
  that reveal the exact timestamp on hover.
- Direction now comes from the `type` the API returns. It was inferred from
  whether `sender` was present, which is only a proxy; the old check survives
  as a fallback for rows written before `type` existed.
- Pagination renders only when there is more than one page.
- A search that matches nothing is a different state from a device with no
  messages, and offers a way back.

Filter bar collapsed into one compact row: search, device, refresh, and auto
refresh moved into a dropdown instead of four inline buttons occupying a whole
row for a rarely-changed setting.

Details dialog reordered around why it is opened: the message body first, then
metadata, then errors, then actions. The exact timestamp is text here because
the list tooltip is hover-only and unreachable on touch.

Composer dialog gains real field labels (it used placeholders as labels, which
vanish on focus and are not reliably announced) and the same segment counter
bulk send has.

Two fixes found along the way:
- Reply passed message.device?._id with no fallback. The endpoint does
  populate device, but if it were ever absent the composer would open with no
  device and could not send. It now falls back to the device being viewed.
- The device-defaulting effect became derived state, so the list no longer
  renders once with no device selected before correcting itself.

Segment helpers moved to lib/sms.ts now that the send page, bulk send and the
composer all use them. Fixtures now carry the populated `device` and relative
dates, so the mocked path matches production and exercises day grouping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:46:27 +03:00