FEATURE (clipboard): Add parsing from clipboard via dialog in HTTP\no navigator mode

This commit is contained in:
Rostislav Dugin
2026-03-31 11:20:13 +03:00
parent 189573fa1b
commit 7913c1b474
14 changed files with 333 additions and 154 deletions

View File

@@ -1566,6 +1566,13 @@ export const ReactComponent = ({ someValue }: Props): JSX.Element => {
- **Calculated values** - Derived data from props/state
- **Return** - JSX markup
### Clipboard operations
Always use `ClipboardHelper` (`shared/lib/ClipboardHelper.ts`) for clipboard operations — never call `navigator.clipboard` directly.
- **Copy:** `ClipboardHelper.copyToClipboard(text)` — uses `navigator.clipboard` with `execCommand('copy')` fallback for non-secure contexts (HTTP).
- **Paste:** Check `ClipboardHelper.isClipboardApiAvailable()` first. If available, use `ClipboardHelper.readFromClipboard()`. If not, show `ClipboardPasteModalComponent` (`shared/ui`) which lets the user paste manually via a text input modal.
---
## Summary

View File

@@ -1 +0,0 @@
package secrets

View File

@@ -6,6 +6,7 @@ import { useState } from 'react';
import { getApplicationServer } from '../../../constants';
import { type Backup, PgWalBackupType } from '../../../entity/backups';
import { type Database } from '../../../entity/databases';
import { ClipboardHelper } from '../../../shared/lib/ClipboardHelper';
import { getUserTimeFormat } from '../../../shared/time';
interface Props {
@@ -26,7 +27,7 @@ export const AgentRestoreComponent = ({ database, backup }: Props) => {
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
await ClipboardHelper.copyToClipboard(text);
message.success('Copied to clipboard');
} catch {
message.error('Failed to copy');

View File

@@ -1,5 +1,7 @@
import { useState } from 'react';
import { ClipboardHelper } from '../../../shared/lib/ClipboardHelper';
interface DbSizeCommand {
label: string;
code: string;
@@ -44,7 +46,7 @@ export function DbSizeCommands({ commands }: Props) {
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(cmd.code);
await ClipboardHelper.copyToClipboard(cmd.code);
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
} catch {

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { getApplicationServer } from '../../../constants';
import { type Database, databaseApi } from '../../../entity/databases';
import { ClipboardHelper } from '../../../shared/lib/ClipboardHelper';
type Architecture = 'amd64' | 'arm64';
type PgDeploymentType = 'system' | 'folder' | 'docker';
@@ -42,7 +43,7 @@ export const AgentInstallationComponent = ({ database, onTokenGenerated }: Props
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
await ClipboardHelper.copyToClipboard(text);
message.success('Copied to clipboard');
} catch {
message.error('Failed to copy');

View File

@@ -5,7 +5,9 @@ import { useEffect, useState } from 'react';
import { IS_CLOUD } from '../../../../constants';
import { type Database, databaseApi } from '../../../../entity/databases';
import { MariadbConnectionStringParser } from '../../../../entity/databases/model/mariadb/MariadbConnectionStringParser';
import { ClipboardHelper } from '../../../../shared/lib/ClipboardHelper';
import { ToastHelper } from '../../../../shared/toast';
import { ClipboardPasteModalComponent } from '../../../../shared/ui';
interface Props {
database: Database;
@@ -49,9 +51,9 @@ export const EditMariaDbSpecificDataComponent = ({
const hasAdvancedValues = !!database.mariadb?.isExcludeEvents;
const [isShowAdvanced, setShowAdvanced] = useState(hasAdvancedValues);
const parseFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText();
const [isShowPasteModal, setIsShowPasteModal] = useState(false);
const applyConnectionString = (text: string) => {
const trimmedText = text.trim();
if (!trimmedText) {
@@ -84,6 +86,17 @@ export const EditMariaDbSpecificDataComponent = ({
setEditingDatabase(updatedDatabase);
setIsConnectionTested(false);
message.success('Connection string parsed successfully');
};
const parseFromClipboard = async () => {
if (!ClipboardHelper.isClipboardApiAvailable()) {
setIsShowPasteModal(true);
return;
}
try {
const text = await ClipboardHelper.readFromClipboard();
applyConnectionString(text);
} catch {
message.error('Failed to read clipboard. Please check browser permissions.');
}
@@ -408,6 +421,15 @@ export const EditMariaDbSpecificDataComponent = ({
list.
</div>
)}
<ClipboardPasteModalComponent
open={isShowPasteModal}
onSubmit={(text) => {
setIsShowPasteModal(false);
applyConnectionString(text);
}}
onCancel={() => setIsShowPasteModal(false)}
/>
</div>
);
};

View File

@@ -5,7 +5,9 @@ import { useEffect, useState } from 'react';
import { IS_CLOUD } from '../../../../constants';
import { type Database, databaseApi } from '../../../../entity/databases';
import { MongodbConnectionStringParser } from '../../../../entity/databases/model/mongodb/MongodbConnectionStringParser';
import { ClipboardHelper } from '../../../../shared/lib/ClipboardHelper';
import { ToastHelper } from '../../../../shared/toast';
import { ClipboardPasteModalComponent } from '../../../../shared/ui';
interface Props {
database: Database;
@@ -52,9 +54,9 @@ export const EditMongoDbSpecificDataComponent = ({
!!database.mongodb?.isDirectConnection;
const [isShowAdvanced, setShowAdvanced] = useState(hasAdvancedValues);
const parseFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText();
const [isShowPasteModal, setIsShowPasteModal] = useState(false);
const applyConnectionString = (text: string) => {
const trimmedText = text.trim();
if (!trimmedText) {
@@ -96,12 +98,21 @@ export const EditMongoDbSpecificDataComponent = ({
setIsConnectionTested(false);
if (!result.password) {
message.warning(
'Connection string parsed successfully. Please enter the password manually.',
);
message.warning('Connection string parsed successfully. Please enter the password manually.');
} else {
message.success('Connection string parsed successfully');
}
};
const parseFromClipboard = async () => {
if (!ClipboardHelper.isClipboardApiAvailable()) {
setIsShowPasteModal(true);
return;
}
try {
const text = await ClipboardHelper.readFromClipboard();
applyConnectionString(text);
} catch {
message.error('Failed to read clipboard. Please check browser permissions.');
}
@@ -501,6 +512,15 @@ export const EditMongoDbSpecificDataComponent = ({
list.
</div>
)}
<ClipboardPasteModalComponent
open={isShowPasteModal}
onSubmit={(text) => {
setIsShowPasteModal(false);
applyConnectionString(text);
}}
onCancel={() => setIsShowPasteModal(false)}
/>
</div>
);
};

View File

@@ -5,7 +5,9 @@ import { useEffect, useState } from 'react';
import { IS_CLOUD } from '../../../../constants';
import { type Database, databaseApi } from '../../../../entity/databases';
import { MySqlConnectionStringParser } from '../../../../entity/databases/model/mysql/MySqlConnectionStringParser';
import { ClipboardHelper } from '../../../../shared/lib/ClipboardHelper';
import { ToastHelper } from '../../../../shared/toast';
import { ClipboardPasteModalComponent } from '../../../../shared/ui';
interface Props {
database: Database;
@@ -46,9 +48,9 @@ export const EditMySqlSpecificDataComponent = ({
const [isTestingConnection, setIsTestingConnection] = useState(false);
const [isConnectionFailed, setIsConnectionFailed] = useState(false);
const parseFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText();
const [isShowPasteModal, setIsShowPasteModal] = useState(false);
const applyConnectionString = (text: string) => {
const trimmedText = text.trim();
if (!trimmedText) {
@@ -81,6 +83,17 @@ export const EditMySqlSpecificDataComponent = ({
setEditingDatabase(updatedDatabase);
setIsConnectionTested(false);
message.success('Connection string parsed successfully');
};
const parseFromClipboard = async () => {
if (!ClipboardHelper.isClipboardApiAvailable()) {
setIsShowPasteModal(true);
return;
}
try {
const text = await ClipboardHelper.readFromClipboard();
applyConnectionString(text);
} catch {
message.error('Failed to read clipboard. Please check browser permissions.');
}
@@ -359,6 +372,15 @@ export const EditMySqlSpecificDataComponent = ({
list.
</div>
)}
<ClipboardPasteModalComponent
open={isShowPasteModal}
onSubmit={(text) => {
setIsShowPasteModal(false);
applyConnectionString(text);
}}
onCancel={() => setIsShowPasteModal(false)}
/>
</div>
);
};

View File

@@ -5,7 +5,9 @@ import { useEffect, useState } from 'react';
import { IS_CLOUD } from '../../../../constants';
import { type Database, PostgresBackupType, databaseApi } from '../../../../entity/databases';
import { ConnectionStringParser } from '../../../../entity/databases/model/postgresql/ConnectionStringParser';
import { ClipboardHelper } from '../../../../shared/lib/ClipboardHelper';
import { ToastHelper } from '../../../../shared/toast';
import { ClipboardPasteModalComponent } from '../../../../shared/ui';
interface Props {
database: Database;
@@ -54,9 +56,9 @@ export const EditPostgreSqlSpecificDataComponent = ({
const [hasAutoAddedPublicSchema, setHasAutoAddedPublicSchema] = useState(false);
const parseFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText();
const [isShowPasteModal, setIsShowPasteModal] = useState(false);
const applyConnectionString = (text: string) => {
const trimmedText = text.trim();
if (!trimmedText) {
@@ -90,6 +92,17 @@ export const EditPostgreSqlSpecificDataComponent = ({
setEditingDatabase(autoAddPublicSchemaForSupabase(updatedDatabase));
setIsConnectionTested(false);
message.success('Connection string parsed successfully');
};
const parseFromClipboard = async () => {
if (!ClipboardHelper.isClipboardApiAvailable()) {
setIsShowPasteModal(true);
return;
}
try {
const text = await ClipboardHelper.readFromClipboard();
applyConnectionString(text);
} catch {
message.error('Failed to read clipboard. Please check browser permissions.');
}
@@ -603,6 +616,15 @@ export const EditPostgreSqlSpecificDataComponent = ({
<div>
{renderBackupTypeSelector()}
{renderFormContent()}
<ClipboardPasteModalComponent
open={isShowPasteModal}
onSubmit={(text) => {
setIsShowPasteModal(false);
applyConnectionString(text);
}}
onCancel={() => setIsShowPasteModal(false)}
/>
</div>
);
};

View File

@@ -12,6 +12,7 @@ import { useEffect, useRef, useState } from 'react';
import type { Backup } from '../../../entity/backups';
import { type Database, DatabaseType } from '../../../entity/databases';
import { type Restore, RestoreStatus, restoreApi } from '../../../entity/restores';
import { ClipboardHelper } from '../../../shared/lib/ClipboardHelper';
import { getUserTimeFormat } from '../../../shared/time';
import { ConfirmationComponent } from '../../../shared/ui';
import { EditDatabaseSpecificDataComponent } from '../../databases/ui/edit/EditDatabaseSpecificDataComponent';
@@ -328,7 +329,7 @@ export const RestoresComponent = ({ database, backup }: Props) => {
<Button
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(showingRestoreError.failMessage || '');
ClipboardHelper.copyToClipboard(showingRestoreError.failMessage || '');
message.success('Error message copied to clipboard');
}}
>

View File

@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from 'react';
import { IS_CLOUD, getApplicationServer } from '../../../constants';
import { settingsApi } from '../../../entity/users/api/settingsApi';
import type { UsersSettings } from '../../../entity/users/model/UsersSettings';
import { ClipboardHelper } from '../../../shared/lib/ClipboardHelper';
import { AuditLogsComponent } from './AuditLogsComponent';
interface Props {
@@ -247,7 +248,9 @@ export function SettingsComponent({ contentHeight }: Props) {
size="small"
className="ml-2 opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => {
navigator.clipboard.writeText(`${getApplicationServer()}/api/v1/system/health`);
ClipboardHelper.copyToClipboard(
`${getApplicationServer()}/api/v1/system/health`,
);
message.success('Health-check endpoint copied to clipboard');
}}
>

View File

@@ -0,0 +1,26 @@
export class ClipboardHelper {
static isClipboardApiAvailable(): boolean {
return !!(navigator.clipboard && window.isSecureContext);
}
static async copyToClipboard(text: string): Promise<void> {
if (this.isClipboardApiAvailable()) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
static async readFromClipboard(): Promise<string> {
const text = await navigator.clipboard.readText();
return text;
}
}

View File

@@ -0,0 +1,52 @@
import { Button, Input, Modal } from 'antd';
import { useState } from 'react';
interface Props {
open: boolean;
onSubmit(text: string): void;
onCancel(): void;
}
export function ClipboardPasteModalComponent({ open, onSubmit, onCancel }: Props) {
const [value, setValue] = useState('');
const handleSubmit = () => {
const trimmed = value.trim();
if (!trimmed) return;
onSubmit(trimmed);
setValue('');
};
const handleCancel = () => {
setValue('');
onCancel();
};
return (
<Modal
title="Paste from clipboard"
open={open}
onCancel={handleCancel}
footer={
<div className="flex justify-end gap-2">
<Button onClick={handleCancel}>Cancel</Button>
<Button type="primary" disabled={!value.trim()} onClick={handleSubmit}>
Submit
</Button>
</div>
}
>
<p className="mb-2 text-sm text-gray-500 dark:text-gray-400">
Automatic clipboard access is not available. Please paste your content below.
</p>
<Input.TextArea
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Paste your connection string here..."
rows={4}
autoFocus
/>
</Modal>
);
}

View File

@@ -1,3 +1,4 @@
export { ClipboardPasteModalComponent } from './ClipboardPasteModalComponent';
export { CloudflareTurnstileWidget } from './CloudflareTurnstileWidget';
export { ConfirmationComponent } from './ConfirmationComponent';
export { StarButtonComponent } from './StarButtonComponent';