Skip to content

Commit 58ec5e9

Browse files
committed
Refactor and cleanup mfa management code
1 parent bce4833 commit 58ec5e9

7 files changed

Lines changed: 750 additions & 420 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { Form } from "@remix-run/react";
2+
import { useState } from "react";
3+
import { Button } from "~/components/primitives/Buttons";
4+
import {
5+
Dialog,
6+
DialogContent,
7+
DialogFooter,
8+
DialogHeader,
9+
DialogTitle,
10+
} from "~/components/primitives/Dialog";
11+
import { Fieldset } from "~/components/primitives/Fieldset";
12+
import { FormError } from "~/components/primitives/FormError";
13+
import { Input } from "~/components/primitives/Input";
14+
import { InputGroup } from "~/components/primitives/InputGroup";
15+
import { InputOTP, InputOTPGroup, InputOTPSlot } from "~/components/primitives/InputOTP";
16+
import { Paragraph } from "~/components/primitives/Paragraph";
17+
import { Spinner } from "~/components/primitives/Spinner";
18+
19+
interface MfaDisableDialogProps {
20+
isOpen: boolean;
21+
isSubmitting: boolean;
22+
error?: string;
23+
onDisable: (totpCode?: string, recoveryCode?: string) => void;
24+
onCancel: () => void;
25+
}
26+
27+
export function MfaDisableDialog({
28+
isOpen,
29+
isSubmitting,
30+
error,
31+
onDisable,
32+
onCancel,
33+
}: MfaDisableDialogProps) {
34+
const [totpCode, setTotpCode] = useState("");
35+
const [recoveryCode, setRecoveryCode] = useState("");
36+
const [useRecoveryCode, setUseRecoveryCode] = useState(false);
37+
38+
const handleSubmit = (e: React.FormEvent) => {
39+
e.preventDefault();
40+
onDisable(useRecoveryCode ? undefined : totpCode, useRecoveryCode ? recoveryCode : undefined);
41+
};
42+
43+
const handleCancel = () => {
44+
setTotpCode("");
45+
setRecoveryCode("");
46+
setUseRecoveryCode(false);
47+
onCancel();
48+
};
49+
50+
const handleSwitchToRecoveryCode = () => {
51+
setUseRecoveryCode(true);
52+
setTotpCode("");
53+
};
54+
55+
const handleSwitchToTotpCode = () => {
56+
setUseRecoveryCode(false);
57+
setRecoveryCode("");
58+
};
59+
60+
return (
61+
<Dialog open={isOpen} onOpenChange={handleCancel}>
62+
<DialogContent>
63+
<DialogHeader>
64+
<DialogTitle>Disable multi-factor authentication</DialogTitle>
65+
</DialogHeader>
66+
<Form method="post" onSubmit={handleSubmit}>
67+
{useRecoveryCode ? (
68+
<>
69+
<Paragraph className="mb-6 text-center">
70+
Enter one of your recovery codes.
71+
</Paragraph>
72+
<Fieldset className="flex w-full flex-col items-center gap-y-2">
73+
<InputGroup>
74+
<Input
75+
type="password"
76+
name="recoveryCode"
77+
spellCheck={false}
78+
placeholder="Enter recovery code"
79+
variant="large"
80+
required
81+
autoFocus
82+
value={recoveryCode}
83+
onChange={(e) => setRecoveryCode(e.target.value)}
84+
/>
85+
</InputGroup>
86+
</Fieldset>
87+
88+
<Button
89+
type="button"
90+
onClick={handleSwitchToTotpCode}
91+
variant="minimal/small"
92+
className="mt-4"
93+
>
94+
Use an authenticator app
95+
</Button>
96+
</>
97+
) : (
98+
<>
99+
<Paragraph variant="base" className="mb-6 text-center">
100+
Enter the code from your authenticator app.
101+
</Paragraph>
102+
<Fieldset className="flex w-full flex-col items-center gap-y-2">
103+
<InputOTP
104+
maxLength={6}
105+
value={totpCode}
106+
onChange={(value) => setTotpCode(value)}
107+
variant="large"
108+
fullWidth
109+
>
110+
<InputOTPGroup variant="large" fullWidth>
111+
<InputOTPSlot index={0} autoFocus variant="large" fullWidth />
112+
<InputOTPSlot index={1} variant="large" fullWidth />
113+
<InputOTPSlot index={2} variant="large" fullWidth />
114+
<InputOTPSlot index={3} variant="large" fullWidth />
115+
<InputOTPSlot index={4} variant="large" fullWidth />
116+
<InputOTPSlot index={5} variant="large" fullWidth />
117+
</InputOTPGroup>
118+
</InputOTP>
119+
</Fieldset>
120+
<Button
121+
type="button"
122+
onClick={handleSwitchToRecoveryCode}
123+
variant="minimal/small"
124+
className="mt-4"
125+
>
126+
Use a recovery code
127+
</Button>
128+
</>
129+
)}
130+
131+
{error && <FormError>{error}</FormError>}
132+
133+
<DialogFooter>
134+
<Button type="button" variant="secondary/medium" onClick={handleCancel}>
135+
Cancel
136+
</Button>
137+
<Button type="submit" variant="primary/medium" disabled={isSubmitting}>
138+
{isSubmitting ? <Spinner className="mr-2 size-5" color="white" /> : null}
139+
{isSubmitting ? (
140+
<span className="text-text-bright">Disabling…</span>
141+
) : (
142+
<span className="text-text-bright">Disable MFA</span>
143+
)}
144+
</Button>
145+
</DialogFooter>
146+
</Form>
147+
</DialogContent>
148+
</Dialog>
149+
);
150+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { Form } from "@remix-run/react";
2+
import { DownloadIcon } from "lucide-react";
3+
import { Button } from "~/components/primitives/Buttons";
4+
import { CopyButton } from "~/components/primitives/CopyButton";
5+
import {
6+
Dialog,
7+
DialogContent,
8+
DialogFooter,
9+
DialogHeader,
10+
DialogTitle,
11+
} from "~/components/primitives/Dialog";
12+
import { Paragraph } from "~/components/primitives/Paragraph";
13+
14+
interface MfaRecoveryDialogProps {
15+
isOpen: boolean;
16+
recoveryCodes?: string[];
17+
onSave: () => void;
18+
}
19+
20+
export function MfaRecoveryDialog({
21+
isOpen,
22+
recoveryCodes,
23+
onSave,
24+
}: MfaRecoveryDialogProps) {
25+
const handleSubmit = (e: React.FormEvent) => {
26+
e.preventDefault();
27+
onSave();
28+
};
29+
30+
const downloadRecoveryCodes = () => {
31+
if (!recoveryCodes) return;
32+
33+
const content = recoveryCodes.join("\n");
34+
const blob = new Blob([content], { type: "text/plain" });
35+
const url = URL.createObjectURL(blob);
36+
const a = document.createElement("a");
37+
a.href = url;
38+
a.download = "trigger-dev-recovery-codes.txt";
39+
document.body.appendChild(a);
40+
a.click();
41+
document.body.removeChild(a);
42+
URL.revokeObjectURL(url);
43+
};
44+
45+
if (!recoveryCodes) return null;
46+
47+
return (
48+
<Dialog open={isOpen}>
49+
<DialogContent showCloseButton={false}>
50+
<DialogHeader>
51+
<DialogTitle>Recovery codes</DialogTitle>
52+
</DialogHeader>
53+
<Form method="post" onSubmit={handleSubmit}>
54+
<div className="flex flex-col gap-2 pb-0 pt-3">
55+
<Paragraph spacing>
56+
Copy and store these recovery codes carefully in case you lose your device.
57+
</Paragraph>
58+
59+
<div className="flex flex-col gap-6 rounded border border-grid-dimmed bg-background-bright pt-6">
60+
<div className="grid grid-cols-3 gap-2">
61+
{recoveryCodes.map((code, index) => (
62+
<div key={index} className="text-center font-mono text-sm text-text-bright">
63+
{code}
64+
</div>
65+
))}
66+
</div>
67+
<div className="flex items-center justify-end border-t border-grid-bright px-1.5 py-1.5">
68+
<Button
69+
type="button"
70+
variant="minimal/medium"
71+
onClick={downloadRecoveryCodes}
72+
LeadingIcon={DownloadIcon}
73+
>
74+
Download
75+
</Button>
76+
<CopyButton
77+
value={recoveryCodes.join("\n")}
78+
buttonVariant="minimal"
79+
showTooltip={false}
80+
>
81+
Copy
82+
</CopyButton>
83+
</div>
84+
</div>
85+
</div>
86+
87+
<DialogFooter className="justify-end border-t-0">
88+
<Button
89+
type="submit"
90+
variant="primary/medium"
91+
shortcut={{ key: "Enter" }}
92+
hideShortcutKey
93+
autoFocus
94+
>
95+
Continue
96+
</Button>
97+
</DialogFooter>
98+
</Form>
99+
</DialogContent>
100+
</Dialog>
101+
);
102+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { Form } from "@remix-run/react";
2+
import { QRCodeSVG } from "qrcode.react";
3+
import { useState } from "react";
4+
import { Button } from "~/components/primitives/Buttons";
5+
import { CopyableText } from "~/components/primitives/CopyableText";
6+
import {
7+
Dialog,
8+
DialogContent,
9+
DialogFooter,
10+
DialogHeader,
11+
DialogTitle,
12+
} from "~/components/primitives/Dialog";
13+
import { FormError } from "~/components/primitives/FormError";
14+
import { InputOTP, InputOTPGroup, InputOTPSlot } from "~/components/primitives/InputOTP";
15+
import { Paragraph } from "~/components/primitives/Paragraph";
16+
17+
interface MfaSetupDialogProps {
18+
isOpen: boolean;
19+
setupData?: {
20+
secret: string;
21+
otpAuthUrl: string;
22+
};
23+
error?: string;
24+
isSubmitting: boolean;
25+
onValidate: (code: string) => void;
26+
onCancel: () => void;
27+
}
28+
29+
export function MfaSetupDialog({
30+
isOpen,
31+
setupData,
32+
error,
33+
isSubmitting,
34+
onValidate,
35+
onCancel,
36+
}: MfaSetupDialogProps) {
37+
const [totpCode, setTotpCode] = useState("");
38+
39+
const handleSubmit = (e: React.FormEvent) => {
40+
e.preventDefault();
41+
onValidate(totpCode);
42+
setTotpCode("");
43+
};
44+
45+
const handleCancel = () => {
46+
setTotpCode("");
47+
onCancel();
48+
};
49+
50+
if (!setupData) return null;
51+
52+
return (
53+
<Dialog open={isOpen}>
54+
<DialogContent showCloseButton={false}>
55+
<DialogHeader>
56+
<DialogTitle>Enable authenticator app</DialogTitle>
57+
</DialogHeader>
58+
<Form method="post" onSubmit={handleSubmit}>
59+
<div className="flex flex-col gap-4 pt-3">
60+
<Paragraph>
61+
Scan the QR code below with your preferred authenticator app then enter the 6 digit
62+
code that the app generates. Alternatively, you can copy the secret below and paste
63+
it into your app.
64+
</Paragraph>
65+
66+
<div className="flex flex-col items-center justify-center gap-y-4 rounded border border-grid-dimmed bg-background-bright py-4">
67+
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
68+
<QRCodeSVG value={setupData.otpAuthUrl} size={300} marginSize={3} />
69+
</div>
70+
<CopyableText value={setupData.secret} className="font-mono text-base tracking-wide" />
71+
</div>
72+
73+
<div className="mb-4 flex items-center justify-center">
74+
<InputOTP
75+
maxLength={6}
76+
value={totpCode}
77+
onChange={(value) => setTotpCode(value)}
78+
variant="large"
79+
name="totpCode"
80+
onKeyDown={(e) => {
81+
if (e.key === "Enter" && totpCode.length === 6) {
82+
handleSubmit(e);
83+
}
84+
}}
85+
>
86+
<InputOTPGroup variant="large">
87+
<InputOTPSlot index={0} variant="large" autoFocus />
88+
<InputOTPSlot index={1} variant="large" />
89+
<InputOTPSlot index={2} variant="large" />
90+
<InputOTPSlot index={3} variant="large" />
91+
<InputOTPSlot index={4} variant="large" />
92+
<InputOTPSlot index={5} variant="large" />
93+
</InputOTPGroup>
94+
</InputOTP>
95+
</div>
96+
</div>
97+
98+
{error && <FormError>{error}</FormError>}
99+
100+
<DialogFooter>
101+
<Button type="button" variant="secondary/medium" onClick={handleCancel}>
102+
Cancel
103+
</Button>
104+
<Button
105+
type="submit"
106+
variant="primary/medium"
107+
disabled={totpCode.length !== 6 || isSubmitting}
108+
shortcut={{ key: "Enter" }}
109+
hideShortcutKey
110+
>
111+
Confirm
112+
</Button>
113+
</DialogFooter>
114+
</Form>
115+
</DialogContent>
116+
</Dialog>
117+
);
118+
}

0 commit comments

Comments
 (0)