diff --git a/apps/desktop/src/components/connection/ReadOnlySessionControl.vue b/apps/desktop/src/components/connection/ReadOnlySessionControl.vue new file mode 100644 index 0000000000..4bac382d83 --- /dev/null +++ b/apps/desktop/src/components/connection/ReadOnlySessionControl.vue @@ -0,0 +1,73 @@ + + + diff --git a/apps/desktop/src/components/consul/ConsulAcl.vue b/apps/desktop/src/components/consul/ConsulAcl.vue index 5f0ec7b172..b46a2a58a9 100644 --- a/apps/desktop/src/components/consul/ConsulAcl.vue +++ b/apps/desktop/src/components/consul/ConsulAcl.vue @@ -6,6 +6,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from " import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import NacosConfigDiffDialog from "@/components/nacos/NacosConfigDiffDialog.vue"; import { useConnectionStore } from "@/stores/connectionStore"; +import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess"; import * as api from "@/lib/backend/api"; import type { ConsulAclItem, ConsulAclKind, ConsulAclReferences, ConsulAclWrite, ConsulCapabilities } from "@/types/consul"; import { useI18n } from "vue-i18n"; @@ -13,7 +14,7 @@ import { useI18n } from "vue-i18n"; const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>(); const { t } = useI18n(); const store = useConnectionStore(); -const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only)); +const readOnly = computed(() => connectionIsEffectivelyReadOnly(store.getConfig(props.connectionId))); const active = ref("token"); const items = ref[]>([]); const loading = ref(false); diff --git a/apps/desktop/src/components/consul/ConsulKeyBrowser.vue b/apps/desktop/src/components/consul/ConsulKeyBrowser.vue index 3ca55fe3b0..8b3c04d1e6 100644 --- a/apps/desktop/src/components/consul/ConsulKeyBrowser.vue +++ b/apps/desktop/src/components/consul/ConsulKeyBrowser.vue @@ -15,6 +15,7 @@ import * as api from "@/lib/backend/api"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { kvExportFilenameStem, type KvExportScopeRequest } from "@/lib/kv/kvExportScope"; import { useConnectionStore } from "@/stores/connectionStore"; +import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess"; import { useConsulStore } from "@/stores/consulStore"; import { isCurrentWatchEvent, nextWatchIndex } from "@/lib/consul/watchState"; import type { ConsulDeletePrefixPreview, ConsulDeletePrefixReport, ConsulImportConflictPolicy, ConsulImportPreview, ConsulImportReport, ConsulKvBundle, ConsulSearchMatch, ConsulScope, ConsulWatchEvent } from "@/types/consul"; @@ -26,7 +27,7 @@ const connectionStore = useConnectionStore(); const consulStore = useConsulStore(); const browserRef = ref | null>(null); const fileInputRef = ref(); -const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only)); +const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId))); const consulConnections = computed(() => connectionStore.connections.filter((connection) => connection.db_type === "consul")); const transferTargetConnections = computed(() => (transferMode.value === "migration" ? consulConnections.value.filter((connection) => connection.id !== props.connectionId) : consulConnections.value)); @@ -66,7 +67,7 @@ const transferReport = ref(null); const transferLoading = ref(false); const transferApplying = ref(false); const transferError = ref(""); -const transferTargetReadOnly = computed(() => Boolean(connectionStore.getConfig(transferTargetId.value)?.read_only)); +const transferTargetReadOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(transferTargetId.value))); const deleteOpen = ref(false); const deletePrefix = ref(""); diff --git a/apps/desktop/src/components/consul/ConsulMesh.vue b/apps/desktop/src/components/consul/ConsulMesh.vue index cebfae8e19..dba6415f7b 100644 --- a/apps/desktop/src/components/consul/ConsulMesh.vue +++ b/apps/desktop/src/components/consul/ConsulMesh.vue @@ -10,13 +10,14 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import NacosConfigDiffDialog from "@/components/nacos/NacosConfigDiffDialog.vue"; import * as api from "@/lib/backend/api"; import { useConnectionStore } from "@/stores/connectionStore"; +import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess"; import type { ConsulCapabilities, ConsulConfigEntry, ConsulDiscoveryChain, ConsulExportedService, ConsulIntention, ConsulPeering } from "@/types/consul"; import { useI18n } from "vue-i18n"; type MeshTab = "config" | "intentions" | "discovery" | "peering" | "exported"; const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>(); const store = useConnectionStore(); -const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only)); +const readOnly = computed(() => connectionIsEffectivelyReadOnly(store.getConfig(props.connectionId))); const { t } = useI18n(); function capabilityLabel(value?: string) { if (value === "supported") return t("consul.ui.capabilitySupported"); diff --git a/apps/desktop/src/components/consul/ConsulOperator.vue b/apps/desktop/src/components/consul/ConsulOperator.vue index 67795f19ef..2722c3a43a 100644 --- a/apps/desktop/src/components/consul/ConsulOperator.vue +++ b/apps/desktop/src/components/consul/ConsulOperator.vue @@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label"; import * as api from "@/lib/backend/api"; import type { ConsulCapabilities, ConsulOperatorDocument, ConsulOperatorReadKind } from "@/types/consul"; import { useConnectionStore } from "@/stores/connectionStore"; +import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess"; import { useI18n } from "vue-i18n"; const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>(); @@ -26,7 +27,7 @@ const settings = computed(() => { const external = connectionStore.getConfig(props.connectionId)?.external_config; return external && typeof external === "object" && !Array.isArray(external) ? (external as Record) : {}; }); -const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only)); +const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId))); const autopilotWriteVisible = computed(() => settings.value.consulOperatorAutopilotWriteEnabled && props.capabilities?.operatorAutopilot === "supported"); const raftWriteVisible = computed(() => settings.value.consulOperatorRaftWriteEnabled && props.capabilities?.operatorRaft === "supported"); const keyringWriteVisible = computed(() => settings.value.consulOperatorKeyringWriteEnabled && props.capabilities?.operatorKeyring === "supported"); diff --git a/apps/desktop/src/components/consul/ConsulScope.vue b/apps/desktop/src/components/consul/ConsulScope.vue index 66f19671c0..8e0d5da5c5 100644 --- a/apps/desktop/src/components/consul/ConsulScope.vue +++ b/apps/desktop/src/components/consul/ConsulScope.vue @@ -6,13 +6,14 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from " import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import * as api from "@/lib/backend/api"; import { useConnectionStore } from "@/stores/connectionStore"; +import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess"; import { useI18n } from "vue-i18n"; import type { ConsulCapabilities, ConsulEnterpriseKind, ConsulEnterpriseWrite, ConsulScopeImpact } from "@/types/consul"; const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>(); const { t } = useI18n(); const store = useConnectionStore(); -const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only)); +const readOnly = computed(() => connectionIsEffectivelyReadOnly(store.getConfig(props.connectionId))); const active = ref("namespace"); const items = ref[]>([]); const loading = ref(false); diff --git a/apps/desktop/src/components/consul/ConsulSessions.vue b/apps/desktop/src/components/consul/ConsulSessions.vue index c621c0f161..609b1443cb 100644 --- a/apps/desktop/src/components/consul/ConsulSessions.vue +++ b/apps/desktop/src/components/consul/ConsulSessions.vue @@ -11,6 +11,7 @@ import { clampConsulPage, CONSUL_LIST_PAGE_SIZE, paginateConsulItems } from "@/l import { consulSessionRenewDelayMs, parseConsulDurationMs } from "@/lib/consul/sessionRenew"; import { normalizeConsulSession } from "@/lib/consul/sessionModel"; import { useConnectionStore } from "@/stores/connectionStore"; +import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess"; import { useConsulStore } from "@/stores/consulStore"; import { useI18n } from "vue-i18n"; import type { ConsulResponseMetadata, ConsulSession, ConsulSessionKeysResponse, ConsulSessionServiceCheck } from "@/types/consul"; @@ -19,7 +20,7 @@ const props = defineProps<{ connectionId: string }>(); const { t } = useI18n(); const store = useConnectionStore(); const consulStore = useConsulStore(); -const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only)); +const readOnly = computed(() => connectionIsEffectivelyReadOnly(store.getConfig(props.connectionId))); function behaviorLabel(behavior: string) { if (behavior === "delete") return t("consul.ui.behaviorDelete"); if (behavior === "release") return t("consul.ui.behaviorRelease"); diff --git a/apps/desktop/src/components/consul/ConsulTools.vue b/apps/desktop/src/components/consul/ConsulTools.vue index fc69c51777..6387b38151 100644 --- a/apps/desktop/src/components/consul/ConsulTools.vue +++ b/apps/desktop/src/components/consul/ConsulTools.vue @@ -7,6 +7,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import * as api from "@/lib/backend/api"; import type { ConsulCapabilities, ConsulCoordinate, ConsulEvent, ConsulPreparedQuery, ConsulPreparedQueryExecuteResponse, ConsulPreparedQueryNode } from "@/types/consul"; import { useConnectionStore } from "@/stores/connectionStore"; +import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess"; import { useI18n } from "vue-i18n"; type ToolTab = "queries" | "events" | "coordinates"; @@ -21,7 +22,7 @@ function capabilityLabel(value?: string) { return t("consul.ui.capabilityUnknown"); } const connectionStore = useConnectionStore(); -const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only)); +const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId))); const canWriteQueries = computed(() => !readOnly.value && props.capabilities?.preparedQueries === "supported"); const canFireEvents = computed(() => !readOnly.value && props.capabilities?.events === "supported"); const queryAvailable = computed(() => !["unsupported", "disabled", "forbidden"].includes(props.capabilities?.preparedQueries || "")); diff --git a/apps/desktop/src/components/document/MongoBucketBrowser.vue b/apps/desktop/src/components/document/MongoBucketBrowser.vue index aac92140df..f8e2600fa1 100644 --- a/apps/desktop/src/components/document/MongoBucketBrowser.vue +++ b/apps/desktop/src/components/document/MongoBucketBrowser.vue @@ -18,6 +18,7 @@ import { buildGridFsFilesStructuredFilter, createGridFsFileFilterRule, currentGr import { buildGridFsDownloadArchive, defaultGridFsArchiveFileName, formatGridFsUploadDate } from "@/lib/document/gridfsFiles"; import { clampGridFsPage, gridFsTotalPages, paginateGridFsItems } from "@/lib/document/gridfsPagination"; import { useConnectionStore } from "@/stores/connectionStore"; +import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess"; import { useSettingsStore } from "@/stores/settingsStore"; const props = defineProps<{ @@ -62,7 +63,7 @@ const partiallyChecked = computed(() => checkedFileIds.value.size > 0 && checked const downloadTargets = computed(() => (checkedFileCount.value > 0 ? checkedFiles.value : selectedFile.value ? [selectedFile.value] : [])); const downloadButtonLabel = computed(() => (checkedFileCount.value > 0 ? t("gridfsBrowser.downloadSelected") : t("gridfsBrowser.downloadFile"))); const selectedMetadata = computed(() => (selectedFile.value?.metadata ? JSON.stringify(selectedFile.value.metadata, null, 2) : "")); -const isReadonly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only ?? false); +const isReadonly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId))); const mongoProvider = documentStoreProviderFor("mongodb"); const activeStructuredRuleCount = computed(() => { if (!appliedStructuredFilter.value) return 0; diff --git a/apps/desktop/src/components/document/MongoGridFsBrowser.vue b/apps/desktop/src/components/document/MongoGridFsBrowser.vue index 6e92d0e35c..0466db3045 100644 --- a/apps/desktop/src/components/document/MongoGridFsBrowser.vue +++ b/apps/desktop/src/components/document/MongoGridFsBrowser.vue @@ -11,6 +11,7 @@ import { useToast } from "@/composables/useToast"; import * as api from "@/lib/backend/api"; import { currentGridFsBucketFilter, currentGridFsBucketSort, currentGridFsBucketSortDirection, gridFsBucketSortInputForColumn } from "@/lib/document/gridFsBrowser"; import { useConnectionStore } from "@/stores/connectionStore"; +import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess"; import { useQueryStore } from "@/stores/queryStore"; const props = defineProps<{ @@ -39,7 +40,7 @@ const filterBuilderOpen = ref(false); const selectedBucket = computed(() => buckets.value.find((bucket) => bucket.name === selectedBucketName.value) || null); const totalFiles = computed(() => buckets.value.reduce((sum, bucket) => sum + bucket.fileCount, 0)); const totalBytes = computed(() => buckets.value.reduce((sum, bucket) => sum + bucket.totalBytes, 0)); -const isReadonly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only ?? false); +const isReadonly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId))); function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; diff --git a/apps/desktop/src/components/dolt/DoltVersionControl.vue b/apps/desktop/src/components/dolt/DoltVersionControl.vue index 6739caf787..86caa70529 100644 --- a/apps/desktop/src/components/dolt/DoltVersionControl.vue +++ b/apps/desktop/src/components/dolt/DoltVersionControl.vue @@ -60,6 +60,7 @@ import { type DoltClientSessionScope, } from "@/lib/dolt/doltVersionControl"; import { useConnectionStore } from "@/stores/connectionStore"; +import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess"; import { useQueryStore } from "@/stores/queryStore"; import { useToast } from "@/composables/useToast"; import { copyToClipboard } from "@/lib/common/clipboard"; @@ -240,7 +241,7 @@ const tableDiffPageSize = ref(DOLT_DIFF_DEFAULT_PAGE_SIZE); const tableDiffTotalRows = ref(0); const tableDiffMaximumPage = computed(() => Math.max(1, Math.ceil(tableDiffTotalRows.value / tableDiffPageSize.value))); const selectedRevisionKeySet = computed(() => new Set(selectedRevisionKeys.value)); -const connectionReadOnly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only ?? false); +const connectionReadOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId))); const createsNamedRef = computed(() => branchDialog.value === "create" || branchDialog.value === "create-tag"); const mutationDialogTitle = computed(() => { if (branchDialog.value === "create") return t("doltVersionControl.createBranch"); diff --git a/apps/desktop/src/components/etcd/EtcdAccessControl.vue b/apps/desktop/src/components/etcd/EtcdAccessControl.vue index 80a77415b3..76072b2d1c 100644 --- a/apps/desktop/src/components/etcd/EtcdAccessControl.vue +++ b/apps/desktop/src/components/etcd/EtcdAccessControl.vue @@ -8,6 +8,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from " import { Input } from "@/components/ui/input"; import * as api from "@/lib/backend/api"; import { useConnectionStore } from "@/stores/connectionStore"; +import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess"; type AccessView = "users" | "roles"; type PermissionAccess = "read" | "write" | "readwrite"; @@ -52,7 +53,7 @@ const approvalExpected = ref(""); let pendingApproval: (() => Promise) | null = null; let detailRequest = 0; -const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only)); +const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId))); const selectedUserRoles = computed(() => userDetail.value?.roles ?? []); const grantableRoles = computed(() => roles.value.filter((role) => !selectedUserRoles.value.includes(role))); const initialUserRoleLabel = computed(() => (newUserRoles.value.length ? t("etcd.access.createAndAssignRoles", { count: newUserRoles.value.length }) : t("etcd.access.createUserAction"))); diff --git a/apps/desktop/src/components/etcd/EtcdKeyBrowser.vue b/apps/desktop/src/components/etcd/EtcdKeyBrowser.vue index 97889663fb..8b0c1b5322 100644 --- a/apps/desktop/src/components/etcd/EtcdKeyBrowser.vue +++ b/apps/desktop/src/components/etcd/EtcdKeyBrowser.vue @@ -17,6 +17,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { isKeyInKvExportScope, kvExportFilenameStem, kvValueByteIdentity, type KvExportScopeKind, type KvExportScopeRequest } from "@/lib/kv/kvExportScope"; import { detectKvValueFormat } from "@/lib/kv/kvValueFormat"; import { useConnectionStore } from "@/stores/connectionStore"; +import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess"; type WorkbenchMode = "keys" | "search" | "maintenance" | "watch" | "lease"; type SearchScope = "key" | "value" | "all"; @@ -153,9 +154,9 @@ const transferLoadingDetail = ref(""); const transferPreviewLoaded = ref(false); const syncConfigurationExpanded = ref(true); -const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only)); +const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId))); const etcdConnections = computed(() => connectionStore.connections.filter((connection) => connection.db_type === "etcd")); -const targetReadOnly = computed(() => Boolean(connectionStore.getConfig(targetConnectionId.value)?.read_only)); +const targetReadOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(targetConnectionId.value))); const selectedTransferRows = computed(() => transferRows.value.filter((row) => row.selected && isTransferRowSelectable(row))); const selectableTransferRows = computed(() => transferRows.value.filter(isTransferRowSelectable)); const filteredTransferRows = computed(() => { diff --git a/apps/desktop/src/components/hbase/HBaseBrowser.vue b/apps/desktop/src/components/hbase/HBaseBrowser.vue index 99a42e8201..ee647a8bbc 100644 --- a/apps/desktop/src/components/hbase/HBaseBrowser.vue +++ b/apps/desktop/src/components/hbase/HBaseBrowser.vue @@ -17,6 +17,7 @@ import type { CellValue } from "@/lib/dataGrid/cellValue"; import { encodeHBaseTextInput, hbaseCellInput } from "@/lib/hbase/hbaseValues"; import { loadHBaseRowLimit, saveHBaseRowLimit } from "@/lib/hbase/hbaseBrowserPreferences"; import { useConnectionStore } from "@/stores/connectionStore"; +import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess"; import { useQueryStore } from "@/stores/queryStore"; import type { QueryResult } from "@/types/database"; import type { HBaseCellInput, HBasePutRowInput, HBaseRow, HBaseTableSchema, HBaseValueEncoding } from "@/types/hbase"; @@ -61,7 +62,7 @@ const deleteTableLoading = ref(false); const deleteTableError = ref(""); const hasTable = computed(() => props.table.trim().length > 0); -const readOnly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only === true); +const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId))); const qualifiedTableLabel = computed(() => { if (!hasTable.value) return props.namespace; return props.namespace && props.namespace !== "default" ? `${props.namespace}:${props.table}` : props.table; diff --git a/apps/desktop/src/components/import/TableImportDialog.vue b/apps/desktop/src/components/import/TableImportDialog.vue index 73cef8bcd8..6c23b05405 100644 --- a/apps/desktop/src/components/import/TableImportDialog.vue +++ b/apps/desktop/src/components/import/TableImportDialog.vue @@ -10,6 +10,7 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { AlertTriangle, ArrowLeft, ArrowRight, Check, CheckCircle2, FileCode, FileJson, FileSpreadsheet, FileText, FileUp, Loader2, RefreshCw, Square, Upload, X } from "@lucide/vue"; import { useConnectionStore } from "@/stores/connectionStore"; +import { ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess"; import { useSettingsStore } from "@/stores/settingsStore"; import { useToast } from "@/composables/useToast"; import { @@ -713,6 +714,9 @@ async function goNext() { async function startImport() { saveActiveBatchTask(); + if (!(await ensureReadOnlyWriteAccess({ connection: store.getConfig(props.prefillConnectionId ?? ""), source: t("readOnlyUnlock.sourceImport"), treatAsMutation: true }))) { + return; + } if (isBatchImport.value) { await startBatchImport(); return; diff --git a/apps/desktop/src/components/layout/AppDialogs.vue b/apps/desktop/src/components/layout/AppDialogs.vue index 8c33c2bfae..c87afef9fd 100644 --- a/apps/desktop/src/components/layout/AppDialogs.vue +++ b/apps/desktop/src/components/layout/AppDialogs.vue @@ -1,5 +1,5 @@