Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/web-ui/src/api/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export interface ThemePackageRequest {
export type UiStateEntries = Readonly<Record<string, unknown>>;

export interface ConnectionParameters {
read_only?: boolean;
driver: string;
database: string | string[];
default_database?: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {SyntheticEvent} from 'react';
import {Shield, PlugZap} from 'lucide-react';
import {Shield, PlugZap, LockKeyhole} from 'lucide-react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import type {SavedConnection} from '@/contexts/DatabaseContext';
Expand Down Expand Up @@ -84,6 +84,11 @@ export const ConnectionChips = ({
</UpdateTooltip>
)}
<EnvironmentBadge environment={conn.environment} />
{(conn.params.read_only || driverManifest?.capabilities.readonly) && (
<Chip tone="warning" icon={<LockKeyhole size={8} />}>
{t('readOnly.label')}
</Chip>
)}
<TagChips tagIds={conn.tag_ids} tags={tags} />
{conn.params.ssh_enabled && (
<Chip tone="success" icon={<Shield size={8} />}>
Expand Down
2 changes: 2 additions & 0 deletions packages/web-ui/src/components/layout/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {CommandPaletteModal} from '@/components/modals/CommandPaletteModal';
import {DataFileGate} from '@/components/modals/DataFileGate';
import {CommandPaletteScopeBridge} from './CommandPaletteScopeBridge';
import {ProductionBanner} from './ProductionBanner';
import {ReadOnlyBanner} from './ReadOnlyBanner';
import {RightSidebar} from './RightSidebar';
import {Sidebar} from './Sidebar';
const SplitPaneLayout = lazy(() =>
Expand Down Expand Up @@ -43,6 +44,7 @@ const MainLayoutContent = () => {
<Sidebar />
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
{isEditorRoute(location.pathname) && <ProductionBanner />}
{isEditorRoute(location.pathname) && <ReadOnlyBanner />}
{/* Routed pages size themselves with h-full, which resolves against
this wrapper — not against <main>. Without it the production banner
would push the page down without shrinking it, clipping the bottom
Expand Down
19 changes: 19 additions & 0 deletions packages/web-ui/src/components/layout/ReadOnlyBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {LockKeyhole} from 'lucide-react';
import {useTranslation} from 'react-i18next';
import {useDatabase} from '@/hooks/useDatabase';

export function ReadOnlyBanner() {
const {t} = useTranslation();
const {activeConnectionId, connections} = useDatabase();
const connection = connections.find((entry) => entry.id === activeConnectionId);
if (!connection?.params.read_only) return null;
return (
<div
role="status"
className="flex items-center justify-center gap-1.5 px-3 py-1 bg-accent-warning/15 border-b border-accent-warning/30 text-accent-warning text-xs shrink-0"
>
<LockKeyhole size={12} aria-hidden="true" />
{t('readOnly.banner', {name: connection.name})}
</div>
);
}
34 changes: 34 additions & 0 deletions packages/web-ui/src/components/modals/NewConnectionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import {
supportsQueryTimeout,
} from '@/utils/driverCapabilities';
import {toErrorMessage} from '@/utils/errors';
import {supportsReadOnlyQueries} from '@/utils/connectionPolicy';
import {classifyConnectionError, type ClassifiedConnectionError} from '@/utils/connectionErrors';
import {ConnectionDiagnosticsModal} from './connection/ConnectionDiagnosticsModal';
import {testStepLabelKey, type ConnectionTestLogEntry} from '@/utils/connectionTest';
Expand Down Expand Up @@ -126,6 +127,7 @@ const paradigmAccent = (p: string): string => PARADIGM_ACCENT[p] ?? '#64748b';

interface ConnectionParams {
driver: string;
read_only?: boolean;
host?: string;
port?: number;
username?: string;
Expand Down Expand Up @@ -2440,6 +2442,12 @@ export const NewConnectionModal = ({
const params = withoutBlankSslMode(
withInlineK8sPaths(paramsBase, inlinePaths.options),
) as ConnectionParams;
if (params.read_only && !supportsReadOnlyQueries(activeDriver?.capabilities)) {
setStatus('error');
setMessage(t('readOnly.unsupported'));
setTestResult('error');
return;
}
// Only drivers with capabilities.query_timeout honour the field;
// drop a stale value so a plugin connection can't save a no-op.
if (!showsQueryTimeout) {
Expand Down Expand Up @@ -3216,6 +3224,32 @@ export const NewConnectionModal = ({
// ── rendered Advanced tab content (driver-specific options + startup SQL) ──
const advancedTabContent = (
<div className="space-y-4">
<div className="space-y-1">
<label className="flex items-center gap-2.5 text-sm text-secondary">
<input
type="checkbox"
checked={
formData.read_only === true ||
activeDriver?.capabilities.readonly === true
}
disabled={
activeDriver?.capabilities.readonly === true ||
(!formData.read_only &&
!supportsReadOnlyQueries(activeDriver?.capabilities))
}
onChange={(event) => updateField('read_only', event.target.checked)}
className="accent-accent-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
/>
{t('readOnly.label')}
</label>
<p className="text-xs text-muted">
{t(
supportsReadOnlyQueries(activeDriver?.capabilities)
? 'readOnly.description'
: 'readOnly.unsupported',
)}
</p>
</div>
{/* MySQL: PIPES_AS_CONCAT compatibility (Vitess/PlanetScale) */}
{driver === 'mysql' && (
<div className="flex flex-col gap-1">
Expand Down
1 change: 1 addition & 0 deletions packages/web-ui/src/contexts/DatabaseContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface SavedConnection {
plugin_secret_keys?: string[];
params: {
driver: string;
read_only?: boolean;
host?: string;
database: string | string[];
default_database?: string;
Expand Down
31 changes: 25 additions & 6 deletions packages/web-ui/src/contexts/DatabaseProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {PluginManifest} from '@/types/plugins';
import {clearAutocompleteCache} from '@/utils/autocomplete';
import {loadOptionalMetadata} from '@/utils/connectionMetadata';
import {toErrorMessage} from '@/utils/errors';
import {effectiveConnectionCapabilities} from '@/utils/connectionPolicy';
import {useSettings} from '@/hooks/useSettings';
import {useToast} from '@/hooks/useToast';
import {findConnectionsForDrivers} from '@/utils/connectionManager';
Expand Down Expand Up @@ -148,7 +149,12 @@ export const DatabaseProvider = ({children}: {children: ReactNode}) => {
const activeData = getActiveConnectionData();

const activeDriver = activeData?.driver ?? null;
const activeCapabilities = activeData?.capabilities ?? null;
const activeReadOnly = connections.find((connection) => connection.id === activeConnectionId)
?.params.read_only;
const activeCapabilities = useMemo(
() => effectiveConnectionCapabilities(activeData?.capabilities ?? null, activeReadOnly),
[activeData?.capabilities, activeReadOnly],
);
const activeConnectionName = activeData?.connectionName ?? null;
const activeDatabaseName = activeData?.databaseName ?? null;
const tables = activeData?.tables ?? [];
Expand Down Expand Up @@ -2092,11 +2098,24 @@ export const DatabaseProvider = ({children}: {children: ReactNode}) => {
[client],
);

const connectionDataWithPolicy = useMemo(() => {
const effective = {...connectionDataMap};
for (const connection of connections) {
const data = effective[connection.id];
if (data && connection.params.read_only) {
effective[connection.id] = {
...data,
capabilities: effectiveConnectionCapabilities(data.capabilities, true),
};
}
}
return effective;
}, [connectionDataMap, connections]);

const getConnectionData = useCallback(
(connectionId: string): ConnectionData | undefined => {
return connectionDataMap[connectionId];
},
[connectionDataMap],
(connectionId: string): ConnectionData | undefined =>
connectionDataWithPolicy[connectionId],
[connectionDataWithPolicy],
);

const isConnectionOpen = useCallback(
Expand Down Expand Up @@ -2414,7 +2433,7 @@ export const DatabaseProvider = ({children}: {children: ReactNode}) => {
value={{
activeConnectionId,
openConnectionIds,
connectionDataMap,
connectionDataMap: connectionDataWithPolicy,
activeTable,
activeDriver,
activeCapabilities,
Expand Down
3 changes: 3 additions & 0 deletions packages/web-ui/src/hooks/useDrivers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const FALLBACK_DRIVERS: PluginManifest[] = [
supports_ssl: true,
query_timeout: true,
sql_dialect: 'postgres',
read_only_queries: true,
},
},
{
Expand Down Expand Up @@ -103,6 +104,7 @@ const FALLBACK_DRIVERS: PluginManifest[] = [
create_foreign_keys: true,
supports_ssl: true,
sql_dialect: 'mysql',
read_only_queries: true,
},
},
{
Expand Down Expand Up @@ -130,6 +132,7 @@ const FALLBACK_DRIVERS: PluginManifest[] = [
alter_column: false,
create_foreign_keys: false,
sql_dialect: 'sqlite',
read_only_queries: true,
},
},
];
Expand Down
15 changes: 12 additions & 3 deletions packages/web-ui/src/hooks/useProductionGuard.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import {createContext, useCallback, useContext} from 'react';
import {useDatabase} from './useDatabase';
import {isReadOnlyQuery} from '@/utils/sqlAnalysis';
import {useTranslation} from 'react-i18next';
import {useAlert} from './useAlert';

/**
* Confirmation gate for writes against production connections.
* Connection write gate: read-only policy first, then production confirmation.
*
* `useProductionGuard()` returns `guardWrite(connectionId, sql?)`: it
* resolves `true` immediately unless the connection is classified as
* refuses writes on a read-only connection regardless of snooze or provider.
* Otherwise it resolves `true` immediately unless the connection is classified as
* production AND the operation is (or may be) a write, in which case a
* confirmation dialog is shown by `ProductionGuardProvider`. Ticking
* "don't ask again" silences the prompt for that connection until the
Expand All @@ -27,18 +30,24 @@ export const snoozedConnectionIds = new Set<string>();
export function useProductionGuard() {
const {connections} = useDatabase();
const request = useContext(ProductionGuardContext);
const {t} = useTranslation();
const {showAlert} = useAlert();

return useCallback(
async (connectionId: string | null | undefined, sql?: string): Promise<boolean> => {
if (!connectionId) return true;
const conn = connections.find((c) => c.id === connectionId);
if (conn?.params.read_only && (sql === undefined || !isReadOnlyQuery(sql))) {
showAlert(t('readOnly.writeBlocked'), {kind: 'warning'});
return false;
}
if (conn?.environment !== 'production') return true;
if (sql !== undefined && isReadOnlyQuery(sql)) return true;
if (snoozedConnectionIds.has(connectionId)) return true;
// No provider in this window: don't silently block the operation.
if (!request) return true;
return request(connectionId, conn.name, sql);
},
[connections, request],
[connections, request, showAlert, t],
);
}
7 changes: 7 additions & 0 deletions packages/web-ui/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@
"warnConfirm": "Trotzdem ausführen",
"warnSnooze": "Für diese Verbindung nicht mehr fragen (diese Sitzung)"
},
"readOnly": {
"label": "Schreibgeschützter Modus",
"description": "Blockiert Schreibzugriffe auf diese Verbindung. Startskripte werden in diesem Modus übersprungen.",
"unsupported": "Dieser Treiber unterstützt keine geschützten schreibgeschützten Abfragen.",
"writeBlocked": "Diese Verbindung ist schreibgeschützt. Deaktivieren Sie diesen Modus in den Verbindungseinstellungen, um Schreibzugriffe zu erlauben.",
"banner": "{{name}} — schreibgeschützt"
},
"textStats": {
"characters": "Zeichen: {{count}}",
"lines": "Zeilen: {{count}}",
Expand Down
7 changes: 7 additions & 0 deletions packages/web-ui/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@
"warnConfirm": "Run anyway",
"warnSnooze": "Don't ask again for this connection (this session)"
},
"readOnly": {
"label": "Read-only mode",
"description": "Block writes on this connection. Startup scripts are skipped while enabled.",
"unsupported": "This driver does not support protected read-only queries.",
"writeBlocked": "This connection is read-only. Disable read-only mode in its settings to allow writes.",
"banner": "{{name}} — read-only mode"
},
"textStats": {
"characters": "Characters: {{count}}",
"lines": "Lines: {{count}}",
Expand Down
7 changes: 7 additions & 0 deletions packages/web-ui/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@
"warnConfirm": "Ejecutar de todos modos",
"warnSnooze": "No volver a preguntar para esta conexión (esta sesión)"
},
"readOnly": {
"label": "Modo de solo lectura",
"description": "Bloquea las escrituras en esta conexión. Los scripts de inicio se omiten en este modo.",
"unsupported": "Este controlador no admite consultas protegidas de solo lectura.",
"writeBlocked": "Esta conexión es de solo lectura. Desactiva este modo en su configuración para permitir escrituras.",
"banner": "{{name}} — solo lectura"
},
"textStats": {
"characters": "Caracteres: {{count}}",
"lines": "Líneas: {{count}}",
Expand Down
7 changes: 7 additions & 0 deletions packages/web-ui/src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@
"warnConfirm": "Exécuter quand même",
"warnSnooze": "Ne plus demander pour cette connexion (cette session)"
},
"readOnly": {
"label": "Mode lecture seule",
"description": "Bloque les écritures sur cette connexion. Les scripts de démarrage sont ignorés dans ce mode.",
"unsupported": "Ce pilote ne prend pas en charge les requêtes protégées en lecture seule.",
"writeBlocked": "Cette connexion est en lecture seule. Désactivez ce mode dans ses paramètres pour autoriser les écritures.",
"banner": "{{name}} — mode lecture seule"
},
"textStats": {
"characters": "Caractères : {{count}}",
"lines": "Lignes : {{count}}",
Expand Down
7 changes: 7 additions & 0 deletions packages/web-ui/src/i18n/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,13 @@
"warnConfirm": "Esegui comunque",
"warnSnooze": "Non chiedere più per questa connessione (questa sessione)"
},
"readOnly": {
"label": "Modalità sola lettura",
"description": "Blocca le scritture su questa connessione. Gli script di avvio vengono ignorati in questa modalità.",
"unsupported": "Questo driver non supporta query protette in sola lettura.",
"writeBlocked": "Questa connessione è in sola lettura. Disattiva questa modalità nelle impostazioni per consentire le scritture.",
"banner": "{{name}} — sola lettura"
},
"textStats": {
"characters": "Caratteri: {{count}}",
"lines": "Righe: {{count}}",
Expand Down
7 changes: 7 additions & 0 deletions packages/web-ui/src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@
"warnConfirm": "実行する",
"warnSnooze": "この接続では今後確認しない(このセッション中)"
},
"readOnly": {
"label": "読み取り専用モード",
"description": "この接続への書き込みをブロックします。有効な間は起動スクリプトを実行しません。",
"unsupported": "このドライバーは保護された読み取り専用クエリに対応していません。",
"writeBlocked": "この接続は読み取り専用です。書き込みを許可するには接続設定でこのモードを無効にしてください。",
"banner": "{{name}} — 読み取り専用"
},
"textStats": {
"characters": "文字数: {{count}}",
"lines": "行数: {{count}}",
Expand Down
7 changes: 7 additions & 0 deletions packages/web-ui/src/i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -3178,5 +3178,12 @@
"installAndOpen": "설치 후 열기",
"dropTitle": "놓아서 테이블로 열기",
"dropHint": "CSV, TSV, JSON, NDJSON, Parquet"
},
"readOnly": {
"label": "읽기 전용 모드",
"description": "이 연결의 쓰기를 차단합니다. 활성화된 동안 시작 스크립트를 실행하지 않습니다.",
"unsupported": "이 드라이버는 보호된 읽기 전용 쿼리를 지원하지 않습니다.",
"writeBlocked": "이 연결은 읽기 전용입니다. 쓰기를 허용하려면 연결 설정에서 이 모드를 해제하세요.",
"banner": "{{name}} — 읽기 전용"
}
}
7 changes: 7 additions & 0 deletions packages/web-ui/src/i18n/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -3279,5 +3279,12 @@
"installAndOpen": "Instalar e abrir",
"dropTitle": "Solte para abrir como tabela",
"dropHint": "CSV, TSV, JSON, NDJSON, Parquet"
},
"readOnly": {
"label": "Modo somente leitura",
"description": "Bloqueia gravações nesta conexão. Os scripts de inicialização são ignorados neste modo.",
"unsupported": "Este driver não oferece consultas protegidas somente leitura.",
"writeBlocked": "Esta conexão é somente leitura. Desative este modo nas configurações para permitir gravações.",
"banner": "{{name}} — somente leitura"
}
}
7 changes: 7 additions & 0 deletions packages/web-ui/src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@
"warnConfirm": "Выполнить всё равно",
"warnSnooze": "Больше не спрашивать для этого подключения (в этой сессии)"
},
"readOnly": {
"label": "Режим только для чтения",
"description": "Блокирует запись через это подключение. Скрипты запуска в этом режиме пропускаются.",
"unsupported": "Этот драйвер не поддерживает защищённые запросы только для чтения.",
"writeBlocked": "Это подключение доступно только для чтения. Отключите этот режим в настройках подключения, чтобы разрешить запись.",
"banner": "{{name}} — только чтение"
},
"textStats": {
"characters": "Символов: {{count}}",
"lines": "Строк: {{count}}",
Expand Down
7 changes: 7 additions & 0 deletions packages/web-ui/src/i18n/locales/tl.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@
"warnConfirm": "Ituloy pa rin",
"warnSnooze": "Huwag nang itanong para sa koneksyong ito (ngayong session)"
},
"readOnly": {
"label": "Read-only na mode",
"description": "Hinaharangan ang pagsusulat sa koneksyong ito. Nilalaktawan ang mga startup script habang naka-enable.",
"unsupported": "Hindi sinusuportahan ng driver na ito ang mga protektadong read-only na query.",
"writeBlocked": "Read-only ang koneksyong ito. I-disable ang mode na ito sa mga setting nito upang payagan ang pagsusulat.",
"banner": "{{name}} — read-only"
},
"textStats": {
"characters": "Mga character: {{count}}",
"lines": "Mga linya: {{count}}",
Expand Down
Loading