copy key without clicking reveal first

This commit is contained in:
miloschwartz
2026-08-13 15:16:39 -04:00
parent 9b10292e02
commit 4a9b0117a2
5 changed files with 183 additions and 160 deletions
+24 -10
View File
@@ -8,29 +8,40 @@ import { useTranslations } from "next-intl";
type CopyTextBoxProps = {
text?: string;
displayText?: string;
getCopyText?: () => Promise<string>;
wrapText?: boolean;
outline?: boolean;
centered?: boolean;
};
export default function CopyTextBox({
text = "",
displayText,
getCopyText,
wrapText = false,
outline = true
outline = true,
centered = false
}: CopyTextBoxProps) {
const [isCopied, setIsCopied] = useState(false);
const [isCopying, setIsCopying] = useState(false);
const textRef = useRef<HTMLPreElement>(null);
const t = useTranslations();
const copyToClipboard = async () => {
if (textRef.current) {
try {
await navigator.clipboard.writeText(text);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (err) {
console.error(t("copyTextFailed"), err);
}
if (!textRef.current || isCopying) {
return;
}
setIsCopying(true);
try {
const value = getCopyText ? await getCopyText() : text;
await navigator.clipboard.writeText(value);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (err) {
console.error(t("copyTextFailed"), err);
} finally {
setIsCopying(false);
}
};
@@ -40,7 +51,9 @@ export default function CopyTextBox({
>
<pre
ref={textRef}
className={`p-4 pr-16 text-sm w-full ${
className={`py-4 text-sm w-full ${
centered ? "px-16 text-center" : "pl-4 pr-16"
} ${
wrapText
? "whitespace-pre-wrap break-words"
: "overflow-x-auto"
@@ -54,6 +67,7 @@ export default function CopyTextBox({
type="button"
className="absolute top-0.5 right-0 z-10 bg-card"
onClick={copyToClipboard}
loading={isCopying}
aria-label={t("copyTextClipboard")}
>
{isCopied ? (
+27 -10
View File
@@ -1,5 +1,5 @@
import { cn } from "@app/lib/cn";
import { Check, Copy } from "lucide-react";
import { Check, Copy, Loader2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useTranslations } from "next-intl";
@@ -7,6 +7,7 @@ import { useTranslations } from "next-intl";
type CopyToClipboardProps = {
text: string;
displayText?: string;
getCopyText?: () => Promise<string>;
isLink?: boolean;
className?: string;
};
@@ -14,18 +15,31 @@ type CopyToClipboardProps = {
const CopyToClipboard = ({
text,
displayText,
getCopyText,
isLink,
className
}: CopyToClipboardProps) => {
const [copied, setCopied] = useState(false);
const [copying, setCopying] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
const handleCopy = async () => {
if (copying) {
return;
}
setTimeout(() => {
setCopied(false);
}, 2000);
setCopying(true);
try {
const value = getCopyText ? await getCopyText() : text;
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
} catch {
// Fetch errors are toasted by the caller; clipboard failures stay silent.
} finally {
setCopying(false);
}
};
const displayValue = displayText ?? text;
@@ -38,11 +52,14 @@ const CopyToClipboard = ({
type="button"
className="h-4 w-4 p-0 flex items-center justify-center cursor-pointer flex-shrink-0"
onClick={handleCopy}
disabled={copying}
>
{!copied ? (
<Copy className="h-4 w-4" />
) : (
{copying ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : copied ? (
<Check className="text-green-500 h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
<span className="sr-only">{t("copyText")}</span>
</button>
+17 -72
View File
@@ -1,8 +1,6 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { AxiosResponse } from "axios";
import moment from "moment";
import { Button } from "@app/components/ui/button";
import CopyTextBox from "@app/components/CopyTextBox";
@@ -17,73 +15,18 @@ import {
SettingsSectionHeader,
SettingsSectionTitle as SectionTitle
} from "@app/components/Settings";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { useMyVirtualApiKeySecret } from "@app/hooks/useMyVirtualApiKeySecret";
import type {
GetMyVirtualApiKeyResponse,
ListMyVirtualApiKeysResponse,
VirtualApiKeyWithResources
} from "@server/routers/virtualApiKey/types";
import {
formatVirtualApiKeyCredential,
formatVirtualApiKeyPreview
} from "@app/lib/virtualApiKeyFormat";
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
type UserVirtualApiKeysProps = {
orgId: string;
initialData: ListMyVirtualApiKeysResponse;
};
function useRevealSecret(orgId: string, virtualApiKeyId: string) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [credential, setCredential] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const revealSecret = () => {
if (credential || loading) {
return;
}
setLoading(true);
api.get<AxiosResponse<GetMyVirtualApiKeyResponse>>(
`/org/${orgId}/my-virtual-api-keys/${virtualApiKeyId}`
)
.then((res) => {
const secret = res.data.data.virtualApiKey.secret;
if (secret) {
setCredential(
formatVirtualApiKeyCredential(virtualApiKeyId, secret)
);
} else {
toast({
variant: "destructive",
title: t("virtualApiKeysErrorFetchSecret"),
description: t(
"virtualApiKeysErrorFetchSecretDescription"
)
});
}
})
.catch((e) => {
toast({
variant: "destructive",
title: t("virtualApiKeysErrorFetchSecret"),
description: formatAxiosError(
e,
t("virtualApiKeysErrorFetchSecretDescription")
)
});
})
.finally(() => {
setLoading(false);
});
};
return { credential, loading, revealSecret };
}
function OwnedKeySecret({
orgId,
virtualApiKeyId,
@@ -95,11 +38,9 @@ function OwnedKeySecret({
}) {
const t = useTranslations();
const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars);
const { credential, loading, revealSecret } = useRevealSecret(
orgId,
virtualApiKeyId
);
const displayValue = credential ?? preview;
const { credential, revealed, loading, getCopyText, revealSecret } =
useMyVirtualApiKeySecret(orgId, virtualApiKeyId);
const displayValue = revealed && credential ? credential : preview;
return (
<div className="flex items-center gap-3 min-w-0">
@@ -107,9 +48,10 @@ function OwnedKeySecret({
<CopyToClipboard
text={displayValue}
displayText={displayValue}
getCopyText={getCopyText}
/>
</div>
{!credential ? (
{!revealed ? (
<Button
variant="link"
size="sm"
@@ -137,11 +79,9 @@ function IdentityKeyCenterpiece({
}) {
const t = useTranslations();
const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars);
const { credential, loading, revealSecret } = useRevealSecret(
orgId,
virtualApiKeyId
);
const displayValue = credential ?? preview;
const { credential, revealed, loading, getCopyText, revealSecret } =
useMyVirtualApiKeySecret(orgId, virtualApiKeyId);
const displayValue = revealed && credential ? credential : preview;
const headline = resourceName
? t("myVirtualApiKeysIdentityResourceHeadline", { resourceName })
: t("myVirtualApiKeysIdentityHeadline");
@@ -159,9 +99,14 @@ function IdentityKeyCenterpiece({
</p>
<div className="mt-8 w-full max-w-2xl">
<div className="[&_pre]:text-base [&_code]:font-mono [&_code]:tracking-wide">
<CopyTextBox text={displayValue} wrapText={false} />
<CopyTextBox
text={displayValue}
getCopyText={getCopyText}
wrapText={false}
centered
/>
</div>
{!credential ? (
{!revealed ? (
<div className="mt-3 flex justify-center">
<Button
variant="link"
@@ -12,78 +12,19 @@ import {
SettingsSubsectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { useMyVirtualApiKeySecret } from "@app/hooks/useMyVirtualApiKeySecret";
import { launcherQueries } from "@app/lib/queries";
import type {
GetMyVirtualApiKeyResponse,
VirtualApiKeyWithResources
} from "@server/routers/virtualApiKey/types";
import {
formatVirtualApiKeyCredential,
formatVirtualApiKeyPreview
} from "@app/lib/virtualApiKeyFormat";
import type { VirtualApiKeyWithResources } from "@server/routers/virtualApiKey/types";
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
import { useQuery } from "@tanstack/react-query";
import type { AxiosResponse } from "axios";
import { Loader2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
type LauncherInferenceApiKeysSectionProps = {
orgId: string;
resourceGuid: string;
};
function useRevealSecret(orgId: string, virtualApiKeyId: string) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [credential, setCredential] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const revealSecret = () => {
if (credential || loading) {
return;
}
setLoading(true);
api.get<AxiosResponse<GetMyVirtualApiKeyResponse>>(
`/org/${orgId}/my-virtual-api-keys/${virtualApiKeyId}`
)
.then((res) => {
const secret = res.data.data.virtualApiKey.secret;
if (secret) {
setCredential(
formatVirtualApiKeyCredential(virtualApiKeyId, secret)
);
} else {
toast({
variant: "destructive",
title: t("virtualApiKeysErrorFetchSecret"),
description: t(
"virtualApiKeysErrorFetchSecretDescription"
)
});
}
})
.catch((e) => {
toast({
variant: "destructive",
title: t("virtualApiKeysErrorFetchSecret"),
description: formatAxiosError(
e,
t("virtualApiKeysErrorFetchSecretDescription")
)
});
})
.finally(() => {
setLoading(false);
});
};
return { credential, loading, revealSecret };
}
function PanelKeySecret({
orgId,
virtualApiKeyId,
@@ -95,11 +36,9 @@ function PanelKeySecret({
}) {
const t = useTranslations();
const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars);
const { credential, loading, revealSecret } = useRevealSecret(
orgId,
virtualApiKeyId
);
const displayValue = credential ?? preview;
const { credential, revealed, loading, getCopyText, revealSecret } =
useMyVirtualApiKeySecret(orgId, virtualApiKeyId);
const displayValue = revealed && credential ? credential : preview;
return (
<div className="flex items-center gap-3 min-w-0">
@@ -107,9 +46,10 @@ function PanelKeySecret({
<CopyToClipboard
text={displayValue}
displayText={displayValue}
getCopyText={getCopyText}
/>
</div>
{!credential ? (
{!revealed ? (
<Button
variant="link"
size="sm"
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { AxiosResponse } from "axios";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import type { GetMyVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat";
export function useMyVirtualApiKeySecret(
orgId: string,
virtualApiKeyId: string
) {
const t = useTranslations();
const env = useEnvContext();
const [credential, setCredential] = useState<string | null>(null);
const [revealed, setRevealed] = useState(false);
const [loading, setLoading] = useState(false);
const credentialRef = useRef<string | null>(null);
const inFlightRef = useRef<Promise<string | null> | null>(null);
const getCredential = useCallback(async (): Promise<string | null> => {
if (credentialRef.current) {
return credentialRef.current;
}
if (inFlightRef.current) {
return inFlightRef.current;
}
const api = createApiClient(env);
const request = api
.get<AxiosResponse<GetMyVirtualApiKeyResponse>>(
`/org/${orgId}/my-virtual-api-keys/${virtualApiKeyId}`
)
.then((res) => {
const secret = res.data.data.virtualApiKey.secret;
if (!secret) {
toast({
variant: "destructive",
title: t("virtualApiKeysErrorFetchSecret"),
description: t(
"virtualApiKeysErrorFetchSecretDescription"
)
});
return null;
}
const formatted = formatVirtualApiKeyCredential(
virtualApiKeyId,
secret
);
credentialRef.current = formatted;
setCredential(formatted);
return formatted;
})
.catch((e) => {
toast({
variant: "destructive",
title: t("virtualApiKeysErrorFetchSecret"),
description: formatAxiosError(
e,
t("virtualApiKeysErrorFetchSecretDescription")
)
});
return null;
})
.finally(() => {
setLoading(false);
inFlightRef.current = null;
});
setLoading(true);
inFlightRef.current = request;
return request;
}, [env, orgId, t, virtualApiKeyId]);
const getCopyText = useCallback(async () => {
const value = await getCredential();
if (!value) {
throw new Error("Failed to fetch virtual API key secret");
}
return value;
}, [getCredential]);
const revealSecret = useCallback(async () => {
if (revealed) {
return;
}
const value = await getCredential();
if (value) {
setRevealed(true);
}
}, [getCredential, revealed]);
return {
credential,
revealed,
loading,
getCredential,
getCopyText,
revealSecret
};
}