feat: database secrets
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DatabaseSecret" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"databaseId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "DatabaseSecret_databaseId_fkey" FOREIGN KEY ("databaseId") REFERENCES "Database" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DatabaseSecret_name_databaseId_key" ON "DatabaseSecret"("name", "databaseId");
|
||||||
@@ -328,6 +328,19 @@ model Database {
|
|||||||
settings DatabaseSettings?
|
settings DatabaseSettings?
|
||||||
teams Team[]
|
teams Team[]
|
||||||
applicationConnectedDatabase ApplicationConnectedDatabase[]
|
applicationConnectedDatabase ApplicationConnectedDatabase[]
|
||||||
|
databaseSecret DatabaseSecret[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model DatabaseSecret {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
value String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
databaseId String
|
||||||
|
database Database @relation(fields: [databaseId], references: [id])
|
||||||
|
|
||||||
|
@@unique([name, databaseId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model DatabaseSettings {
|
model DatabaseSettings {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import fs from 'fs/promises';
|
|||||||
import { ComposeFile, createDirectories, decrypt, defaultComposeConfiguration, encrypt, errorHandler, executeDockerCmd, generateDatabaseConfiguration, generatePassword, getContainerUsage, getDatabaseImage, getDatabaseVersions, getFreePublicPort, listSettings, makeLabelForStandaloneDatabase, prisma, startTraefikTCPProxy, stopDatabaseContainer, stopTcpHttpProxy, supportedDatabaseTypesAndVersions, uniqueName, updatePasswordInDb } from '../../../../lib/common';
|
import { ComposeFile, createDirectories, decrypt, defaultComposeConfiguration, encrypt, errorHandler, executeDockerCmd, generateDatabaseConfiguration, generatePassword, getContainerUsage, getDatabaseImage, getDatabaseVersions, getFreePublicPort, listSettings, makeLabelForStandaloneDatabase, prisma, startTraefikTCPProxy, stopDatabaseContainer, stopTcpHttpProxy, supportedDatabaseTypesAndVersions, uniqueName, updatePasswordInDb } from '../../../../lib/common';
|
||||||
import { day } from '../../../../lib/dayjs';
|
import { day } from '../../../../lib/dayjs';
|
||||||
|
|
||||||
import { GetDatabaseLogs, OnlyId, SaveDatabase, SaveDatabaseDestination, SaveDatabaseSettings, SaveVersion } from '../../../../types';
|
import { DeleteDatabaseSecret, GetDatabaseLogs, OnlyId, SaveDatabase, SaveDatabaseDestination, SaveDatabaseSecret, SaveDatabaseSettings, SaveVersion } from '../../../../types';
|
||||||
import { DeleteDatabase, SaveDatabaseType } from './types';
|
import { DeleteDatabase, SaveDatabaseType } from './types';
|
||||||
|
|
||||||
export async function listDatabases(request: FastifyRequest) {
|
export async function listDatabases(request: FastifyRequest) {
|
||||||
@@ -220,7 +220,7 @@ export async function startDatabase(request: FastifyRequest<OnlyId>) {
|
|||||||
|
|
||||||
const database = await prisma.database.findFirst({
|
const database = await prisma.database.findFirst({
|
||||||
where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } },
|
where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } },
|
||||||
include: { destinationDocker: true, settings: true }
|
include: { destinationDocker: true, settings: true, databaseSecret: true }
|
||||||
});
|
});
|
||||||
const { arch } = await listSettings();
|
const { arch } = await listSettings();
|
||||||
if (database.dbUserPassword) database.dbUserPassword = decrypt(database.dbUserPassword);
|
if (database.dbUserPassword) database.dbUserPassword = decrypt(database.dbUserPassword);
|
||||||
@@ -230,7 +230,8 @@ export async function startDatabase(request: FastifyRequest<OnlyId>) {
|
|||||||
destinationDockerId,
|
destinationDockerId,
|
||||||
destinationDocker,
|
destinationDocker,
|
||||||
publicPort,
|
publicPort,
|
||||||
settings: { isPublic }
|
settings: { isPublic },
|
||||||
|
databaseSecret
|
||||||
} = database;
|
} = database;
|
||||||
const { privatePort, command, environmentVariables, image, volume, ulimits } =
|
const { privatePort, command, environmentVariables, image, volume, ulimits } =
|
||||||
generateDatabaseConfiguration(database, arch);
|
generateDatabaseConfiguration(database, arch);
|
||||||
@@ -240,7 +241,11 @@ export async function startDatabase(request: FastifyRequest<OnlyId>) {
|
|||||||
const labels = await makeLabelForStandaloneDatabase({ id, image, volume });
|
const labels = await makeLabelForStandaloneDatabase({ id, image, volume });
|
||||||
|
|
||||||
const { workdir } = await createDirectories({ repository: type, buildId: id });
|
const { workdir } = await createDirectories({ repository: type, buildId: id });
|
||||||
|
if (databaseSecret.length > 0) {
|
||||||
|
databaseSecret.forEach((secret) => {
|
||||||
|
environmentVariables[secret.name] = decrypt(secret.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
const composeFile: ComposeFile = {
|
const composeFile: ComposeFile = {
|
||||||
version: '3.8',
|
version: '3.8',
|
||||||
services: {
|
services: {
|
||||||
@@ -262,25 +267,16 @@ export async function startDatabase(request: FastifyRequest<OnlyId>) {
|
|||||||
},
|
},
|
||||||
volumes: {
|
volumes: {
|
||||||
[volumeName]: {
|
[volumeName]: {
|
||||||
external: true
|
name: volumeName,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const composeFileDestination = `${workdir}/docker-compose.yaml`;
|
const composeFileDestination = `${workdir}/docker-compose.yaml`;
|
||||||
await fs.writeFile(composeFileDestination, yaml.dump(composeFile));
|
await fs.writeFile(composeFileDestination, yaml.dump(composeFile));
|
||||||
try {
|
await executeDockerCmd({ dockerId: destinationDocker.id, command: `docker compose -f ${composeFileDestination} up -d` })
|
||||||
await executeDockerCmd({ dockerId: destinationDocker.id, command: `docker volume create ${volumeName}` })
|
if (isPublic) await startTraefikTCPProxy(destinationDocker, id, publicPort, privatePort);
|
||||||
} catch (error) { }
|
return {};
|
||||||
try {
|
|
||||||
await executeDockerCmd({ dockerId: destinationDocker.id, command: `docker compose -f ${composeFileDestination} up -d` })
|
|
||||||
if (isPublic) await startTraefikTCPProxy(destinationDocker, id, publicPort, privatePort);
|
|
||||||
return {};
|
|
||||||
} catch (error) {
|
|
||||||
throw {
|
|
||||||
error
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch ({ status, message }) {
|
} catch ({ status, message }) {
|
||||||
return errorHandler({ status, message })
|
return errorHandler({ status, message })
|
||||||
}
|
}
|
||||||
@@ -463,3 +459,68 @@ export async function saveDatabaseSettings(request: FastifyRequest<SaveDatabaseS
|
|||||||
return errorHandler({ status, message })
|
return errorHandler({ status, message })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export async function getDatabaseSecrets(request: FastifyRequest<OnlyId>) {
|
||||||
|
try {
|
||||||
|
const { id } = request.params
|
||||||
|
let secrets = await prisma.databaseSecret.findMany({
|
||||||
|
where: { databaseId: id },
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
secrets = secrets.map((secret) => {
|
||||||
|
secret.value = decrypt(secret.value);
|
||||||
|
return secret;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
secrets
|
||||||
|
}
|
||||||
|
} catch ({ status, message }) {
|
||||||
|
return errorHandler({ status, message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveDatabaseSecret(request: FastifyRequest<SaveDatabaseSecret>, reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const { id } = request.params
|
||||||
|
let { name, value, isNew } = request.body
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
const found = await prisma.databaseSecret.findFirst({ where: { name, databaseId: id } });
|
||||||
|
if (found) {
|
||||||
|
throw `Secret ${name} already exists.`
|
||||||
|
} else {
|
||||||
|
value = encrypt(value.trim());
|
||||||
|
await prisma.databaseSecret.create({
|
||||||
|
data: { name, value, database: { connect: { id } } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
value = encrypt(value.trim());
|
||||||
|
const found = await prisma.databaseSecret.findFirst({ where: { databaseId: id, name } });
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
await prisma.databaseSecret.updateMany({
|
||||||
|
where: { databaseId: id, name },
|
||||||
|
data: { value }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.databaseSecret.create({
|
||||||
|
data: { name, value, database: { connect: { id } } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reply.code(201).send()
|
||||||
|
} catch ({ status, message }) {
|
||||||
|
return errorHandler({ status, message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function deleteDatabaseSecret(request: FastifyRequest<DeleteDatabaseSecret>) {
|
||||||
|
try {
|
||||||
|
const { id } = request.params
|
||||||
|
const { name } = request.body
|
||||||
|
await prisma.databaseSecret.deleteMany({ where: { databaseId: id, name } });
|
||||||
|
return {}
|
||||||
|
} catch ({ status, message }) {
|
||||||
|
return errorHandler({ status, message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify';
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
import { deleteDatabase, getDatabase, getDatabaseLogs, getDatabaseStatus, getDatabaseTypes, getDatabaseUsage, getVersions, listDatabases, newDatabase, saveDatabase, saveDatabaseDestination, saveDatabaseSettings, saveDatabaseType, saveVersion, startDatabase, stopDatabase } from './handlers';
|
import { deleteDatabase, deleteDatabaseSecret, getDatabase, getDatabaseLogs, getDatabaseSecrets, getDatabaseStatus, getDatabaseTypes, getDatabaseUsage, getVersions, listDatabases, newDatabase, saveDatabase, saveDatabaseDestination, saveDatabaseSecret, saveDatabaseSettings, saveDatabaseType, saveVersion, startDatabase, stopDatabase } from './handlers';
|
||||||
|
|
||||||
import type { DeleteDatabase, GetDatabaseLogs, OnlyId, SaveDatabase, SaveDatabaseDestination, SaveDatabaseSettings, SaveVersion } from '../../../../types';
|
import type { DeleteDatabase, DeleteDatabaseSecret, GetDatabaseLogs, OnlyId, SaveDatabase, SaveDatabaseDestination, SaveDatabaseSecret, SaveDatabaseSettings, SaveVersion } from '../../../../types';
|
||||||
import type { SaveDatabaseType } from './types';
|
import type { SaveDatabaseType } from './types';
|
||||||
|
|
||||||
const root: FastifyPluginAsync = async (fastify): Promise<void> => {
|
const root: FastifyPluginAsync = async (fastify): Promise<void> => {
|
||||||
@@ -19,6 +19,10 @@ const root: FastifyPluginAsync = async (fastify): Promise<void> => {
|
|||||||
|
|
||||||
fastify.post<SaveDatabaseSettings>('/:id/settings', async (request) => await saveDatabaseSettings(request));
|
fastify.post<SaveDatabaseSettings>('/:id/settings', async (request) => await saveDatabaseSettings(request));
|
||||||
|
|
||||||
|
fastify.get<OnlyId>('/:id/secrets', async (request) => await getDatabaseSecrets(request));
|
||||||
|
fastify.post<SaveDatabaseSecret>('/:id/secrets', async (request, reply) => await saveDatabaseSecret(request, reply));
|
||||||
|
fastify.delete<DeleteDatabaseSecret>('/:id/secrets', async (request) => await deleteDatabaseSecret(request));
|
||||||
|
|
||||||
fastify.get('/:id/configuration/type', async (request) => await getDatabaseTypes(request));
|
fastify.get('/:id/configuration/type', async (request) => await getDatabaseTypes(request));
|
||||||
fastify.post<SaveDatabaseType>('/:id/configuration/type', async (request, reply) => await saveDatabaseType(request, reply));
|
fastify.post<SaveDatabaseType>('/:id/configuration/type', async (request, reply) => await saveDatabaseType(request, reply));
|
||||||
|
|
||||||
|
|||||||
@@ -35,4 +35,17 @@ export interface SaveDatabaseSettings extends OnlyId {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SaveDatabaseSecret extends OnlyId {
|
||||||
|
Body: {
|
||||||
|
name: string,
|
||||||
|
value: string,
|
||||||
|
isNew: string,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export interface DeleteDatabaseSecret extends OnlyId {
|
||||||
|
Body: {
|
||||||
|
name: string,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
186
apps/ui/src/routes/databases/[id]/_Secret.svelte
Normal file
186
apps/ui/src/routes/databases/[id]/_Secret.svelte
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let name = '';
|
||||||
|
export let value = '';
|
||||||
|
export let isBuildSecret = false;
|
||||||
|
export let isNewSecret = false;
|
||||||
|
export let isPRMRSecret = false;
|
||||||
|
export let PRMRSecret: any = {};
|
||||||
|
|
||||||
|
if (isPRMRSecret) value = PRMRSecret.value;
|
||||||
|
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { del } from '$lib/api';
|
||||||
|
import { errorNotification } from '$lib/common';
|
||||||
|
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
|
||||||
|
import { addToast } from '$lib/store';
|
||||||
|
import { t } from '$lib/translations';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { saveSecret } from './utils';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
const { id } = $page.params;
|
||||||
|
async function removeSecret() {
|
||||||
|
try {
|
||||||
|
await del(`/databases/${id}/secrets`, { name });
|
||||||
|
dispatch('refresh');
|
||||||
|
if (isNewSecret) {
|
||||||
|
name = '';
|
||||||
|
value = '';
|
||||||
|
isBuildSecret = false;
|
||||||
|
}
|
||||||
|
addToast({
|
||||||
|
message: 'Secret removed.',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorNotification(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSecret(isNew: any) {
|
||||||
|
try {
|
||||||
|
if (!name || !value) return;
|
||||||
|
await saveSecret({
|
||||||
|
isNew,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
isBuildSecret,
|
||||||
|
isPRMRSecret,
|
||||||
|
isNewSecret,
|
||||||
|
databaseId: id
|
||||||
|
});
|
||||||
|
if (isNewSecret) {
|
||||||
|
name = '';
|
||||||
|
value = '';
|
||||||
|
isBuildSecret = false;
|
||||||
|
addToast({
|
||||||
|
message: 'Secret added.',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
addToast({
|
||||||
|
message: 'Secret updated.',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dispatch('refresh');
|
||||||
|
} catch (error) {
|
||||||
|
return errorNotification(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setSecretValue() {
|
||||||
|
if (!isPRMRSecret) {
|
||||||
|
isBuildSecret = !isBuildSecret;
|
||||||
|
if (!isNewSecret) {
|
||||||
|
await saveSecret({
|
||||||
|
isNew: isNewSecret,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
isBuildSecret,
|
||||||
|
isPRMRSecret,
|
||||||
|
isNewSecret,
|
||||||
|
databaseId: id
|
||||||
|
});
|
||||||
|
addToast({
|
||||||
|
message: 'Secret updated.',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
id={isNewSecret ? 'secretName' : 'secretNameNew'}
|
||||||
|
bind:value={name}
|
||||||
|
required
|
||||||
|
placeholder="EXAMPLE_VARIABLE"
|
||||||
|
readonly={!isNewSecret}
|
||||||
|
class:bg-transparent={!isNewSecret}
|
||||||
|
class:cursor-not-allowed={!isNewSecret}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<CopyPasswordField
|
||||||
|
id={isNewSecret ? 'secretValue' : 'secretValueNew'}
|
||||||
|
name={isNewSecret ? 'secretValue' : 'secretValueNew'}
|
||||||
|
isPasswordField={true}
|
||||||
|
bind:value
|
||||||
|
required
|
||||||
|
placeholder="J$#@UIO%HO#$U%H"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<button
|
||||||
|
on:click={setSecretValue}
|
||||||
|
aria-pressed="false"
|
||||||
|
class="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out"
|
||||||
|
class:bg-green-600={isBuildSecret}
|
||||||
|
class:bg-stone-700={!isBuildSecret}
|
||||||
|
class:opacity-50={isPRMRSecret}
|
||||||
|
class:cursor-not-allowed={isPRMRSecret}
|
||||||
|
class:cursor-pointer={!isPRMRSecret}
|
||||||
|
>
|
||||||
|
<span class="sr-only">Need during buildtime?</span>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow transition duration-200 ease-in-out"
|
||||||
|
class:translate-x-5={isBuildSecret}
|
||||||
|
class:translate-x-0={!isBuildSecret}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class=" absolute inset-0 flex h-full w-full items-center justify-center transition-opacity duration-200 ease-in"
|
||||||
|
class:opacity-0={isBuildSecret}
|
||||||
|
class:opacity-100={!isBuildSecret}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<svg class="h-3 w-3 bg-white text-red-600" fill="none" viewBox="0 0 12 12">
|
||||||
|
<path
|
||||||
|
d="M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="absolute inset-0 flex h-full w-full items-center justify-center transition-opacity duration-100 ease-out"
|
||||||
|
aria-hidden="true"
|
||||||
|
class:opacity-100={isBuildSecret}
|
||||||
|
class:opacity-0={!isBuildSecret}
|
||||||
|
>
|
||||||
|
<svg class="h-3 w-3 bg-white text-green-600" fill="currentColor" viewBox="0 0 12 12">
|
||||||
|
<path
|
||||||
|
d="M3.707 5.293a1 1 0 00-1.414 1.414l1.414-1.414zM5 8l-.707.707a1 1 0 001.414 0L5 8zm4.707-3.293a1 1 0 00-1.414-1.414l1.414 1.414zm-7.414 2l2 2 1.414-1.414-2-2-1.414 1.414zm3.414 2l4-4-1.414-1.414-4 4 1.414 1.414z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if isNewSecret}
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<button class="btn bg-databases btn-sm" on:click={() => createSecret(true)}
|
||||||
|
>{$t('forms.add')}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-row justify-center space-x-2">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<button class="btn bg-databases btn-sm" on:click={() => createSecret(false)}
|
||||||
|
>{$t('forms.set')}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if !isPRMRSecret}
|
||||||
|
<div class="flex justify-center items-end">
|
||||||
|
<button class="btn btn-sm bg-red-600 hover:bg-red-500" on:click={removeSecret}
|
||||||
|
>{$t('forms.remove')}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { errorNotification, handlerNotFoundLoad } from '$lib/common';
|
import { errorNotification, handlerNotFoundLoad } from '$lib/common';
|
||||||
import { appSession, status, disabledButton } from '$lib/store';
|
import { appSession, status, isDeploymentEnabled } from '$lib/store';
|
||||||
import DeleteIcon from '$lib/components/DeleteIcon.svelte';
|
import DeleteIcon from '$lib/components/DeleteIcon.svelte';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
let statusInterval: any = false;
|
let statusInterval: any = false;
|
||||||
let forceDelete = false;
|
let forceDelete = false;
|
||||||
|
|
||||||
$disabledButton = !$appSession.isAdmin;
|
$isDeploymentEnabled = !$appSession.isAdmin;
|
||||||
|
|
||||||
async function deleteDatabase(force: boolean) {
|
async function deleteDatabase(force: boolean) {
|
||||||
const sure = confirm(`Are you sure you would like to delete '${database.name}'?`);
|
const sure = confirm(`Are you sure you would like to delete '${database.name}'?`);
|
||||||
@@ -148,11 +148,11 @@
|
|||||||
|
|
||||||
{#if id !== 'new'}
|
{#if id !== 'new'}
|
||||||
<nav class="nav-side">
|
<nav class="nav-side">
|
||||||
{#if database.type && database.destinationDockerId && database.version && database.defaultDatabase}
|
{#if database.type && database.destinationDockerId && database.version}
|
||||||
{#if $status.database.isExited}
|
{#if $status.database.isExited}
|
||||||
<a
|
<a
|
||||||
id="exited"
|
id="exited"
|
||||||
href={!$disabledButton ? `/databases/${id}/logs` : null}
|
href={!$status.database.isRunning ? `/databases/${id}/logs` : null}
|
||||||
class="icons bg-transparent text-sm flex items-center text-red-500 tooltip-error"
|
class="icons bg-transparent text-sm flex items-center text-red-500 tooltip-error"
|
||||||
sveltekit:prefetch
|
sveltekit:prefetch
|
||||||
>
|
>
|
||||||
@@ -281,6 +281,34 @@
|
|||||||
></a
|
></a
|
||||||
>
|
>
|
||||||
<Tooltip triggeredBy="#configuration">{'Configuration'}</Tooltip>
|
<Tooltip triggeredBy="#configuration">{'Configuration'}</Tooltip>
|
||||||
|
<a
|
||||||
|
href="/databases/{id}/secrets"
|
||||||
|
sveltekit:prefetch
|
||||||
|
class="hover:text-pink-500 rounded"
|
||||||
|
class:text-pink-500={$page.url.pathname === `/databases/${id}/secrets`}
|
||||||
|
class:bg-coolgray-500={$page.url.pathname === `/databases/${id}/secrets`}
|
||||||
|
>
|
||||||
|
<button id="secrets" disabled={$isDeploymentEnabled} class="icons bg-transparent text-sm ">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="w-6 h-6"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path
|
||||||
|
d="M12 3a12 12 0 0 0 8.5 3a12 12 0 0 1 -8.5 15a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="11" r="1" />
|
||||||
|
<line x1="12" y1="12" x2="12" y2="14.5" />
|
||||||
|
</svg></button
|
||||||
|
></a
|
||||||
|
>
|
||||||
|
<Tooltip triggeredBy="#secrets">Secrets</Tooltip>
|
||||||
<div class="border border-stone-700 h-8" />
|
<div class="border border-stone-700 h-8" />
|
||||||
<a
|
<a
|
||||||
id="databaselogs"
|
id="databaselogs"
|
||||||
|
|||||||
109
apps/ui/src/routes/databases/[id]/secrets.svelte
Normal file
109
apps/ui/src/routes/databases/[id]/secrets.svelte
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<script context="module" lang="ts">
|
||||||
|
import type { Load } from '@sveltejs/kit';
|
||||||
|
export const load: Load = async ({ fetch, params, stuff, url }) => {
|
||||||
|
try {
|
||||||
|
const response = await get(`/databases/${params.id}/secrets`);
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
database: stuff.database,
|
||||||
|
...response
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
error: new Error(`Could not load ${url}`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export let secrets: any;
|
||||||
|
export let database: any;
|
||||||
|
import pLimit from 'p-limit';
|
||||||
|
import Secret from './_Secret.svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { t } from '$lib/translations';
|
||||||
|
import { get } from '$lib/api';
|
||||||
|
import { saveSecret } from './utils';
|
||||||
|
import { addToast } from '$lib/store';
|
||||||
|
|
||||||
|
const limit = pLimit(1);
|
||||||
|
const { id } = $page.params;
|
||||||
|
|
||||||
|
let batchSecrets = '';
|
||||||
|
async function refreshSecrets() {
|
||||||
|
const data = await get(`/databases/${id}/secrets`);
|
||||||
|
secrets = [...data.secrets];
|
||||||
|
}
|
||||||
|
async function getValues(e: any) {
|
||||||
|
e.preventDefault();
|
||||||
|
const eachValuePair = batchSecrets.split('\n');
|
||||||
|
const batchSecretsPairs = eachValuePair
|
||||||
|
.filter((secret) => !secret.startsWith('#') && secret)
|
||||||
|
.map((secret) => {
|
||||||
|
const [name, ...rest] = secret.split('=');
|
||||||
|
const value = rest.join('=');
|
||||||
|
const cleanValue = value?.replaceAll('"', '') || '';
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
value: cleanValue,
|
||||||
|
isNew: !secrets.find((secret: any) => name === secret.name)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
batchSecretsPairs.map(({ name, value, isNew }) =>
|
||||||
|
limit(() => saveSecret({ name, value, databaseId: id, isNew }))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
batchSecrets = '';
|
||||||
|
await refreshSecrets();
|
||||||
|
addToast({
|
||||||
|
message: 'Secrets saved.',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2 p-5 px-6 font-bold">
|
||||||
|
<div class="-mb-5 flex-col">
|
||||||
|
<div class="md:max-w-64 truncate text-base tracking-tight md:text-2xl lg:block">Secrets</div>
|
||||||
|
<span class="text-xs">{database.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mx-auto max-w-6xl px-6 pt-4">
|
||||||
|
<table class="mx-auto border-separate text-left">
|
||||||
|
<thead>
|
||||||
|
<tr class="h-12">
|
||||||
|
<th scope="col">{$t('forms.name')}</th>
|
||||||
|
<th scope="col">{$t('forms.value')}</th>
|
||||||
|
<th scope="col" class="w-64 text-center">Need during buildtime?</th>
|
||||||
|
<th scope="col" class="w-96 text-center">{$t('forms.action')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each secrets as secret}
|
||||||
|
{#key secret.id}
|
||||||
|
<tr>
|
||||||
|
<Secret
|
||||||
|
name={secret.name}
|
||||||
|
value={secret.value}
|
||||||
|
isBuildSecret={secret.isBuildSecret}
|
||||||
|
on:refresh={refreshSecrets}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
{/key}
|
||||||
|
{/each}
|
||||||
|
<tr>
|
||||||
|
<Secret isNewSecret on:refresh={refreshSecrets} />
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h2 class="title my-6 font-bold">Paste .env file</h2>
|
||||||
|
<form on:submit|preventDefault={getValues} class="mb-12 w-full">
|
||||||
|
<textarea bind:value={batchSecrets} class="mb-2 min-h-[200px] w-full" />
|
||||||
|
<button class="btn btn-sm bg-databases" type="submit">Batch add secrets</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
42
apps/ui/src/routes/databases/[id]/utils.ts
Normal file
42
apps/ui/src/routes/databases/[id]/utils.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { post } from '$lib/api';
|
||||||
|
import { t } from '$lib/translations';
|
||||||
|
import { errorNotification } from '$lib/common';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isNew: boolean;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
isBuildSecret?: boolean;
|
||||||
|
isPRMRSecret?: boolean;
|
||||||
|
isNewSecret?: boolean;
|
||||||
|
databaseId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function saveSecret({
|
||||||
|
isNew,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
isBuildSecret,
|
||||||
|
isPRMRSecret,
|
||||||
|
isNewSecret,
|
||||||
|
databaseId
|
||||||
|
}: Props): Promise<void> {
|
||||||
|
if (!name) return errorNotification(`${t.get('forms.name')} ${t.get('forms.is_required')}`);
|
||||||
|
if (!value) return errorNotification(`${t.get('forms.value')} ${t.get('forms.is_required')}`);
|
||||||
|
try {
|
||||||
|
await post(`/databases/${databaseId}/secrets`, {
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
isBuildSecret,
|
||||||
|
isPRMRSecret,
|
||||||
|
isNew: isNew || false
|
||||||
|
});
|
||||||
|
if (isNewSecret) {
|
||||||
|
name = '';
|
||||||
|
value = '';
|
||||||
|
isBuildSecret = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,12 +73,12 @@
|
|||||||
<td>
|
<td>
|
||||||
{#if isNewSecret}
|
{#if isNewSecret}
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<button class="btn btn-sm bg-success" on:click={() => saveSecret(true)}>Add</button>
|
<button class="btn btn-sm bg-services" on:click={() => saveSecret(true)}>Add</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-row justify-center space-x-2">
|
<div class="flex flex-row justify-center space-x-2">
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<button class="btn btn-sm bg-success" on:click={() => saveSecret(false)}>Set</button>
|
<button class="btn btn-sm bg-services" on:click={() => saveSecret(false)}>Set</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center items-end">
|
<div class="flex justify-center items-end">
|
||||||
<button class="btn btn-sm bg-error" on:click={removeSecret}>Remove</button>
|
<button class="btn btn-sm bg-error" on:click={removeSecret}>Remove</button>
|
||||||
|
|||||||
@@ -129,6 +129,6 @@
|
|||||||
<h2 class="title my-6 font-bold">Paste .env file</h2>
|
<h2 class="title my-6 font-bold">Paste .env file</h2>
|
||||||
<form on:submit|preventDefault={getValues} class="mb-12 w-full">
|
<form on:submit|preventDefault={getValues} class="mb-12 w-full">
|
||||||
<textarea bind:value={batchSecrets} class="mb-2 min-h-[200px] w-full" />
|
<textarea bind:value={batchSecrets} class="mb-2 min-h-[200px] w-full" />
|
||||||
<button class="btn btn-sm bg-applications" type="submit">Batch add secrets</button>
|
<button class="btn btn-sm bg-services" type="submit">Batch add secrets</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user