@activecollab/components
Version:
ActiveCollab Components
383 lines (366 loc) • 17.5 kB
JavaScript
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