mirror of https://github.com/coder/coder.git
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:
parent
c7f51a9d70
commit
b0a855caa4
|
@ -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"],
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
@ -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>>;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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="" />
|
||||
|
|
|
@ -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…
|
||||
|
|
|
@ -34,6 +34,7 @@ export const ResetPasswordDialog: FC<ResetPasswordDialogProps> = ({
|
|||
<>
|
||||
<p>{Language.message(user?.username)}</p>
|
||||
<CodeExample
|
||||
secret={false}
|
||||
code={newPassword ?? ""}
|
||||
css={{
|
||||
minHeight: "auto",
|
||||
|
|
Loading…
Reference in New Issue