diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 0249315071b13..9299831985b40 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -13,6 +13,7 @@ import type { UpdateUserAppearanceSettingsRequest, UsersRequest, User, + GenerateAPIKeyResponse, } from "api/typesGenerated"; import { getAuthorizationKey } from "./authCheck"; import { getMetadataAsJSON } from "utils/metadata"; @@ -134,6 +135,13 @@ export const me = (): UseQueryOptions & { }; }; +export function apiKey(): UseQueryOptions { + return { + queryKey: [...meKey, "apiKey"], + queryFn: () => API.getApiKey(), + }; +} + export const hasFirstUser = () => { return { queryKey: ["hasFirstUser"], diff --git a/site/src/components/CodeExample/CodeExample.stories.tsx b/site/src/components/CodeExample/CodeExample.stories.tsx index 1af88cd98dafd..e7fe5203d4177 100644 --- a/site/src/components/CodeExample/CodeExample.stories.tsx +++ b/site/src/components/CodeExample/CodeExample.stories.tsx @@ -5,6 +5,7 @@ const meta: Meta = { title: "components/CodeExample", component: CodeExample, args: { + secret: false, code: `echo "hello, friend!"`, }, }; @@ -12,7 +13,11 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Example: Story = {}; +export const Example: Story = { + args: { + secret: false, + }, +}; export const Secret: Story = { args: { @@ -22,6 +27,7 @@ export const Secret: Story = { export const LongCode: Story = { args: { + secret: false, code: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICnKzATuWwmmt5+CKTPuRGN0R1PBemA+6/SStpLiyX+L", }, }; diff --git a/site/src/components/CodeExample/CodeExample.tsx b/site/src/components/CodeExample/CodeExample.tsx index 9014f04a58ce5..14afaf11c30c7 100644 --- a/site/src/components/CodeExample/CodeExample.tsx +++ b/site/src/components/CodeExample/CodeExample.tsx @@ -1,7 +1,8 @@ -import { type FC } from "react"; +import { type FC, type KeyboardEvent, type MouseEvent, useRef } from "react"; import { type Interpolation, type Theme } from "@emotion/react"; import { MONOSPACE_FONT_FAMILY } from "theme/constants"; import { CopyButton } from "../CopyButton/CopyButton"; +import { visuallyHidden } from "@mui/utils"; export interface CodeExampleProps { code: string; @@ -14,19 +15,72 @@ export interface CodeExampleProps { */ export const CodeExample: FC = ({ code, - secret, className, + + // Defaulting to true to be on the safe side; you should have to opt out of + // the secure option, not remember to opt in + secret = true, }) => { + const buttonRef = useRef(null); + const triggerButton = (event: KeyboardEvent | MouseEvent) => { + if (event.target !== buttonRef.current) { + buttonRef.current?.click(); + } + }; + return ( -
- {code} - + /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- + Expanding clickable area of CodeExample for better ergonomics, but don't + want to change the semantics of the HTML elements being rendered + */ +
{ + if (event.key === "Enter") { + triggerButton(event); + } + }} + onKeyUp={(event) => { + if (event.key === " ") { + triggerButton(event); + } + }} + > + + {secret ? ( + <> + {/* + * Obfuscating text even though we have the characters replaced with + * discs in the CSS for two reasons: + * 1. The CSS property is non-standard and won't work everywhere; + * MDN warns you not to rely on it alone in production + * 2. Even with it turned on and supported, the plaintext is still + * readily available in the HTML itself + */} + {obfuscateText(code)} + + Encrypted text. Please access via the copy button. + + + ) : ( + <>{code} + )} + + +
); }; +function obfuscateText(text: string): string { + return new Array(text.length).fill("*").join(""); +} + const styles = { container: (theme) => ({ + cursor: "pointer", display: "flex", flexDirection: "row", alignItems: "center", @@ -37,6 +91,10 @@ const styles = { padding: 8, lineHeight: "150%", border: `1px solid ${theme.experimental.l1.outline}`, + + "&:hover": { + backgroundColor: theme.experimental.l2.hover.background, + }, }), code: { diff --git a/site/src/components/CopyButton/CopyButton.tsx b/site/src/components/CopyButton/CopyButton.tsx index b28823948facb..64b01974478b0 100644 --- a/site/src/components/CopyButton/CopyButton.tsx +++ b/site/src/components/CopyButton/CopyButton.tsx @@ -2,7 +2,7 @@ import IconButton from "@mui/material/Button"; import Tooltip from "@mui/material/Tooltip"; import Check from "@mui/icons-material/Check"; import { css, type Interpolation, type Theme } from "@emotion/react"; -import { type FC, type ReactNode } from "react"; +import { forwardRef, type ReactNode } from "react"; import { useClipboard } from "hooks/useClipboard"; import { FileCopyIcon } from "../Icons/FileCopyIcon"; @@ -23,36 +23,40 @@ export const Language = { /** * Copy button used inside the CodeBlock component internally */ -export const CopyButton: FC = ({ - text, - ctaCopy, - wrapperStyles, - buttonStyles, - tooltipTitle = Language.tooltipTitle, -}) => { - const { isCopied, copy: copyToClipboard } = useClipboard(text); +export const CopyButton = forwardRef( + (props, ref) => { + const { + text, + ctaCopy, + wrapperStyles, + buttonStyles, + tooltipTitle = Language.tooltipTitle, + } = props; + const { isCopied, copyToClipboard } = useClipboard(text); - return ( - -
- - {isCopied ? ( - - ) : ( - - )} - {ctaCopy &&
{ctaCopy}
} -
-
-
- ); -}; + return ( + +
+ + {isCopied ? ( + + ) : ( + + )} + {ctaCopy &&
{ctaCopy}
} +
+
+
+ ); + }, +); const styles = { button: (theme) => css` diff --git a/site/src/components/CopyableValue/CopyableValue.tsx b/site/src/components/CopyableValue/CopyableValue.tsx index c2d14e322256d..d8296827305b1 100644 --- a/site/src/components/CopyableValue/CopyableValue.tsx +++ b/site/src/components/CopyableValue/CopyableValue.tsx @@ -16,8 +16,8 @@ export const CopyableValue: FC = ({ children, ...attrs }) => { - const { isCopied, copy } = useClipboard(value); - const clickableProps = useClickable(copy); + const { isCopied, copyToClipboard } = useClipboard(value); + const clickableProps = useClickable(copyToClipboard); return ( Promise } => { - const [isCopied, setIsCopied] = useState(false); +type UseClipboardResult = Readonly<{ + isCopied: boolean; + copyToClipboard: () => Promise; +}>; - const copy = async (): Promise => { +export const useClipboard = (textToCopy: string): UseClipboardResult => { + const [isCopied, setIsCopied] = useState(false); + const timeoutIdRef = useRef(); + + useEffect(() => { + const clearIdsOnUnmount = () => window.clearTimeout(timeoutIdRef.current); + return clearIdsOnUnmount; + }, []); + + const copyToClipboard = async () => { try { - await window.navigator.clipboard.writeText(text); + await window.navigator.clipboard.writeText(textToCopy); setIsCopied(true); - window.setTimeout(() => { + timeoutIdRef.current = window.setTimeout(() => { setIsCopied(false); }, 1000); } catch (err) { - const input = document.createElement("input"); - input.value = text; - document.body.appendChild(input); - input.focus(); - input.select(); - const result = document.execCommand("copy"); - document.body.removeChild(input); - if (result) { + const isCopied = simulateClipboardWrite(); + if (isCopied) { setIsCopied(true); - window.setTimeout(() => { + timeoutIdRef.current = window.setTimeout(() => { setIsCopied(false); }, 1000); } else { @@ -37,8 +40,45 @@ export const useClipboard = ( } }; - return { - isCopied, - copy, - }; + return { isCopied, copyToClipboard }; }; + +/** + * It feels silly that you have to make a whole dummy input just to simulate a + * clipboard, but that's really the recommended approach for older browsers. + * + * @see {@link https://web.dev/patterns/clipboard/copy-text?hl=en} + */ +function simulateClipboardWrite(): boolean { + const previousFocusTarget = document.activeElement; + const dummyInput = document.createElement("input"); + + // Using visually-hidden styling to ensure that inserting the element doesn't + // cause any content reflows on the page (removes any risk of UI flickers). + // Can't use visibility:hidden or display:none, because then the elements + // can't receive focus, which is needed for the execCommand method to work + const style = dummyInput.style; + style.display = "inline-block"; + style.position = "absolute"; + style.overflow = "hidden"; + style.clip = "rect(0 0 0 0)"; + style.clipPath = "rect(0 0 0 0)"; + style.height = "1px"; + style.width = "1px"; + style.margin = "-1px"; + style.padding = "0"; + style.border = "0"; + + document.body.appendChild(dummyInput); + dummyInput.focus(); + dummyInput.select(); + + const isCopied = document.execCommand("copy"); + dummyInput.remove(); + + if (previousFocusTarget instanceof HTMLElement) { + previousFocusTarget.focus(); + } + + return isCopied; +} diff --git a/site/src/modules/resources/SSHButton/SSHButton.tsx b/site/src/modules/resources/SSHButton/SSHButton.tsx index 62d1beb648dbe..d4f8f374f8aba 100644 --- a/site/src/modules/resources/SSHButton/SSHButton.tsx +++ b/site/src/modules/resources/SSHButton/SSHButton.tsx @@ -57,7 +57,7 @@ export const SSHButton: FC = ({ Configure SSH hosts on machine: - +
@@ -67,6 +67,7 @@ export const SSHButton: FC = ({
diff --git a/site/src/pages/CliAuthPage/CliAuthPage.tsx b/site/src/pages/CliAuthPage/CliAuthPage.tsx index 902651d5cdc0c..381ff88507cd8 100644 --- a/site/src/pages/CliAuthPage/CliAuthPage.tsx +++ b/site/src/pages/CliAuthPage/CliAuthPage.tsx @@ -1,14 +1,12 @@ import { type FC } from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; -import { getApiKey } from "api/api"; import { pageTitle } from "utils/page"; import { CliAuthPageView } from "./CliAuthPageView"; +import { apiKey } from "api/queries/users"; export const CliAuthenticationPage: FC = () => { - const { data } = useQuery({ - queryFn: () => getApiKey(), - }); + const { data } = useQuery(apiKey()); return ( <> diff --git a/site/src/pages/CliAuthPage/CliAuthPageView.tsx b/site/src/pages/CliAuthPage/CliAuthPageView.tsx index 135a79580e65e..cdcbeabdaa78c 100644 --- a/site/src/pages/CliAuthPage/CliAuthPageView.tsx +++ b/site/src/pages/CliAuthPage/CliAuthPageView.tsx @@ -1,4 +1,3 @@ -import Button from "@mui/material/Button"; import { type Interpolation, type Theme } from "@emotion/react"; import { type FC } from "react"; import { Link as RouterLink } from "react-router-dom"; @@ -6,11 +5,14 @@ import { CodeExample } from "components/CodeExample/CodeExample"; import { SignInLayout } from "components/SignInLayout/SignInLayout"; import { Welcome } from "components/Welcome/Welcome"; import { FullScreenLoader } from "components/Loader/FullScreenLoader"; +import { visuallyHidden } from "@mui/utils"; export interface CliAuthPageViewProps { sessionToken?: string; } +const VISUALLY_HIDDEN_SPACE = " "; + export const CliAuthPageView: FC = ({ sessionToken }) => { if (!sessionToken) { return ; @@ -21,19 +23,22 @@ export const CliAuthPageView: FC = ({ sessionToken }) => { Session token

- Copy the session token below and{" "} - - paste it in your terminal - - . + Copy the session token below and + {/* + * This looks silly, but it's a case where you want to hide the space + * visually because it messes up the centering, but you want the space + * to still be available to screen readers + */} + {VISUALLY_HIDDEN_SPACE} + paste it in your terminal.

-
- +
); @@ -43,14 +48,26 @@ const styles = { instructions: (theme) => ({ fontSize: 16, color: theme.palette.text.secondary, - marginBottom: 32, + paddingBottom: 8, textAlign: "center", - lineHeight: "160%", + lineHeight: 1.4, + + // Have to undo styling side effects from component + marginTop: -24, }), - backButton: { - display: "flex", - justifyContent: "flex-end", - paddingTop: 8, - }, + backLink: (theme) => ({ + display: "block", + textAlign: "center", + color: theme.palette.text.primary, + textDecoration: "underline", + textUnderlineOffset: 3, + textDecorationColor: "hsla(0deg, 0%, 100%, 0.7)", + paddingTop: 16, + paddingBottom: 16, + + "&:hover": { + textDecoration: "none", + }, + }), } satisfies Record>; diff --git a/site/src/pages/CreateTokenPage/CreateTokenPage.tsx b/site/src/pages/CreateTokenPage/CreateTokenPage.tsx index 0c6bad22e9cd6..e48cba07c55fc 100644 --- a/site/src/pages/CreateTokenPage/CreateTokenPage.tsx +++ b/site/src/pages/CreateTokenPage/CreateTokenPage.tsx @@ -71,6 +71,7 @@ export const CreateTokenPage: FC = () => { <>

Make sure you copy the below token before proceeding:

= ({ clipboard.isCopied ? : } variant="contained" - onClick={clipboard.copy} + onClick={clipboard.copyToClipboard} disabled={clipboard.isCopied} > Copy button code diff --git a/site/src/pages/TemplatesPage/EmptyTemplates.tsx b/site/src/pages/TemplatesPage/EmptyTemplates.tsx index b628c30e86fb7..372c3dd326a30 100644 --- a/site/src/pages/TemplatesPage/EmptyTemplates.tsx +++ b/site/src/pages/TemplatesPage/EmptyTemplates.tsx @@ -91,7 +91,7 @@ export const EmptyTemplates: FC = ({ css={styles.withImage} message="Create a Template" description="Contact your Coder administrator to create a template. You can share the code below." - cta={} + cta={} image={
diff --git a/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPageView.tsx b/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPageView.tsx index 97d05761b2c6d..141d3709f35f6 100644 --- a/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPageView.tsx +++ b/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPageView.tsx @@ -61,7 +61,7 @@ export const SSHKeysPageView: FC = ({ .

- +