Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 9 additions & 3 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,14 @@
},
"editor.formatOnSave": true,
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"],
[
"cva\\(([^)]*)\\)",
"[\"'`]([^\"'`]*).*?[\"'`]"
],
[
"cx\\(([^)]*)\\)",
"(?:'|\"|`)([^']*)(?:'|\"|`)"
],
[
"\\b\\w+ClassName\\s*=\\s*[\"'`]([^\"'`]*)[\"'`]",
"[\"'`]([^\"'`]*)[\"'`]"
Expand All @@ -34,4 +40,4 @@
"next/router.d.ts",
"next/dist/client/router.d.ts"
]
}
}
6 changes: 5 additions & 1 deletion apps/start/src/components/auth/sign-in-email-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '/';
},
Expand Down
2 changes: 1 addition & 1 deletion apps/start/src/components/auth/sign-up-email-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions apps/start/src/components/profile-toggle.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useParams, useRouter } from '@tanstack/react-router';
import { CheckIcon, UserIcon } from 'lucide-react';
import { themeConfig } from './theme-provider';
import {
Expand All @@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
Expand All @@ -35,6 +49,8 @@ export function ProfileToggle({ className }: Props) {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-56">
<DropdownMenuItem onClick={goToAccount}>Account</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger className="flex w-full items-center justify-between">
Theme
Expand Down
57 changes: 48 additions & 9 deletions apps/start/src/modals/Modal/Container.tsx
Original file line number Diff line number Diff line change
@@ -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 <DialogContent {...props}>{children}</DialogContent>;
const contentRef = useRef<HTMLDivElement>(null);
return (
<DialogContent
{...props}
onPointerDownOutside={(e) => {
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}
</DialogContent>
);
}

interface ModalHeaderProps {
Expand All @@ -31,7 +70,7 @@ export function ModalHeader({
<div
className={cn(
'relative -m-6 mb-4 flex justify-between rounded-t-lg p-6 pb-0',
className,
className
)}
>
<div className="row relative w-full justify-between gap-4">
Expand All @@ -45,10 +84,10 @@ export function ModalHeader({
</div>
{onClose !== false && (
<Button
variant="ghost"
size="sm"
onClick={() => (onClose ? onClose() : popModal())}
className="-mt-2"
onClick={() => (onClose ? onClose() : popModal())}
size="sm"
variant="ghost"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
Expand Down
58 changes: 58 additions & 0 deletions apps/start/src/modals/disable-two-factor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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 { Input } from '@/components/ui/input';
import { useTRPC } from '@/integrations/trpc/react';

export default function DisableTwoFactor() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const [code, setCode] = useState('');

const mutation = useMutation(
trpc.auth.totpDisable.mutationOptions({
onSuccess: () => {
toast.success('Two-factor authentication disabled');
queryClient.invalidateQueries(trpc.auth.totpStatus.pathFilter());
popModal();
},
onError: (error) => {
toast.error(error.message);
setCode('');
},
})
);

return (
<ModalContent>
<ModalHeader
text="Enter a code from your authenticator app, or a recovery code, to confirm."
title="Disable two-factor authentication"
/>
<Input
autoCapitalize="characters"
autoFocus
onChange={(e) => setCode(e.target.value)}
placeholder="123456 or ABCDE-FGHIJ"
value={code}
/>
<DialogFooter>
<Button onClick={() => popModal()} variant="secondary">
Cancel
</Button>
<Button
disabled={!code.trim()}
loading={mutation.isPending}
onClick={() => mutation.mutate({ code })}
variant="destructive"
>
Disable
</Button>
</DialogFooter>
</ModalContent>
);
}
6 changes: 6 additions & 0 deletions apps/start/src/modals/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -75,6 +78,9 @@ const modals = {
CreateInvite,
SelectBillingPlan,
BillingSuccess,
SetupTwoFactor,
DisableTwoFactor,
RegenerateRecoveryCodes,
};

export const {
Expand Down
98 changes: 98 additions & 0 deletions apps/start/src/modals/regenerate-recovery-codes.tsx
Original file line number Diff line number Diff line change
@@ -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<string[] | null>(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 (
<ModalContent>
<ModalHeader
text="Your old recovery codes are no longer valid."
title="New recovery codes"
/>
<div className="col gap-2">
<div className="grid grid-cols-2 gap-2 rounded-md border border-border bg-def-100 p-3 font-mono text-sm">
{newCodes.map((c) => (
<span key={c}>{c}</span>
))}
</div>
<Button
className="self-start"
onClick={() => {
navigator.clipboard.writeText(newCodes.join('\n'));
toast.success('Copied to clipboard');
}}
size="sm"
variant="outline"
>
Copy all
</Button>
</div>
<DialogFooter>
<Button onClick={() => popModal()}>Done</Button>
</DialogFooter>
</ModalContent>
);
}

return (
<ModalContent>
<ModalHeader
text="Enter a code from your authenticator app to generate a fresh set of recovery codes."
title="Regenerate recovery codes"
/>
<div className="col items-center gap-2">
<InputOTP maxLength={6} onChange={setCode} value={code}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
<DialogFooter>
<Button onClick={() => popModal()} variant="secondary">
Cancel
</Button>
<Button
disabled={code.length !== 6}
loading={mutation.isPending}
onClick={() => mutation.mutate({ code })}
>
Regenerate
</Button>
</DialogFooter>
</ModalContent>
);
}
Loading
Loading