Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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