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
9 changes: 9 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(grep -r \"$util\" /c/github/beekeeper-studio/apps/studio/src --include=\"*.ts\" -l)",
"Bash(yarn build:esbuild)",
"Bash(yarn build:vite)"
]
}
}
2 changes: 2 additions & 0 deletions apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
"@aws-sdk/credential-providers": "^3.1000.0",
"@aws-sdk/rds-signer": "^3.1000.0",
"@aws-sdk/shared-ini-file-loader": "^3.374.0",
"@azure/identity": "^4.13.1",
"@azure/keyvault-secrets": "^4.10.0",
"@azure/msal-node": "^2.12.0",
"@babel/core": "^7.29.0",
"@babel/plugin-transform-class-static-block": "^7.26.0",
Expand Down
4 changes: 3 additions & 1 deletion apps/studio/src-commercial/backend/handlers/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { IFileHandlers } from "@/handlers/fileHandlers";
import { IGeneratorHandlers } from "@/handlers/generatorHandlers";
import { IQueryHandlers } from "@/handlers/queryHandlers";
import { ITempHandlers } from "@/handlers/tempHandlers";
import { IAzureVaultHandlers } from "@/handlers/azureVaultHandlers";

// commercial
import { IConnectionHandlers } from "./connHandlers";
Expand All @@ -21,4 +22,5 @@ export interface Handlers
IFileHandlers,
IEnumHandlers,
ITempHandlers,
IAwsHandlers {}
IAwsHandlers,
IAzureVaultHandlers {}
2 changes: 2 additions & 0 deletions apps/studio/src-commercial/entrypoints/utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { TabHistoryHandlers } from '@/handlers/tabHistoryHandlers'
import { ExportHandlers } from '@commercial/backend/handlers/exportHandlers';
import { BackupHandlers } from '@commercial/backend/handlers/backupHandlers';
import { AwsHandlers } from '@commercial/backend/handlers/awsHandlers';
import { AzureVaultHandlers } from '@/handlers/azureVaultHandlers';
import { ImportHandlers } from '@commercial/backend/handlers/importHandlers';
import { EnumHandlers } from '@commercial/backend/handlers/enumHandlers';
import { TempHandlers } from '@/handlers/tempHandlers';
Expand Down Expand Up @@ -70,6 +71,7 @@ export const handlers: Handlers = {
...TabHistoryHandlers,
...LockHandlers,
...FormatterPresetHandlers,
...AzureVaultHandlers,
...(platformInfo.isDevelopment && DevHandlers),
};

Expand Down
3 changes: 3 additions & 0 deletions apps/studio/src/common/appdb/models/saved_connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,9 @@ export class SavedConnection extends DbConnectionBase implements IConnection {
@JoinColumn({ name: 'connectionFolderId' })
connectionFolder?: ConnectionFolder

@Column({ type: 'varchar', nullable: true })
vaultSecretName: Nullable<string> = null

@Column({type: 'varchar', nullable: true, transformer: [encrypt]})
password: Nullable<string> = null

Expand Down
1 change: 1 addition & 0 deletions apps/studio/src/common/interfaces/IConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export interface IConnection extends ISimpleConnection {
password: Nullable<string>
sshPassword: Nullable<string>
sshKeyfilePassword: Nullable<string>
vaultSecretName?: Nullable<string>
}

export interface ICloudSavedConnection extends IConnection {
Expand Down
8 changes: 6 additions & 2 deletions apps/studio/src/components/connection/CommonServerInputs.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template>
<div class="host-port-user-password">
<slot name="header" />
<vault-loader v-if="vaultEnabled" :config="config" />
<div class="row">
<div
class="form-group col"
Expand Down Expand Up @@ -119,7 +120,8 @@
import { findClient } from '@/lib/db/clients'
import MaskedInput from '@/components/MaskedInput.vue'
import CommonSsl from './CommonSsl.vue'
import { mapState } from 'vuex'
import VaultLoader from './VaultLoader.vue'
import { mapState, mapGetters } from 'vuex'

export default {
props: {
Expand All @@ -136,7 +138,8 @@ export default {
},
components: {
MaskedInput,
CommonSsl
CommonSsl,
VaultLoader,
},
data() {
return {
Expand All @@ -145,6 +148,7 @@ export default {
},
computed: {
...mapState('settings', ['privacyMode']),
...mapGetters('azureVault', { vaultEnabled: 'enabled' }),
togglePasswordIcon() {
return this.showPassword ? "visibility_off" : "visibility"
},
Expand Down
186 changes: 186 additions & 0 deletions apps/studio/src/components/connection/VaultLoader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<template>
<div class="vault-loader">
<div class="vault-loader-header" @click.prevent="expanded = !expanded">
<i class="material-icons">lock</i>
<span>Azure Key Vault</span>
<i class="material-icons expand-icon">{{ expanded ? 'expand_less' : 'expand_more' }}</i>
</div>

<div v-if="expanded" class="vault-loader-body">
<div class="row gutter">
<div class="col form-group expand">
<label>Secret Name</label>
<div class="secret-input-wrap">
<input
:type="showSecret ? 'text' : 'password'"
class="form-control"
:value="config.vaultSecretName || ''"
@input="config.vaultSecretName = $event.target.value"
placeholder="e.g. prod-orders-db"
@keydown.enter.prevent="load"
/>
<i
class="material-icons secret-toggle"
@click.prevent="showSecret = !showSecret"
:title="showSecret ? 'Hide secret name' : 'Show secret name'"
>{{ showSecret ? 'visibility_off' : 'visibility' }}</i>
</div>
</div>
<div class="col form-group load-btn-col">
<button
class="btn btn-primary"
:disabled="!config.vaultSecretName || loading"
@click.prevent="load"
>
<i class="material-icons" v-if="!loading">download</i>
<i class="material-icons spin" v-else>refresh</i>
Load
</button>
</div>
</div>

<div v-if="status" :class="['vault-status', status.type]">
<i class="material-icons">{{ status.type === 'success' ? 'check_circle' : 'error' }}</i>
{{ status.message }}
</div>
</div>
</div>
</template>

<script lang="ts">
import Vue from 'vue'
import { mapGetters } from 'vuex'
import { AzureVaultConfig } from '@/lib/azure/AzureVaultService'

export default Vue.extend({
props: {
config: {
type: Object,
required: true,
},
},
data() {
return {
expanded: true,
showSecret: false,
loading: false,
status: null as { type: 'success' | 'error'; message: string } | null,
}
},
computed: {
...mapGetters('azureVault', { vaultConfig: 'config' }),
},
methods: {
async load() {
if (!this.config.vaultSecretName) return
this.loading = true
this.status = null
try {
const result = await this.$util.send('azure-vault/get-secret', {
config: this.vaultConfig as AzureVaultConfig,
secretName: this.config.vaultSecretName,
})

const mapped: string[] = []

if (result.host !== undefined) { this.config.host = result.host; mapped.push('host') }
if (result.port !== undefined) { this.config.port = parseInt(result.port, 10) || result.port; mapped.push('port') }
if (result.username !== undefined) { this.config.username = result.username; mapped.push('username') }
if (result.password !== undefined) { this.config.password = result.password; mapped.push('password') }
if (result.defaultDatabase !== undefined) { this.config.defaultDatabase = result.defaultDatabase; mapped.push('database') }
if (result.sslCa !== undefined) { this.$set(this.config, 'sslCaFile', result.sslCa); mapped.push('SSL CA') }
if (result.sslCert !== undefined) { this.$set(this.config, 'sslCertFile', result.sslCert); mapped.push('SSL cert') }
if (result.sslKey !== undefined) { this.$set(this.config, 'sslKeyFile', result.sslKey); mapped.push('SSL key') }
if (result.sshHost !== undefined) { this.$set(this.config, 'sshHost', result.sshHost); mapped.push('SSH host') }
if (result.sshPort !== undefined) { this.$set(this.config, 'sshPort', result.sshPort); mapped.push('SSH port') }
if (result.sshUsername !== undefined) { this.$set(this.config, 'sshUsername', result.sshUsername); mapped.push('SSH user') }
if (result.sshPassword !== undefined) { this.$set(this.config, 'sshPassword', result.sshPassword); mapped.push('SSH password') }

if (mapped.length === 0) {
this.status = { type: 'error', message: 'Secret found but no fields matched the configured mappings.' }
} else {
this.status = { type: 'success', message: `Loaded: ${mapped.join(', ')}` }
}
} catch (e) {
this.status = { type: 'error', message: e?.message ?? String(e) }
} finally {
this.loading = false
}
},
},
})
</script>

<style lang="scss" scoped>
.vault-loader {
border: 1px solid var(--border-color);
border-radius: 4px;
margin-bottom: 1rem;
}

.vault-loader-header {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
user-select: none;
font-size: 0.875rem;
font-weight: 600;

.material-icons { font-size: 1.1rem; }

.expand-icon { margin-left: auto; }
}

.vault-loader-body {
padding: 0.5rem 0.75rem 0.75rem;
border-top: 1px solid var(--border-color);
}

.secret-input-wrap {
position: relative;

.form-control {
padding-right: 2rem;
}

.secret-toggle {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
font-size: 1.1rem;
color: var(--text-dark);
user-select: none;
}
}

.load-btn-col {
flex: 0 0 auto;
padding-top: 1.5rem;
}

.vault-status {
display: flex;
align-items: flex-start;
gap: 0.35rem;
font-size: 0.8rem;
margin-top: 0.25rem;

.material-icons { font-size: 1rem; margin-top: 1px; }

&.success { color: var(--theme-success, #4caf50); }
&.error { color: var(--theme-danger, #f44336); }
}

.spin {
animation: spin 1s linear infinite;
}

@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
Loading