diff --git a/js/apps/admin-ui/public/locales/en/clients.json b/js/apps/admin-ui/public/locales/en/clients.json index 6390a5447f..ebb68aecf7 100644 --- a/js/apps/admin-ui/public/locales/en/clients.json +++ b/js/apps/admin-ui/public/locales/en/clients.json @@ -317,6 +317,7 @@ "copyInitialAccessToken": "Please copy and paste the initial access token before closing as it can not be retrieved later.", "copySuccess": "Successfully copied to clipboard!", "clipboardCopyError": "Error copying to clipboard.", + "clipboardCopyDenied": "Your browser is blocking access to the clipboard.", "copyToClipboard": "Copy to clipboard", "clientRegistration": "Client registration", "anonymousAccessPolicies": "Anonymous access polices", diff --git a/js/apps/admin-ui/src/clients/scopes/CopyToClipboardButton.tsx b/js/apps/admin-ui/src/clients/scopes/CopyToClipboardButton.tsx index d2f786384f..d1fb936c1b 100644 --- a/js/apps/admin-ui/src/clients/scopes/CopyToClipboardButton.tsx +++ b/js/apps/admin-ui/src/clients/scopes/CopyToClipboardButton.tsx @@ -6,6 +6,7 @@ import { } from "@patternfly/react-core"; import useSetTimeout from "../../utils/useSetTimeout"; +import useQueryPermission from "../../utils/useQueryPermission"; enum CopyState { Ready, @@ -27,32 +28,39 @@ export const CopyToClipboardButton = ({ }: CopyToClipboardButtonProps) => { const { t } = useTranslation("clients"); const setTimeout = useSetTimeout(); + const permission = useQueryPermission("clipboard-write" as PermissionName); + const permissionDenied = permission?.state === "denied"; + const [copyState, setCopyState] = useState(CopyState.Ready); - const [copy, setCopy] = useState(CopyState.Ready); + // Determine the message to use for the copy button. + const copyMessageKey = useMemo(() => { + if (permissionDenied) { + return "clipboardCopyDenied"; + } - const copyMessage = useMemo(() => { - switch (copy) { + switch (copyState) { case CopyState.Ready: - return t("copyToClipboard"); + return "copyToClipboard"; case CopyState.Copied: - return t("copySuccess"); + return "copySuccess"; case CopyState.Error: - return t("clipboardCopyError"); + return "clipboardCopyError"; } - }, [copy]); + }, [permissionDenied, copyState]); + // Reset the message of the copy button after copying to the clipboard. useEffect(() => { - if (copy !== CopyState.Ready) { - return setTimeout(() => setCopy(CopyState.Ready), 1000); + if (copyState !== CopyState.Ready) { + return setTimeout(() => setCopyState(CopyState.Ready), 1000); } - }, [copy]); + }, [copyState, setTimeout]); const copyToClipboard = async (text: string) => { try { await navigator.clipboard.writeText(text); - setCopy(CopyState.Copied); + setCopyState(CopyState.Copied); } catch (error) { - setCopy(CopyState.Error); + setCopyState(CopyState.Error); } }; @@ -65,7 +73,7 @@ export const CopyToClipboardButton = ({ exitDelay={600} variant={variant} > - {copyMessage} + {t(copyMessageKey)} ); }; diff --git a/js/apps/admin-ui/src/utils/useQueryPermission.ts b/js/apps/admin-ui/src/utils/useQueryPermission.ts new file mode 100644 index 0000000000..1d6ca6f580 --- /dev/null +++ b/js/apps/admin-ui/src/utils/useQueryPermission.ts @@ -0,0 +1,54 @@ +import { useState, useEffect } from "react"; + +/** A 'plain' object version of the permission status. */ +export type PlainPermissionStatus = { + readonly name: string; + readonly state: PermissionState; +}; + +export default function useQueryPermission( + name: PermissionName, +): PlainPermissionStatus | null { + const [status, setStatus] = useState(null); + const [plainStatus, setPlainStatus] = useState( + null, + ); + + function updatePlainStatus(newStatus: PermissionStatus) { + setPlainStatus({ + name: newStatus.name, + state: newStatus.state, + }); + } + + // Query the permission status when the name changes. + useEffect(() => { + setStatus(null); + setPlainStatus(null); + + navigator.permissions.query({ name }).then((newStatus) => { + setStatus(newStatus); + updatePlainStatus(newStatus); + }); + }, [name]); + + // Update the 'plain' status when the permission status changes. + useEffect(() => { + if (!status) { + return; + } + + function onStatusChange() { + if (!status) { + return; + } + + updatePlainStatus(status); + } + + status.addEventListener("change", onStatusChange); + return () => status.removeEventListener("change", onStatusChange); + }, [status]); + + return plainStatus; +} diff --git a/js/apps/admin-ui/src/utils/useSetTimeout.ts b/js/apps/admin-ui/src/utils/useSetTimeout.ts index 502075f0de..d6897f6874 100644 --- a/js/apps/admin-ui/src/utils/useSetTimeout.ts +++ b/js/apps/admin-ui/src/utils/useSetTimeout.ts @@ -1,8 +1,8 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useCallback } from "react"; export default function useSetTimeout() { const didUnmountRef = useRef(false); - const { current: scheduledTimers } = useRef(new Set()); + const scheduledTimersRef = useRef(new Set()); useEffect(() => { didUnmountRef.current = false; @@ -14,27 +14,27 @@ export default function useSetTimeout() { }, []); function clearAll() { - scheduledTimers.forEach((timer) => clearTimeout(timer)); - scheduledTimers.clear(); + scheduledTimersRef.current.forEach((timer) => clearTimeout(timer)); + scheduledTimersRef.current.clear(); } - return function scheduleTimeout(callback: () => void, delay: number) { + return useCallback((callback: () => void, delay: number) => { if (didUnmountRef.current) { throw new Error("Can't schedule a timeout on an unmounted component."); } const timer = Number(setTimeout(handleCallback, delay)); - scheduledTimers.add(timer); + scheduledTimersRef.current.add(timer); function handleCallback() { - scheduledTimers.delete(timer); + scheduledTimersRef.current.delete(timer); callback(); } return function cancelTimeout() { clearTimeout(timer); - scheduledTimers.delete(timer); + scheduledTimersRef.current.delete(timer); }; - }; + }, []); }