Skip to content

Commit d7f85bb

Browse files
samejrericallam
authored andcommitted
New Security route for setting up MFA
1 parent 4bac355 commit d7f85bb

1 file changed

Lines changed: 349 additions & 0 deletions

File tree

  • apps/webapp/app/routes/account.security
Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
import { conform, useForm } from "@conform-to/react";
2+
import { parse } from "@conform-to/zod";
3+
import { Form, type MetaFunction, useActionData } from "@remix-run/react";
4+
import { type ActionFunction, json } from "@remix-run/server-runtime";
5+
import { useState } from "react";
6+
import { QRCodeSVG } from "qrcode.react";
7+
import { z } from "zod";
8+
import {
9+
MainHorizontallyCenteredContainer,
10+
PageBody,
11+
PageContainer,
12+
} from "~/components/layout/AppLayout";
13+
import { Button } from "~/components/primitives/Buttons";
14+
import { CopyableText } from "~/components/primitives/CopyableText";
15+
import {
16+
Dialog,
17+
DialogContent,
18+
DialogHeader,
19+
DialogFooter,
20+
DialogTitle,
21+
} from "~/components/primitives/Dialog";
22+
import { DialogClose } from "@radix-ui/react-dialog";
23+
import { Header2 } from "~/components/primitives/Headers";
24+
import { Input } from "~/components/primitives/Input";
25+
import { InputGroup } from "~/components/primitives/InputGroup";
26+
import { Label } from "~/components/primitives/Label";
27+
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
28+
import { Paragraph } from "~/components/primitives/Paragraph";
29+
import { Switch } from "~/components/primitives/Switch";
30+
import { prisma } from "~/db.server";
31+
import { useUser } from "~/hooks/useUser";
32+
import { redirectWithSuccessMessage } from "~/models/message.server";
33+
import { updateUser } from "~/models/user.server";
34+
import { requireUserId } from "~/services/session.server";
35+
import { accountPath } from "~/utils/pathBuilder";
36+
import { CopyButton } from "~/components/primitives/CopyButton";
37+
import { DownloadIcon } from "lucide-react";
38+
39+
export const meta: MetaFunction = () => {
40+
return [
41+
{
42+
title: `Security | Trigger.dev`,
43+
},
44+
];
45+
};
46+
47+
function createSchema(
48+
constraints: {
49+
isEmailUnique?: (email: string) => Promise<boolean>;
50+
} = {}
51+
) {
52+
return z.object({
53+
name: z
54+
.string({ required_error: "You must enter a name" })
55+
.min(2, "Your name must be at least 2 characters long")
56+
.max(50),
57+
email: z
58+
.string()
59+
.email()
60+
.superRefine((email, ctx) => {
61+
if (constraints.isEmailUnique === undefined) {
62+
//client-side validation skips this
63+
ctx.addIssue({
64+
code: z.ZodIssueCode.custom,
65+
message: conform.VALIDATION_UNDEFINED,
66+
});
67+
} else {
68+
// Tell zod this is an async validation by returning the promise
69+
return constraints.isEmailUnique(email).then((isUnique) => {
70+
if (isUnique) {
71+
return;
72+
}
73+
74+
ctx.addIssue({
75+
code: z.ZodIssueCode.custom,
76+
message: "Email is already being used by a different account",
77+
});
78+
});
79+
}
80+
}),
81+
marketingEmails: z.preprocess((value) => value === "on", z.boolean()),
82+
});
83+
}
84+
85+
export const action: ActionFunction = async ({ request }) => {
86+
const userId = await requireUserId(request);
87+
88+
const formData = await request.formData();
89+
90+
// TODO: Handle MFA actions here (enable/disable/validate TOTP)
91+
const action = formData.get("action");
92+
93+
if (action === "enable-mfa") {
94+
// TODO: Validate TOTP code and enable MFA for user
95+
return json({ success: true });
96+
}
97+
98+
if (action === "disable-mfa") {
99+
// TODO: Disable MFA for user
100+
return json({ success: true });
101+
}
102+
103+
const formSchema = createSchema({
104+
isEmailUnique: async (email) => {
105+
const existingUser = await prisma.user.findFirst({
106+
where: {
107+
email,
108+
},
109+
});
110+
111+
if (!existingUser) {
112+
return true;
113+
}
114+
115+
if (existingUser.id === userId) {
116+
return true;
117+
}
118+
119+
return false;
120+
},
121+
});
122+
123+
const submission = await parse(formData, { schema: formSchema, async: true });
124+
125+
if (!submission.value || submission.intent !== "submit") {
126+
return json(submission);
127+
}
128+
129+
return json({ success: true });
130+
};
131+
132+
export default function Page() {
133+
const lastSubmission = useActionData();
134+
135+
// MFA state management - TODO: Get actual MFA state from backend
136+
const [isMfaEnabled, setIsMfaEnabled] = useState(false);
137+
const [showQrDialog, setShowQrDialog] = useState(false);
138+
const [showRecoveryDialog, setShowRecoveryDialog] = useState(false);
139+
const [totpCode, setTotpCode] = useState("");
140+
141+
// TODO: Replace with actual data from backend
142+
const qrCodeValue =
143+
"otpauth://totp/Trigger.dev:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Trigger.dev";
144+
const secretKey = "JBSWY3DPEHPK3PXP";
145+
const recoveryCodes = [
146+
"abc123def456",
147+
"ghi789jkl012",
148+
"mno345pqr678",
149+
"stu901vwx234",
150+
"yz567abc890d",
151+
"efg123hij456",
152+
"klm789nop012",
153+
"qrs345tuv678",
154+
];
155+
156+
const [form, {}] = useForm({
157+
id: "security",
158+
// TODO: type this
159+
lastSubmission: lastSubmission as any,
160+
onValidate({ formData }) {
161+
return parse(formData, { schema: createSchema() });
162+
},
163+
});
164+
165+
const handleSwitchChange = (checked: boolean) => {
166+
if (checked && !isMfaEnabled) {
167+
// Show QR code dialog to enable MFA
168+
setShowQrDialog(true);
169+
} else if (!checked && isMfaEnabled) {
170+
// TODO: Handle disabling MFA - might need backend call
171+
setIsMfaEnabled(false);
172+
}
173+
};
174+
175+
const handleQrConfirm = () => {
176+
// TODO: Submit TOTP code to backend for validation
177+
console.log("Validating TOTP code:", totpCode);
178+
179+
// For now, simulate successful validation
180+
setShowQrDialog(false);
181+
setShowRecoveryDialog(true);
182+
setTotpCode("");
183+
};
184+
185+
const handleQrCancel = () => {
186+
setShowQrDialog(false);
187+
setTotpCode("");
188+
// Don't change the switch state when canceling
189+
};
190+
191+
const handleRecoveryComplete = () => {
192+
setShowRecoveryDialog(false);
193+
setIsMfaEnabled(true);
194+
};
195+
196+
const handleEditMfa = () => {
197+
// Show QR dialog again with fresh QR code
198+
setShowQrDialog(true);
199+
};
200+
201+
const downloadRecoveryCodes = () => {
202+
const content = recoveryCodes.join("\n");
203+
const blob = new Blob([content], { type: "text/plain" });
204+
const url = URL.createObjectURL(blob);
205+
const a = document.createElement("a");
206+
a.href = url;
207+
a.download = "trigger-dev-recovery-codes.txt";
208+
document.body.appendChild(a);
209+
a.click();
210+
document.body.removeChild(a);
211+
URL.revokeObjectURL(url);
212+
};
213+
214+
return (
215+
<PageContainer>
216+
<NavBar>
217+
<PageTitle title="Security" />
218+
</NavBar>
219+
220+
<PageBody>
221+
<MainHorizontallyCenteredContainer className="grid place-items-center">
222+
<div className="mb-3 w-full border-b border-grid-dimmed pb-3">
223+
<Header2>Security</Header2>
224+
</div>
225+
<Form method="post" {...form.props} className="w-full">
226+
<InputGroup className="mb-4">
227+
<Label>Multi-factor authentication</Label>
228+
<Paragraph variant="small">
229+
Enable an extra layer of security by requiring a one-time code from your
230+
authenticator app (TOTP) each time you log in.
231+
</Paragraph>
232+
</InputGroup>
233+
<div className="flex items-center justify-between">
234+
<Switch
235+
id="mfa"
236+
variant="medium"
237+
label="Enable"
238+
labelPosition="right"
239+
className="w-fit pr-3"
240+
checked={isMfaEnabled}
241+
onCheckedChange={handleSwitchChange}
242+
/>
243+
{isMfaEnabled && (
244+
<Button type="button" variant="minimal/medium" onClick={handleEditMfa}>
245+
Edit
246+
</Button>
247+
)}
248+
</div>
249+
</Form>
250+
251+
{/* QR Code Dialog */}
252+
<Dialog open={showQrDialog} onOpenChange={setShowQrDialog}>
253+
<DialogContent showCloseButton={false}>
254+
<DialogHeader>
255+
<DialogTitle>Enable authenticator app</DialogTitle>
256+
</DialogHeader>
257+
<div className="flex flex-col gap-4 pt-3">
258+
<Paragraph>
259+
Scan the QR code below with your preferred authenticator app then enter the 6
260+
digit code that the app generates. Alternatively, you can copy the secret below
261+
and paste it into your app.
262+
</Paragraph>
263+
264+
<div className="flex flex-col items-center justify-center gap-y-4 rounded border border-grid-dimmed bg-background-bright py-4">
265+
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
266+
<QRCodeSVG value={qrCodeValue} size={300} marginSize={3} />
267+
</div>
268+
<CopyableText value={secretKey} className="font-mono text-sm" />
269+
</div>
270+
271+
<Input
272+
type="text"
273+
variant="large"
274+
value={totpCode}
275+
onChange={(e) => {
276+
const value = e.target.value.replace(/\D/g, "").slice(0, 6);
277+
setTotpCode(value);
278+
}}
279+
placeholder="000000"
280+
maxLength={6}
281+
className="text-center font-mono tracking-wider"
282+
/>
283+
</div>
284+
285+
<DialogFooter>
286+
<Button variant="secondary/medium" onClick={handleQrCancel}>
287+
Cancel
288+
</Button>
289+
<Button
290+
variant="primary/medium"
291+
onClick={handleQrConfirm}
292+
disabled={totpCode.length !== 6}
293+
>
294+
Confirm
295+
</Button>
296+
</DialogFooter>
297+
</DialogContent>
298+
</Dialog>
299+
300+
{/* Recovery Codes Dialog */}
301+
<Dialog open={showRecoveryDialog} onOpenChange={setShowRecoveryDialog}>
302+
<DialogContent showCloseButton={false}>
303+
<DialogHeader>
304+
<DialogTitle>Recovery codes</DialogTitle>
305+
</DialogHeader>
306+
<div className="flex flex-col gap-2 pb-0 pt-3">
307+
<Paragraph spacing>
308+
Copy and store these recovery codes carefully in case you lose your device.
309+
</Paragraph>
310+
311+
<div className="flex flex-col gap-6 rounded border border-grid-dimmed bg-background-bright pt-6">
312+
<div className="grid grid-cols-3 gap-2">
313+
{recoveryCodes.map((code, index) => (
314+
<div key={index} className="text-center font-mono text-sm text-text-bright">
315+
{code}
316+
</div>
317+
))}
318+
</div>
319+
<div className="flex items-center justify-end border-t border-grid-bright py-1.5 pr-1.5">
320+
<Button
321+
variant="minimal/medium"
322+
onClick={downloadRecoveryCodes}
323+
LeadingIcon={DownloadIcon}
324+
>
325+
Download
326+
</Button>
327+
<CopyButton
328+
value={recoveryCodes.join("\n")}
329+
buttonVariant="minimal"
330+
showTooltip={false}
331+
>
332+
Copy
333+
</CopyButton>
334+
</div>
335+
</div>
336+
</div>
337+
338+
<DialogFooter className="justify-end">
339+
<Button variant="primary/medium" onClick={handleRecoveryComplete}>
340+
Continue
341+
</Button>
342+
</DialogFooter>
343+
</DialogContent>
344+
</Dialog>
345+
</MainHorizontallyCenteredContainer>
346+
</PageBody>
347+
</PageContainer>
348+
);
349+
}

0 commit comments

Comments
 (0)