Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 32 commits into from
Feb 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
7e4d964
wip: commit progress for clipboard update
Parkreiner Jan 26, 2024
ece3f6d
wip: push more progress
Parkreiner Jan 26, 2024
bf6e73a
chore: finish initial version of useClipboard revamp
Parkreiner Jan 26, 2024
697bdf0
refactor: update API query to use newer RQ patterns
Parkreiner Jan 26, 2024
9b881c0
fix: update importers of useClipboard
Parkreiner Jan 26, 2024
ccdca08
fix: increase clickable area of CodeExample
Parkreiner Jan 26, 2024
a7c7cc7
fix: update styles for CliAuthPageView
Parkreiner Jan 26, 2024
163db92
fix: resolve issue with ref re-routing
Parkreiner Jan 26, 2024
38b80a8
docs: update comments for clarity
Parkreiner Jan 27, 2024
92f94d7
wip: commit progress on clipboard tests
Parkreiner Jan 27, 2024
e0e5e6f
chore: add extra test case for referential stability
Parkreiner Jan 27, 2024
0ca1558
wip: disable test stub to avoid breaking CI
Parkreiner Jan 27, 2024
88b96df
wip: add test case for tab-switching
Parkreiner Jan 27, 2024
15feb14
feat: finish changes
Parkreiner Jan 27, 2024
e3feffc
fix: improve styling for strong text
Parkreiner Jan 27, 2024
3ec5196
fix: make sure period doesn't break onto separate line
Parkreiner Jan 27, 2024
cf2d179
fix: make center styling more friendly to screen readers
Parkreiner Jan 27, 2024
98bd1af
refactor: clean up mocking implementation
Parkreiner Jan 28, 2024
400e07c
fix: resolve security concern for clipboard text
Parkreiner Jan 28, 2024
3307432
fix: update CodeExample to obscure text when appropriate
Parkreiner Jan 28, 2024
f10134f
fix: apply secret changes to relevant code examples
Parkreiner Jan 28, 2024
c4469f3
refactor: simplify code for obfuscating text
Parkreiner Jan 28, 2024
d8b6727
fix: partially revert clipboard changes
Parkreiner Jan 29, 2024
6157049
fix: clean up page styling further
Parkreiner Jan 29, 2024
89c74da
fix: remove duplicate property identifier
Parkreiner Jan 29, 2024
b95cfd6
refactor: rename variables for clarity
Parkreiner Feb 1, 2024
4ab6326
fix: simplify/revert CopyButton component design
Parkreiner Feb 1, 2024
778560f
fix: update how dummy input is hidden from page
Parkreiner Feb 1, 2024
cf53114
fix: remove unused onClick handler prop
Parkreiner Feb 1, 2024
97fed6f
fix: resolve unused import
Parkreiner Feb 1, 2024
83febe2
Merge branch 'main' into mes/clipboard-fix
Parkreiner Feb 1, 2024
fb0f4b7
fix: opt code examples out of secret behavior
Parkreiner Feb 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions site/src/api/queries/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
UpdateUserAppearanceSettingsRequest,
UsersRequest,
User,
GenerateAPIKeyResponse,
} from "api/typesGenerated";
import { getAuthorizationKey } from "./authCheck";
import { getMetadataAsJSON } from "utils/metadata";
Expand Down Expand Up @@ -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"],
Expand Down
8 changes: 7 additions & 1 deletion site/src/components/CodeExample/CodeExample.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@ const meta: Meta<typeof CodeExample> = {
title: "components/CodeExample",
component: CodeExample,
args: {
secret: false,
code: `echo "hello, friend!"`,
},
};

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

export const Example: Story = {};
export const Example: Story = {
args: {
secret: false,
},
};

export const Secret: Story = {
args: {
Expand All @@ -22,6 +27,7 @@ export const Secret: Story = {

export const LongCode: Story = {
args: {
secret: false,
code: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICnKzATuWwmmt5+CKTPuRGN0R1PBemA+6/SStpLiyX+L",
},
};
68 changes: 63 additions & 5 deletions site/src/components/CodeExample/CodeExample.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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",
Expand All @@ -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: {
Expand Down
64 changes: 34 additions & 30 deletions site/src/components/CopyButton/CopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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`
Expand Down
4 changes: 2 additions & 2 deletions site/src/components/CopyableValue/CopyableValue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 61 additions & 21 deletions site/src/hooks/useClipboard.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
}
3 changes: 2 additions & 1 deletion site/src/modules/resources/SSHButton/SSHButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -67,6 +67,7 @@ export const SSHButton: FC<SSHButtonProps> = ({
</strong>
</HelpTooltipText>
<CodeExample
secret={false}
code={`ssh ${sshPrefix}${workspaceName}.${agentName}`}
/>
</div>
Expand Down
6 changes: 2 additions & 4 deletions site/src/pages/CliAuthPage/CliAuthPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
Expand Down
Loading