refactor: enhance type safety by replacing any types with stricter types across the codebase, improving error handling and reducing potential bugs

This commit is contained in:
Usman Baig
2026-02-22 20:29:16 +01:00
parent 1947c6a886
commit 06f54176f1
15 changed files with 94 additions and 65 deletions

View File

@@ -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

View File

@@ -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')
}
}

View File

@@ -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<string, unknown>)?.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')

View File

@@ -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')
}
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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<string, React.ComponentType<{ className?: string }>>)[countryCode]
return FlagComponent ? <FlagComponent className="w-5 h-5 rounded-sm shadow-sm" /> : null
}

View File

@@ -67,9 +67,9 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
// Prepare data
const exportData = data.map((item) => {
const filteredItem: Partial<DailyStat> = {}
const filteredItem: Record<string, string | number> = {}
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

View File

@@ -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<Tab>('map')
const [isModalOpen, setIsModalOpen] = useState(false)
const [fullData, setFullData] = useState<any[]>([])
type LocationItem = { country?: string; city?: string; region?: string; pageviews: number }
const [fullData, setFullData] = useState<LocationItem[]>([])
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 <GlobeIcon className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
}
const FlagComponent = (Flags as any)[countryCode]
const FlagComponent = (Flags as Record<string, React.ComponentType<{ className?: string }>>)[countryCode]
return FlagComponent ? <FlagComponent className="w-5 h-5 rounded-sm shadow-sm" /> : 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 = '
<p className="text-neutral-500 dark:text-neutral-400 text-sm">{getDisabledMessage()}</p>
</div>
) : activeTab === 'map' ? (
hasData ? <WorldMap data={filterUnknown(countries)} /> : (
hasData ? <WorldMap data={filterUnknown(countries) as { country: string; pageviews: number }[]} /> : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
@@ -247,13 +247,13 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
{displayedData.map((item, index) => (
<div key={index} className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
{activeTab === 'countries' && <span className="shrink-0">{getFlagComponent(item.country)}</span>}
{activeTab !== 'countries' && <span className="shrink-0">{getFlagComponent(item.country)}</span>}
{activeTab === 'countries' && <span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>}
{activeTab !== 'countries' && <span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>}
<span className="truncate">
{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 ?? '')}
</span>
</div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
@@ -293,14 +293,14 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
<ListSkeleton rows={10} />
</div>
) : (
(fullData.length > 0 ? fullData : data as any[]).map((item, index) => (
(fullData.length > 0 ? fullData : data).map((item, index) => (
<div key={index} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
<span className="shrink-0">{getFlagComponent(item.country)}</span>
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
<span className="truncate">
{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 ?? '')}
</span>
</div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">

View File

@@ -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<Tab>('browsers')
const [isModalOpen, setIsModalOpen] = useState(false)
const [fullData, setFullData] = useState<any[]>([])
type TechItem = { name: string; pageviews: number; icon: React.ReactNode }
const [fullData, setFullData] = useState<TechItem[]>([])
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) }))

View File

@@ -49,7 +49,6 @@ function BellIcon({ className }: { className?: string }) {
</svg>
)
}
// @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() {
<Input
type="text"
value={orgName}
onChange={(e: any) => setOrgName(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setOrgName(e.target.value)}
required
minLength={2}
maxLength={50}
@@ -623,7 +622,7 @@ export default function OrganizationSettings() {
<Input
type="text"
value={orgSlug}
onChange={(e: any) => setOrgSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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<HTMLInputElement>) => setInviteEmail(e.target.value)}
required
className="bg-white dark:bg-neutral-900"
/>

View File

@@ -24,9 +24,9 @@ export function getSignupUrl(redirectPath = '/auth/callback') {
export class ApiError extends Error {
status: number
data?: any
data?: Record<string, unknown>
constructor(message: string, status: number, data?: any) {
constructor(message: string, status: number, data?: Record<string, unknown>) {
super(message)
this.status = status
this.data = data

View File

@@ -86,10 +86,7 @@ export async function sendInvitation(
role: string = 'member',
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
): Promise<OrganizationInvitation> {
const body: any = {
email,
role
}
const body: Record<string, string> = { email, role }
if (captcha?.captcha_id) body.captcha_id = captcha.captcha_id
if (captcha?.captcha_solution) body.captcha_solution = captcha.captcha_solution

21
types/iso-3166-2.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
declare module 'iso-3166-2' {
interface SubdivisionInfo {
name: string
type: string
parent?: string
}
interface CountryInfo {
name: string
sub: Record<string, SubdivisionInfo>
}
const iso3166: {
data: Record<string, CountryInfo>
country(code: string): CountryInfo | undefined
subdivision(code: string): SubdivisionInfo | undefined
codes: string[]
}
export default iso3166
}

9
types/jspdf-autotable.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import 'jspdf'
declare module 'jspdf' {
interface jsPDF {
lastAutoTable: {
finalY: number
}
}
}