Conversation
Establish the testing safety net for the web dashboard before refactoring. - Vitest + React Testing Library + jsdom for unit/component tests - MSW node server mocks the API; unhandled requests throw so tests can never reach a real backend - Playwright e2e boots the real Next app but intercepts every /api/v1 call with shared fixtures, and mints a NextAuth session cookie to run authed - Shared fixtures in test/fixtures.ts back both layers - Seed tests: Button unit tests, MSW sanity checks, and a dashboard e2e happy path plus an auth-guard redirect Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- next 14.2 -> 16.2, react/react-dom 18 -> 19, eslint 8 -> 9, typescript-eslint 6 -> 8, typescript 5.3 -> 5.7, @types/react* -> 19 - remove i18n config from next.config.js (unsupported in App Router; it forced a pages-router _document and broke the 404 prerender) - Next 16 reconfigured tsconfig (moduleResolution bundler, jsx react-jsx) Build, unit tests, and e2e all green on the new stack. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- CSS-first config: @import "tailwindcss" + @theme inline mapping the shadcn HSL tokens; colors unchanged (rebrand lands in the design part) - switch postcss to @tailwindcss/postcss, drop autoprefixer - replace tailwindcss-animate with tw-animate-css for the animate-in / data-state utilities the Radix primitives rely on - remove tailwind.config.js (v4 auto-detects content) and point components.json at the CSS - suppressHydrationWarning on <html> for the next-themes class swap Build, unit tests, and e2e all green; generated CSS verified to contain bg-primary, the brand ramp, border-border, and the accordion/animate utilities. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- remove unused deps: prisma (no schema/usage), tailwindcss-animate (replaced by tw-animate-css), @radix-ui/react-separator (no imports) - drop unused DATABASE_URL from .env.example - bump Radix, react-query, react-table, next-auth, next-themes, react-hook-form, react-dropzone, tailwind-merge, axios, uuid, etc. - pin three packages off their newest majors to avoid churn outside this scope (revisit later): - lucide-react 0.577 (1.x removes brand/social icons still used in 6 files) - zod 3.25 (4.x changes resolver input/output typing across all forms) - react-day-picker 9 (10.x changes the classNames API in ui/calendar) Build, unit tests, and e2e all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce a typed data layer under lib/api (query-key factory, response
types, feature hooks) plus shared lib/format and lib/status helpers, and
migrate the canonical dashboard consumers (subscription-info, overview,
device-list, api-keys) onto it, deleting their duplicated inline queries,
mutations and formatters.
List hooks keep the raw { data: [] } envelope in the cache and unwrap
per-observer with react-query `select`, so shared keys like ['devices']
stay compatible with the not-yet-migrated components that still read the
raw shape (avoids a cache-shape collision surfaced by the dashboard e2e).
Adds unit tests for the formatters and the hooks (against MSW). Build,
19 unit tests, and 2 e2e all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- extract the provider tree into app/(app)/providers.tsx and create the QueryClient once via useState (it was rebuilt on every render, discarding the cache); remove the old layout-wrapper - replace the per-navigation whoAmI session check with a global 401 response interceptor in httpBrowserClient that routes expired sessions to /logout - rename middleware.ts -> proxy.ts (Next 16 convention), clearing the deprecation warning; auth guard verified by e2e - fix .env.example: NEXTAUTH_SECRET (was mislabeled AUTH_SECRET) + NEXTAUTH_URL - add an e2e assertion for the authenticated login -> dashboard redirect Build, 19 unit tests, and 3 e2e all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- new primary accent: warm orange #EA580C (hsl 21 90% 48%) in light + dark, with the matching Tailwind-orange brand-* ramp; focus ring now uses the accent, and the primary-on-color foreground is white for contrast - refine dark mode into layered neutrals (background below cards/popovers, clearer borders) instead of the previous flat gray - warm the accent token and align chart-1 to the brand - adopt Inter via next/font (--font-inter -> font-sans), antialiased body - motion foundation: fade-in / fade-in-up keyframes as theme animations plus a prefers-reduced-motion guard; drop dead imports from the root layout Build, 19 unit tests, and e2e green; dashboard visually verified in the new orange. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the floating top-[20%] icon sidebar with a proper dashboard shell: - a labeled, full-height desktop sidebar (below the app header) with a shared nav config, active states using the primary/accent tokens, and a quick-start footer - a Cmd/Ctrl+K command palette (cmdk) for navigation and logout, plus a search trigger in the sidebar - a refined mobile bottom tab bar, all driven by the same nav config so the three surfaces never drift - everything uses semantic tokens instead of hardcoded gray/white Build, unit tests, and e2e green; desktop + mobile shells visually verified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- recolor the Pro upsell banner's promotional state from the off-palette
purple/pink gradient to the brand orange; normalize its raw orange CTAs to
brand tokens, and migrate it onto useSubscription
- sweep subscription-info off hardcoded gray/white classes onto semantic
tokens (bg-card, text-muted-foreground, text-foreground, bg-muted) and
swap the teal "Upgrade to Scale" button to the brand ramp
- remove a stray `import { truncate } from 'fs'` in webhooks-history (a Node
module imported into a client component; the used `truncate` is a CSS class)
Build, 19 unit tests, and e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nto tokens Replace the common hardcoded gray/white color pairs (bg-white dark:bg-gray-800, text-gray-900 dark:text-white, text-gray-500 dark:text-gray-400, border-gray-200 dark:border-gray-700, etc.) with semantic tokens (bg-card, text-foreground, text-muted-foreground, border-border, bg-muted) in the app header, message history, verify-email and download pages, so light/dark stay consistent and the theme is the single source of truth. Build, 19 unit tests, and e2e green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Next 16 removed the `next lint` command, so `pnpm lint` was broken. Replace .eslintrc.json with an ESLint 9 flat config (eslint.config.mjs) that spreads eslint-config-next's native core-web-vitals flat config, and point the lint script at `eslint .`. Keep Next 16's newer react-hooks (v6) compiler-adjacent rules advisory (warn) rather than blocking, since they flag working-but-non-optimal patterns across the existing codebase. `pnpm lint` now passes (0 errors, warnings only). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Guards the messaging screen (Send/Bulk/History tabs and the message-history list against mocked data) before decomposing the 1051-line message-history component. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Split app/(app)/dashboard/(components)/message-history.tsx into a message-history/ folder (import path unchanged via index.tsx): - sms-composer-dialog: one parameterized compose dialog replacing the near-duplicate ReplyDialog and FollowUpDialog (~300 lines of duplication) - sms-details-dialog, message-card (+skeleton), filters-bar, pagination, utils (timestamp/status badge), types - index.tsx is a slim container owning filter/pagination state, wired to new useSendSms and useDeviceMessages hooks in lib/api - design cleanups: off-palette sky gradient on the filters bar replaced with card tokens, bg-gray-* -> bg-muted, brand-tinted controls -> accent tokens, proper empty states with icons, subtle fade-in on cards Every module is now 26-240 lines. Build, 19 unit tests, and 5 e2e green (including the messaging history guard added before the refactor). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Split app/(app)/dashboard/(components)/get-started.tsx into a get-started/ folder (import path unchanged via index.tsx): - steps.ts: pure step definitions + completion rules, now unit-tested - use-onboarding.ts: queries, onboarding mutation, derived step state and the auto-complete / legacy-account effects (query keys unchanged, still shared with verify-email-alert and account-deletion-alert) - step-actions.tsx + plan-picker.tsx: per-step CTAs and the Free/Pro chooser - register-help-dialog.tsx, skeleton.tsx: presentational pieces - index.tsx: the timeline card view (~180 lines) - bg-gradient-to-br -> bg-linear-to-br (Tailwind v4 canonical name) Build, 25 unit tests (6 new), and 5 e2e green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Split app/(app)/dashboard/(components)/webhooks-history.tsx into a webhooks-history/ folder (import path unchanged via index.tsx): - use-filters.ts: filter state hook (device, webhook, event, status, preset and custom date ranges) with page reset handled in one place - filters.tsx: the five filter selects + custom-range popover, deduplicated via a FilterField wrapper and option tables - index.tsx: slim container on new useWebhooks / useWebhookNotifications hooks in lib/api - promote the numbered pagination (previously duplicated verbatim between message-history and webhooks-history) to components/shared/numbered-pagination.tsx - drop dead code: empty useEffect, never-set isLoading state, ~10 unused imports; replace the off-palette sky gradient with card tokens - add a webhooks history e2e guard Build, 25 unit tests, and 6 e2e green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ename - promote the message-history EmptyState into components/shared/empty-state and use it for the bare "No devices found" / "No API keys found" placeholders (icon + title + actionable hint, fade-in) - button primitive: subtle active:scale press state (transition covers transform; the global prefers-reduced-motion guard still disables it) - finish the deferred bg-gradient-to-* -> bg-linear-to-* rename (Tailwind v4 canonical utility) across the remaining four alert/modal components Build, 25 unit tests, and 6 e2e green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds rel='nofollow noopener noreferrer' to the target=_blank Status link. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root causes of the sideways-scroll blank strip at 375px: - fixed-width auth cards (w-[400px]/w-[450px]) -> w-full + max-w so they shrink on small screens - min-w-[500px] on the community-links dialog -> removed - webhooks section header: two w-full buttons in a non-wrapping flex row (403px wide) -> stacks on mobile - devices/api-keys cards blown past the grid track by min-content sizing -> min-w-0 max-w-full on the cards - api-guide SyntaxHighlighter blocks get overflowX auto + maxWidth 100% - safety net: overflow-x clip on body Adds e2e/mobile-overflow.spec.ts: at 375px, scrollWidth must not exceed the viewport on home, messaging, webhooks, account, and login. This guard caught the webhooks-header and card offenders during the fix. Build, 25 unit tests, and 11 e2e green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mobile tab bar caps at 4 items (5 don't fit at 375px): Dashboard, Messaging, Community, Account. Webhooks is marked mobileHidden and remains in the desktop sidebar and the command palette; mobile users keep a path via the webhooks summary row added to Home in the home redesign part. Also includes the auto-regenerated next-env.d.ts route-types path flip. Build and 25 unit tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tory - new shared RouteTabs primitive (components/shared/route-tabs.tsx): a link-based segmented control; tabs are real routes so the active tab survives refresh and deep links are shareable; aria-current on the active link and the active pill scrolls into view on mobile - messaging/layout.tsx renders the section header + tabs; views become subroutes: / (Send, focused composer), /bulk, /history (now full width, previously cramped in a half column), /api-guide (the 419-line guide gets its own URL instead of half the page) - delete the obsolete client-side tab wrapper (components)/messaging.tsx and dead main-dashboard.tsx - messaging e2e rewritten for route tabs, including a direct-load test of /dashboard/messaging/history as the refresh-survival guarantee Build, 25 unit tests, and 12 e2e green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- webhooks/layout.tsx: section header + route tabs (Webhooks | Deliveries), fixing the page that was previously titled "Webhook Notification Delivery History" inside a component named MessagingPage - /dashboard/webhooks now shows subscription management (WebhooksSection, moved off the dashboard home); /dashboard/webhooks/deliveries shows the delivery history - webhooks-section slims down: the layout owns the title and the Deliveries view, so its header is now just count + Create button; drops the unused router/icon imports - webhooks e2e rewritten for the two subroutes including a deliveries deep-link refresh-survival test Build, 25 unit tests, and 13 e2e green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- personal greeting hero (first name) with a quick-actions row: Send SMS (primary, -> messaging), Add device and New API key (both open the generate-key/QR flow; "Add device" keeps the user's mental-model label), and Quick Start - webhooks summary row: active-webhook count linking to /dashboard/webhooks, keeping a discoverable mobile path now that Webhooks left the tab bar and its section moved off Home - Devices and API Keys cards show counts in their headers - mobile-first: actions wrap under the greeting at 375px; verified visually Build, 25 unit tests, and 13 e2e green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Get Started card is the activation funnel; this makes it exceptional: - animated progress bar + "x of 6" + momentum copy that changes with progress; per-step time-estimate chips; benefit-led step copy - focus model: completed steps collapse to compact checked rows (check-pop animation), only the active step expands with one primary CTA; staggered entrance; auto-advance when the poll detects a step completed - register_device is now self-serve inline: numbered instructions beside an in-place generate-key + QR panel (reuses the existing key mutation and react-qr-code); help dialog demoted to a "Need help?" fallback - verify_email shows the actual email and an inline resend with a 60s cooldown via the existing sendEmailVerificationEmail endpoint - completion is celebrated (one-time success state with "Send a message" CTA) instead of the card vanishing mid-glance - minimize control collapses the card to a slim resumable progress pill (localStorage), so setup never nags Fail-closed states (bug fixes): - previously a backend failure rendered the checklist from missing data, showing verified users stuck on "Verify your email"; now a status machine (loading/error/ready/hidden/celebrate) only renders the checklist when all three queries succeeded; error shows a quiet retry row - the full-height 6-row loading skeleton is replaced by a compact ~90px placeholder; background poll failures keep last-good data Tests: MSW handlers for onboarding PATCH + resend; RTL tests for the error policy, mid-funnel progress, and all-done progress; e2e asserts the progress bar on Home. Build, 28 unit tests, and 13 e2e green; verified at 375px. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y/support - account/layout.tsx: section header + route tabs (Billing & plan first, since the subscription is the most-visited account content), same RouteTabs interaction grammar as messaging and webhooks; active section survives refresh with shareable URLs - /dashboard/account redirects to /billing (SubscriptionInfo + plan CTAs); /profile, /security and /support are thin pages reusing the existing forms - Security combines password change with the delete-account flow in a clearly separated destructive "Danger zone" card - legacy routes (edit-profile, change-password, delete-account, get-support) redirect into the merged sections so old links keep working - account e2e: billing redirect + mocked subscription, security deep-link refresh survival, legacy redirects Build, 28 unit tests, and 16 e2e green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
app.textbee.dev/contribute returns 404 (flagged in Ahrefs audit). The public footer/header Contribute links now use the absolute https://textbee.dev/contribute page, consistent with the existing useCases/quickstart routes, fixing the broken link and consolidating to one contribute page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rrors
Three distinct issues surfaced by React 19 / Next 16:
1. next-themes script warning + hydration mismatch. ThemeProvider lived in
the nested (app) layout, so its pre-paint <script> was re-rendered during
client navigation ("Scripts inside React components are never executed
when rendering on the client") and shifted the SSR/client tree. Moved it
to the root layout via app/theme-provider.tsx, its canonical placement.
2. Invalid <p> nesting. Radix's DialogDescription and shadcn's
CardDescription render a <p>, but call sites pass block content, giving
"<p> cannot be a descendant of <p>" / "<div> cannot be a descendant of
<p>". Fixed at the primitives: both now render a <div> (DialogDescription
uses asChild so aria-describedby wiring is preserved), which fixes every
call site at once. Also dropped a redundant wrapper in generate-api-key.
3. Nested <main>: the root layout wrapped children in <main> while the (app)
layout renders its own. Removed the outer one.
Verified by driving the app with Playwright and capturing console output
across navigation and with the API key dialog open: zero hydration, script
tag, or nesting messages (previously several). Dark mode still applies.
Build, lint (0 errors), 28 unit tests, and 16 e2e green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The entrance motion added in the previous rounds was too loud. Softened at the token level and removed at the call sites where it replayed repeatedly. Tokens (styles/main.css): - fade-in 0.3s -> 0.15s - fade-in-up 0.4s / 8px rise -> 0.2s / 3px rise - check-pop 0.35s / 0.6-1.15-1 overshoot -> 0.2s / 0.92-1.02-1 Call sites, which mattered more than the token tuning: - message-card: dropped animate-fade-in. Message history refetches on filter change, pagination and auto-refresh, so every row replayed its fade on every tick. - get-started: dropped the per-index animationDelay stagger. The card polls every 10s, so any remount replayed a cascading six-row stagger. - empty-state: dropped animate-fade-in from a static block. - community-links: hover:scale-105 -> border and shadow change. - onboarding progress bars: duration-500 -> duration-300. Radix enter/exit animations on dialogs, dropdowns, selects, toasts and the accordion are unchanged: they are short, conventional and expected. The prefers-reduced-motion guard is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Top bar (app-header): - Removed the Contribute button. It stays reachable from the footer. - Removed the theme selector; it moves to the sidebar footer. - Removed the mobile sheet for signed-in users: it duplicated the bottom tab bar for navigation and the avatar menu for identity. Signed-out visitors keep it, since auth pages have no tab bar. The header is now brand on the left, account on the right. Footer: - Rebuilt as a slim single bar. A logged-in user is already converted, so the app does not need the marketing site's multi-column link farm. It borrows that footer's visual language (muted surface, muted-to-foreground hovers, green status pill) so the two still read as one product. - Fixed the "cut by the side nav" bug. The footer was rendered in (app)/layout.tsx as a sibling of <main>, so it spanned the full viewport while the dashboard's fixed sidebar painted over its left 240px, and the fixed mobile tab bar covered its bottom edge. It is now rendered per section, inside each content column, with the dashboard copy padded clear of the tab bar. Theme control: - Moved to the sidebar footer and restyled from a dropdown into a three-way Light/Dark/System segmented control, which now has room to show all three states at once. Sidebar icon: - Dashboard uses LayoutDashboard instead of Home, matching the icon the avatar menu already uses for the same destination. E2e harness, two real fixes found while verifying: - The suite ran against `next dev`, which compiles routes on demand, so parallel workers hitting cold routes timed out at random. Switched to a production build; the suite went from 1.3m to ~31s. - The survey modal (a Math.random() coin flip) and the update-app prompt (whenever a mocked device reports an old version) could open over any page. A Radix dialog marks the rest of the page aria-hidden while open, so every getByRole query found nothing and a different test failed each run. Both are now suppressed deterministically in the session helper. Full suite: 16/16 on four consecutive runs. New chrome.spec.ts guards the footer geometry against both the sidebar and the mobile tab bar, asserts the top bar stays reduced, and asserts the sidebar theme control actually toggles the dark class. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Search previously listed only the 5 sidebar routes plus Log out, so subroutes like bulk send, message history, webhook deliveries and every account section were unreachable by search. Typing "csv" or "invoice" or "password" returned nothing. New search-registry.ts is the single source of truth: every user-reachable destination with a description and the words people actually type, rather than the labels we happened to choose for the nav. cmdk matches those keywords in addition to the label, so "csv" finds Bulk send and "invoice" finds Billing. External resources (Android app, quick start, status, contribute) are included and open in a new tab. Mobile had no search at all: the trigger and the dialog were one component living inside the `hidden md:flex` sidebar. Split them. The dialog now mounts in the dashboard layout with open state lifted, the sidebar keeps its trigger, and mobile gets a labelled sticky search bar. That matters most on mobile, where the tab bar is capped at 4 items and search is the only path to Webhooks and every subroute. The palette also gains theme actions, since the theme control now lives in the desktop-only sidebar. search-registry.test.ts walks the App Router tree on disk and fails if any page.tsx has no registry entry, naming the offending route. Redirect-only routes are allowlisted. Verified it actually fails by removing an entry, rather than assuming a green test means coverage. Plus e2e proving keyword search, the mobile path to webhooks, and the keyboard shortcut. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The home page was four all-time counters that could not answer either question a user actually opens the dashboard for: how much quota is left, and did my recent sends work. Removed fabricated data: - Every stat card rendered a green TrendingUp arrow whenever a value existed. Nothing computed a trend. The stats endpoint returns running totals with no time window, so there was nothing to compare against. - "Since last year" on the sent and received counts. getStatsForUser sums device counters with no date filter, so these are all-time totals. - "Active Devices" with a "Connected now" caption, over a number that counts every device including disabled ones. Enabled count is now derived from the device list we already fetch. - "Active keys" over a count that included revoked keys; now uses the active API key list. Added, all from fields the API already returns: - Usage cards leading the page: today and this month against their limits, with progress bars, an amber near-limit state past 80% and an upgrade link. Unlimited plans (-1) show usage with no meter, since there is no ceiling to measure against. - Recent activity: the last 5 messages for the connected device. Messages are only exposed per device, so the card names the device rather than implying account-wide coverage. - All-time totals compacted from four large cards into one honest strip. lib/usage.ts extracts the daily/monthly derivation that subscription-info already did inline, so the dashboard and billing page cannot disagree about remaining quota. Unit tested including the -1 sentinel, custom limit overrides, and an over-100 percentage (possible when a limit is lowered mid-period). No backend changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
/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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Dashboard refactor: UI rebuild, typed data layer, strict TS, CI test gate
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>
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>
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>
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>
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>
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>
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>
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>
fix(web): show auth errors instead of silently reloading
test(api): lock security-critical behaviour, then small guarded fixes
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.