fix: improve click UX and styling for Auth Token page (#11863)

* wip: commit progress for clipboard update

* wip: push more progress

* chore: finish initial version of useClipboard revamp

* refactor: update API query to use newer RQ patterns

* fix: update importers of useClipboard

* fix: increase clickable area of CodeExample

* fix: update styles for CliAuthPageView

* fix: resolve issue with ref re-routing

* docs: update comments for clarity

* wip: commit progress on clipboard tests

* chore: add extra test case for referential stability

* wip: disable test stub to avoid breaking CI

* wip: add test case for tab-switching

* feat: finish changes

* fix: improve styling for strong text

* fix: make sure period doesn't break onto separate line

* fix: make center styling more friendly to screen readers

* refactor: clean up mocking implementation

* fix: resolve security concern for clipboard text

* fix: update CodeExample to obscure text when appropriate

* fix: apply secret changes to relevant code examples

* refactor: simplify code for obfuscating text

* fix: partially revert clipboard changes

* fix: clean up page styling further

* fix: remove duplicate property identifier

* refactor: rename variables for clarity

* fix: simplify/revert CopyButton component design

* fix: update how dummy input is hidden from page

* fix: remove unused onClick handler prop

* fix: resolve unused import

* fix: opt code examples out of secret behavior
This commit is contained in:
Michael Smith 2024-01-31 21:25:30 -05:00 committed by GitHub
parent c7f51a9d70
commit b0a855caa4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 217 additions and 83 deletions

View File

@ -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<User> & {
};
};
export function apiKey(): UseQueryOptions<GenerateAPIKeyResponse> {
return {
queryKey: [...meKey, "apiKey"],
queryFn: () => API.getApiKey(),
};
}
export const hasFirstUser = () => {
return {
queryKey: ["hasFirstUser"],

View File

@ -5,6 +5,7 @@ const meta: Meta<typeof CodeExample> = {
title: "components/CodeExample",
component: CodeExample,
args: {
secret: false,
code: `echo "hello, friend!"`,
},
};
@ -12,7 +13,11 @@ const meta: Meta<typeof CodeExample> = {
export default meta;
type Story = StoryObj<typeof CodeExample>;
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",
},
};

View File

@ -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<CodeExampleProps> = ({
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<HTMLButtonElement>(null);
const triggerButton = (event: KeyboardEvent | MouseEvent) => {
if (event.target !== buttonRef.current) {
buttonRef.current?.click();
}
};
return (
<div css={styles.container} className={className}>
<code css={[styles.code, secret && styles.secret]}>{code}</code>
<CopyButton text={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
*/
<div
css={styles.container}
className={className}
onClick={triggerButton}
onKeyDown={(event) => {
if (event.key === "Enter") {
triggerButton(event);
}
}}
onKeyUp={(event) => {
if (event.key === " ") {
triggerButton(event);
}
}}
>
<code css={[styles.code, secret && styles.secret]}>
{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
*/}
<span aria-hidden>{obfuscateText(code)}</span>
<span css={{ ...visuallyHidden }}>
Encrypted text. Please access via the copy button.
</span>
</>
) : (
<>{code}</>
)}
</code>
<CopyButton ref={buttonRef} text={code} />
</div>
);
};
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: {

View File

@ -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<CopyButtonProps> = ({
text,
ctaCopy,
wrapperStyles,
buttonStyles,
tooltipTitle = Language.tooltipTitle,
}) => {
const { isCopied, copy: copyToClipboard } = useClipboard(text);
export const CopyButton = forwardRef<HTMLButtonElement, CopyButtonProps>(
(props, ref) => {
const {
text,
ctaCopy,
wrapperStyles,
buttonStyles,
tooltipTitle = Language.tooltipTitle,
} = props;
const { isCopied, copyToClipboard } = useClipboard(text);
return (
<Tooltip title={tooltipTitle} placement="top">
<div css={[{ display: "flex" }, wrapperStyles]}>
<IconButton
css={[styles.button, buttonStyles]}
onClick={copyToClipboard}
size="small"
aria-label={Language.ariaLabel}
variant="text"
>
{isCopied ? (
<Check css={styles.copyIcon} />
) : (
<FileCopyIcon css={styles.copyIcon} />
)}
{ctaCopy && <div css={{ marginLeft: 8 }}>{ctaCopy}</div>}
</IconButton>
</div>
</Tooltip>
);
};
return (
<Tooltip title={tooltipTitle} placement="top">
<div css={[{ display: "flex" }, wrapperStyles]}>
<IconButton
ref={ref}
css={[styles.button, buttonStyles]}
size="small"
aria-label={Language.ariaLabel}
variant="text"
onClick={copyToClipboard}
>
{isCopied ? (
<Check css={styles.copyIcon} />
) : (
<FileCopyIcon css={styles.copyIcon} />
)}
{ctaCopy && <div css={{ marginLeft: 8 }}>{ctaCopy}</div>}
</IconButton>
</div>
</Tooltip>
);
},
);
const styles = {
button: (theme) => css`

View File

@ -16,8 +16,8 @@ export const CopyableValue: FC<CopyableValueProps> = ({
children,
...attrs
}) => {
const { isCopied, copy } = useClipboard(value);
const clickableProps = useClickable<HTMLSpanElement>(copy);
const { isCopied, copyToClipboard } = useClipboard(value);
const clickableProps = useClickable<HTMLSpanElement>(copyToClipboard);
return (
<Tooltip

View File

@ -1,28 +1,31 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
export const useClipboard = (
text: string,
): { isCopied: boolean; copy: () => Promise<void> } => {
const [isCopied, setIsCopied] = useState<boolean>(false);
type UseClipboardResult = Readonly<{
isCopied: boolean;
copyToClipboard: () => Promise<void>;
}>;
const copy = async (): Promise<void> => {
export const useClipboard = (textToCopy: string): UseClipboardResult => {
const [isCopied, setIsCopied] = useState(false);
const timeoutIdRef = useRef<number | undefined>();
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;
}

View File

@ -57,7 +57,7 @@ export const SSHButton: FC<SSHButtonProps> = ({
Configure SSH hosts on machine:
</strong>
</HelpTooltipText>
<CodeExample code="coder config-ssh" />
<CodeExample secret={false} code="coder config-ssh" />
</div>
<div>
@ -67,6 +67,7 @@ export const SSHButton: FC<SSHButtonProps> = ({
</strong>
</HelpTooltipText>
<CodeExample
secret={false}
code={`ssh ${sshPrefix}${workspaceName}.${agentName}`}
/>
</div>

View File

@ -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 (
<>

View File

@ -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<CliAuthPageViewProps> = ({ sessionToken }) => {
if (!sessionToken) {
return <FullScreenLoader />;
@ -21,19 +23,22 @@ export const CliAuthPageView: FC<CliAuthPageViewProps> = ({ sessionToken }) => {
<Welcome>Session token</Welcome>
<p css={styles.instructions}>
Copy the session token below and{" "}
<strong css={{ whiteSpace: "nowrap" }}>
paste it in your terminal
</strong>
.
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
*/}
<span css={{ ...visuallyHidden }}>{VISUALLY_HIDDEN_SPACE}</span>
<strong css={{ display: "block" }}>paste it in your terminal.</strong>
</p>
<CodeExample code={sessionToken} secret />
<div css={styles.backButton}>
<Button component={RouterLink} size="large" to="/workspaces" fullWidth>
<div css={{ paddingTop: 16 }}>
<RouterLink to="/workspaces" css={styles.backLink}>
Go to workspaces
</Button>
</RouterLink>
</div>
</SignInLayout>
);
@ -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 <Welcome> 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<string, Interpolation<Theme>>;

View File

@ -71,6 +71,7 @@ export const CreateTokenPage: FC = () => {
<>
<p>Make sure you copy the below token before proceeding:</p>
<CodeExample
secret={false}
code={newToken?.key ?? ""}
css={{
minHeight: "auto",

View File

@ -176,7 +176,7 @@ export const TemplateEmbedPageView: FC<TemplateEmbedPageViewProps> = ({
clipboard.isCopied ? <CheckOutlined /> : <FileCopyOutlined />
}
variant="contained"
onClick={clipboard.copy}
onClick={clipboard.copyToClipboard}
disabled={clipboard.isCopied}
>
Copy button code

View File

@ -91,7 +91,7 @@ export const EmptyTemplates: FC<EmptyTemplatesProps> = ({
css={styles.withImage}
message="Create a Template"
description="Contact your Coder administrator to create a template. You can share the code below."
cta={<CodeExample code="coder templates init" />}
cta={<CodeExample secret={false} code="coder templates init" />}
image={
<div css={styles.emptyImage}>
<img src="/featured/templates.webp" alt="" />

View File

@ -61,7 +61,7 @@ export const SSHKeysPageView: FC<SSHKeysPageViewProps> = ({
</code>
.
</p>
<CodeExample code={sshKey.public_key.trim()} />
<CodeExample secret={false} code={sshKey.public_key.trim()} />
<div>
<Button onClick={onRegenerateClick} data-testid="regenerate">
Regenerate&hellip;

View File

@ -34,6 +34,7 @@ export const ResetPasswordDialog: FC<ResetPasswordDialogProps> = ({
<>
<p>{Language.message(user?.username)}</p>
<CodeExample
secret={false}
code={newPassword ?? ""}
css={{
minHeight: "auto",