diff --git a/.env.example b/.env.example index b5a950ad7..e801933c3 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,10 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres?schema=publ DATABASE_URL_DIRECT="$DATABASE_URL" CLICKHOUSE_URL="http://localhost:8123/openpanel" +# Symmetric key used to encrypt TOTP secrets and other sensitive data at rest. +# Generate with: openssl rand -hex 32 +ENCRYPTION_KEY="" + # REST BATCH_SIZE="5000" BATCH_INTERVAL="10000" diff --git a/.vscode/settings.json b/.vscode/settings.json index 95b7c494e..4e9e13dd0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,8 +21,14 @@ }, "editor.formatOnSave": true, "tailwindCSS.experimental.classRegex": [ - ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], - ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"], + [ + "cva\\(([^)]*)\\)", + "[\"'`]([^\"'`]*).*?[\"'`]" + ], + [ + "cx\\(([^)]*)\\)", + "(?:'|\"|`)([^']*)(?:'|\"|`)" + ], [ "\\b\\w+ClassName\\s*=\\s*[\"'`]([^\"'`]*)[\"'`]", "[\"'`]([^\"'`]*)[\"'`]" @@ -34,4 +40,4 @@ "next/router.d.ts", "next/dist/client/router.d.ts" ] -} +} \ No newline at end of file diff --git a/apps/start/src/components/auth/sign-in-email-form.tsx b/apps/start/src/components/auth/sign-in-email-form.tsx index 12598009b..4bfe1d410 100644 --- a/apps/start/src/components/auth/sign-in-email-form.tsx +++ b/apps/start/src/components/auth/sign-in-email-form.tsx @@ -17,7 +17,11 @@ export function SignInEmailForm({ isLastUsed }: { isLastUsed?: boolean }) { const trpc = useTRPC(); const mutation = useMutation( trpc.auth.signInEmail.mutationOptions({ - async onSuccess() { + async onSuccess(data) { + if (data.type === 'totp_required') { + window.location.href = '/verify'; + return; + } toast.success('Successfully signed in'); window.location.href = '/'; }, diff --git a/apps/start/src/components/auth/sign-up-email-form.tsx b/apps/start/src/components/auth/sign-up-email-form.tsx index 6e102b6ae..f85df49ff 100644 --- a/apps/start/src/components/auth/sign-up-email-form.tsx +++ b/apps/start/src/components/auth/sign-up-email-form.tsx @@ -21,7 +21,7 @@ export function SignUpEmailForm({ trpc.auth.signUpEmail.mutationOptions({ async onSuccess() { toast.success('Successfully signed up'); - window.location.href = '/onboarding/project'; + window.location.href = '/'; }, onError(error) { toast.error(error.message); diff --git a/apps/start/src/components/profile-toggle.tsx b/apps/start/src/components/profile-toggle.tsx index 160e4798b..264f73c4f 100644 --- a/apps/start/src/components/profile-toggle.tsx +++ b/apps/start/src/components/profile-toggle.tsx @@ -1,3 +1,4 @@ +import { useParams, useRouter } from '@tanstack/react-router'; import { CheckIcon, UserIcon } from 'lucide-react'; import { themeConfig } from './theme-provider'; import { @@ -20,9 +21,22 @@ interface Props { } export function ProfileToggle({ className }: Props) { + const router = useRouter(); + const { organizationId } = useParams({ strict: false }); const { setTheme, userTheme, themes } = useTheme(); const logout = useLogout(); + const goToAccount = () => { + if (organizationId) { + router.navigate({ + to: '/$organizationId/account', + params: { organizationId }, + }); + return; + } + router.navigate({ to: '/account' }); + }; + return ( @@ -35,6 +49,8 @@ export function ProfileToggle({ className }: Props) { + Account + Theme diff --git a/apps/start/src/modals/Modal/Container.tsx b/apps/start/src/modals/Modal/Container.tsx index 568b66e2a..89a7126f4 100644 --- a/apps/start/src/modals/Modal/Container.tsx +++ b/apps/start/src/modals/Modal/Container.tsx @@ -1,17 +1,56 @@ -import { Button } from '@/components/ui/button'; -import { DialogContent, DialogTitle } from '@/components/ui/dialog'; -import { cn } from '@/utils/cn'; import type { DialogContentProps } from '@radix-ui/react-dialog'; import { X } from 'lucide-react'; - +import { useRef } from 'react'; import { popModal } from '..'; +import { Button } from '@/components/ui/button'; +import { DialogContent, DialogTitle } from '@/components/ui/dialog'; +import { cn } from '@/utils/cn'; interface ModalContentProps extends DialogContentProps { children: React.ReactNode; } export function ModalContent({ children, ...props }: ModalContentProps) { - return {children}; + const contentRef = useRef(null); + return ( + { + if (!contentRef.current) { + return; + } + const contentRect = contentRef.current.getBoundingClientRect(); + // Detect if click actually happened within the bounds of content. + // This can happen if click was on an absolutely positioned element overlapping content, + // such as the 1password extension icon in the text input. + const actuallyClickedInside = + e.detail.originalEvent.clientX > contentRect.left && + e.detail.originalEvent.clientX < + contentRect.left + contentRect.width && + e.detail.originalEvent.clientY > contentRect.top && + e.detail.originalEvent.clientY < contentRect.top + contentRect.height; + + if (actuallyClickedInside) { + e.preventDefault(); + } + + // Best for shadow DOM/web components (1Password uses this) + const path = e.detail.originalEvent.composedPath(); + const clicked1Password = path.some( + (node) => + node instanceof Element && + node.tagName.toLowerCase() === 'com-1password-button' + ); + if (clicked1Password) { + e.preventDefault(); + return; + } + }} + ref={contentRef} + > + {children} + + ); } interface ModalHeaderProps { @@ -31,7 +70,7 @@ export function ModalHeader({
@@ -45,10 +84,10 @@ export function ModalHeader({
{onClose !== false && ( + + + + ); +} diff --git a/apps/start/src/modals/index.tsx b/apps/start/src/modals/index.tsx index 36d9f27a9..865e3ecbc 100644 --- a/apps/start/src/modals/index.tsx +++ b/apps/start/src/modals/index.tsx @@ -14,6 +14,7 @@ import Confirm from './confirm'; import CreateInvite from './create-invite'; import DateRangerPicker from './date-ranger-picker'; import DateTimePicker from './date-time-picker'; +import DisableTwoFactor from './disable-two-factor'; import EditClient from './edit-client'; import EditCohort from './edit-cohort'; import EditDashboard from './edit-dashboard'; @@ -27,9 +28,11 @@ import Instructions from './Instructions'; import OverviewChartDetails from './overview-chart-details'; import OverviewFilters from './overview-filters'; import PageDetails from './page-details'; +import RegenerateRecoveryCodes from './regenerate-recovery-codes'; import RequestPasswordReset from './request-reset-password'; import SaveReport from './save-report'; import SelectBillingPlan from './select-billing-plan'; +import SetupTwoFactor from './setup-two-factor'; import ShareDashboardModal from './share-dashboard-modal'; import ShareOverviewModal from './share-overview-modal'; import ShareReportModal from './share-report-modal'; @@ -75,6 +78,9 @@ const modals = { CreateInvite, SelectBillingPlan, BillingSuccess, + SetupTwoFactor, + DisableTwoFactor, + RegenerateRecoveryCodes, }; export const { diff --git a/apps/start/src/modals/regenerate-recovery-codes.tsx b/apps/start/src/modals/regenerate-recovery-codes.tsx new file mode 100644 index 000000000..5580362b0 --- /dev/null +++ b/apps/start/src/modals/regenerate-recovery-codes.tsx @@ -0,0 +1,98 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { popModal } from '.'; +import { ModalContent, ModalHeader } from './Modal/Container'; +import { Button } from '@/components/ui/button'; +import { DialogFooter } from '@/components/ui/dialog'; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from '@/components/ui/input-otp'; +import { useTRPC } from '@/integrations/trpc/react'; + +export default function RegenerateRecoveryCodes() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [code, setCode] = useState(''); + const [newCodes, setNewCodes] = useState(null); + + const mutation = useMutation( + trpc.auth.totpRegenerateRecoveryCodes.mutationOptions({ + onSuccess: (data) => { + setNewCodes(data.recoveryCodes); + queryClient.invalidateQueries(trpc.auth.totpStatus.pathFilter()); + }, + onError: (error) => { + toast.error(error.message); + setCode(''); + }, + }) + ); + + if (newCodes) { + return ( + + +
+
+ {newCodes.map((c) => ( + {c} + ))} +
+ +
+ + + +
+ ); + } + + return ( + + +
+ + + + + + + + + + +
+ + + + +
+ ); +} diff --git a/apps/start/src/modals/setup-two-factor.tsx b/apps/start/src/modals/setup-two-factor.tsx new file mode 100644 index 000000000..fd9edf811 --- /dev/null +++ b/apps/start/src/modals/setup-two-factor.tsx @@ -0,0 +1,175 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { DownloadIcon } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { popModal } from '.'; +import { ModalContent, ModalHeader } from './Modal/Container'; +import CopyInput from '@/components/forms/copy-input'; +import { Button } from '@/components/ui/button'; +import { DialogFooter } from '@/components/ui/dialog'; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from '@/components/ui/input-otp'; +import { Label } from '@/components/ui/label'; +import { handleError, useTRPC } from '@/integrations/trpc/react'; + +export default function SetupTwoFactor() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [step, setStep] = useState<'scan' | 'recovery'>('scan'); + const [code, setCode] = useState(''); + const [setupData, setSetupData] = useState<{ + qrDataUrl: string; + secret: string; + } | null>(null); + const [recoveryCodes, setRecoveryCodes] = useState([]); + + const setupMutation = useMutation( + trpc.auth.totpSetup.mutationOptions({ + onSuccess: (data) => { + setSetupData({ qrDataUrl: data.qrDataUrl, secret: data.secret }); + }, + onError: handleError, + }) + ); + + const enableMutation = useMutation( + trpc.auth.totpEnable.mutationOptions({ + onSuccess: (data) => { + setRecoveryCodes(data.recoveryCodes); + setStep('recovery'); + queryClient.invalidateQueries(trpc.auth.totpStatus.pathFilter()); + }, + onError: (error) => { + toast.error(error.message); + setCode(''); + }, + }) + ); + + useEffect(() => { + setupMutation.mutate(); + }, []); + + if (step === 'recovery') { + return ( + + + + + + + + ); + } + + return ( + + + + {setupMutation.isPending || !setupData ? ( +
+ + Generating secret… + +
+ ) : ( +
+ Authenticator QR code + +
+ + + + + + + + + + + +
+
+ )} + + + + + +
+ ); +} + +function RecoveryCodesBlock({ codes }: { codes: string[] }) { + return ( +
+
+ {codes.map((c) => ( + {c} + ))} +
+
+ + +
+
+ ); +} diff --git a/apps/start/src/routeTree.gen.ts b/apps/start/src/routeTree.gen.ts index 475bb06e1..558fcbee0 100644 --- a/apps/start/src/routeTree.gen.ts +++ b/apps/start/src/routeTree.gen.ts @@ -24,8 +24,10 @@ import { Route as WidgetBadgeRouteImport } from './routes/widget/badge' import { Route as ApiHealthcheckRouteImport } from './routes/api/healthcheck' import { Route as ApiConfigRouteImport } from './routes/api/config' import { Route as PublicOnboardingRouteImport } from './routes/_public.onboarding' +import { Route as LoginVerifyRouteImport } from './routes/_login.verify' import { Route as LoginResetPasswordRouteImport } from './routes/_login.reset-password' import { Route as LoginLoginRouteImport } from './routes/_login.login' +import { Route as AppAccountRouteImport } from './routes/_app.account' import { Route as AppOrganizationIdRouteImport } from './routes/_app.$organizationId' import { Route as AppOrganizationIdIndexRouteImport } from './routes/_app.$organizationId.index' import { Route as ShareReportShareIdRouteImport } from './routes/share.report.$shareId' @@ -38,9 +40,9 @@ import { Route as AppOrganizationIdProjectIdRouteImport } from './routes/_app.$o import { Route as AppOrganizationIdProjectIdIndexRouteImport } from './routes/_app.$organizationId.$projectId.index' import { Route as StepsOnboardingProjectIdVerifyRouteImport } from './routes/_steps.onboarding.$projectId.verify' import { Route as StepsOnboardingProjectIdConnectRouteImport } from './routes/_steps.onboarding.$projectId.connect' -import { Route as AppOrganizationIdProfileTabsRouteImport } from './routes/_app.$organizationId.profile._tabs' import { Route as AppOrganizationIdMembersTabsRouteImport } from './routes/_app.$organizationId.members._tabs' import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs' +import { Route as AppOrganizationIdAccountTabsRouteImport } from './routes/_app.$organizationId.account._tabs' import { Route as AppOrganizationIdProjectIdSessionsRouteImport } from './routes/_app.$organizationId.$projectId.sessions' import { Route as AppOrganizationIdProjectIdSeoRouteImport } from './routes/_app.$organizationId.$projectId.seo' import { Route as AppOrganizationIdProjectIdReportsRouteImport } from './routes/_app.$organizationId.$projectId.reports' @@ -51,14 +53,15 @@ import { Route as AppOrganizationIdProjectIdInsightsRouteImport } from './routes import { Route as AppOrganizationIdProjectIdGroupsRouteImport } from './routes/_app.$organizationId.$projectId.groups' import { Route as AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards' import { Route as AppOrganizationIdProjectIdCohortsRouteImport } from './routes/_app.$organizationId.$projectId.cohorts' -import { Route as AppOrganizationIdProfileTabsIndexRouteImport } from './routes/_app.$organizationId.profile._tabs.index' import { Route as AppOrganizationIdMembersTabsIndexRouteImport } from './routes/_app.$organizationId.members._tabs.index' import { Route as AppOrganizationIdIntegrationsTabsIndexRouteImport } from './routes/_app.$organizationId.integrations._tabs.index' -import { Route as AppOrganizationIdProfileTabsEmailPreferencesRouteImport } from './routes/_app.$organizationId.profile._tabs.email-preferences' +import { Route as AppOrganizationIdAccountTabsIndexRouteImport } from './routes/_app.$organizationId.account._tabs.index' import { Route as AppOrganizationIdMembersTabsMembersRouteImport } from './routes/_app.$organizationId.members._tabs.members' import { Route as AppOrganizationIdMembersTabsInvitationsRouteImport } from './routes/_app.$organizationId.members._tabs.invitations' import { Route as AppOrganizationIdIntegrationsTabsInstalledRouteImport } from './routes/_app.$organizationId.integrations._tabs.installed' import { Route as AppOrganizationIdIntegrationsTabsAvailableRouteImport } from './routes/_app.$organizationId.integrations._tabs.available' +import { Route as AppOrganizationIdAccountTabsTwoFactorRouteImport } from './routes/_app.$organizationId.account._tabs.two-factor' +import { Route as AppOrganizationIdAccountTabsEmailPreferencesRouteImport } from './routes/_app.$organizationId.account._tabs.email-preferences' import { Route as AppOrganizationIdProjectIdSettingsTabsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs' import { Route as AppOrganizationIdProjectIdSessionsSessionIdRouteImport } from './routes/_app.$organizationId.$projectId.sessions_.$sessionId' import { Route as AppOrganizationIdProjectIdReportsReportIdRouteImport } from './routes/_app.$organizationId.$projectId.reports_.$reportId' @@ -99,15 +102,15 @@ import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRouteImport } import { Route as AppOrganizationIdProjectIdCohortsCohortIdTabsMembersRouteImport } from './routes/_app.$organizationId.$projectId.cohorts_.$cohortId._tabs.members' import { Route as AppOrganizationIdProjectIdCohortsCohortIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.cohorts_.$cohortId._tabs.events' -const AppOrganizationIdProfileRouteImport = createFileRoute( - '/_app/$organizationId/profile', -)() const AppOrganizationIdMembersRouteImport = createFileRoute( '/_app/$organizationId/members', )() const AppOrganizationIdIntegrationsRouteImport = createFileRoute( '/_app/$organizationId/integrations', )() +const AppOrganizationIdAccountRouteImport = createFileRoute( + '/_app/$organizationId/account', +)() const AppOrganizationIdProjectIdSettingsRouteImport = createFileRoute( '/_app/$organizationId/$projectId/settings', )() @@ -191,6 +194,11 @@ const PublicOnboardingRoute = PublicOnboardingRouteImport.update({ path: '/onboarding', getParentRoute: () => PublicRoute, } as any) +const LoginVerifyRoute = LoginVerifyRouteImport.update({ + id: '/verify', + path: '/verify', + getParentRoute: () => LoginRoute, +} as any) const LoginResetPasswordRoute = LoginResetPasswordRouteImport.update({ id: '/reset-password', path: '/reset-password', @@ -201,17 +209,16 @@ const LoginLoginRoute = LoginLoginRouteImport.update({ path: '/login', getParentRoute: () => LoginRoute, } as any) +const AppAccountRoute = AppAccountRouteImport.update({ + id: '/account', + path: '/account', + getParentRoute: () => AppRoute, +} as any) const AppOrganizationIdRoute = AppOrganizationIdRouteImport.update({ id: '/$organizationId', path: '/$organizationId', getParentRoute: () => AppRoute, } as any) -const AppOrganizationIdProfileRoute = - AppOrganizationIdProfileRouteImport.update({ - id: '/profile', - path: '/profile', - getParentRoute: () => AppOrganizationIdRoute, - } as any) const AppOrganizationIdMembersRoute = AppOrganizationIdMembersRouteImport.update({ id: '/members', @@ -224,6 +231,12 @@ const AppOrganizationIdIntegrationsRoute = path: '/integrations', getParentRoute: () => AppOrganizationIdRoute, } as any) +const AppOrganizationIdAccountRoute = + AppOrganizationIdAccountRouteImport.update({ + id: '/account', + path: '/account', + getParentRoute: () => AppOrganizationIdRoute, + } as any) const AppOrganizationIdIndexRoute = AppOrganizationIdIndexRouteImport.update({ id: '/', path: '/', @@ -309,11 +322,6 @@ const StepsOnboardingProjectIdConnectRoute = path: '/onboarding/$projectId/connect', getParentRoute: () => StepsRoute, } as any) -const AppOrganizationIdProfileTabsRoute = - AppOrganizationIdProfileTabsRouteImport.update({ - id: '/_tabs', - getParentRoute: () => AppOrganizationIdProfileRoute, - } as any) const AppOrganizationIdMembersTabsRoute = AppOrganizationIdMembersTabsRouteImport.update({ id: '/_tabs', @@ -324,6 +332,11 @@ const AppOrganizationIdIntegrationsTabsRoute = id: '/_tabs', getParentRoute: () => AppOrganizationIdIntegrationsRoute, } as any) +const AppOrganizationIdAccountTabsRoute = + AppOrganizationIdAccountTabsRouteImport.update({ + id: '/_tabs', + getParentRoute: () => AppOrganizationIdAccountRoute, + } as any) const AppOrganizationIdProjectIdSessionsRoute = AppOrganizationIdProjectIdSessionsRouteImport.update({ id: '/sessions', @@ -402,12 +415,6 @@ const AppOrganizationIdProjectIdCohortsCohortIdRoute = path: '/cohorts/$cohortId', getParentRoute: () => AppOrganizationIdProjectIdRoute, } as any) -const AppOrganizationIdProfileTabsIndexRoute = - AppOrganizationIdProfileTabsIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => AppOrganizationIdProfileTabsRoute, - } as any) const AppOrganizationIdMembersTabsIndexRoute = AppOrganizationIdMembersTabsIndexRouteImport.update({ id: '/', @@ -420,11 +427,11 @@ const AppOrganizationIdIntegrationsTabsIndexRoute = path: '/', getParentRoute: () => AppOrganizationIdIntegrationsTabsRoute, } as any) -const AppOrganizationIdProfileTabsEmailPreferencesRoute = - AppOrganizationIdProfileTabsEmailPreferencesRouteImport.update({ - id: '/email-preferences', - path: '/email-preferences', - getParentRoute: () => AppOrganizationIdProfileTabsRoute, +const AppOrganizationIdAccountTabsIndexRoute = + AppOrganizationIdAccountTabsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AppOrganizationIdAccountTabsRoute, } as any) const AppOrganizationIdMembersTabsMembersRoute = AppOrganizationIdMembersTabsMembersRouteImport.update({ @@ -450,6 +457,18 @@ const AppOrganizationIdIntegrationsTabsAvailableRoute = path: '/available', getParentRoute: () => AppOrganizationIdIntegrationsTabsRoute, } as any) +const AppOrganizationIdAccountTabsTwoFactorRoute = + AppOrganizationIdAccountTabsTwoFactorRouteImport.update({ + id: '/two-factor', + path: '/two-factor', + getParentRoute: () => AppOrganizationIdAccountTabsRoute, + } as any) +const AppOrganizationIdAccountTabsEmailPreferencesRoute = + AppOrganizationIdAccountTabsEmailPreferencesRouteImport.update({ + id: '/email-preferences', + path: '/email-preferences', + getParentRoute: () => AppOrganizationIdAccountTabsRoute, + } as any) const AppOrganizationIdProjectIdSettingsTabsRoute = AppOrganizationIdProjectIdSettingsTabsRouteImport.update({ id: '/_tabs', @@ -682,8 +701,10 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/unsubscribe': typeof UnsubscribeRoute '/$organizationId': typeof AppOrganizationIdRouteWithChildren + '/account': typeof AppAccountRoute '/login': typeof LoginLoginRoute '/reset-password': typeof LoginResetPasswordRoute + '/verify': typeof LoginVerifyRoute '/onboarding': typeof PublicOnboardingRoute '/api/config': typeof ApiConfigRoute '/api/healthcheck': typeof ApiHealthcheckRoute @@ -709,9 +730,9 @@ export interface FileRoutesByFullPath { '/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute '/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute '/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute + '/$organizationId/account': typeof AppOrganizationIdAccountTabsRouteWithChildren '/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren '/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren - '/$organizationId/profile': typeof AppOrganizationIdProfileTabsRouteWithChildren '/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute '/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute '/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute @@ -722,14 +743,15 @@ export interface FileRoutesByFullPath { '/$organizationId/$projectId/reports/$reportId': typeof AppOrganizationIdProjectIdReportsReportIdRoute '/$organizationId/$projectId/sessions/$sessionId': typeof AppOrganizationIdProjectIdSessionsSessionIdRoute '/$organizationId/$projectId/settings': typeof AppOrganizationIdProjectIdSettingsTabsRouteWithChildren + '/$organizationId/account/email-preferences': typeof AppOrganizationIdAccountTabsEmailPreferencesRoute + '/$organizationId/account/two-factor': typeof AppOrganizationIdAccountTabsTwoFactorRoute '/$organizationId/integrations/available': typeof AppOrganizationIdIntegrationsTabsAvailableRoute '/$organizationId/integrations/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute '/$organizationId/members/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute '/$organizationId/members/members': typeof AppOrganizationIdMembersTabsMembersRoute - '/$organizationId/profile/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute + '/$organizationId/account/': typeof AppOrganizationIdAccountTabsIndexRoute '/$organizationId/integrations/': typeof AppOrganizationIdIntegrationsTabsIndexRoute '/$organizationId/members/': typeof AppOrganizationIdMembersTabsIndexRoute - '/$organizationId/profile/': typeof AppOrganizationIdProfileTabsIndexRoute '/$organizationId/$projectId/cohorts/$cohortId': typeof AppOrganizationIdProjectIdCohortsCohortIdTabsRouteWithChildren '/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute '/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute @@ -766,8 +788,10 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/unsubscribe': typeof UnsubscribeRoute + '/account': typeof AppAccountRoute '/login': typeof LoginLoginRoute '/reset-password': typeof LoginResetPasswordRoute + '/verify': typeof LoginVerifyRoute '/onboarding': typeof PublicOnboardingRoute '/api/config': typeof ApiConfigRoute '/api/healthcheck': typeof ApiHealthcheckRoute @@ -792,9 +816,9 @@ export interface FileRoutesByTo { '/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute '/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute '/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute + '/$organizationId/account': typeof AppOrganizationIdAccountTabsIndexRoute '/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute '/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute - '/$organizationId/profile': typeof AppOrganizationIdProfileTabsIndexRoute '/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute '/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute '/$organizationId/$projectId': typeof AppOrganizationIdProjectIdIndexRoute @@ -805,11 +829,12 @@ export interface FileRoutesByTo { '/$organizationId/$projectId/reports/$reportId': typeof AppOrganizationIdProjectIdReportsReportIdRoute '/$organizationId/$projectId/sessions/$sessionId': typeof AppOrganizationIdProjectIdSessionsSessionIdRoute '/$organizationId/$projectId/settings': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute + '/$organizationId/account/email-preferences': typeof AppOrganizationIdAccountTabsEmailPreferencesRoute + '/$organizationId/account/two-factor': typeof AppOrganizationIdAccountTabsTwoFactorRoute '/$organizationId/integrations/available': typeof AppOrganizationIdIntegrationsTabsAvailableRoute '/$organizationId/integrations/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute '/$organizationId/members/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute '/$organizationId/members/members': typeof AppOrganizationIdMembersTabsMembersRoute - '/$organizationId/profile/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute '/$organizationId/$projectId/cohorts/$cohortId': typeof AppOrganizationIdProjectIdCohortsCohortIdTabsIndexRoute '/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute '/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute @@ -845,8 +870,10 @@ export interface FileRoutesById { '/_steps': typeof StepsRouteWithChildren '/unsubscribe': typeof UnsubscribeRoute '/_app/$organizationId': typeof AppOrganizationIdRouteWithChildren + '/_app/account': typeof AppAccountRoute '/_login/login': typeof LoginLoginRoute '/_login/reset-password': typeof LoginResetPasswordRoute + '/_login/verify': typeof LoginVerifyRoute '/_public/onboarding': typeof PublicOnboardingRoute '/api/config': typeof ApiConfigRoute '/api/healthcheck': typeof ApiHealthcheckRoute @@ -872,12 +899,12 @@ export interface FileRoutesById { '/_app/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute '/_app/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute '/_app/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute + '/_app/$organizationId/account': typeof AppOrganizationIdAccountRouteWithChildren + '/_app/$organizationId/account/_tabs': typeof AppOrganizationIdAccountTabsRouteWithChildren '/_app/$organizationId/integrations': typeof AppOrganizationIdIntegrationsRouteWithChildren '/_app/$organizationId/integrations/_tabs': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren '/_app/$organizationId/members': typeof AppOrganizationIdMembersRouteWithChildren '/_app/$organizationId/members/_tabs': typeof AppOrganizationIdMembersTabsRouteWithChildren - '/_app/$organizationId/profile': typeof AppOrganizationIdProfileRouteWithChildren - '/_app/$organizationId/profile/_tabs': typeof AppOrganizationIdProfileTabsRouteWithChildren '/_steps/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute '/_steps/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute '/_app/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute @@ -892,14 +919,15 @@ export interface FileRoutesById { '/_app/$organizationId/$projectId/sessions_/$sessionId': typeof AppOrganizationIdProjectIdSessionsSessionIdRoute '/_app/$organizationId/$projectId/settings': typeof AppOrganizationIdProjectIdSettingsRouteWithChildren '/_app/$organizationId/$projectId/settings/_tabs': typeof AppOrganizationIdProjectIdSettingsTabsRouteWithChildren + '/_app/$organizationId/account/_tabs/email-preferences': typeof AppOrganizationIdAccountTabsEmailPreferencesRoute + '/_app/$organizationId/account/_tabs/two-factor': typeof AppOrganizationIdAccountTabsTwoFactorRoute '/_app/$organizationId/integrations/_tabs/available': typeof AppOrganizationIdIntegrationsTabsAvailableRoute '/_app/$organizationId/integrations/_tabs/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute '/_app/$organizationId/members/_tabs/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute '/_app/$organizationId/members/_tabs/members': typeof AppOrganizationIdMembersTabsMembersRoute - '/_app/$organizationId/profile/_tabs/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute + '/_app/$organizationId/account/_tabs/': typeof AppOrganizationIdAccountTabsIndexRoute '/_app/$organizationId/integrations/_tabs/': typeof AppOrganizationIdIntegrationsTabsIndexRoute '/_app/$organizationId/members/_tabs/': typeof AppOrganizationIdMembersTabsIndexRoute - '/_app/$organizationId/profile/_tabs/': typeof AppOrganizationIdProfileTabsIndexRoute '/_app/$organizationId/$projectId/cohorts_/$cohortId': typeof AppOrganizationIdProjectIdCohortsCohortIdRouteWithChildren '/_app/$organizationId/$projectId/cohorts_/$cohortId/_tabs': typeof AppOrganizationIdProjectIdCohortsCohortIdTabsRouteWithChildren '/_app/$organizationId/$projectId/events/_tabs/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute @@ -942,8 +970,10 @@ export interface FileRouteTypes { | '/' | '/unsubscribe' | '/$organizationId' + | '/account' | '/login' | '/reset-password' + | '/verify' | '/onboarding' | '/api/config' | '/api/healthcheck' @@ -969,9 +999,9 @@ export interface FileRouteTypes { | '/$organizationId/$projectId/reports' | '/$organizationId/$projectId/seo' | '/$organizationId/$projectId/sessions' + | '/$organizationId/account' | '/$organizationId/integrations' | '/$organizationId/members' - | '/$organizationId/profile' | '/onboarding/$projectId/connect' | '/onboarding/$projectId/verify' | '/$organizationId/$projectId/' @@ -982,14 +1012,15 @@ export interface FileRouteTypes { | '/$organizationId/$projectId/reports/$reportId' | '/$organizationId/$projectId/sessions/$sessionId' | '/$organizationId/$projectId/settings' + | '/$organizationId/account/email-preferences' + | '/$organizationId/account/two-factor' | '/$organizationId/integrations/available' | '/$organizationId/integrations/installed' | '/$organizationId/members/invitations' | '/$organizationId/members/members' - | '/$organizationId/profile/email-preferences' + | '/$organizationId/account/' | '/$organizationId/integrations/' | '/$organizationId/members/' - | '/$organizationId/profile/' | '/$organizationId/$projectId/cohorts/$cohortId' | '/$organizationId/$projectId/events/conversions' | '/$organizationId/$projectId/events/events' @@ -1026,8 +1057,10 @@ export interface FileRouteTypes { to: | '/' | '/unsubscribe' + | '/account' | '/login' | '/reset-password' + | '/verify' | '/onboarding' | '/api/config' | '/api/healthcheck' @@ -1052,9 +1085,9 @@ export interface FileRouteTypes { | '/$organizationId/$projectId/reports' | '/$organizationId/$projectId/seo' | '/$organizationId/$projectId/sessions' + | '/$organizationId/account' | '/$organizationId/integrations' | '/$organizationId/members' - | '/$organizationId/profile' | '/onboarding/$projectId/connect' | '/onboarding/$projectId/verify' | '/$organizationId/$projectId' @@ -1065,11 +1098,12 @@ export interface FileRouteTypes { | '/$organizationId/$projectId/reports/$reportId' | '/$organizationId/$projectId/sessions/$sessionId' | '/$organizationId/$projectId/settings' + | '/$organizationId/account/email-preferences' + | '/$organizationId/account/two-factor' | '/$organizationId/integrations/available' | '/$organizationId/integrations/installed' | '/$organizationId/members/invitations' | '/$organizationId/members/members' - | '/$organizationId/profile/email-preferences' | '/$organizationId/$projectId/cohorts/$cohortId' | '/$organizationId/$projectId/events/conversions' | '/$organizationId/$projectId/events/events' @@ -1104,8 +1138,10 @@ export interface FileRouteTypes { | '/_steps' | '/unsubscribe' | '/_app/$organizationId' + | '/_app/account' | '/_login/login' | '/_login/reset-password' + | '/_login/verify' | '/_public/onboarding' | '/api/config' | '/api/healthcheck' @@ -1131,12 +1167,12 @@ export interface FileRouteTypes { | '/_app/$organizationId/$projectId/reports' | '/_app/$organizationId/$projectId/seo' | '/_app/$organizationId/$projectId/sessions' + | '/_app/$organizationId/account' + | '/_app/$organizationId/account/_tabs' | '/_app/$organizationId/integrations' | '/_app/$organizationId/integrations/_tabs' | '/_app/$organizationId/members' | '/_app/$organizationId/members/_tabs' - | '/_app/$organizationId/profile' - | '/_app/$organizationId/profile/_tabs' | '/_steps/onboarding/$projectId/connect' | '/_steps/onboarding/$projectId/verify' | '/_app/$organizationId/$projectId/' @@ -1151,14 +1187,15 @@ export interface FileRouteTypes { | '/_app/$organizationId/$projectId/sessions_/$sessionId' | '/_app/$organizationId/$projectId/settings' | '/_app/$organizationId/$projectId/settings/_tabs' + | '/_app/$organizationId/account/_tabs/email-preferences' + | '/_app/$organizationId/account/_tabs/two-factor' | '/_app/$organizationId/integrations/_tabs/available' | '/_app/$organizationId/integrations/_tabs/installed' | '/_app/$organizationId/members/_tabs/invitations' | '/_app/$organizationId/members/_tabs/members' - | '/_app/$organizationId/profile/_tabs/email-preferences' + | '/_app/$organizationId/account/_tabs/' | '/_app/$organizationId/integrations/_tabs/' | '/_app/$organizationId/members/_tabs/' - | '/_app/$organizationId/profile/_tabs/' | '/_app/$organizationId/$projectId/cohorts_/$cohortId' | '/_app/$organizationId/$projectId/cohorts_/$cohortId/_tabs' | '/_app/$organizationId/$projectId/events/_tabs/conversions' @@ -1307,6 +1344,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PublicOnboardingRouteImport parentRoute: typeof PublicRoute } + '/_login/verify': { + id: '/_login/verify' + path: '/verify' + fullPath: '/verify' + preLoaderRoute: typeof LoginVerifyRouteImport + parentRoute: typeof LoginRoute + } '/_login/reset-password': { id: '/_login/reset-password' path: '/reset-password' @@ -1321,6 +1365,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginLoginRouteImport parentRoute: typeof LoginRoute } + '/_app/account': { + id: '/_app/account' + path: '/account' + fullPath: '/account' + preLoaderRoute: typeof AppAccountRouteImport + parentRoute: typeof AppRoute + } '/_app/$organizationId': { id: '/_app/$organizationId' path: '/$organizationId' @@ -1328,13 +1379,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdRouteImport parentRoute: typeof AppRoute } - '/_app/$organizationId/profile': { - id: '/_app/$organizationId/profile' - path: '/profile' - fullPath: '/$organizationId/profile' - preLoaderRoute: typeof AppOrganizationIdProfileRouteImport - parentRoute: typeof AppOrganizationIdRoute - } '/_app/$organizationId/members': { id: '/_app/$organizationId/members' path: '/members' @@ -1349,6 +1393,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdIntegrationsRouteImport parentRoute: typeof AppOrganizationIdRoute } + '/_app/$organizationId/account': { + id: '/_app/$organizationId/account' + path: '/account' + fullPath: '/$organizationId/account' + preLoaderRoute: typeof AppOrganizationIdAccountRouteImport + parentRoute: typeof AppOrganizationIdRoute + } '/_app/$organizationId/': { id: '/_app/$organizationId/' path: '/' @@ -1454,13 +1505,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StepsOnboardingProjectIdConnectRouteImport parentRoute: typeof StepsRoute } - '/_app/$organizationId/profile/_tabs': { - id: '/_app/$organizationId/profile/_tabs' - path: '/profile' - fullPath: '/$organizationId/profile' - preLoaderRoute: typeof AppOrganizationIdProfileTabsRouteImport - parentRoute: typeof AppOrganizationIdProfileRoute - } '/_app/$organizationId/members/_tabs': { id: '/_app/$organizationId/members/_tabs' path: '/members' @@ -1475,6 +1519,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdIntegrationsTabsRouteImport parentRoute: typeof AppOrganizationIdIntegrationsRoute } + '/_app/$organizationId/account/_tabs': { + id: '/_app/$organizationId/account/_tabs' + path: '/account' + fullPath: '/$organizationId/account' + preLoaderRoute: typeof AppOrganizationIdAccountTabsRouteImport + parentRoute: typeof AppOrganizationIdAccountRoute + } '/_app/$organizationId/$projectId/sessions': { id: '/_app/$organizationId/$projectId/sessions' path: '/sessions' @@ -1566,13 +1617,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdProjectIdCohortsCohortIdRouteImport parentRoute: typeof AppOrganizationIdProjectIdRoute } - '/_app/$organizationId/profile/_tabs/': { - id: '/_app/$organizationId/profile/_tabs/' - path: '/' - fullPath: '/$organizationId/profile/' - preLoaderRoute: typeof AppOrganizationIdProfileTabsIndexRouteImport - parentRoute: typeof AppOrganizationIdProfileTabsRoute - } '/_app/$organizationId/members/_tabs/': { id: '/_app/$organizationId/members/_tabs/' path: '/' @@ -1587,12 +1631,12 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdIntegrationsTabsIndexRouteImport parentRoute: typeof AppOrganizationIdIntegrationsTabsRoute } - '/_app/$organizationId/profile/_tabs/email-preferences': { - id: '/_app/$organizationId/profile/_tabs/email-preferences' - path: '/email-preferences' - fullPath: '/$organizationId/profile/email-preferences' - preLoaderRoute: typeof AppOrganizationIdProfileTabsEmailPreferencesRouteImport - parentRoute: typeof AppOrganizationIdProfileTabsRoute + '/_app/$organizationId/account/_tabs/': { + id: '/_app/$organizationId/account/_tabs/' + path: '/' + fullPath: '/$organizationId/account/' + preLoaderRoute: typeof AppOrganizationIdAccountTabsIndexRouteImport + parentRoute: typeof AppOrganizationIdAccountTabsRoute } '/_app/$organizationId/members/_tabs/members': { id: '/_app/$organizationId/members/_tabs/members' @@ -1622,6 +1666,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdIntegrationsTabsAvailableRouteImport parentRoute: typeof AppOrganizationIdIntegrationsTabsRoute } + '/_app/$organizationId/account/_tabs/two-factor': { + id: '/_app/$organizationId/account/_tabs/two-factor' + path: '/two-factor' + fullPath: '/$organizationId/account/two-factor' + preLoaderRoute: typeof AppOrganizationIdAccountTabsTwoFactorRouteImport + parentRoute: typeof AppOrganizationIdAccountTabsRoute + } + '/_app/$organizationId/account/_tabs/email-preferences': { + id: '/_app/$organizationId/account/_tabs/email-preferences' + path: '/email-preferences' + fullPath: '/$organizationId/account/email-preferences' + preLoaderRoute: typeof AppOrganizationIdAccountTabsEmailPreferencesRouteImport + parentRoute: typeof AppOrganizationIdAccountTabsRoute + } '/_app/$organizationId/$projectId/settings/_tabs': { id: '/_app/$organizationId/$projectId/settings/_tabs' path: '/settings' @@ -2246,6 +2304,42 @@ const AppOrganizationIdProjectIdRouteWithChildren = AppOrganizationIdProjectIdRouteChildren, ) +interface AppOrganizationIdAccountTabsRouteChildren { + AppOrganizationIdAccountTabsEmailPreferencesRoute: typeof AppOrganizationIdAccountTabsEmailPreferencesRoute + AppOrganizationIdAccountTabsTwoFactorRoute: typeof AppOrganizationIdAccountTabsTwoFactorRoute + AppOrganizationIdAccountTabsIndexRoute: typeof AppOrganizationIdAccountTabsIndexRoute +} + +const AppOrganizationIdAccountTabsRouteChildren: AppOrganizationIdAccountTabsRouteChildren = + { + AppOrganizationIdAccountTabsEmailPreferencesRoute: + AppOrganizationIdAccountTabsEmailPreferencesRoute, + AppOrganizationIdAccountTabsTwoFactorRoute: + AppOrganizationIdAccountTabsTwoFactorRoute, + AppOrganizationIdAccountTabsIndexRoute: + AppOrganizationIdAccountTabsIndexRoute, + } + +const AppOrganizationIdAccountTabsRouteWithChildren = + AppOrganizationIdAccountTabsRoute._addFileChildren( + AppOrganizationIdAccountTabsRouteChildren, + ) + +interface AppOrganizationIdAccountRouteChildren { + AppOrganizationIdAccountTabsRoute: typeof AppOrganizationIdAccountTabsRouteWithChildren +} + +const AppOrganizationIdAccountRouteChildren: AppOrganizationIdAccountRouteChildren = + { + AppOrganizationIdAccountTabsRoute: + AppOrganizationIdAccountTabsRouteWithChildren, + } + +const AppOrganizationIdAccountRouteWithChildren = + AppOrganizationIdAccountRoute._addFileChildren( + AppOrganizationIdAccountRouteChildren, + ) + interface AppOrganizationIdIntegrationsTabsRouteChildren { AppOrganizationIdIntegrationsTabsAvailableRoute: typeof AppOrganizationIdIntegrationsTabsAvailableRoute AppOrganizationIdIntegrationsTabsInstalledRoute: typeof AppOrganizationIdIntegrationsTabsInstalledRoute @@ -2318,47 +2412,14 @@ const AppOrganizationIdMembersRouteWithChildren = AppOrganizationIdMembersRouteChildren, ) -interface AppOrganizationIdProfileTabsRouteChildren { - AppOrganizationIdProfileTabsEmailPreferencesRoute: typeof AppOrganizationIdProfileTabsEmailPreferencesRoute - AppOrganizationIdProfileTabsIndexRoute: typeof AppOrganizationIdProfileTabsIndexRoute -} - -const AppOrganizationIdProfileTabsRouteChildren: AppOrganizationIdProfileTabsRouteChildren = - { - AppOrganizationIdProfileTabsEmailPreferencesRoute: - AppOrganizationIdProfileTabsEmailPreferencesRoute, - AppOrganizationIdProfileTabsIndexRoute: - AppOrganizationIdProfileTabsIndexRoute, - } - -const AppOrganizationIdProfileTabsRouteWithChildren = - AppOrganizationIdProfileTabsRoute._addFileChildren( - AppOrganizationIdProfileTabsRouteChildren, - ) - -interface AppOrganizationIdProfileRouteChildren { - AppOrganizationIdProfileTabsRoute: typeof AppOrganizationIdProfileTabsRouteWithChildren -} - -const AppOrganizationIdProfileRouteChildren: AppOrganizationIdProfileRouteChildren = - { - AppOrganizationIdProfileTabsRoute: - AppOrganizationIdProfileTabsRouteWithChildren, - } - -const AppOrganizationIdProfileRouteWithChildren = - AppOrganizationIdProfileRoute._addFileChildren( - AppOrganizationIdProfileRouteChildren, - ) - interface AppOrganizationIdRouteChildren { AppOrganizationIdProjectIdRoute: typeof AppOrganizationIdProjectIdRouteWithChildren AppOrganizationIdBillingRoute: typeof AppOrganizationIdBillingRoute AppOrganizationIdSettingsRoute: typeof AppOrganizationIdSettingsRoute AppOrganizationIdIndexRoute: typeof AppOrganizationIdIndexRoute + AppOrganizationIdAccountRoute: typeof AppOrganizationIdAccountRouteWithChildren AppOrganizationIdIntegrationsRoute: typeof AppOrganizationIdIntegrationsRouteWithChildren AppOrganizationIdMembersRoute: typeof AppOrganizationIdMembersRouteWithChildren - AppOrganizationIdProfileRoute: typeof AppOrganizationIdProfileRouteWithChildren } const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = { @@ -2366,10 +2427,10 @@ const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = { AppOrganizationIdBillingRoute: AppOrganizationIdBillingRoute, AppOrganizationIdSettingsRoute: AppOrganizationIdSettingsRoute, AppOrganizationIdIndexRoute: AppOrganizationIdIndexRoute, + AppOrganizationIdAccountRoute: AppOrganizationIdAccountRouteWithChildren, AppOrganizationIdIntegrationsRoute: AppOrganizationIdIntegrationsRouteWithChildren, AppOrganizationIdMembersRoute: AppOrganizationIdMembersRouteWithChildren, - AppOrganizationIdProfileRoute: AppOrganizationIdProfileRouteWithChildren, } const AppOrganizationIdRouteWithChildren = @@ -2377,10 +2438,12 @@ const AppOrganizationIdRouteWithChildren = interface AppRouteChildren { AppOrganizationIdRoute: typeof AppOrganizationIdRouteWithChildren + AppAccountRoute: typeof AppAccountRoute } const AppRouteChildren: AppRouteChildren = { AppOrganizationIdRoute: AppOrganizationIdRouteWithChildren, + AppAccountRoute: AppAccountRoute, } const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) @@ -2388,11 +2451,13 @@ const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) interface LoginRouteChildren { LoginLoginRoute: typeof LoginLoginRoute LoginResetPasswordRoute: typeof LoginResetPasswordRoute + LoginVerifyRoute: typeof LoginVerifyRoute } const LoginRouteChildren: LoginRouteChildren = { LoginLoginRoute: LoginLoginRoute, LoginResetPasswordRoute: LoginResetPasswordRoute, + LoginVerifyRoute: LoginVerifyRoute, } const LoginRouteWithChildren = LoginRoute._addFileChildren(LoginRouteChildren) diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx index b1341cdc0..c2ac9ab80 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx @@ -41,7 +41,7 @@ function ProjectDashboard() { const settingsTabs = [ { id: 'details', label: 'Details' }, { id: 'events', label: 'Events' }, - { id: 'clients', label: 'Clients' }, + { id: 'clients', label: 'Clients / API keys' }, { id: 'tracking', label: 'Tracking script' }, { id: 'mcp', label: 'MCP' }, { id: 'widgets', label: 'Widgets' }, diff --git a/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx b/apps/start/src/routes/_app.$organizationId.account._tabs.email-preferences.tsx similarity index 75% rename from apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx rename to apps/start/src/routes/_app.$organizationId.account._tabs.email-preferences.tsx index f4fbb29e6..9de9f95a4 100644 --- a/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx +++ b/apps/start/src/routes/_app.$organizationId.account._tabs.email-preferences.tsx @@ -1,18 +1,20 @@ -import { WithLabel } from '@/components/forms/input-with-label'; -import FullPageLoadingState from '@/components/full-page-loading-state'; -import { Button } from '@/components/ui/button'; -import { Switch } from '@/components/ui/switch'; -import { Widget, WidgetBody, WidgetHead } from '@/components/widget'; -import { useTRPC } from '@/integrations/trpc/react'; -import { handleError } from '@/integrations/trpc/react'; +import { zodResolver } from '@hookform/resolvers/zod'; import { emailCategories } from '@openpanel/constants'; -import { useSuspenseQuery } from '@tanstack/react-query'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + useMutation, + useQueryClient, + useSuspenseQuery, +} from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; import { SaveIcon } from 'lucide-react'; import { Controller, useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; +import FullPageLoadingState from '@/components/full-page-loading-state'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Widget, WidgetBody, WidgetHead } from '@/components/widget'; +import { handleError, useTRPC } from '@/integrations/trpc/react'; const validator = z.object({ categories: z.record(z.string(), z.boolean()), @@ -20,24 +22,20 @@ const validator = z.object({ type IForm = z.infer; -/** - * Build explicit boolean values for every key in emailCategories. - * Uses saved preferences when available, falling back to true (opted-in). - */ function buildCategoryDefaults( - savedPreferences?: Record, + savedPreferences?: Record ): Record { return Object.keys(emailCategories).reduce( (acc, category) => { acc[category] = savedPreferences?.[category] ?? true; return acc; }, - {} as Record, + {} as Record ); } export const Route = createFileRoute( - '/_app/$organizationId/profile/_tabs/email-preferences', + '/_app/$organizationId/account/_tabs/email-preferences' )({ component: Component, pendingComponent: FullPageLoadingState, @@ -48,13 +46,14 @@ function Component() { const queryClient = useQueryClient(); const preferencesQuery = useSuspenseQuery( - trpc.email.getPreferences.queryOptions(), + trpc.email.getPreferences.queryOptions() ); const { control, handleSubmit, formState, reset } = useForm({ defaultValues: { categories: buildCategoryDefaults(preferencesQuery.data), }, + resolver: zodResolver(validator), }); const mutation = useMutation( @@ -64,18 +63,17 @@ function Component() { description: 'Your email preferences have been saved.', }); await queryClient.invalidateQueries( - trpc.email.getPreferences.pathFilter(), + trpc.email.getPreferences.pathFilter() ); - // Reset form with fresh data after refetch const freshData = await queryClient.fetchQuery( - trpc.email.getPreferences.queryOptions(), + trpc.email.getPreferences.queryOptions() ); reset({ categories: buildCategoryDefaults(freshData), }); }, onError: handleError, - }), + }) ); return ( @@ -84,12 +82,12 @@ function Component() { mutation.mutate(values); })} > - + Email Preferences - -

+ +

Choose which types of emails you want to receive. Uncheck a category to stop receiving those emails.

@@ -97,14 +95,14 @@ function Component() {
{Object.entries(emailCategories).map(([category, label]) => ( ( -
+
{label}
-
+
{category === 'onboarding' && 'Get started tips and guidance emails'} {category === 'billing' && @@ -113,8 +111,8 @@ function Component() {
)} @@ -123,12 +121,12 @@ function Component() {
diff --git a/apps/start/src/routes/_app.$organizationId.profile._tabs.index.tsx b/apps/start/src/routes/_app.$organizationId.account._tabs.index.tsx similarity index 92% rename from apps/start/src/routes/_app.$organizationId.profile._tabs.index.tsx rename to apps/start/src/routes/_app.$organizationId.account._tabs.index.tsx index 9791abed6..714fc69c2 100644 --- a/apps/start/src/routes/_app.$organizationId.profile._tabs.index.tsx +++ b/apps/start/src/routes/_app.$organizationId.account._tabs.index.tsx @@ -1,7 +1,5 @@ import { InputWithLabel } from '@/components/forms/input-with-label'; import FullPageLoadingState from '@/components/full-page-loading-state'; -import { PageContainer } from '@/components/page-container'; -import { PageHeader } from '@/components/page-header'; import { Button } from '@/components/ui/button'; import { Widget, WidgetBody, WidgetHead } from '@/components/widget'; import { handleError, useTRPC } from '@/integrations/trpc/react'; @@ -20,7 +18,7 @@ const validator = z.object({ type IForm = z.infer; -export const Route = createFileRoute('/_app/$organizationId/profile/_tabs/')({ +export const Route = createFileRoute('/_app/$organizationId/account/_tabs/')({ component: Component, pendingComponent: FullPageLoadingState, }); @@ -69,6 +67,12 @@ function Component() { Profile + { + if (tabId === 'account') { + router.navigate({ + to: '/$organizationId/account', + params: { organizationId }, + }); + return; + } router.navigate({ from: Route.fullPath, to: tabId, @@ -34,8 +35,7 @@ function Component() { return ( - - + + + Two-factor authentication + + +

+ Protect your account with an authenticator app (Google Authenticator, + 1Password, Authy, etc.). You'll be asked for a 6-digit code each time + you sign in with email and password. +

+ + {status.data.enabled ? ( + + ) : ( + + )} +
+ + ); +} + +function DisabledView({ hasEmailProvider }: { hasEmailProvider: boolean }) { + if (!hasEmailProvider) { + return ( +
+
+ + Two-factor authentication is not available. +
+

+ Your account signs in with Google or GitHub, which handle two-factor + authentication in their account settings. Enable it in your provider's + security settings. +

+
+ ); + } + + return ( +
+
+ + Two-factor authentication is disabled. +
+ +
+ ); +} + +function EnabledView({ + enabledAt, + remainingRecoveryCodes, +}: { + enabledAt: Date; + remainingRecoveryCodes: number; +}) { + return ( + <> +
+
+
+ +
+
+ Two-factor authentication is enabled. + + Enabled {new Date(enabledAt).toLocaleString()} ·{' '} + {remainingRecoveryCodes} recovery code + {remainingRecoveryCodes === 1 ? '' : 's'} remaining + +
+
+
+ +
+ + +
+ + ); +} diff --git a/apps/start/src/routes/_app.account.tsx b/apps/start/src/routes/_app.account.tsx new file mode 100644 index 000000000..4889d4a08 --- /dev/null +++ b/apps/start/src/routes/_app.account.tsx @@ -0,0 +1,24 @@ +import { createFileRoute, redirect } from '@tanstack/react-router'; + +export const Route = createFileRoute('/_app/account')({ + beforeLoad: async ({ context }) => { + const organizations = await context.queryClient + .fetchQuery( + context.trpc.organization.list.queryOptions(undefined, { + staleTime: 0, + gcTime: 0, + }), + ) + .catch(() => []); + + const firstOrg = organizations[0]; + if (!firstOrg) { + throw redirect({ to: '/onboarding/project' }); + } + + throw redirect({ + to: '/$organizationId/account', + params: { organizationId: firstOrg.id }, + }); + }, +}); diff --git a/apps/start/src/routes/_login.verify.tsx b/apps/start/src/routes/_login.verify.tsx new file mode 100644 index 000000000..ee053e382 --- /dev/null +++ b/apps/start/src/routes/_login.verify.tsx @@ -0,0 +1,129 @@ +import { useTRPC } from '@/integrations/trpc/react'; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from '@/components/ui/input-otp'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { createTitle, PAGE_TITLES } from '@/utils/title'; +import { useMutation } from '@tanstack/react-query'; +import { createFileRoute } from '@tanstack/react-router'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +export const Route = createFileRoute('/_login/verify')({ + component: VerifyPage, + head: () => ({ + meta: [ + { title: createTitle(PAGE_TITLES.LOGIN) }, + { name: 'robots', content: 'noindex, follow' }, + ], + }), +}); + +function VerifyPage() { + const trpc = useTRPC(); + const [mode, setMode] = useState<'totp' | 'recovery'>('totp'); + const [code, setCode] = useState(''); + + const mutation = useMutation( + trpc.auth.signInTotp.mutationOptions({ + onSuccess() { + toast.success('Signed in'); + window.location.href = '/'; + }, + onError(error) { + toast.error(error.message); + setCode(''); + }, + }), + ); + + const canSubmit = + mode === 'totp' ? code.length === 6 : code.trim().length > 0; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!canSubmit) return; + mutation.mutate({ code }); + }; + + return ( +
+
+

+ Two-factor authentication +

+

+ {mode === 'totp' + ? 'Enter the 6-digit code from your authenticator app.' + : 'Enter one of your recovery codes. Each code can be used only once.'} +

+
+ + {mode === 'totp' ? ( +
+ { + setCode(value); + if (value.length === 6) { + mutation.mutate({ code: value }); + } + }} + disabled={mutation.isPending} + autoFocus + > + + + + + + + + + +
+ ) : ( + setCode(e.target.value)} + autoFocus + autoCapitalize="characters" + disabled={mutation.isPending} + /> + )} + + + + + + Sign in with a different account + +
+ ); +} diff --git a/biome.json b/biome.json index 910c45a3d..6ed4ff388 100644 --- a/biome.json +++ b/biome.json @@ -68,7 +68,8 @@ "noAccumulatingSpread": "off", "noBarrelFile": "off", "noNamespaceImport": "off", - "useTopLevelRegex": "off" + "useTopLevelRegex": "off", + "noImgElement": "off" }, "suspicious": { "noExplicitAny": "off", diff --git a/packages/auth/package.json b/packages/auth/package.json index 4c508efcd..3dd179e6f 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -12,11 +12,14 @@ "@openpanel/validation": "workspace:^", "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0", - "arctic": "^2.3.0" + "@oslojs/otp": "^1.1.0", + "arctic": "^2.3.0", + "qrcode": "^1.5.4" }, "devDependencies": { "@openpanel/tsconfig": "workspace:*", "@types/node": "catalog:", + "@types/qrcode": "^1.5.6", "@types/react": "catalog:", "prisma": "^5.1.1", "typescript": "catalog:" diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index fdb955d12..1f170515a 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -2,3 +2,4 @@ export * from './cookie'; export * from './oauth'; export * from './password'; export * from './session'; +export * from './totp'; diff --git a/packages/auth/src/totp.ts b/packages/auth/src/totp.ts new file mode 100644 index 000000000..adcbbdb9c --- /dev/null +++ b/packages/auth/src/totp.ts @@ -0,0 +1,103 @@ +import crypto from 'node:crypto'; +import { createTOTPKeyURI, verifyTOTPWithGracePeriod } from '@oslojs/otp'; +import { + decodeBase32IgnorePadding, + encodeBase32UpperCaseNoPadding, +} from '@oslojs/encoding'; +import qrcode from 'qrcode'; +import { hashPassword, verifyPasswordHash } from './password'; + +const ISSUER = 'OpenPanel'; +const PERIOD_SECONDS = 30; +const DIGITS = 6; +// ±60s grace — ~2 windows on each side. Covers clock drift and the case where +// an authenticator (e.g. 1Password autofill) emits a code that rolls over +// between fill and submit. +const GRACE_PERIOD_SECONDS = 60; + +export function generateTotpSecret(): string { + const bytes = new Uint8Array(20); + crypto.getRandomValues(bytes); + return encodeBase32UpperCaseNoPadding(bytes); +} + +export function buildOtpauthUrl({ + secret, + accountName, +}: { + secret: string; + accountName: string; +}): string { + const key = decodeBase32IgnorePadding(secret); + return createTOTPKeyURI(ISSUER, accountName, key, PERIOD_SECONDS, DIGITS); +} + +export async function generateQrDataUrl(otpauthUrl: string): Promise { + return qrcode.toDataURL(otpauthUrl, { margin: 1, width: 240 }); +} + +export function verifyTotpCode(secret: string, code: string): boolean { + // Strip any non-digits — some authenticators emit codes like "123 456" or + // "123-456"; paste-from-clipboard can also carry whitespace. + const normalized = code.replace(/\D/g, ''); + if (normalized.length !== DIGITS) { + return false; + } + const key = decodeBase32IgnorePadding(secret); + return verifyTOTPWithGracePeriod( + key, + PERIOD_SECONDS, + DIGITS, + normalized, + GRACE_PERIOD_SECONDS, + ); +} + +// Human-friendly 10-char code split with a dash: `ABCDE-FGHIJ`. +// ~51 bits of entropy; argon2-hashed for storage. +const RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + +function randomRecoveryCode(): string { + const bytes = new Uint8Array(10); + crypto.getRandomValues(bytes); + let out = ''; + for (let i = 0; i < bytes.length; i++) { + out += RECOVERY_ALPHABET[bytes[i]! % RECOVERY_ALPHABET.length]; + if (i === 4) { + out += '-'; + } + } + return out; +} + +export function generateRecoveryCodes(count = 10): string[] { + return Array.from({ length: count }, randomRecoveryCode); +} + +export async function hashRecoveryCodes(codes: string[]): Promise { + return Promise.all(codes.map((code) => hashPassword(normalizeRecoveryCode(code)))); +} + +export function normalizeRecoveryCode(input: string): string { + return input.trim().toUpperCase().replace(/\s+/g, ''); +} + +export async function consumeRecoveryCode({ + hashes, + input, +}: { + hashes: string[]; + input: string; +}): Promise<{ valid: boolean; remaining: string[] }> { + const normalized = normalizeRecoveryCode(input); + for (let i = 0; i < hashes.length; i++) { + const hash = hashes[i]!; + // Sequential verify is fine — argon2 is slow on purpose and the list is 10 entries. + const matched = await verifyPasswordHash(hash, normalized); + if (matched) { + const remaining = hashes.slice(0, i).concat(hashes.slice(i + 1)); + return { valid: true, remaining }; + } + } + return { valid: false, remaining: hashes }; +} diff --git a/packages/db/prisma/migrations/20260422194907_add_user_totp_and_2fa_challenge/migration.sql b/packages/db/prisma/migrations/20260422194907_add_user_totp_and_2fa_challenge/migration.sql new file mode 100644 index 000000000..e782d3cd4 --- /dev/null +++ b/packages/db/prisma/migrations/20260422194907_add_user_totp_and_2fa_challenge/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "public"."user_totp" ( + "id" TEXT NOT NULL DEFAULT gen_random_uuid(), + "userId" TEXT NOT NULL, + "secret" TEXT NOT NULL, + "recoveryCodes" TEXT[], + "enabledAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_totp_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."two_factor_challenges" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "two_factor_challenges_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "user_totp_userId_key" ON "public"."user_totp"("userId"); + +-- CreateIndex +CREATE INDEX "two_factor_challenges_userId_idx" ON "public"."two_factor_challenges"("userId"); + +-- AddForeignKey +ALTER TABLE "public"."user_totp" ADD CONSTRAINT "user_totp_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."two_factor_challenges" ADD CONSTRAINT "two_factor_challenges_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 4897bbf98..707df5fc1 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -112,26 +112,52 @@ model Organization { } model User { - id String @id @default(dbgenerated("gen_random_uuid()")) - email String @unique + id String @id @default(dbgenerated("gen_random_uuid()")) + email String @unique firstName String? lastName String? - createdOrganizations Organization[] @relation("organizationCreatedBy") - subscriptions Organization[] @relation("subscriptionCreatedBy") + createdOrganizations Organization[] @relation("organizationCreatedBy") + subscriptions Organization[] @relation("subscriptionCreatedBy") membership Member[] - sentInvites Member[] @relation("invitedBy") - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt + sentInvites Member[] @relation("invitedBy") + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt deletedAt DateTime? ProjectAccess ProjectAccess[] sessions Session[] accounts Account[] invites Invite[] conversations Conversation[] + totp UserTotp? + twoFactorChallenges TwoFactorChallenge[] @@map("users") } +model UserTotp { + id String @id @default(dbgenerated("gen_random_uuid()")) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + secret String + recoveryCodes String[] + enabledAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("user_totp") +} + +model TwoFactorChallenge { + id String @id + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + expiresAt DateTime + createdAt DateTime @default(now()) + + @@index([userId]) + @@map("two_factor_challenges") +} + model Account { id String @id @default(dbgenerated("gen_random_uuid()")) userId String @@ -211,16 +237,16 @@ model Project { /// [IPrismaProjectFilters] filters Json @default("[]") - clients Client[] - reports Report[] - dashboards Dashboard[] - share ShareOverview? - shareDashboards ShareDashboard[] - shareReports ShareReport[] - shareWidgets ShareWidget[] - meta EventMeta[] - references Reference[] - access ProjectAccess[] + clients Client[] + reports Report[] + dashboards Dashboard[] + share ShareOverview? + shareDashboards ShareDashboard[] + shareReports ShareReport[] + shareWidgets ShareWidget[] + meta EventMeta[] + references Reference[] + access ProjectAccess[] notificationRules NotificationRule[] notifications Notification[] imports Import[] @@ -255,7 +281,6 @@ model Cohort { @@map("cohorts") } - enum AccessLevel { read write @@ -355,23 +380,23 @@ enum Metric { } model Report { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - name String - interval Interval - range String @default("30d") - chartType ChartType - lineType String @default("monotone") - breakdowns Json - events Json - formula String? - unit String? - metric Metric @default(sum) - projectId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - previous Boolean @default(false) - criteria String? - funnelGroup String? - funnelWindow Float? + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String + interval Interval + range String @default("30d") + chartType ChartType + lineType String @default("monotone") + breakdowns Json + events Json + formula String? + unit String? + metric Metric @default(sum) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + previous Boolean @default(false) + criteria String? + funnelGroup String? + funnelWindow Float? /// [IReportOptions] options Json? visibleSeries String[] diff --git a/packages/trpc/src/routers/auth.ts b/packages/trpc/src/routers/auth.ts index c8b82ea87..8d83bdf4f 100644 --- a/packages/trpc/src/routers/auth.ts +++ b/packages/trpc/src/routers/auth.ts @@ -1,22 +1,31 @@ import { Arctic, + buildOtpauthUrl, COOKIE_OPTIONS, + consumeRecoveryCode, createSession, deleteSessionTokenCookie, + generateQrDataUrl, + generateRecoveryCodes, generateSessionToken, + generateTotpSecret, github, google, hashPassword, + hashRecoveryCodes, invalidateSession, setLastAuthProviderCookie, setSessionTokenCookie, validateSessionToken, verifyPasswordHash, + verifyTotpCode, } from '@openpanel/auth'; import { generateSecureId } from '@openpanel/common/server'; import { connectUserToOrganization, db, + decrypt, + encrypt, getShareOverviewById, getUserAccount, } from '@openpanel/db'; @@ -27,15 +36,21 @@ import { zSignInEmail, zSignInShare, zSignUpEmail, + zTotpCode, + zTotpOrRecoveryCode, } from '@openpanel/validation'; import { z } from 'zod'; import { TRPCAccessError, TRPCNotFoundError } from '../errors'; import { createTRPCRouter, + protectedProcedure, publicProcedure, rateLimitMiddleware, } from '../trpc'; +const TWO_FACTOR_COOKIE = '2fa_challenge'; +const TWO_FACTOR_CHALLENGE_TTL_SECONDS = 5 * 60; + const zProvider = z.enum(['email', 'google', 'github']); async function getIsRegistrationAllowed(inviteId?: string | null) { @@ -223,15 +238,252 @@ export const authRouter = createTRPCRouter({ } } + const totp = await db.userTotp.findUnique({ + where: { userId: user.id }, + }); + if (totp?.enabledAt) { + const challengeId = generateSecureId('2fa'); + await db.twoFactorChallenge.create({ + data: { + id: challengeId, + userId: user.id, + expiresAt: new Date( + Date.now() + TWO_FACTOR_CHALLENGE_TTL_SECONDS * 1000 + ), + }, + }); + ctx.setCookie(TWO_FACTOR_COOKIE, challengeId, { + maxAge: TWO_FACTOR_CHALLENGE_TTL_SECONDS, + }); + return { type: 'totp_required' as const }; + } + const token = generateSessionToken(); const session = await createSession(token, user.id); setSessionTokenCookie(ctx.setCookie, token, session.expiresAt); setLastAuthProviderCookie(ctx.setCookie, 'email'); return { - type: 'email', + type: 'email' as const, }; }), + signInTotp: publicProcedure + .use( + rateLimitMiddleware({ + max: 5, + windowMs: 60_000, + }) + ) + .input(z.object({ code: zTotpOrRecoveryCode })) + .mutation(async ({ input, ctx }) => { + const challengeId = ctx.cookies[TWO_FACTOR_COOKIE]; + if (!challengeId) { + throw TRPCAccessError('No active two-factor challenge'); + } + + const challenge = await db.twoFactorChallenge.findUnique({ + where: { id: challengeId }, + }); + + if (!challenge || challenge.expiresAt < new Date()) { + if (challenge) { + await db.twoFactorChallenge.delete({ where: { id: challenge.id } }); + } + ctx.setCookie(TWO_FACTOR_COOKIE, '', { maxAge: 0 }); + throw TRPCAccessError('Two-factor challenge has expired'); + } + + const totp = await db.userTotp.findUnique({ + where: { userId: challenge.userId }, + }); + if (!totp?.enabledAt) { + await db.twoFactorChallenge.delete({ where: { id: challenge.id } }); + ctx.setCookie(TWO_FACTOR_COOKIE, '', { maxAge: 0 }); + throw TRPCAccessError('Two-factor is not enabled'); + } + + const secret = decrypt(totp.secret); + const isTotpCode = /^\d{6}$/.test(input.code.replace(/\s+/g, '')); + let valid = false; + + if (isTotpCode) { + valid = verifyTotpCode(secret, input.code); + } else { + const result = await consumeRecoveryCode({ + hashes: totp.recoveryCodes, + input: input.code, + }); + if (result.valid) { + valid = true; + await db.userTotp.update({ + where: { userId: challenge.userId }, + data: { recoveryCodes: result.remaining }, + }); + } + } + + if (!valid) { + throw TRPCAccessError('Invalid code'); + } + + await db.twoFactorChallenge.delete({ where: { id: challenge.id } }); + ctx.setCookie(TWO_FACTOR_COOKIE, '', { maxAge: 0 }); + + const token = generateSessionToken(); + const session = await createSession(token, challenge.userId); + setSessionTokenCookie(ctx.setCookie, token, session.expiresAt); + setLastAuthProviderCookie(ctx.setCookie, 'email'); + return { type: 'email' as const }; + }), + + totpStatus: protectedProcedure.query(async ({ ctx }) => { + const userId = ctx.session.userId!; + const [totp, emailAccount] = await Promise.all([ + db.userTotp.findUnique({ where: { userId } }), + db.account.findFirst({ + where: { userId, provider: 'email' }, + select: { id: true }, + }), + ]); + return { + enabled: Boolean(totp?.enabledAt), + enabledAt: totp?.enabledAt ?? null, + remainingRecoveryCodes: totp?.recoveryCodes.length ?? 0, + hasEmailProvider: Boolean(emailAccount), + }; + }), + + totpSetup: protectedProcedure.mutation(async ({ ctx }) => { + const userId = ctx.session.userId!; + const emailAccount = await db.account.findFirst({ + where: { userId, provider: 'email' }, + select: { id: true }, + }); + if (!emailAccount) { + throw TRPCAccessError( + 'Two-factor authentication is only available for email/password sign-ins. Your account uses a social provider, which handles 2FA on its end.' + ); + } + const existing = await db.userTotp.findUnique({ where: { userId } }); + if (existing?.enabledAt) { + throw TRPCAccessError( + 'Two-factor is already enabled. Disable it first to re-configure.' + ); + } + + const user = await db.user.findUniqueOrThrow({ + where: { id: userId }, + select: { email: true }, + }); + + const secret = generateTotpSecret(); + const otpauthUrl = buildOtpauthUrl({ + secret, + accountName: user.email, + }); + const qrDataUrl = await generateQrDataUrl(otpauthUrl); + + await db.userTotp.upsert({ + where: { userId }, + create: { + userId, + secret: encrypt(secret), + recoveryCodes: [], + }, + update: { + secret: encrypt(secret), + recoveryCodes: [], + enabledAt: null, + }, + }); + + return { otpauthUrl, qrDataUrl, secret }; + }), + + totpEnable: protectedProcedure + .use(rateLimitMiddleware({ max: 5, windowMs: 60_000 })) + .input(z.object({ code: zTotpCode })) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.userId!; + const totp = await db.userTotp.findUnique({ where: { userId } }); + if (!totp) { + throw TRPCNotFoundError('Start two-factor setup first'); + } + if (totp.enabledAt) { + throw TRPCAccessError('Two-factor is already enabled'); + } + + const secret = decrypt(totp.secret); + if (!verifyTotpCode(secret, input.code)) { + throw TRPCAccessError('Invalid code'); + } + + const recoveryCodes = generateRecoveryCodes(); + const hashed = await hashRecoveryCodes(recoveryCodes); + + await db.userTotp.update({ + where: { userId }, + data: { + enabledAt: new Date(), + recoveryCodes: hashed, + }, + }); + + return { recoveryCodes }; + }), + + totpDisable: protectedProcedure + .use(rateLimitMiddleware({ max: 5, windowMs: 60_000 })) + .input(z.object({ code: zTotpOrRecoveryCode })) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.userId!; + const totp = await db.userTotp.findUnique({ where: { userId } }); + if (!totp?.enabledAt) { + throw TRPCAccessError('Two-factor is not enabled'); + } + + const secret = decrypt(totp.secret); + const isTotpCode = /^\d{6}$/.test(input.code.replace(/\s+/g, '')); + const valid = isTotpCode + ? verifyTotpCode(secret, input.code) + : ( + await consumeRecoveryCode({ + hashes: totp.recoveryCodes, + input: input.code, + }) + ).valid; + + if (!valid) { + throw TRPCAccessError('Invalid code'); + } + + await db.userTotp.delete({ where: { userId } }); + await db.twoFactorChallenge.deleteMany({ where: { userId } }); + return { disabled: true }; + }), + + totpRegenerateRecoveryCodes: protectedProcedure + .use(rateLimitMiddleware({ max: 3, windowMs: 60_000 })) + .input(z.object({ code: zTotpCode })) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.userId!; + const totp = await db.userTotp.findUnique({ where: { userId } }); + if (!totp?.enabledAt) { + throw TRPCAccessError('Two-factor is not enabled'); + } + const secret = decrypt(totp.secret); + if (!verifyTotpCode(secret, input.code)) { + throw TRPCAccessError('Invalid code'); + } + const recoveryCodes = generateRecoveryCodes(); + const hashed = await hashRecoveryCodes(recoveryCodes); + await db.userTotp.update({ + where: { userId }, + data: { recoveryCodes: hashed }, + }); + return { recoveryCodes }; + }), + resetPassword: publicProcedure .input(zResetPassword) .use( diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 8817d9447..b79cf79cc 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -543,6 +543,18 @@ export const zRequestResetPassword = z.object({ }); export type IRequestResetPassword = z.infer; +export const zTotpCode = z + .string() + .transform((v) => v.replace(/\s+/g, '')) + .refine((v) => /^\d{6}$/.test(v), { message: 'Enter a 6-digit code' }); +export type ITotpCode = z.infer; + +export const zTotpOrRecoveryCode = z + .string() + .min(1) + .transform((v) => v.trim()); +export type ITotpOrRecoveryCode = z.infer; + export const zSignInShare = z.object({ password: z.string().min(1), shareId: z.string().min(1), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa12e8b74..d05bbbc98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1047,9 +1047,15 @@ importers: '@oslojs/encoding': specifier: ^1.1.0 version: 1.1.0 + '@oslojs/otp': + specifier: ^1.1.0 + version: 1.1.0 arctic: specifier: ^2.3.0 version: 2.3.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: 'catalog:' version: 19.2.3 @@ -1060,6 +1066,9 @@ importers: '@types/node': specifier: 'catalog:' version: 24.10.1 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: 'catalog:' version: 19.2.7 @@ -6378,18 +6387,27 @@ packages: '@oslojs/binary@1.0.0': resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==} + '@oslojs/crypto@1.0.0': + resolution: {integrity: sha512-dVz8TkkgYdr3tlwxHd7SCYGxoN7ynwHLA0nei/Aq9C+ERU0BK+U8+/3soEzBUxUNKYBf42351DyJUZ2REla50w==} + '@oslojs/crypto@1.0.1': resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==} '@oslojs/encoding@0.4.1': resolution: {integrity: sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==} + '@oslojs/encoding@1.0.0': + resolution: {integrity: sha512-dyIB0SdZgMm5BhGwdSp8rMxEFIopLKxDG1vxIBaiogyom6ZqH2aXPb6DEC2WzOOWKdPSq1cxdNeRx2wAn1Z+ZQ==} + '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} '@oslojs/jwt@0.2.0': resolution: {integrity: sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==} + '@oslojs/otp@1.1.0': + resolution: {integrity: sha512-tpdxlnCLcY6IZLLqH8kGD8PSvIVyev/+Gbglgvrk9e4YzgKO7+7FL8NWBofL7LZI6MgQ1HnNUuotRG6t1JJ0dg==} + '@oxc-minify/binding-android-arm64@0.102.0': resolution: {integrity: sha512-pknM+ttJTwRr7ezn1v5K+o2P4RRjLAzKI10bjVDPybwWQ544AZW6jxm7/YDgF2yUbWEV9o7cAQPkIUOmCiW8vg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -9919,6 +9937,9 @@ packages: '@types/pug@2.0.10': resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/qs@6.9.11': resolution: {integrity: sha512-oGk0gmhnEJK4Yyk+oI7EfXsLayXatCWPHary1MtcmbAifkobT9cM9yutG/hZKIseOU0MqbIwQ/u2nn/Gb+ltuQ==} @@ -11929,6 +11950,9 @@ packages: resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -15838,6 +15862,10 @@ packages: resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} engines: {node: '>=4.0.0'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + possible-typed-array-names@1.0.0: resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} engines: {node: '>= 0.4'} @@ -16227,6 +16255,11 @@ packages: resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} hasBin: true + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.11.0: resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} engines: {node: '>=0.6'} @@ -25736,6 +25769,11 @@ snapshots: '@oslojs/binary@1.0.0': {} + '@oslojs/crypto@1.0.0': + dependencies: + '@oslojs/asn1': 1.0.0 + '@oslojs/binary': 1.0.0 + '@oslojs/crypto@1.0.1': dependencies: '@oslojs/asn1': 1.0.0 @@ -25743,12 +25781,20 @@ snapshots: '@oslojs/encoding@0.4.1': {} + '@oslojs/encoding@1.0.0': {} + '@oslojs/encoding@1.1.0': {} '@oslojs/jwt@0.2.0': dependencies: '@oslojs/encoding': 0.4.1 + '@oslojs/otp@1.1.0': + dependencies: + '@oslojs/binary': 1.0.0 + '@oslojs/crypto': 1.0.0 + '@oslojs/encoding': 1.0.0 + '@oxc-minify/binding-android-arm64@0.102.0': optional: true @@ -29727,6 +29773,10 @@ snapshots: '@types/pug@2.0.10': {} + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 20.19.24 + '@types/qs@6.9.11': {} '@types/ramda@0.29.10': @@ -32152,6 +32202,8 @@ snapshots: diff@8.0.2: {} + dijkstrajs@1.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -37517,6 +37569,8 @@ snapshots: pngjs@3.4.0: {} + pngjs@5.0.0: {} + possible-typed-array-names@1.0.0: {} postcss-calc@10.1.1(postcss@8.5.6): @@ -37886,6 +37940,12 @@ snapshots: qrcode-terminal@0.11.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.11.0: dependencies: side-channel: 1.0.5