forked from jsnbuchanan/crowd-funder-for-time-pwa
add encryption & decryption for the sensitive identity & mnemonic in SQL DB
This commit is contained in:
@@ -62,7 +62,7 @@ backup and database export, with platform-specific download instructions. * *
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Prop, Vue } from "vue-facing-decorator";
|
import { Component, Prop, Vue } from "vue-facing-decorator";
|
||||||
import { NotificationIface } from "../constants/app";
|
import { AppString, NotificationIface } from "../constants/app";
|
||||||
import { db } from "../db/index";
|
import { db } from "../db/index";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
||||||
@@ -145,7 +145,7 @@ export default class DataExportSection extends Vue {
|
|||||||
return { value, key };
|
return { value, key };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const fileName = `${db.name}-backup.json`;
|
const fileName = `${AppString.APP_NAME_NO_SPACES}-backup.json`;
|
||||||
|
|
||||||
if (this.platformCapabilities.hasFileDownload) {
|
if (this.platformCapabilities.hasFileDownload) {
|
||||||
// Web platform: Use download link
|
// Web platform: Use download link
|
||||||
@@ -159,6 +159,8 @@ export default class DataExportSection extends Vue {
|
|||||||
// Native platform: Write to app directory
|
// Native platform: Write to app directory
|
||||||
const content = await blob.text();
|
const content = await blob.text();
|
||||||
await this.platformService.writeAndShareFile(fileName, content);
|
await this.platformService.writeAndShareFile(fileName, content);
|
||||||
|
} else {
|
||||||
|
throw new Error("This platform does not support file downloads.");
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$notify(
|
this.$notify(
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const IMAGE_TYPE_PROFILE = "profile";
|
|||||||
export const PASSKEYS_ENABLED =
|
export const PASSKEYS_ENABLED =
|
||||||
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
|
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
|
||||||
|
|
||||||
export const USE_DEXIE_DB = false;
|
export const USE_DEXIE_DB = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The possible values for "group" and "type" are in App.vue.
|
* The possible values for "group" and "type" are in App.vue.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import migrationService from "../services/migrationService";
|
import migrationService from "../services/migrationService";
|
||||||
import type { QueryExecResult, SqlValue } from "../interfaces/database";
|
import type { QueryExecResult, SqlValue } from "../interfaces/database";
|
||||||
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
|
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
|
||||||
|
import { arrayBufferToBase64 } from "@/libs/crypto";
|
||||||
|
|
||||||
// Generate a random secret for the secret table
|
// Generate a random secret for the secret table
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
|
|||||||
// where they couldn't take action because they couldn't unlock that identity.)
|
// where they couldn't take action because they couldn't unlock that identity.)
|
||||||
|
|
||||||
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||||
const secret = btoa(String.fromCharCode(...randomBytes));
|
const secretBase64 = arrayBufferToBase64(randomBytes);
|
||||||
|
|
||||||
// Each migration can include multiple SQL statements (with semicolons)
|
// Each migration can include multiple SQL statements (with semicolons)
|
||||||
const MIGRATIONS = [
|
const MIGRATIONS = [
|
||||||
@@ -51,7 +52,7 @@ const MIGRATIONS = [
|
|||||||
secretBase64 TEXT NOT NULL
|
secretBase64 TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO secret (id, secretBase64) VALUES (1, '${secret}');
|
INSERT INTO secret (id, secretBase64) VALUES (1, '${secretBase64}');
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ export type Account = {
|
|||||||
publicKeyHex: string;
|
publicKeyHex: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AccountEncrypted = Account & {
|
||||||
|
identityEncrBase64: string;
|
||||||
|
mnemonicEncrBase64: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema for the accounts table in the database.
|
* Schema for the accounts table in the database.
|
||||||
* Fields starting with a $ character are encrypted.
|
* Fields starting with a $ character are encrypted.
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export const nextDerivationPath = (origDerivPath: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Base64 encoding/decoding utilities for browser
|
// Base64 encoding/decoding utilities for browser
|
||||||
function base64ToArrayBuffer(base64: string): Uint8Array {
|
export function base64ToArrayBuffer(base64: string): Uint8Array {
|
||||||
const binaryString = atob(base64);
|
const binaryString = atob(base64);
|
||||||
const bytes = new Uint8Array(binaryString.length);
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
for (let i = 0; i < binaryString.length; i++) {
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
@@ -168,7 +168,7 @@ function base64ToArrayBuffer(base64: string): Uint8Array {
|
|||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||||
const binary = String.fromCharCode(...new Uint8Array(buffer));
|
const binary = String.fromCharCode(...new Uint8Array(buffer));
|
||||||
return btoa(binary);
|
return btoa(binary);
|
||||||
}
|
}
|
||||||
@@ -178,7 +178,7 @@ const IV_LENGTH = 12;
|
|||||||
const KEY_LENGTH = 256;
|
const KEY_LENGTH = 256;
|
||||||
const ITERATIONS = 100000;
|
const ITERATIONS = 100000;
|
||||||
|
|
||||||
// Encryption helper function
|
// Message encryption helper function, used for onboarding meeting messages
|
||||||
export async function encryptMessage(message: string, password: string) {
|
export async function encryptMessage(message: string, password: string) {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
||||||
@@ -226,7 +226,7 @@ export async function encryptMessage(message: string, password: string) {
|
|||||||
return btoa(JSON.stringify(result));
|
return btoa(JSON.stringify(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decryption helper function
|
// Message decryption helper function, used for onboarding meeting messages
|
||||||
export async function decryptMessage(encryptedJson: string, password: string) {
|
export async function decryptMessage(encryptedJson: string, password: string) {
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const { salt, iv, encrypted } = JSON.parse(atob(encryptedJson));
|
const { salt, iv, encrypted } = JSON.parse(atob(encryptedJson));
|
||||||
@@ -311,17 +311,17 @@ export async function testMessageEncryptionDecryption() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple encryption/decryption using Node's crypto
|
// Simple encryption using Node's crypto, used for the initial encryption of the identity and mnemonic
|
||||||
export async function simpleEncrypt(
|
export async function simpleEncrypt(
|
||||||
text: string,
|
text: string,
|
||||||
secret: string,
|
secret: ArrayBuffer,
|
||||||
): Promise<string> {
|
): Promise<ArrayBuffer> {
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
const iv = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
|
||||||
// Derive a 256-bit key from the secret using SHA-256
|
// Derive a 256-bit key from the secret using SHA-256
|
||||||
const keyData = await crypto.subtle.digest(
|
const keyData = await crypto.subtle.digest(
|
||||||
"SHA-256",
|
"SHA-256",
|
||||||
new TextEncoder().encode(secret),
|
secret,
|
||||||
);
|
);
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await crypto.subtle.importKey(
|
||||||
"raw",
|
"raw",
|
||||||
@@ -342,14 +342,15 @@ export async function simpleEncrypt(
|
|||||||
result.set(iv);
|
result.set(iv);
|
||||||
result.set(new Uint8Array(encrypted), iv.length);
|
result.set(new Uint8Array(encrypted), iv.length);
|
||||||
|
|
||||||
return btoa(String.fromCharCode(...result));
|
return result.buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simple decryption using Node's crypto, used for the default decryption of identity and mnemonic
|
||||||
export async function simpleDecrypt(
|
export async function simpleDecrypt(
|
||||||
encryptedText: string,
|
encryptedText: ArrayBuffer,
|
||||||
secret: string,
|
secret: ArrayBuffer,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const data = Uint8Array.from(atob(encryptedText), (c) => c.charCodeAt(0));
|
const data = new Uint8Array(encryptedText);
|
||||||
|
|
||||||
// Extract IV and encrypted data
|
// Extract IV and encrypted data
|
||||||
const iv = data.slice(0, 16);
|
const iv = data.slice(0, 16);
|
||||||
@@ -358,7 +359,7 @@ export async function simpleDecrypt(
|
|||||||
// Derive the same 256-bit key from the secret using SHA-256
|
// Derive the same 256-bit key from the secret using SHA-256
|
||||||
const keyData = await crypto.subtle.digest(
|
const keyData = await crypto.subtle.digest(
|
||||||
"SHA-256",
|
"SHA-256",
|
||||||
new TextEncoder().encode(secret),
|
secret,
|
||||||
);
|
);
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await crypto.subtle.importKey(
|
||||||
"raw",
|
"raw",
|
||||||
@@ -381,18 +382,20 @@ export async function simpleDecrypt(
|
|||||||
export async function testSimpleEncryptionDecryption() {
|
export async function testSimpleEncryptionDecryption() {
|
||||||
try {
|
try {
|
||||||
const testMessage = "Hello, this is a test message! 🚀";
|
const testMessage = "Hello, this is a test message! 🚀";
|
||||||
const testSecret = "myTestSecret123";
|
const testSecret = crypto.getRandomValues(new Uint8Array(32));
|
||||||
|
|
||||||
logger.log("Original message:", testMessage);
|
logger.log("Original message:", testMessage);
|
||||||
|
|
||||||
// Test encryption
|
// Test encryption
|
||||||
logger.log("Encrypting...");
|
logger.log("Encrypting...");
|
||||||
const encrypted = await simpleEncrypt(testMessage, testSecret);
|
const encrypted = await simpleEncrypt(testMessage, testSecret);
|
||||||
logger.log("Encrypted result:", encrypted);
|
const encryptedBase64 = arrayBufferToBase64(encrypted);
|
||||||
|
logger.log("Encrypted result:", encryptedBase64);
|
||||||
|
|
||||||
// Test decryption
|
// Test decryption
|
||||||
logger.log("Decrypting...");
|
logger.log("Decrypting...");
|
||||||
const decrypted = await simpleDecrypt(encrypted, testSecret);
|
const encryptedArrayBuffer = base64ToArrayBuffer(encryptedBase64);
|
||||||
|
const decrypted = await simpleDecrypt(encryptedArrayBuffer, testSecret);
|
||||||
logger.log("Decrypted result:", decrypted);
|
logger.log("Decrypted result:", decrypted);
|
||||||
|
|
||||||
// Verify
|
// Verify
|
||||||
@@ -403,7 +406,7 @@ export async function testSimpleEncryptionDecryption() {
|
|||||||
// Test with wrong secret
|
// Test with wrong secret
|
||||||
logger.log("\nTesting with wrong secret...");
|
logger.log("\nTesting with wrong secret...");
|
||||||
try {
|
try {
|
||||||
await simpleDecrypt(encrypted, "wrongSecret");
|
await simpleDecrypt(encryptedArrayBuffer, new Uint8Array(32));
|
||||||
logger.log("Incorrectly decrypted with wrong secret ❌");
|
logger.log("Incorrectly decrypted with wrong secret ❌");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.log("Correctly failed to decrypt with wrong secret ✅");
|
logger.log("Correctly failed to decrypt with wrong secret ✅");
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ import {
|
|||||||
updateAccountSettings,
|
updateAccountSettings,
|
||||||
updateDefaultSettings,
|
updateDefaultSettings,
|
||||||
} from "../db/index";
|
} from "../db/index";
|
||||||
import { Account } from "../db/tables/accounts";
|
import { Account, AccountEncrypted } from "../db/tables/accounts";
|
||||||
import { Contact } from "../db/tables/contacts";
|
import { Contact } from "../db/tables/contacts";
|
||||||
import * as databaseUtil from "../db/databaseUtil";
|
import * as databaseUtil from "../db/databaseUtil";
|
||||||
import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings";
|
import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings";
|
||||||
import { deriveAddress, generateSeed, newIdentifier } from "../libs/crypto";
|
import { arrayBufferToBase64, base64ToArrayBuffer, deriveAddress, generateSeed, newIdentifier, simpleDecrypt, simpleEncrypt } from "../libs/crypto";
|
||||||
import * as serverUtil from "../libs/endorserServer";
|
import * as serverUtil from "../libs/endorserServer";
|
||||||
import {
|
import {
|
||||||
containsHiddenDid,
|
containsHiddenDid,
|
||||||
@@ -466,9 +466,17 @@ export function findAllVisibleToDids(
|
|||||||
export interface AccountKeyInfo extends Account, KeyMeta {}
|
export interface AccountKeyInfo extends Account, KeyMeta {}
|
||||||
|
|
||||||
export const retrieveAccountCount = async (): Promise<number> => {
|
export const retrieveAccountCount = async (): Promise<number> => {
|
||||||
// one of the few times we use accountsDBPromise directly; try to avoid more usage
|
let result;
|
||||||
const accountsDB = await accountsDBPromise;
|
const platformService = PlatformServiceFactory.getInstance();
|
||||||
return await accountsDB.accounts.count();
|
const dbResult = await platformService.dbQuery(`SELECT COUNT(*) FROM accounts`);
|
||||||
|
result = dbResult.values[0][0] as number;
|
||||||
|
|
||||||
|
if (USE_DEXIE_DB) {
|
||||||
|
// one of the few times we use accountsDBPromise directly; try to avoid more usage
|
||||||
|
const accountsDB = await accountsDBPromise;
|
||||||
|
result = await accountsDB.accounts.count();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const retrieveAccountDids = async (): Promise<string[]> => {
|
export const retrieveAccountDids = async (): Promise<string[]> => {
|
||||||
@@ -513,13 +521,35 @@ export const retrieveAllAccountsMetadata = async (): Promise<Account[]> => {
|
|||||||
export const retrieveFullyDecryptedAccount = async (
|
export const retrieveFullyDecryptedAccount = async (
|
||||||
activeDid: string,
|
activeDid: string,
|
||||||
): Promise<AccountKeyInfo | undefined> => {
|
): Promise<AccountKeyInfo | undefined> => {
|
||||||
// one of the few times we use accountsDBPromise directly; try to avoid more usage
|
let result: AccountKeyInfo | undefined = undefined;
|
||||||
const accountsDB = await accountsDBPromise;
|
const platformService = PlatformServiceFactory.getInstance();
|
||||||
const account = (await accountsDB.accounts
|
const dbSecrets = await platformService.dbQuery(`SELECT secretBase64 from secret`);
|
||||||
.where("did")
|
if (!dbSecrets || dbSecrets.values.length === 0 || dbSecrets.values[0].length === 0) {
|
||||||
.equals(activeDid)
|
throw new Error("No secret found. We recommend you clear your data and start over.");
|
||||||
.first()) as Account;
|
}
|
||||||
return account;
|
const secretBase64 = dbSecrets.values[0][0] as string;
|
||||||
|
const secret = base64ToArrayBuffer(secretBase64);
|
||||||
|
const dbAccount = await platformService.dbQuery(`SELECT * FROM accounts WHERE did = ?`, [activeDid]);
|
||||||
|
if (!dbAccount || dbAccount.values.length === 0 || dbAccount.values[0].length === 0) {
|
||||||
|
throw new Error("Account not found.");
|
||||||
|
}
|
||||||
|
const fullAccountData = databaseUtil.mapColumnsToValues(dbAccount.columns, dbAccount.values)[0] as AccountEncrypted;
|
||||||
|
const identityEncr = base64ToArrayBuffer(fullAccountData.identityEncrBase64);
|
||||||
|
const mnemonicEncr = base64ToArrayBuffer(fullAccountData.mnemonicEncrBase64);
|
||||||
|
fullAccountData.identity = await simpleDecrypt(identityEncr, secret);
|
||||||
|
fullAccountData.mnemonic = await simpleDecrypt(mnemonicEncr, secret);
|
||||||
|
result = fullAccountData;
|
||||||
|
|
||||||
|
if (USE_DEXIE_DB) {
|
||||||
|
// one of the few times we use accountsDBPromise directly; try to avoid more usage
|
||||||
|
const accountsDB = await accountsDBPromise;
|
||||||
|
const account = (await accountsDB.accounts
|
||||||
|
.where("did")
|
||||||
|
.equals(activeDid)
|
||||||
|
.first()) as Account;
|
||||||
|
result = account;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// let's try and eliminate this
|
// let's try and eliminate this
|
||||||
@@ -548,15 +578,25 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
|
|||||||
try {
|
try {
|
||||||
// add to the new sql db
|
// add to the new sql db
|
||||||
const platformService = PlatformServiceFactory.getInstance();
|
const platformService = PlatformServiceFactory.getInstance();
|
||||||
|
const secrets = await platformService.dbQuery(`SELECT secretBase64 FROM secret`);
|
||||||
|
if (secrets.values.length === 0 || secrets.values[0].length === 0) {
|
||||||
|
throw new Error("No initial encryption supported. We recommend you clear your data and start over.");
|
||||||
|
}
|
||||||
|
const secretBase64 = secrets.values[0][0] as string;
|
||||||
|
const secret = base64ToArrayBuffer(secretBase64);
|
||||||
|
const encryptedIdentity = await simpleEncrypt(identity, secret);
|
||||||
|
const encryptedMnemonic = await simpleEncrypt(mnemonic, secret);
|
||||||
|
const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity);
|
||||||
|
const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic);
|
||||||
await platformService.dbExec(
|
await platformService.dbExec(
|
||||||
`INSERT INTO accounts (dateCreated, derivationPath, did, identity, mnemonic, publicKeyHex)
|
`INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
derivationPath,
|
derivationPath,
|
||||||
newId.did,
|
newId.did,
|
||||||
identity,
|
encryptedIdentityBase64,
|
||||||
mnemonic,
|
encryptedMnemonicBase64,
|
||||||
newId.keys[0].publicKeyHex,
|
newId.keys[0].publicKeyHex,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -109,9 +109,10 @@ import { Component, Vue } from "vue-facing-decorator";
|
|||||||
import { useClipboard } from "@vueuse/core";
|
import { useClipboard } from "@vueuse/core";
|
||||||
|
|
||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
import { NotificationIface } from "../constants/app";
|
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||||
import { Account } from "../db/tables/accounts";
|
import { Account } from "../db/tables/accounts";
|
||||||
|
import * as databaseUtil from "../db/databaseUtil";
|
||||||
import {
|
import {
|
||||||
retrieveAccountCount,
|
retrieveAccountCount,
|
||||||
retrieveFullyDecryptedAccount,
|
retrieveFullyDecryptedAccount,
|
||||||
@@ -131,8 +132,14 @@ export default class SeedBackupView extends Vue {
|
|||||||
// 'created' hook runs when the Vue instance is first created
|
// 'created' hook runs when the Vue instance is first created
|
||||||
async created() {
|
async created() {
|
||||||
try {
|
try {
|
||||||
const settings = await retrieveSettingsForActiveAccount();
|
let activeDid = "";
|
||||||
const activeDid = settings.activeDid || "";
|
const settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||||
|
activeDid = settings.activeDid || "";
|
||||||
|
|
||||||
|
if (USE_DEXIE_DB) {
|
||||||
|
const settings = await retrieveSettingsForActiveAccount();
|
||||||
|
activeDid = settings.activeDid || "";
|
||||||
|
}
|
||||||
|
|
||||||
this.numAccounts = await retrieveAccountCount();
|
this.numAccounts = await retrieveAccountCount();
|
||||||
this.activeAccount = await retrieveFullyDecryptedAccount(activeDid);
|
this.activeAccount = await retrieveFullyDecryptedAccount(activeDid);
|
||||||
|
|||||||
@@ -163,13 +163,6 @@
|
|||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<h2 class="text-xl font-bold mb-4">SQL Operations</h2>
|
<h2 class="text-xl font-bold mb-4">SQL Operations</h2>
|
||||||
<div>
|
|
||||||
<textarea
|
|
||||||
v-model="sqlQuery"
|
|
||||||
class="w-full h-32 p-2 border border-gray-300 rounded-md font-mono"
|
|
||||||
placeholder="Enter your SQL query here..."
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 mt-2">
|
<div class="flex gap-2 mt-2">
|
||||||
<button
|
<button
|
||||||
class="text-sm text-blue-600 hover:text-blue-800 underline"
|
class="text-sm text-blue-600 hover:text-blue-800 underline"
|
||||||
@@ -180,7 +173,13 @@
|
|||||||
All Tables
|
All Tables
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<textarea
|
||||||
|
v-model="sqlQuery"
|
||||||
|
class="w-full h-32 p-2 border border-gray-300 rounded-md font-mono"
|
||||||
|
placeholder="Enter your SQL query here..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<button
|
<button
|
||||||
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||||
|
|||||||
Reference in New Issue
Block a user