UNPKG

@activecollab/components

Version:

ActiveCollab Components

383 lines (366 loc) 17.5 kB
import React, { useEffect, useMemo, useState } from "react"; import styled from "styled-components"; import { Button } from "../../components/Button"; import { Dialog } from "../../components/Dialog"; import { IconButton } from "../../components/IconButton"; import { CancelCrossIcon, CopyIcon } from "../../components/Icons"; import { InfoBox } from "../../components/InfoBox"; import { InputPassword } from "../../components/Input"; import { Label } from "../../components/Label"; import { SkeletonLoader } from "../../components/Loaders"; import { ToastMessage } from "../../components/ToastMessage"; import { Tooltip } from "../../components/Tooltip"; import { SlideFromTop } from "../../components/Transitions"; import { Body2, Caption1, Header3 } from "../../components/Typography"; /** * Self-hosted 2FA dialogs shared by the People stories (Users list + User * profile). Two flows, both reached from the user three-dot menu and both * gated (in the menu) behind the self-hosted + 2FA-enabled guard: * * - **Backup Codes** (own account, `self` menu): a single dialog that both * explains the destructive regenerate and takes the password (identity * confirmation is "valid password + submit", no separate confirm step). * Submitting opens the reveal dialog, which shows a skeleton while the new * set "loads", then the 10 codes once. * - **Create Login Code** (another user, `member`/`client` menu): an owner * re-enters their own password, then a one-time code is revealed once with * its expiry and a "user notified" note. (Task #18.) * * Everything is mocked locally: passwords aren't checked, the "load" is a * timer, and the codes are static samples. */ const SANS = '-apple-system, BlinkMacSystemFont, "Roboto", "Helvetica Neue", Arial, sans-serif'; const MONO = '"SF Mono", "Roboto Mono", Menlo, Consolas, "Courier New", monospace'; /** * DS `InputPassword` calls `onChange` with the string value at runtime, but its * type is still inherited from the native input (`ChangeEventHandler`). Adapt a * string handler so TypeScript is satisfied without lying about the value. */ const asInputChange = fn => fn; /* Sample codes (10+10 A-Z0-9), mirroring the real generator's format. */ const SAMPLE_BACKUP_CODES = ["4K7Q9ZM2XN-8V3TJRD6WP", "P2W8XK4M9Q-3RJ7VND5TZ", "9ZM2XN4K7Q-6WP8V3TJRD", "TJRD6WP8V3-2XN4K7Q9ZM", "V3TJRD6WP8-K7Q9ZM2XN4", "8V3TJRD6WP-Q9ZM2XN4K7", "RD5TZP2W8X-M9Q3RJ7VND", "3RJ7VND5TZ-8XK4M9QP2W", "N4K7Q9ZM2X-6WP8V3TJRD", "Q9ZM2XN4K7-8V3TJRD6WP"]; const SAMPLE_LOGIN_CODE = "4K7Q9ZM2XN-8V3TJRD6WP"; /* ------------------------------------------------------------------ */ /* Toast (reuses the fixed upper-right anchor pattern from headers) */ /* ------------------------------------------------------------------ */ const StyledToastAnchor = styled.div.withConfig({ displayName: "TwoFactorDialogs__StyledToastAnchor", componentId: "sc-nn5qbh-0" })(["position:fixed;top:16px;right:16px;z-index:10000;pointer-events:none;.c-toast-message{pointer-events:auto;}"]); const CopyToast = _ref => { let open = _ref.open, toastKey = _ref.toastKey, text = _ref.text, onClose = _ref.onClose; return /*#__PURE__*/React.createElement(StyledToastAnchor, null, /*#__PURE__*/React.createElement(SlideFromTop, { in: open }, /*#__PURE__*/React.createElement(ToastMessage, { key: toastKey, className: "c-toast-message", type: "success", text: text, dismissible: true, dropShadow: true, timeout: 3000, onClose: onClose }))); }; /* ------------------------------------------------------------------ */ /* Shared password-gate body (describe + password in one dialog) */ /* ------------------------------------------------------------------ */ const StyledGateBody = styled.div.withConfig({ displayName: "TwoFactorDialogs__StyledGateBody", componentId: "sc-nn5qbh-1" })(["font-family:", ";display:flex;flex-direction:column;gap:16px;.pg-field{display:flex;flex-direction:column;gap:4px;}.pg-input.c-input-wrapper,.pg-input{width:100%;max-width:none;}"], SANS); /* ------------------------------------------------------------------ */ /* Backup Codes — combined describe + password dialog */ /* ------------------------------------------------------------------ */ /** * The full Backup Codes flow: the combined regenerate/password dialog, then the * one-time reveal dialog with its loading skeleton. Cancelling the first dialog * is a pure no-op — no codes are issued, no toast. */ export const BackupCodesDialogs = _ref2 => { let open = _ref2.open, onClose = _ref2.onClose; const _useState = useState(""), password = _useState[0], setPassword = _useState[1]; const _useState2 = useState(false), attempted = _useState2[0], setAttempted = _useState2[1]; const _useState3 = useState(false), revealOpen = _useState3[0], setRevealOpen = _useState3[1]; const _useState4 = useState(true), loading = _useState4[0], setLoading = _useState4[1]; const _useState5 = useState(false), copyOpen = _useState5[0], setCopyOpen = _useState5[1]; const _useState6 = useState(0), copyKey = _useState6[0], setCopyKey = _useState6[1]; // Reset the gate each time it (re)opens. useEffect(() => { if (open) { setPassword(""); setAttempted(false); } }, [open]); // Simulate the API generating the new set: a ~1s skeleton on reveal. useEffect(() => { if (!revealOpen) { return undefined; } setLoading(true); const timer = window.setTimeout(() => setLoading(false), 1100); return () => window.clearTimeout(timer); }, [revealOpen]); const passwordInvalid = attempted && password.trim() === ""; const handleConfirm = () => { if (password.trim() === "") { setAttempted(true); return; } // Valid password + submit is all the confirmation we need: close the gate // and open the reveal (which regenerates the set). onClose(); setRevealOpen(true); }; const handleCopy = () => { setCopyOpen(true); setCopyKey(k => k + 1); }; const closeReveal = () => { setRevealOpen(false); }; return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(Dialog, { open: open, onClose: onClose, disableCloseOnEsc: true }, /*#__PURE__*/React.createElement(Dialog.Title, null, "Regenerate Backup Codes"), /*#__PURE__*/React.createElement(Dialog.ContentDivider, null), /*#__PURE__*/React.createElement(Dialog.Content, null, /*#__PURE__*/React.createElement(StyledGateBody, null, /*#__PURE__*/React.createElement(Body2, { color: "secondary", lineHeight: "loose" }, "This drops all of your existing backup codes and creates a new set of 10. Any codes you saved before will stop working. The new codes are shown only once. Enter your password to confirm it's you."), /*#__PURE__*/React.createElement("div", { className: "pg-field" }, /*#__PURE__*/React.createElement(Label, { htmlFor: "backup-codes-password" }, "Your Password"), /*#__PURE__*/React.createElement(InputPassword, { id: "backup-codes-password", wrapperClassName: "pg-input", value: password, onChange: asInputChange(value => { setPassword(value); setAttempted(false); }), invalid: passwordInvalid, errorMessage: passwordInvalid ? "Your password is required." : undefined, autoFocus: true, autoComplete: "current-password" })))), /*#__PURE__*/React.createElement(Dialog.ContentDivider, null), /*#__PURE__*/React.createElement(Dialog.Actions, null, /*#__PURE__*/React.createElement(Button, { variant: "primary", style: { marginRight: 12 }, onClick: handleConfirm }, "Regenerate Codes"), /*#__PURE__*/React.createElement(Button, { variant: "secondary", onClick: onClose }, "Cancel"))), /*#__PURE__*/React.createElement(BackupCodesRevealDialog, { open: revealOpen, loading: loading, onClose: closeReveal, onCopy: handleCopy }), /*#__PURE__*/React.createElement(CopyToast, { open: copyOpen, toastKey: copyKey, text: "Backup codes copied to clipboard.", onClose: () => setCopyOpen(false) })); }; /* ------------------------------------------------------------------ */ /* Backup Codes — one-time reveal (skeleton while loading) */ /* ------------------------------------------------------------------ */ const StyledRevealBody = styled.div.withConfig({ displayName: "TwoFactorDialogs__StyledRevealBody", componentId: "sc-nn5qbh-2" })(["font-family:", ";display:flex;flex-direction:column;gap:16px;.bc-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px 16px;}.bc-code{font-family:", ";font-size:14px;letter-spacing:0.5px;color:var(--color-theme-900);padding:6px 10px;border-radius:6px;background-color:var(--color-theme-200);text-align:center;}.bc-skeleton{height:32px;border-radius:6px;width:100%;}"], SANS, MONO); const BackupCodesRevealDialog = _ref3 => { let open = _ref3.open, loading = _ref3.loading, onClose = _ref3.onClose, onCopy = _ref3.onCopy; return /*#__PURE__*/React.createElement(Dialog, { open: open, onClose: onClose, disableCloseOnEsc: true }, /*#__PURE__*/React.createElement(Dialog.Title, null, "Your Backup Codes"), /*#__PURE__*/React.createElement(Dialog.ContentDivider, null), /*#__PURE__*/React.createElement(Dialog.Content, null, /*#__PURE__*/React.createElement(StyledRevealBody, null, /*#__PURE__*/React.createElement(Body2, { color: "secondary", lineHeight: "loose" }, "Use a backup code to log in when email isn't available. Each code works once."), /*#__PURE__*/React.createElement("div", { className: "bc-grid" }, loading ? [...Array(10)].map((_, index) => /*#__PURE__*/React.createElement(SkeletonLoader, { key: index, className: "bc-skeleton" })) : SAMPLE_BACKUP_CODES.map(code => /*#__PURE__*/React.createElement("div", { key: code, className: "bc-code" }, code))), /*#__PURE__*/React.createElement(InfoBox, { type: "note", showIcon: true, title: "Codes are shown only once" }, /*#__PURE__*/React.createElement(Body2, { color: "secondary" }, "Save them somewhere safe, they can't be displayed again. You can generate a new set from your profile at any time.")))), /*#__PURE__*/React.createElement(Dialog.ContentDivider, null), /*#__PURE__*/React.createElement(Dialog.Actions, null, /*#__PURE__*/React.createElement(Button, { variant: "primary", style: { marginRight: 12 }, onClick: onClose, disabled: loading }, "I've Saved My Codes"), /*#__PURE__*/React.createElement(Button, { variant: "secondary", onClick: onCopy, disabled: loading }, "Copy Codes"))); }; /* ------------------------------------------------------------------ */ /* Create Login Code — password gate + one-time reveal */ /* ------------------------------------------------------------------ */ const StyledCodeRow = styled.div.withConfig({ displayName: "TwoFactorDialogs__StyledCodeRow", componentId: "sc-nn5qbh-3" })(["font-family:", ";display:flex;flex-direction:column;gap:16px;.lc-label{display:block;margin-bottom:6px;}.lc-code-field{display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:8px;background-color:var(--color-theme-200);}.lc-code{font-family:", ";font-size:15px;letter-spacing:0.5px;color:var(--color-theme-900);flex:1 1 auto;}.lc-copy svg{fill:var(--color-theme-600);}.lc-caption{display:block;margin-top:6px;}"], SANS, MONO); /* Title row for the reveal dialog: heading on the left, X close on the right (the app's Use Token dialog pattern). Applied via className since Dialog.Title doesn't accept inline style. */ const StyledRevealTitle = styled.div.withConfig({ displayName: "TwoFactorDialogs__StyledRevealTitle", componentId: "sc-nn5qbh-4" })(["display:flex;justify-content:space-between;align-items:center;width:100%;.lc-close svg{fill:var(--color-theme-600);}"]); /** * Owner-minted login code flow (task #18): password gate first, then the * one-time reveal. The reveal has no footer — an X in the title closes it — * mirroring the app's Use Token dialog. */ export const CreateLoginCodeDialogs = _ref4 => { let open = _ref4.open, onClose = _ref4.onClose, userName = _ref4.userName; const firstName = useMemo(() => userName.split(" ")[0] || userName, [userName]); const _useState7 = useState(""), password = _useState7[0], setPassword = _useState7[1]; const _useState8 = useState(false), attempted = _useState8[0], setAttempted = _useState8[1]; const _useState9 = useState(false), revealOpen = _useState9[0], setRevealOpen = _useState9[1]; // Expiry copy: computed as "now + 10 min" at the moment the code is minted. const _useState0 = useState(""), expiry = _useState0[0], setExpiry = _useState0[1]; const _useState1 = useState(false), copyOpen = _useState1[0], setCopyOpen = _useState1[1]; const _useState10 = useState(0), copyKey = _useState10[0], setCopyKey = _useState10[1]; useEffect(() => { if (open) { setPassword(""); setAttempted(false); } }, [open]); const passwordInvalid = attempted && password.trim() === ""; const handleConfirm = () => { if (password.trim() === "") { setAttempted(true); return; } const when = new Date(Date.now() + 10 * 60000); const hh = String(when.getHours()).padStart(2, "0"); const mm = String(when.getMinutes()).padStart(2, "0"); setExpiry(hh + ":" + mm); onClose(); setRevealOpen(true); }; const handleCopy = () => { setCopyOpen(true); setCopyKey(k => k + 1); }; return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(Dialog, { open: open, onClose: onClose, disableCloseOnEsc: true }, /*#__PURE__*/React.createElement(Dialog.Title, null, "Confirm Password"), /*#__PURE__*/React.createElement(Dialog.ContentDivider, null), /*#__PURE__*/React.createElement(Dialog.Content, null, /*#__PURE__*/React.createElement(StyledGateBody, null, /*#__PURE__*/React.createElement(Body2, { color: "secondary", lineHeight: "loose" }, "You're about to create a one-time login code for ", userName, ". Any code they already had pending will be replaced."), /*#__PURE__*/React.createElement("div", { className: "pg-field" }, /*#__PURE__*/React.createElement(Label, { htmlFor: "login-code-password" }, "Your Password"), /*#__PURE__*/React.createElement(InputPassword, { id: "login-code-password", wrapperClassName: "pg-input", value: password, onChange: asInputChange(value => { setPassword(value); setAttempted(false); }), invalid: passwordInvalid, errorMessage: passwordInvalid ? "Your password is required." : undefined, autoFocus: true, autoComplete: "current-password" })))), /*#__PURE__*/React.createElement(Dialog.ContentDivider, null), /*#__PURE__*/React.createElement(Dialog.Actions, null, /*#__PURE__*/React.createElement(Button, { variant: "primary", style: { marginRight: 12 }, onClick: handleConfirm }, "Confirm"), /*#__PURE__*/React.createElement(Button, { variant: "secondary", onClick: onClose }, "Cancel"))), /*#__PURE__*/React.createElement(Dialog, { open: revealOpen, onClose: () => setRevealOpen(false) }, /*#__PURE__*/React.createElement(Dialog.Title, { disableDefaultHeading: true }, /*#__PURE__*/React.createElement(StyledRevealTitle, null, /*#__PURE__*/React.createElement(Header3, null, "One-Time Login Code"), /*#__PURE__*/React.createElement(IconButton, { variant: "text gray", className: "lc-close", onClick: () => setRevealOpen(false) }, /*#__PURE__*/React.createElement(CancelCrossIcon, null)))), /*#__PURE__*/React.createElement(Dialog.ContentDivider, null), /*#__PURE__*/React.createElement(Dialog.Content, null, /*#__PURE__*/React.createElement(StyledCodeRow, null, /*#__PURE__*/React.createElement(Body2, { color: "secondary", lineHeight: "loose" }, "Share this code with ", firstName, " through a secure channel. They'll still need their password to log in, the code alone won't work."), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement(Label, { className: "lc-label" }, "Login Code"), /*#__PURE__*/React.createElement("div", { className: "lc-code-field" }, /*#__PURE__*/React.createElement("span", { className: "lc-code" }, SAMPLE_LOGIN_CODE), /*#__PURE__*/React.createElement(Tooltip, { title: "Copy code" }, /*#__PURE__*/React.createElement(IconButton, { variant: "text gray", className: "lc-copy", onClick: handleCopy }, /*#__PURE__*/React.createElement(CopyIcon, null)))), /*#__PURE__*/React.createElement(Caption1, { color: "tertiary", className: "lc-caption" }, "Expires at ", expiry, " (in 10 minutes), it can't be shown again.")), /*#__PURE__*/React.createElement(InfoBox, { type: "default", showIcon: true, title: firstName + " has been notified" }, /*#__PURE__*/React.createElement(Body2, { color: "secondary" }, "They received an email saying you created this code for their account. The email doesn't include the code."))))), /*#__PURE__*/React.createElement(CopyToast, { open: copyOpen, toastKey: copyKey, text: "Login code copied to clipboard.", onClose: () => setCopyOpen(false) })); }; //# sourceMappingURL=TwoFactorDialogs.js.map