feat(KAN-55): support app links, file selection and clipboard
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / test_and_check_app (pull_request) Successful in 6m33s
CI/CD / Publish immutable image (pull_request) Skipped

This commit is contained in:
2026-09-26 20:58:29 +02:00
parent 8bed02a484
commit 9d472b5091
18 changed files with 501 additions and 9 deletions
@@ -19,6 +19,7 @@ import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { copyText } from '@/lib/clipboard';
import { confirm } from '@/routes/two-factor';
const props = defineProps<{
@@ -94,7 +95,7 @@ async function copyKey(): Promise<void> {
}
copyError.value = false;
try {
await navigator.clipboard.writeText(key);
await copyText(key);
if (isOpen.value && manualSetupKey.value === key) {
copied.value = true;
}
+64
View File
@@ -0,0 +1,64 @@
/** Copy only after a user action; WebViews may not expose the async Clipboard API. */
export async function copyText(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// Embedded browsers can deny the async API while allowing a selected copy.
}
}
const active = document.activeElement;
const selection = window.getSelection();
const ranges = selection
? Array.from({ length: selection.rangeCount }, (_, index) =>
selection.getRangeAt(index).cloneRange(),
)
: [];
const inputSelection =
active instanceof HTMLInputElement ||
active instanceof HTMLTextAreaElement
? ([
active.selectionStart,
active.selectionEnd,
active.selectionDirection,
] as const)
: null;
const field = document.createElement('textarea');
field.value = text;
field.readOnly = true;
field.style.cssText =
'position:fixed;opacity:0;pointer-events:none;font-size:16px';
(active?.closest('[role="dialog"]') ?? document.body).append(field);
try {
field.focus({ preventScroll: true });
field.select();
field.setSelectionRange(0, text.length);
if (!document.execCommand('copy')) {
throw new Error('Clipboard access unavailable');
}
} finally {
field.remove();
if (active instanceof HTMLElement) {
active.focus({ preventScroll: true });
}
if (selection) {
selection.removeAllRanges();
ranges.forEach((range) => selection.addRange(range));
}
if (
inputSelection &&
inputSelection[0] !== null &&
inputSelection[1] !== null &&
(active instanceof HTMLInputElement ||
active instanceof HTMLTextAreaElement)
) {
active.setSelectionRange(
inputSelection[0],
inputSelection[1],
inputSelection[2] ?? undefined,
);
}
}
}
+2 -1
View File
@@ -23,6 +23,7 @@ import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import { copyText } from '@/lib/clipboard';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/applications/index';
import type { BreadcrumbItem, SettingsApplicationsPageProps } from '@/types';
@@ -83,7 +84,7 @@ async function copyKey(): Promise<void> {
}
copyError.value = false;
try {
await navigator.clipboard.writeText(key);
await copyText(key);
if (props.applicationKey === key) {
copied.value = true;
}