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 @@
+
+
+
+
+
+
+
+ {{ tooltip }}
+
+
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 @@