mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-14 00:09:55 +02:00
copy key without clicking reveal first
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user