diff --git a/web/app/(app)/(auth)/(components)/register-form.tsx b/web/app/(app)/(auth)/(components)/register-form.tsx index 30f7928..7c199d4 100644 --- a/web/app/(app)/(auth)/(components)/register-form.tsx +++ b/web/app/(app)/(auth)/(components)/register-form.tsx @@ -36,12 +36,18 @@ const registerSchema = z.object({ .min(1, { message: 'Please complete the bot verification' }), }) -type RegisterFormValues = z.infer +// marketingOptIn is `.optional().default(true)`, so zod's input and output +// types differ: optional going in, guaranteed coming out. The form holds the +// input shape and the submit handler receives the output shape, which is what +// useForm's third generic is for. Using z.infer (the output) for both made the +// resolver unassignable. +type RegisterFormInput = z.input +type RegisterFormValues = z.output export default function RegisterForm() { const router = useRouter() - const form = useForm({ + const form = useForm({ resolver: zodResolver(registerSchema), defaultValues: { name: '', diff --git a/web/app/(app)/checkout/[planName]/page.tsx b/web/app/(app)/checkout/[planName]/page.tsx index aa42862..4dc07f0 100644 --- a/web/app/(app)/checkout/[planName]/page.tsx +++ b/web/app/(app)/checkout/[planName]/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from 'react' import httpBrowserClient from '@/lib/httpBrowserClient' import { ApiEndpoints } from '@/config/api' +import { apiErrorMessage } from '@/lib/utils/errorHandler' import { useSession } from 'next-auth/react' import { redirect } from 'next/navigation' import { Loader, CheckCircle, ArrowRight } from 'lucide-react' @@ -19,7 +20,11 @@ interface PlanChangePreview { const formatPlan = (plan: string, interval: string) => `${plan.charAt(0).toUpperCase() + plan.slice(1)} (${interval})` -export default function CheckoutPage({ params }) { +export default function CheckoutPage({ + params, +}: { + params: { planName: string } +}) { const [error, setError] = useState(null) const [planChange, setPlanChange] = useState(null) const [isConfirming, setIsConfirming] = useState(false) @@ -60,8 +65,12 @@ export default function CheckoutPage({ params }) { if (retries > 0) { initiateCheckout(retries - 1) } else { - setError(error.response?.data?.message || 'Failed to create checkout session. Please try again or contact billing@textbee.dev.') - console.error(error.response?.data?.message) + const serverMessage = apiErrorMessage(error) + setError( + serverMessage || + 'Failed to create checkout session. Please try again or contact billing@textbee.dev.' + ) + console.error(serverMessage) } } }, @@ -79,11 +88,12 @@ export default function CheckoutPage({ params }) { } catch (error) { // no auto-retry here: the request may have charged the card setPlanChange(null) + const serverMessage = apiErrorMessage(error) setError( - error.response?.data?.message || + serverMessage || 'Failed to change your plan. Please try again or contact billing@textbee.dev.', ) - console.error(error.response?.data?.message) + console.error(serverMessage) setIsConfirming(false) } } diff --git a/web/app/(app)/dashboard/(components)/api-keys.tsx b/web/app/(app)/dashboard/(components)/api-keys.tsx index fe056dc..d7054a3 100644 --- a/web/app/(app)/dashboard/(components)/api-keys.tsx +++ b/web/app/(app)/dashboard/(components)/api-keys.tsx @@ -26,6 +26,7 @@ import { useRenameApiKey, useRevokeApiKey, } from '@/lib/api' +import type { ApiKey } from '@/lib/api/types' import { Skeleton } from '@/components/ui/skeleton' import EmptyState from '@/components/shared/empty-state' import ErrorState from '@/components/shared/error-state' @@ -35,15 +36,10 @@ import GenerateApiKey, { } from './generate-api-key' import { Alert, AlertDescription } from '@/components/ui/alert' -type ApiKeyRow = { - _id: string - apiKey: string - name?: string - revokedAt?: string - createdAt: string - lastUsedAt?: string - usageCount?: number -} +// Was a local duplicate of the shared ApiKey type, which meant the list +// callback annotated its rows as one type while the hook returned the other. +// The two extra fields it carried now live on ApiKey itself. +type ApiKeyRow = ApiKey export default function ApiKeys() { const addApiKeyRef = useRef(null) @@ -438,7 +434,8 @@ export default function ApiKeys() { -
{session.user?.name}
+
{session?.user?.name}
- {session.user?.email} + {session?.user?.email}
diff --git a/web/lib/api/hooks.ts b/web/lib/api/hooks.ts index 4c7444a..156333a 100644 --- a/web/lib/api/hooks.ts +++ b/web/lib/api/hooks.ts @@ -15,6 +15,7 @@ import type { Plan, Subscription, User, + WebhookNotification, WebhookSubscription, } from './types' @@ -186,7 +187,7 @@ export type WebhookNotificationFilters = { // Raw body: { data: { data: rows[], meta: { totalPages, ... } } } export type WebhookNotificationsEnvelope = { data?: { - data?: unknown[] + data?: WebhookNotification[] meta?: { totalPages?: number; total?: number } } } @@ -223,10 +224,12 @@ export function useWebhookNotifications(filters: WebhookNotificationFilters) { // ---------- messaging ---------- -// Matches the zod-inferred SendSmsFormData shape (fields optional because the -// project runs without strict mode; validation happens via the schema). +// Matches the zod-inferred SendSmsFormData shape; validation happens via the +// schema before the payload reaches here. export type SendSmsPayload = { - deviceId?: string + // Required: it goes into the request path, so an absent one would POST to + // /gateway/devices/undefined/send-sms. Both callers validate it first. + deviceId: string recipients?: string[] message?: string simSubscriptionId?: number diff --git a/web/lib/api/types.ts b/web/lib/api/types.ts index 97b2e43..98662d8 100644 --- a/web/lib/api/types.ts +++ b/web/lib/api/types.ts @@ -49,6 +49,8 @@ export interface ApiKey { status?: 'active' | 'revoked' lastUsedAt?: string | null createdAt?: string + revokedAt?: string + usageCount?: number } export interface Plan { @@ -99,6 +101,26 @@ export interface Subscription { export type ApiKeyStatusFilter = 'active' | 'revoked' | 'all' +/** + * One webhook delivery attempt, as the notifications endpoint returns it. + * + * Previously typed as `unknown[]` on the envelope, which meant the deliveries + * table received `unknown[]` and its own row type went unchecked. + */ +export interface WebhookNotification { + _id?: string + event?: string + webhookEvent?: string + deliveryUrl?: string + webhookSubscription?: { deliveryUrl: string } + createdAt?: string + status: string + computedStatus?: string + deviceData?: { brand?: string; model?: string } + smsData?: { _id?: string } + payload?: unknown +} + export interface WebhookSubscription { _id: string name?: string diff --git a/web/lib/auth.ts b/web/lib/auth.ts index d5a3835..ae2e274 100644 --- a/web/lib/auth.ts +++ b/web/lib/auth.ts @@ -1,6 +1,6 @@ import CredentialsProvider from 'next-auth/providers/credentials' import { httpServerClient } from './httpServerClient' -import { DefaultSession } from 'next-auth' +import type { DefaultSession, NextAuthOptions } from 'next-auth' import { ApiEndpoints } from '@/config/api' import { Routes } from '@/config/routes' @@ -17,13 +17,29 @@ declare module 'next-auth' { } interface User { + // The backend returns Mongo documents, so the id arrives as _id and is + // copied onto the token below. + _id?: string + role?: string phone?: string avatar?: string accessToken?: string } } -export const authOptions = { +// The jwt/session callbacks read and write these, so the token has to declare +// them or every callback parameter falls back to an implicit any. +declare module 'next-auth/jwt' { + interface JWT { + id?: string + role?: string + phone?: string + avatar?: string + accessToken?: string + } +} + +export const authOptions: NextAuthOptions = { providers: [ CredentialsProvider({ id: 'email-password-login', @@ -34,6 +50,10 @@ export const authOptions = { turnstileToken: { label: 'Turnstile Token', type: 'text' }, }, async authorize(credentials) { + // NextAuth can invoke authorize with no credentials at all. There is + // nothing to authenticate in that case, so refuse rather than posting + // undefined fields to the login endpoint. + if (!credentials) return null const { email, password, turnstileToken } = credentials try { const res = await httpServerClient.post(ApiEndpoints.auth.login(), { @@ -67,6 +87,7 @@ export const authOptions = { turnstileToken: { label: 'Turnstile Token', type: 'text' }, }, async authorize(credentials) { + if (!credentials) return null const { email, password, name, phone, turnstileToken } = credentials try { const res = await httpServerClient.post( @@ -100,6 +121,7 @@ export const authOptions = { idToken: { label: 'idToken', type: 'text' }, }, async authorize(credentials) { + if (!credentials) return null const { idToken } = credentials try { const res = await httpServerClient.post( diff --git a/web/lib/turnstile.ts b/web/lib/turnstile.ts index 92751fa..9bd0095 100644 --- a/web/lib/turnstile.ts +++ b/web/lib/turnstile.ts @@ -71,7 +71,8 @@ type UseTurnstileOptions = { } type UseTurnstileResult = { - containerRef: RefObject + // Nullable: useRef starts at null and only holds the node once mounted. + containerRef: RefObject token: string error: string | null isReady: boolean diff --git a/web/lib/types.ts b/web/lib/types.ts index 74fe3b8..b32edc3 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -1,5 +1,7 @@ export interface WebhookData { - _id?: string + // Required: it goes into the update and delete request paths, so an absent + // one would PATCH /webhooks/undefined. Anything the API returns has one. + _id: string name?: string deliveryUrl: string events: string[] diff --git a/web/lib/utils/errorHandler.ts b/web/lib/utils/errorHandler.ts index 2a78388..bab99e8 100644 --- a/web/lib/utils/errorHandler.ts +++ b/web/lib/utils/errorHandler.ts @@ -76,6 +76,18 @@ export function formatError(error: unknown): FormattedError { } } +/** + * The `message` an API error carried, or undefined if it had none. + * + * Distinct from formatError, which always substitutes a generic message. + * Use this where the caller has its own fallback copy worth preserving, and + * needs to know whether the server actually said anything. + */ +export function apiErrorMessage(error: unknown): string | undefined { + const data = (error as AxiosError<{ message?: string }>)?.response?.data + return data?.message +} + /** * Checks if an error is a rate limit (429) error */ diff --git a/web/package.json b/web/package.json index d177d09..71db927 100644 --- a/web/package.json +++ b/web/package.json @@ -8,6 +8,7 @@ "vercel-build": "next build", "start": "next start", "lint": "eslint .", + "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", @@ -70,8 +71,10 @@ "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", "@types/node": "^20.11.5", + "@types/papaparse": "^5.5.2", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", + "@types/react-syntax-highlighter": "^15.5.13", "@typescript-eslint/eslint-plugin": "^8.18.0", "@typescript-eslint/parser": "^8.18.0", "@vitejs/plugin-react": "^4.3.4", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 30bf568..150d683 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -159,12 +159,18 @@ importers: '@types/node': specifier: ^20.11.5 version: 20.11.5 + '@types/papaparse': + specifier: ^5.5.2 + version: 5.5.2 '@types/react': specifier: ^19.2.0 version: 19.2.17 '@types/react-dom': specifier: ^19.2.0 version: 19.2.3(@types/react@19.2.17) + '@types/react-syntax-highlighter': + specifier: ^15.5.13 + version: 15.5.13 '@typescript-eslint/eslint-plugin': specifier: ^8.18.0 version: 8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) @@ -1800,11 +1806,17 @@ packages: '@types/node@20.11.5': resolution: {integrity: sha512-g557vgQjUUfN76MZAN/dt1z3dzcUsimuysco0KeluHgrPdJXkP/XdAURgyO2W9fZWHRtRBiVKzKn8vyOAwlG+w==} + '@types/papaparse@5.5.2': + resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: '@types/react': ^19.2.0 + '@types/react-syntax-highlighter@15.5.13': + resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==} + '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} @@ -5720,10 +5732,18 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/papaparse@5.5.2': + dependencies: + '@types/node': 20.11.5 + '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: '@types/react': 19.2.17 + '@types/react-syntax-highlighter@15.5.13': + dependencies: + '@types/react': 19.2.17 + '@types/react@19.2.17': dependencies: csstype: 3.2.3 diff --git a/web/test/msw/handlers.ts b/web/test/msw/handlers.ts index a6ad201..fa5d164 100644 --- a/web/test/msw/handlers.ts +++ b/web/test/msw/handlers.ts @@ -1,4 +1,4 @@ -import { http, HttpResponse } from 'msw' +import { http, HttpResponse, type JsonBodyType } from 'msw' import { ApiEndpoints } from '@/config/api' import { API_BASE_URL, @@ -19,8 +19,8 @@ import { const url = (path: string) => `${API_BASE_URL}${path.split('?')[0]}` // Envelope helpers matching the real API's response shapes. -const dataEnvelope = (data: unknown) => HttpResponse.json({ data }) -const raw = (body: unknown) => HttpResponse.json(body) +const dataEnvelope = (data: JsonBodyType) => HttpResponse.json({ data }) +const raw = (body: JsonBodyType) => HttpResponse.json(body) export const handlers = [ // --- auth --- diff --git a/web/tsconfig.json b/web/tsconfig.json index 2c932fd..d1dbdd9 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "ES2017", "lib": [ "dom", "dom.iterable", @@ -14,7 +14,7 @@ }, "allowJs": true, "skipLibCheck": true, - "strict": false, + "strict": true, "forceConsistentCasingInFileNames": true, "noEmit": true, "esModuleInterop": true, @@ -28,8 +28,7 @@ { "name": "next" } - ], - "strictNullChecks": false + ] }, "include": [ "next-env.d.ts",