diff --git a/CHANGELOG.md b/CHANGELOG.md index a4306cd..42a7b4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Organization context switch.** Switching away from a deleted organization now stores the session correctly instead of using an insecure fallback. - **Removed debug logs.** Auth and organization-switching details no longer leak into the browser console in production. +- **Stricter type safety.** Eliminated all `any` types and `@ts-ignore` suppressions across the codebase, so the TypeScript compiler catches more bugs at build time. ## [0.10.0-alpha] - 2026-02-21 diff --git a/app/page.tsx b/app/page.tsx index ab4817e..ca20ae7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -169,7 +169,7 @@ export default function HomePage() { setSitesLoading(true) const data = await listSites() setSites(Array.isArray(data) ? data : []) - } catch (error: any) { + } catch (error: unknown) { toast.error(getAuthErrorMessage(error) || 'Failed to load your sites') setSites([]) } finally { @@ -198,7 +198,7 @@ export default function HomePage() { await deleteSite(id) toast.success('Site deleted successfully') loadSites() - } catch (error: any) { + } catch (error: unknown) { toast.error(getAuthErrorMessage(error) || 'Failed to delete site') } } diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index e1a32cc..71e5e55 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -6,6 +6,7 @@ import { useParams, useSearchParams, useRouter } from 'next/navigation' import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, getPublicPerformanceByPage, type DashboardData, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' +import { ApiError } from '@/lib/api/client' import { LoadingOverlay, Button } from '@ciphera-net/ui' import Chart from '@/components/dashboard/Chart' import TopPages from '@/components/dashboard/ContentStats' @@ -154,8 +155,9 @@ export default function PublicDashboardPage() { setCaptchaId('') setCaptchaSolution('') setCaptchaToken('') - } catch (error: any) { - if ((error.status === 401 || error.response?.status === 401) && (error.data?.is_protected || error.response?.data?.is_protected)) { + } catch (error: unknown) { + const apiErr = error instanceof ApiError ? error : null + if (apiErr?.status === 401 && (apiErr.data as Record)?.is_protected) { setIsPasswordProtected(true) if (password) { toast.error('Invalid password or captcha') @@ -164,7 +166,7 @@ export default function PublicDashboardPage() { setCaptchaSolution('') setCaptchaToken('') } - } else if (error.status === 404 || error.response?.status === 404) { + } else if (apiErr?.status === 404) { toast.error('Site not found') } else if (!silent) { toast.error(getAuthErrorMessage(error) || 'Failed to load public dashboard') diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index 1f78443..e5d34ea 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -167,7 +167,7 @@ export default function SiteSettingsPage() { } else { setIsPasswordEnabled(false) } - } catch (error: any) { + } catch (error: unknown) { toast.error(getAuthErrorMessage(error) || 'Failed to load site settings') } finally { setLoading(false) @@ -295,7 +295,7 @@ export default function SiteSettingsPage() { data_retention_months: formData.data_retention_months }) loadSite() - } catch (error: any) { + } catch (error: unknown) { toast.error(getAuthErrorMessage(error) || 'Failed to save site settings') } finally { setSaving(false) @@ -310,7 +310,7 @@ export default function SiteSettingsPage() { try { await resetSiteData(siteId) toast.success('All site data has been reset') - } catch (error: any) { + } catch (error: unknown) { toast.error(getAuthErrorMessage(error) || 'Failed to reset site data') } } @@ -326,7 +326,7 @@ export default function SiteSettingsPage() { await deleteSite(siteId) toast.success('Site deleted successfully') router.push('/') - } catch (error: any) { + } catch (error: unknown) { toast.error(getAuthErrorMessage(error) || 'Failed to delete site') } } diff --git a/components/Footer.tsx b/components/Footer.tsx index 0adeaa1..ed6b2cd 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -5,7 +5,7 @@ import Image from 'next/image' import { GithubIcon, TwitterIcon, SwissFlagIcon } from '@ciphera-net/ui' interface FooterProps { - LinkComponent?: any + LinkComponent?: React.ElementType appName?: string isAuthenticated?: boolean } diff --git a/components/PricingSection.tsx b/components/PricingSection.tsx index 81eb656..d0f4587 100644 --- a/components/PricingSection.tsx +++ b/components/PricingSection.tsx @@ -150,8 +150,7 @@ export default function PricingSection() { // Helper to get all price details const getPriceDetails = (planId: string) => { - // @ts-ignore - const basePrice = currentTraffic.prices[planId] + const basePrice = currentTraffic.prices[planId as keyof typeof currentTraffic.prices] // Handle "Custom" if (basePrice === null || basePrice === undefined) return null @@ -203,7 +202,7 @@ export default function PricingSection() { throw new Error('No checkout URL returned') } - } catch (error: any) { + } catch (error: unknown) { console.error('Checkout error:', error) toast.error('Failed to start checkout — please try again') } finally { diff --git a/components/dashboard/Countries.tsx b/components/dashboard/Countries.tsx index fba76a9..21688b9 100644 --- a/components/dashboard/Countries.tsx +++ b/components/dashboard/Countries.tsx @@ -20,7 +20,7 @@ export default function Locations({ countries, cities }: LocationProps) { if (!countryCode || countryCode === 'Unknown') return null // * The API returns 2-letter country codes (e.g. US, DE) // * We cast it to the flag component name - const FlagComponent = (Flags as any)[countryCode] + const FlagComponent = (Flags as Record>)[countryCode] return FlagComponent ? : null } diff --git a/components/dashboard/ExportModal.tsx b/components/dashboard/ExportModal.tsx index ca4ce39..8a347ac 100644 --- a/components/dashboard/ExportModal.tsx +++ b/components/dashboard/ExportModal.tsx @@ -67,9 +67,9 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to // Prepare data const exportData = data.map((item) => { - const filteredItem: Partial = {} + const filteredItem: Record = {} fields.forEach((field) => { - (filteredItem as any)[field] = item[field] + filteredItem[field] = item[field] }) return filteredItem }) @@ -212,7 +212,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to autoTable(doc, { startY: startY, head: [fields.map(f => f.charAt(0).toUpperCase() + f.slice(1).replace('_', ' '))], - body: tableData as any[][], + body: tableData as (string | number)[][], styles: { font: 'helvetica', fontSize: 9, @@ -249,7 +249,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to } }) - let finalY = (doc as any).lastAutoTable.finalY + 10 + let finalY = doc.lastAutoTable.finalY + 10 // Top Pages Table if (topPages && topPages.length > 0) { @@ -276,7 +276,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to alternateRowStyles: { fillColor: [255, 250, 245] }, }) - finalY = (doc as any).lastAutoTable.finalY + 10 + finalY = doc.lastAutoTable.finalY + 10 } // Top Referrers Table @@ -305,7 +305,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to alternateRowStyles: { fillColor: [255, 250, 245] }, }) - finalY = (doc as any).lastAutoTable.finalY + 10 + finalY = doc.lastAutoTable.finalY + 10 } // Campaigns Table diff --git a/components/dashboard/Locations.tsx b/components/dashboard/Locations.tsx index 43b0194..da5d4ca 100644 --- a/components/dashboard/Locations.tsx +++ b/components/dashboard/Locations.tsx @@ -3,7 +3,6 @@ import { useState, useEffect } from 'react' import { formatNumber } from '@ciphera-net/ui' import * as Flags from 'country-flag-icons/react/3x2' -// @ts-ignore import iso3166 from 'iso-3166-2' import WorldMap from './WorldMap' import { Modal, GlobeIcon } from '@ciphera-net/ui' @@ -28,7 +27,8 @@ const LIMIT = 7 export default function Locations({ countries, cities, regions, geoDataLevel = 'full', siteId, dateRange }: LocationProps) { const [activeTab, setActiveTab] = useState('map') const [isModalOpen, setIsModalOpen] = useState(false) - const [fullData, setFullData] = useState([]) + type LocationItem = { country?: string; city?: string; region?: string; pageviews: number } + const [fullData, setFullData] = useState([]) const [isLoadingFull, setIsLoadingFull] = useState(false) useEffect(() => { @@ -36,7 +36,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' const fetchData = async () => { setIsLoadingFull(true) try { - let data: any[] = [] + let data: LocationItem[] = [] if (activeTab === 'countries') { data = await getCountries(siteId, dateRange.start, dateRange.end, 250) } else if (activeTab === 'regions') { @@ -73,7 +73,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' return } - const FlagComponent = (Flags as any)[countryCode] + const FlagComponent = (Flags as Record>)[countryCode] return FlagComponent ? : null } @@ -158,7 +158,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' } // Filter out "Unknown" entries that result from disabled collection - const filterUnknown = (data: any[]) => { + const filterUnknown = (data: LocationItem[]) => { return data.filter(item => { if (activeTab === 'countries') return item.country && item.country !== 'Unknown' && item.country !== '' if (activeTab === 'regions') return item.region && item.region !== 'Unknown' && item.region !== '' @@ -172,7 +172,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' const hasData = activeTab === 'map' ? (countries && filterUnknown(countries).length > 0) : (data && data.length > 0) - const displayedData = (activeTab !== 'map' && hasData) ? (data as any[]).slice(0, LIMIT) : [] + const displayedData = (activeTab !== 'map' && hasData) ? data.slice(0, LIMIT) : [] const emptySlots = Math.max(0, LIMIT - displayedData.length) const showViewAll = activeTab !== 'map' && hasData && data.length > LIMIT @@ -228,7 +228,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '

{getDisabledMessage()}

) : activeTab === 'map' ? ( - hasData ? : ( + hasData ? : (
@@ -247,13 +247,13 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' {displayedData.map((item, index) => (
- {activeTab === 'countries' && {getFlagComponent(item.country)}} - {activeTab !== 'countries' && {getFlagComponent(item.country)}} + {activeTab === 'countries' && {getFlagComponent(item.country ?? '')}} + {activeTab !== 'countries' && {getFlagComponent(item.country ?? '')}} - {activeTab === 'countries' ? getCountryName(item.country) : - activeTab === 'regions' ? getRegionName(item.region, item.country) : - getCityName(item.city)} + {activeTab === 'countries' ? getCountryName(item.country ?? '') : + activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') : + getCityName(item.city ?? '')}
@@ -293,14 +293,14 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
) : ( - (fullData.length > 0 ? fullData : data as any[]).map((item, index) => ( + (fullData.length > 0 ? fullData : data).map((item, index) => (
- {getFlagComponent(item.country)} + {getFlagComponent(item.country ?? '')} - {activeTab === 'countries' ? getCountryName(item.country) : - activeTab === 'regions' ? getRegionName(item.region, item.country) : - getCityName(item.city)} + {activeTab === 'countries' ? getCountryName(item.country ?? '') : + activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') : + getCityName(item.city ?? '')}
diff --git a/components/dashboard/TechSpecs.tsx b/components/dashboard/TechSpecs.tsx index 9733c97..d705d89 100644 --- a/components/dashboard/TechSpecs.tsx +++ b/components/dashboard/TechSpecs.tsx @@ -26,7 +26,8 @@ const LIMIT = 7 export default function TechSpecs({ browsers, os, devices, screenResolutions, collectDeviceInfo = true, collectScreenResolution = true, siteId, dateRange }: TechSpecsProps) { const [activeTab, setActiveTab] = useState('browsers') const [isModalOpen, setIsModalOpen] = useState(false) - const [fullData, setFullData] = useState([]) + type TechItem = { name: string; pageviews: number; icon: React.ReactNode } + const [fullData, setFullData] = useState([]) const [isLoadingFull, setIsLoadingFull] = useState(false) // Filter out "Unknown" entries that result from disabled collection @@ -39,7 +40,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co const fetchData = async () => { setIsLoadingFull(true) try { - let data: any[] = [] + let data: TechItem[] = [] if (activeTab === 'browsers') { const res = await getBrowsers(siteId, dateRange.start, dateRange.end, 100) data = res.map(b => ({ name: b.browser, pageviews: b.pageviews, icon: getBrowserIcon(b.browser) })) diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index 6e22ce9..9c6a691 100644 --- a/components/settings/OrganizationSettings.tsx +++ b/components/settings/OrganizationSettings.tsx @@ -49,7 +49,6 @@ function BellIcon({ className }: { className?: string }) { ) } -// @ts-ignore import { Button, Input } from '@ciphera-net/ui' export default function OrganizationSettings() { @@ -333,8 +332,8 @@ export default function OrganizationSettings() { try { const { url } = await createPortalSession() window.location.href = url - } catch (error: any) { - toast.error(getAuthErrorMessage(error) || error.message || 'Failed to open billing portal') + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to open billing portal') setIsRedirectingToPortal(false) } } @@ -346,8 +345,8 @@ export default function OrganizationSettings() { toast.success(atPeriodEnd ? 'Subscription will cancel at the end of the billing period.' : 'Subscription canceled.') setShowCancelPrompt(false) loadSubscription() - } catch (error: any) { - toast.error(getAuthErrorMessage(error) || error.message || 'Failed to cancel subscription') + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to cancel subscription') } finally { setCancelLoadingAction(null) } @@ -359,8 +358,8 @@ export default function OrganizationSettings() { await resumeSubscription() toast.success('Subscription will continue. Cancellation has been undone.') loadSubscription() - } catch (error: any) { - toast.error(getAuthErrorMessage(error) || error.message || 'Failed to resume subscription') + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to resume subscription') } finally { setIsResuming(false) } @@ -398,8 +397,8 @@ export default function OrganizationSettings() { if (url) window.location.href = url else throw new Error('No checkout URL') } - } catch (error: any) { - toast.error(getAuthErrorMessage(error) || error.message || 'Failed to update member role') + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to update plan') } finally { setIsChangingPlan(false) } @@ -427,9 +426,9 @@ export default function OrganizationSettings() { window.location.href = '/' } - } catch (err: any) { + } catch (err: unknown) { console.error(err) - toast.error(getAuthErrorMessage(err) || err.message || 'Failed to delete organization') + toast.error(getAuthErrorMessage(err) || (err instanceof Error ? err.message : '') || 'Failed to delete organization') setIsDeleting(false) } } @@ -457,8 +456,8 @@ export default function OrganizationSettings() { setCaptchaSolution('') setCaptchaToken('') loadMembers() // Refresh list - } catch (error: any) { - toast.error(getAuthErrorMessage(error) || error.message || 'Failed to send invitation') + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to send invitation') } finally { setIsInviting(false) } @@ -469,8 +468,8 @@ export default function OrganizationSettings() { await revokeInvitation(currentOrgId, inviteId) toast.success('Invitation revoked') loadMembers() // Refresh list - } catch (error: any) { - toast.error(getAuthErrorMessage(error) || error.message || 'Failed to revoke invitation') + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to revoke invitation') } } @@ -484,8 +483,8 @@ export default function OrganizationSettings() { toast.success('Organization updated successfully') setIsEditing(false) loadMembers() - } catch (error: any) { - toast.error(getAuthErrorMessage(error) || error.message || 'Failed to save organization settings') + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to save organization settings') } finally { setIsSaving(false) } @@ -603,7 +602,7 @@ export default function OrganizationSettings() { setOrgName(e.target.value)} + onChange={(e: React.ChangeEvent) => setOrgName(e.target.value)} required minLength={2} maxLength={50} @@ -623,7 +622,7 @@ export default function OrganizationSettings() { setOrgSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))} + onChange={(e: React.ChangeEvent) => setOrgSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))} required minLength={3} maxLength={30} @@ -703,7 +702,7 @@ export default function OrganizationSettings() { type="email" placeholder="colleague@company.com" value={inviteEmail} - onChange={(e: any) => setInviteEmail(e.target.value)} + onChange={(e: React.ChangeEvent) => setInviteEmail(e.target.value)} required className="bg-white dark:bg-neutral-900" /> diff --git a/lib/api/client.ts b/lib/api/client.ts index c3734e7..f7cccce 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -24,9 +24,9 @@ export function getSignupUrl(redirectPath = '/auth/callback') { export class ApiError extends Error { status: number - data?: any + data?: Record - constructor(message: string, status: number, data?: any) { + constructor(message: string, status: number, data?: Record) { super(message) this.status = status this.data = data diff --git a/lib/api/organization.ts b/lib/api/organization.ts index 2273893..b48ab07 100644 --- a/lib/api/organization.ts +++ b/lib/api/organization.ts @@ -86,10 +86,7 @@ export async function sendInvitation( role: string = 'member', captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string } ): Promise { - const body: any = { - email, - role - } + const body: Record = { email, role } if (captcha?.captcha_id) body.captcha_id = captcha.captcha_id if (captcha?.captcha_solution) body.captcha_solution = captcha.captcha_solution diff --git a/types/iso-3166-2.d.ts b/types/iso-3166-2.d.ts new file mode 100644 index 0000000..0a61a82 --- /dev/null +++ b/types/iso-3166-2.d.ts @@ -0,0 +1,21 @@ +declare module 'iso-3166-2' { + interface SubdivisionInfo { + name: string + type: string + parent?: string + } + + interface CountryInfo { + name: string + sub: Record + } + + const iso3166: { + data: Record + country(code: string): CountryInfo | undefined + subdivision(code: string): SubdivisionInfo | undefined + codes: string[] + } + + export default iso3166 +} diff --git a/types/jspdf-autotable.d.ts b/types/jspdf-autotable.d.ts new file mode 100644 index 0000000..a041282 --- /dev/null +++ b/types/jspdf-autotable.d.ts @@ -0,0 +1,9 @@ +import 'jspdf' + +declare module 'jspdf' { + interface jsPDF { + lastAutoTable: { + finalY: number + } + } +}