Skip to content

Commit b0a855c

Browse files
authored
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
1 parent c7f51a9 commit b0a855c

File tree

14 files changed

+217
-83
lines changed

14 files changed

+217
-83
lines changed

site/src/api/queries/users.ts

+8
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
UpdateUserAppearanceSettingsRequest,
1414
UsersRequest,
1515
User,
16+
GenerateAPIKeyResponse,
1617
} from "api/typesGenerated";
1718
import { getAuthorizationKey } from "./authCheck";
1819
import { getMetadataAsJSON } from "utils/metadata";
@@ -134,6 +135,13 @@ export const me = (): UseQueryOptions<User> & {
134135
};
135136
};
136137

138+
export function apiKey(): UseQueryOptions<GenerateAPIKeyResponse> {
139+
return {
140+
queryKey: [...meKey, "apiKey"],
141+
queryFn: () => API.getApiKey(),
142+
};
143+
}
144+
137145
export const hasFirstUser = () => {
138146
return {
139147
queryKey: ["hasFirstUser"],

site/src/components/CodeExample/CodeExample.stories.tsx

+7-1
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@ const meta: Meta<typeof CodeExample> = {
55
title: "components/CodeExample",
66
component: CodeExample,
77
args: {
8+
secret: false,
89
code: `echo "hello, friend!"`,
910
},
1011
};
1112

1213
export default meta;
1314
type Story = StoryObj<typeof CodeExample>;
1415

15-
export const Example: Story = {};
16+
export const Example: Story = {
17+
args: {
18+
secret: false,
19+
},
20+
};
1621

1722
export const Secret: Story = {
1823
args: {
@@ -22,6 +27,7 @@ export const Secret: Story = {
2227

2328
export const LongCode: Story = {
2429
args: {
30+
secret: false,
2531
code: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICnKzATuWwmmt5+CKTPuRGN0R1PBemA+6/SStpLiyX+L",
2632
},
2733
};

site/src/components/CodeExample/CodeExample.tsx

+63-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { type FC } from "react";
1+
import { type FC, type KeyboardEvent, type MouseEvent, useRef } from "react";
22
import { type Interpolation, type Theme } from "@emotion/react";
33
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
44
import { CopyButton } from "../CopyButton/CopyButton";
5+
import { visuallyHidden } from "@mui/utils";
56

67
export interface CodeExampleProps {
78
code: string;
@@ -14,19 +15,72 @@ export interface CodeExampleProps {
1415
*/
1516
export const CodeExample: FC<CodeExampleProps> = ({
1617
code,
17-
secret,
1818
className,
19+
20+
// Defaulting to true to be on the safe side; you should have to opt out of
21+
// the secure option, not remember to opt in
22+
secret = true,
1923
}) => {
24+
const buttonRef = useRef<HTMLButtonElement>(null);
25+
const triggerButton = (event: KeyboardEvent | MouseEvent) => {
26+
if (event.target !== buttonRef.current) {
27+
buttonRef.current?.click();
28+
}
29+
};
30+
2031
return (
21-
<div css={styles.container} className={className}>
22-
<code css={[styles.code, secret && styles.secret]}>{code}</code>
23-
<CopyButton text={code} />
32+
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions --
33+
Expanding clickable area of CodeExample for better ergonomics, but don't
34+
want to change the semantics of the HTML elements being rendered
35+
*/
36+
<div
37+
css={styles.container}
38+
className={className}
39+
onClick={triggerButton}
40+
onKeyDown={(event) => {
41+
if (event.key === "Enter") {
42+
triggerButton(event);
43+
}
44+
}}
45+
onKeyUp={(event) => {
46+
if (event.key === " ") {
47+
triggerButton(event);
48+
}
49+
}}
50+
>
51+
<code css={[styles.code, secret && styles.secret]}>
52+
{secret ? (
53+
<>
54+
{/*
55+
* Obfuscating text even though we have the characters replaced with
56+
* discs in the CSS for two reasons:
57+
* 1. The CSS property is non-standard and won't work everywhere;
58+
* MDN warns you not to rely on it alone in production
59+
* 2. Even with it turned on and supported, the plaintext is still
60+
* readily available in the HTML itself
61+
*/}
62+
<span aria-hidden>{obfuscateText(code)}</span>
63+
<span css={{ ...visuallyHidden }}>
64+
Encrypted text. Please access via the copy button.
65+
</span>
66+
</>
67+
) : (
68+
<>{code}</>
69+
)}
70+
</code>
71+
72+
<CopyButton ref={buttonRef} text={code} />
2473
</div>
2574
);
2675
};
2776

77+
function obfuscateText(text: string): string {
78+
return new Array(text.length).fill("*").join("");
79+
}
80+
2881
const styles = {
2982
container: (theme) => ({
83+
cursor: "pointer",
3084
display: "flex",
3185
flexDirection: "row",
3286
alignItems: "center",
@@ -37,6 +91,10 @@ const styles = {
3791
padding: 8,
3892
lineHeight: "150%",
3993
border: `1px solid ${theme.experimental.l1.outline}`,
94+
95+
"&:hover": {
96+
backgroundColor: theme.experimental.l2.hover.background,
97+
},
4098
}),
4199

42100
code: {

site/src/components/CopyButton/CopyButton.tsx

+34-30
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import IconButton from "@mui/material/Button";
22
import Tooltip from "@mui/material/Tooltip";
33
import Check from "@mui/icons-material/Check";
44
import { css, type Interpolation, type Theme } from "@emotion/react";
5-
import { type FC, type ReactNode } from "react";
5+
import { forwardRef, type ReactNode } from "react";
66
import { useClipboard } from "hooks/useClipboard";
77
import { FileCopyIcon } from "../Icons/FileCopyIcon";
88

@@ -23,36 +23,40 @@ export const Language = {
2323
/**
2424
* Copy button used inside the CodeBlock component internally
2525
*/
26-
export const CopyButton: FC<CopyButtonProps> = ({
27-
text,
28-
ctaCopy,
29-
wrapperStyles,
30-
buttonStyles,
31-
tooltipTitle = Language.tooltipTitle,
32-
}) => {
33-
const { isCopied, copy: copyToClipboard } = useClipboard(text);
26+
export const CopyButton = forwardRef<HTMLButtonElement, CopyButtonProps>(
27+
(props, ref) => {
28+
const {
29+
text,
30+
ctaCopy,
31+
wrapperStyles,
32+
buttonStyles,
33+
tooltipTitle = Language.tooltipTitle,
34+
} = props;
35+
const { isCopied, copyToClipboard } = useClipboard(text);
3436

35-
return (
36-
<Tooltip title={tooltipTitle} placement="top">
37-
<div css={[{ display: "flex" }, wrapperStyles]}>
38-
<IconButton
39-
css={[styles.button, buttonStyles]}
40-
onClick={copyToClipboard}
41-
size="small"
42-
aria-label={Language.ariaLabel}
43-
variant="text"
44-
>
45-
{isCopied ? (
46-
<Check css={styles.copyIcon} />
47-
) : (
48-
<FileCopyIcon css={styles.copyIcon} />
49-
)}
50-
{ctaCopy && <div css={{ marginLeft: 8 }}>{ctaCopy}</div>}
51-
</IconButton>
52-
</div>
53-
</Tooltip>
54-
);
55-
};
37+
return (
38+
<Tooltip title={tooltipTitle} placement="top">
39+
<div css={[{ display: "flex" }, wrapperStyles]}>
40+
<IconButton
41+
ref={ref}
42+
css={[styles.button, buttonStyles]}
43+
size="small"
44+
aria-label={Language.ariaLabel}
45+
variant="text"
46+
onClick={copyToClipboard}
47+
>
48+
{isCopied ? (
49+
<Check css={styles.copyIcon} />
50+
) : (
51+
<FileCopyIcon css={styles.copyIcon} />
52+
)}
53+
{ctaCopy && <div css={{ marginLeft: 8 }}>{ctaCopy}</div>}
54+
</IconButton>
55+
</div>
56+
</Tooltip>
57+
);
58+
},
59+
);
5660

5761
const styles = {
5862
button: (theme) => css`

site/src/components/CopyableValue/CopyableValue.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ export const CopyableValue: FC<CopyableValueProps> = ({
1616
children,
1717
...attrs
1818
}) => {
19-
const { isCopied, copy } = useClipboard(value);
20-
const clickableProps = useClickable<HTMLSpanElement>(copy);
19+
const { isCopied, copyToClipboard } = useClipboard(value);
20+
const clickableProps = useClickable<HTMLSpanElement>(copyToClipboard);
2121

2222
return (
2323
<Tooltip

site/src/hooks/useClipboard.ts

+61-21
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,31 @@
1-
import { useState } from "react";
1+
import { useEffect, useRef, useState } from "react";
22

3-
export const useClipboard = (
4-
text: string,
5-
): { isCopied: boolean; copy: () => Promise<void> } => {
6-
const [isCopied, setIsCopied] = useState<boolean>(false);
3+
type UseClipboardResult = Readonly<{
4+
isCopied: boolean;
5+
copyToClipboard: () => Promise<void>;
6+
}>;
77

8-
const copy = async (): Promise<void> => {
8+
export const useClipboard = (textToCopy: string): UseClipboardResult => {
9+
const [isCopied, setIsCopied] = useState(false);
10+
const timeoutIdRef = useRef<number | undefined>();
11+
12+
useEffect(() => {
13+
const clearIdsOnUnmount = () => window.clearTimeout(timeoutIdRef.current);
14+
return clearIdsOnUnmount;
15+
}, []);
16+
17+
const copyToClipboard = async () => {
918
try {
10-
await window.navigator.clipboard.writeText(text);
19+
await window.navigator.clipboard.writeText(textToCopy);
1120
setIsCopied(true);
12-
window.setTimeout(() => {
21+
timeoutIdRef.current = window.setTimeout(() => {
1322
setIsCopied(false);
1423
}, 1000);
1524
} catch (err) {
16-
const input = document.createElement("input");
17-
input.value = text;
18-
document.body.appendChild(input);
19-
input.focus();
20-
input.select();
21-
const result = document.execCommand("copy");
22-
document.body.removeChild(input);
23-
if (result) {
25+
const isCopied = simulateClipboardWrite();
26+
if (isCopied) {
2427
setIsCopied(true);
25-
window.setTimeout(() => {
28+
timeoutIdRef.current = window.setTimeout(() => {
2629
setIsCopied(false);
2730
}, 1000);
2831
} else {
@@ -37,8 +40,45 @@ export const useClipboard = (
3740
}
3841
};
3942

40-
return {
41-
isCopied,
42-
copy,
43-
};
43+
return { isCopied, copyToClipboard };
4444
};
45+
46+
/**
47+
* It feels silly that you have to make a whole dummy input just to simulate a
48+
* clipboard, but that's really the recommended approach for older browsers.
49+
*
50+
* @see {@link https://web.dev/patterns/clipboard/copy-text?hl=en}
51+
*/
52+
function simulateClipboardWrite(): boolean {
53+
const previousFocusTarget = document.activeElement;
54+
const dummyInput = document.createElement("input");
55+
56+
// Using visually-hidden styling to ensure that inserting the element doesn't
57+
// cause any content reflows on the page (removes any risk of UI flickers).
58+
// Can't use visibility:hidden or display:none, because then the elements
59+
// can't receive focus, which is needed for the execCommand method to work
60+
const style = dummyInput.style;
61+
style.display = "inline-block";
62+
style.position = "absolute";
63+
style.overflow = "hidden";
64+
style.clip = "rect(0 0 0 0)";
65+
style.clipPath = "rect(0 0 0 0)";
66+
style.height = "1px";
67+
style.width = "1px";
68+
style.margin = "-1px";
69+
style.padding = "0";
70+
style.border = "0";
71+
72+
document.body.appendChild(dummyInput);
73+
dummyInput.focus();
74+
dummyInput.select();
75+
76+
const isCopied = document.execCommand("copy");
77+
dummyInput.remove();
78+
79+
if (previousFocusTarget instanceof HTMLElement) {
80+
previousFocusTarget.focus();
81+
}
82+
83+
return isCopied;
84+
}

site/src/modules/resources/SSHButton/SSHButton.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export const SSHButton: FC<SSHButtonProps> = ({
5757
Configure SSH hosts on machine:
5858
</strong>
5959
</HelpTooltipText>
60-
<CodeExample code="coder config-ssh" />
60+
<CodeExample secret={false} code="coder config-ssh" />
6161
</div>
6262

6363
<div>
@@ -67,6 +67,7 @@ export const SSHButton: FC<SSHButtonProps> = ({
6767
</strong>
6868
</HelpTooltipText>
6969
<CodeExample
70+
secret={false}
7071
code={`ssh ${sshPrefix}${workspaceName}.${agentName}`}
7172
/>
7273
</div>

site/src/pages/CliAuthPage/CliAuthPage.tsx

+2-4
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
import { type FC } from "react";
22
import { Helmet } from "react-helmet-async";
33
import { useQuery } from "react-query";
4-
import { getApiKey } from "api/api";
54
import { pageTitle } from "utils/page";
65
import { CliAuthPageView } from "./CliAuthPageView";
6+
import { apiKey } from "api/queries/users";
77

88
export const CliAuthenticationPage: FC = () => {
9-
const { data } = useQuery({
10-
queryFn: () => getApiKey(),
11-
});
9+
const { data } = useQuery(apiKey());
1210

1311
return (
1412
<>

0 commit comments

Comments
 (0)