feat: Dual certificates

desing: Lots of design/css updates
version++
This commit is contained in:
Andras Bacsai 2022-02-17 22:14:06 +01:00
parent 4454287be9
commit bf047e2a3c
32 changed files with 670 additions and 587 deletions

View File

@ -1,7 +1,7 @@
{
"name": "coolify",
"description": "An open-source & self-hostable Heroku / Netlify alternative.",
"version": "2.0.13",
"version": "2.0.14",
"license": "AGPL-3.0",
"scripts": {
"dev": "docker compose -f docker-compose-dev.yaml up -d && NODE_ENV=development svelte-kit dev --host 0.0.0.0",
@ -76,6 +76,7 @@
"jsonwebtoken": "8.5.1",
"node-forge": "1.2.1",
"svelte-kit-cookie-session": "2.0.2",
"tailwindcss-scrollbar": "^0.1.0",
"unique-names-generator": "4.6.0"
},
"prisma": {

View File

@ -43,9 +43,10 @@ specifiers:
prisma: 3.9.2
svelte: 3.46.4
svelte-check: 2.4.3
svelte-kit-cookie-session: 2.0.5
svelte-kit-cookie-session: 2.0.2
svelte-preprocess: 4.10.3
tailwindcss: 3.0.22
tailwindcss-scrollbar: ^0.1.0
ts-node: 10.5.0
tslib: 2.3.1
typescript: 4.5.5
@ -70,7 +71,8 @@ dependencies:
js-yaml: 4.1.0
jsonwebtoken: 8.5.1
node-forge: 1.2.1
svelte-kit-cookie-session: 2.0.5
svelte-kit-cookie-session: 2.0.2
tailwindcss-scrollbar: 0.1.0_tailwindcss@3.0.22
unique-names-generator: 4.6.0
devDependencies:
@ -5203,10 +5205,10 @@ packages:
svelte: 3.46.4
dev: true
/svelte-kit-cookie-session/2.0.5:
/svelte-kit-cookie-session/2.0.2:
resolution:
{
integrity: sha512-IX1IXtn42UTz/isem1LqH0SAZdCx6Z6Iu2V4Q83V2EScFbXZWfeFY08Azl8ZrPKdIDhSNHBLAAumRjA6TBxCvQ==
integrity: sha512-+JfunYbraIOkecOJlC1iYqH9g6YOY8MXyUdE3hTZquR1JrODmOZZ+pVPmZuVIFpM5sStJf/jF1NT5306TWE9Gw==
}
dev: false
@ -5288,6 +5290,17 @@ packages:
strip-ansi: 6.0.1
dev: true
/tailwindcss-scrollbar/0.1.0_tailwindcss@3.0.22:
resolution:
{
integrity: sha512-egipxw4ooQDh94x02XQpPck0P0sfwazwoUGfA9SedPATIuYDR+6qe8d31Gl7YsSMRiOKDkkqfI0kBvEw9lT/Hg==
}
peerDependencies:
tailwindcss: '>= 2.x.x'
dependencies:
tailwindcss: 3.0.22_c940fbabf228b85b1c73d314b43e31f1
dev: false
/tailwindcss/3.0.22_c940fbabf228b85b1c73d314b43e31f1:
resolution:
{

View File

@ -0,0 +1,47 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Setting" (
"id" TEXT NOT NULL PRIMARY KEY,
"fqdn" TEXT,
"isRegistrationEnabled" BOOLEAN NOT NULL DEFAULT false,
"dualCerts" BOOLEAN NOT NULL DEFAULT false,
"proxyPassword" TEXT NOT NULL,
"proxyUser" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Setting" ("createdAt", "fqdn", "id", "isRegistrationEnabled", "proxyPassword", "proxyUser", "updatedAt") SELECT "createdAt", "fqdn", "id", "isRegistrationEnabled", "proxyPassword", "proxyUser", "updatedAt" FROM "Setting";
DROP TABLE "Setting";
ALTER TABLE "new_Setting" RENAME TO "Setting";
CREATE UNIQUE INDEX "Setting_fqdn_key" ON "Setting"("fqdn");
CREATE TABLE "new_ApplicationSettings" (
"id" TEXT NOT NULL PRIMARY KEY,
"applicationId" TEXT NOT NULL,
"dualCerts" BOOLEAN NOT NULL DEFAULT false,
"debug" BOOLEAN NOT NULL DEFAULT false,
"previews" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "ApplicationSettings_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_ApplicationSettings" ("applicationId", "createdAt", "debug", "id", "previews", "updatedAt") SELECT "applicationId", "createdAt", "debug", "id", "previews", "updatedAt" FROM "ApplicationSettings";
DROP TABLE "ApplicationSettings";
ALTER TABLE "new_ApplicationSettings" RENAME TO "ApplicationSettings";
CREATE UNIQUE INDEX "ApplicationSettings_applicationId_key" ON "ApplicationSettings"("applicationId");
CREATE TABLE "new_Service" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"fqdn" TEXT,
"dualCerts" BOOLEAN NOT NULL DEFAULT false,
"type" TEXT,
"version" TEXT,
"destinationDockerId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Service_destinationDockerId_fkey" FOREIGN KEY ("destinationDockerId") REFERENCES "DestinationDocker" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Service" ("createdAt", "destinationDockerId", "fqdn", "id", "name", "type", "updatedAt", "version") SELECT "createdAt", "destinationDockerId", "fqdn", "id", "name", "type", "updatedAt", "version" FROM "Service";
DROP TABLE "Service";
ALTER TABLE "new_Service" RENAME TO "Service";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@ -11,6 +11,7 @@ model Setting {
id String @id @default(cuid())
fqdn String? @unique
isRegistrationEnabled Boolean @default(false)
dualCerts Boolean @default(false)
proxyPassword String
proxyUser String
createdAt DateTime @default(now())
@ -97,6 +98,7 @@ model ApplicationSettings {
id String @id @default(cuid())
application Application @relation(fields: [applicationId], references: [id])
applicationId String @unique
dualCerts Boolean @default(false)
debug Boolean @default(false)
previews Boolean @default(false)
createdAt DateTime @default(now())
@ -234,6 +236,7 @@ model Service {
id String @id @default(cuid())
name String
fqdn String?
dualCerts Boolean @default(false)
type String?
version String?
teams Team[]

View File

@ -1,6 +1,6 @@
<script>
export let text;
export let maxWidthClass = 'max-w-[24rem]';
export let customClass = 'max-w-[24rem]';
</script>
<div class="py-1 text-xs text-stone-400 {maxWidthClass}">{@html text}</div>
<div class="py-1 text-xs text-stone-400 {customClass}">{@html text}</div>

View File

@ -4,15 +4,17 @@
export let setting;
export let title;
export let description;
export let isPadding = true;
export let isCenter = true;
export let disabled = false;
</script>
<li class="flex items-center py-4">
<div class="flex w-96 flex-col" class:px-4={isPadding} class:pr-32={!isPadding}>
<p class="text-xs font-bold text-stone-100 md:text-base">{title}</p>
<div class="flex items-center py-4 pr-8">
<div class="flex w-96 flex-col">
<div class="text-xs font-bold text-stone-100 md:text-base">{title}</div>
<Explainer text={description} />
</div>
</div>
<div class:text-center={isCenter}>
<div
type="button"
on:click
@ -58,5 +60,4 @@
</span>
</span>
</div>
<!-- {/if} -->
</li>
</div>

View File

@ -209,10 +209,10 @@ export async function configureApplication({
});
}
export async function setApplicationSettings({ id, debug, previews }) {
export async function setApplicationSettings({ id, debug, previews, dualCerts }) {
return await prisma.application.update({
where: { id },
data: { settings: { update: { debug, previews } } },
data: { settings: { update: { debug, previews, dualCerts } } },
include: { destinationDocker: true }
});
}

View File

@ -107,13 +107,20 @@ export async function configureServiceType({ id, type }) {
});
}
}
export async function setService({ id, version }) {
export async function setServiceVersion({ id, version }) {
return await prisma.service.update({
where: { id },
data: { version }
});
}
export async function setServiceSettings({ id, dualCerts }) {
return await prisma.service.update({
where: { id },
data: { dualCerts }
});
}
export async function updatePlausibleAnalyticsService({ id, fqdn, email, username, name }) {
await prisma.plausibleAnalytics.update({ where: { serviceId: id }, data: { email, username } });
await prisma.service.update({ where: { id }, data: { name, fqdn } });

View File

@ -2,7 +2,6 @@ import { dev } from '$app/env';
import { asyncExecShell, getDomain, getEngine } from '$lib/common';
import got from 'got';
import * as db from '$lib/database';
import { letsEncrypt } from '$lib/letsencrypt';
const url = dev ? 'http://localhost:5555' : 'http://coolify-haproxy:5555';

View File

@ -3,49 +3,70 @@ import { forceSSLOnApplication } from '$lib/haproxy';
import { asyncExecShell, getEngine } from './common';
import * as db from '$lib/database';
import cuid from 'cuid';
import getPort from 'get-port';
export async function letsEncrypt({ domain, isCoolify = false, id = null }) {
try {
const nakedDomain = domain.replace('www.', '');
const wwwDomain = `www.${nakedDomain}`;
const randomCuid = cuid();
if (dev) {
return await forceSSLOnApplication({ domain });
} else {
const randomPort = getPort();
let host;
let dualCerts = false;
if (isCoolify) {
await asyncExecShell(
`docker run --rm --name certbot-${randomCuid} -p 9080:9080 -v "coolify-letsencrypt:/etc/letsencrypt" certbot/certbot --logs-dir /etc/letsencrypt/logs certonly --standalone --preferred-challenges http --http-01-address 0.0.0.0 --http-01-port 9080 -d ${nakedDomain} -d ${wwwDomain} --expand --agree-tos --non-interactive --register-unsafely-without-email`
);
const { stderr: copyError } = await asyncExecShell(
`docker run --rm -v "coolify-letsencrypt:/etc/letsencrypt" -v "coolify-ssl-certs:/app/ssl" alpine:latest sh -c "test -d /etc/letsencrypt/live/${nakedDomain}/ && cat /etc/letsencrypt/live/${nakedDomain}/fullchain.pem /etc/letsencrypt/live/${nakedDomain}/privkey.pem > /app/ssl/${nakedDomain}.pem || cat /etc/letsencrypt/live/${wwwDomain}/fullchain.pem /etc/letsencrypt/live/${wwwDomain}/privkey.pem > /app/ssl/${wwwDomain}.pem"`
);
if (copyError) throw copyError;
return;
const data = await db.prisma.setting.findFirst();
dualCerts = data.dualCerts;
host = '/var/run/docker.sock';
} else {
// Check Application
const applicationData = await db.prisma.application.findUnique({
where: { id },
include: { destinationDocker: true, settings: true }
});
if (applicationData) {
if (applicationData?.destinationDockerId && applicationData?.destinationDocker) {
host = getEngine(applicationData.destinationDocker.engine);
}
let data: any = await db.prisma.application.findUnique({
where: { id },
include: { destinationDocker: true }
});
if (!data) {
data = await db.prisma.service.findUnique({
if (applicationData?.settings?.dualCerts) {
dualCerts = applicationData.settings.dualCerts;
}
}
// Check Service
const serviceData = await db.prisma.service.findUnique({
where: { id },
include: { destinationDocker: true }
});
if (serviceData) {
if (serviceData?.destinationDockerId && serviceData?.destinationDocker) {
host = getEngine(serviceData.destinationDocker.engine);
}
// Set SSL with Let's encrypt
if (data.destinationDockerId && data.destinationDocker) {
const host = getEngine(data.destinationDocker.engine);
if (serviceData?.dualCerts) {
dualCerts = serviceData.dualCerts;
}
}
}
if (!dev) {
if (dualCerts) {
await asyncExecShell(
`DOCKER_HOST=${host} docker run --rm --name certbot-${randomCuid} -p 9080:9080 -v "coolify-letsencrypt:/etc/letsencrypt" certbot/certbot --logs-dir /etc/letsencrypt/logs certonly --standalone --preferred-challenges http --http-01-address 0.0.0.0 --http-01-port 9080 -d ${nakedDomain} -d ${wwwDomain} --expand --agree-tos --non-interactive --register-unsafely-without-email`
`DOCKER_HOST=${host} docker run --rm --name certbot-${randomCuid} -p ${randomPort}:${randomPort} -v "coolify-letsencrypt:/etc/letsencrypt" certbot/certbot --logs-dir /etc/letsencrypt/logs certonly --standalone --preferred-challenges http --http-01-address 0.0.0.0 --http-01-port ${randomPort} -d ${nakedDomain} -d ${wwwDomain} --expand --agree-tos --non-interactive --register-unsafely-without-email`
);
const { stderr: copyError } = await asyncExecShell(
await asyncExecShell(
`DOCKER_HOST=${host} docker run --rm -v "coolify-letsencrypt:/etc/letsencrypt" -v "coolify-ssl-certs:/app/ssl" alpine:latest sh -c "test -d /etc/letsencrypt/live/${nakedDomain}/ && cat /etc/letsencrypt/live/${nakedDomain}/fullchain.pem /etc/letsencrypt/live/${nakedDomain}/privkey.pem > /app/ssl/${nakedDomain}.pem || cat /etc/letsencrypt/live/${wwwDomain}/fullchain.pem /etc/letsencrypt/live/${wwwDomain}/privkey.pem > /app/ssl/${wwwDomain}.pem"`
);
if (copyError) throw copyError;
await forceSSLOnApplication({ domain });
} else {
await asyncExecShell(
`DOCKER_HOST=${host} docker run --rm --name certbot-${randomCuid} -p ${randomPort}:${randomPort} -v "coolify-letsencrypt:/etc/letsencrypt" certbot/certbot --logs-dir /etc/letsencrypt/logs certonly --standalone --preferred-challenges http --http-01-address 0.0.0.0 --http-01-port ${randomPort} -d ${domain} --expand --agree-tos --non-interactive --register-unsafely-without-email`
);
await asyncExecShell(
`DOCKER_HOST=${host} docker run --rm -v "coolify-letsencrypt:/etc/letsencrypt" -v "coolify-ssl-certs:/app/ssl" alpine:latest sh -c "cat /etc/letsencrypt/live/${domain}/fullchain.pem /etc/letsencrypt/live/${domain}/privkey.pem > /app/ssl/${domain}.pem"`
);
}
} else {
console.log({ dualCerts, host, wwwDomain, nakedDomain, domain });
}
if (!isCoolify) {
await forceSSLOnApplication({ domain });
}
} catch (error) {
console.log(error);

View File

@ -52,6 +52,7 @@
let loading = false;
let debug = application.settings.debug;
let previews = application.settings.previews;
let dualCerts = application.settings.dualCerts;
onMount(() => {
domainEl.focus();
@ -64,8 +65,11 @@
if (name === 'previews') {
previews = !previews;
}
if (name === 'dualCerts') {
dualCerts = !dualCerts;
}
try {
await post(`/applications/${id}/settings.json`, { previews, debug });
await post(`/applications/${id}/settings.json`, { previews, debug, dualCerts });
return toast.push('Settings saved.');
} catch ({ error }) {
return errorNotification(error);
@ -252,7 +256,7 @@
</div>
<div class="grid grid-flow-row gap-2 px-10">
<div class="grid grid-cols-3">
<label for="fqdn" class="pt-2">Domain (FQDN)</label>
<label for="fqdn" class="relative pt-2">Domain (FQDN)</label>
<div class="col-span-2">
<input
readonly={!$session.isAdmin || isRunning}
@ -266,11 +270,19 @@
required
/>
<Explainer
text="If you specify <span class='text-green-600 font-bold'>https</span>, the application will be accessible only over https. SSL certificate will be generated for you.<br>If you specify <span class='text-green-600 font-bold'>www</span>, the application will be redirected (302) from non-www and vice versa.<br><br>To modify the domain, you must first stop the application."
text="If you specify <span class='text-green-500 font-bold'>https</span>, the application will be accessible only over https. SSL certificate will be generated for you.<br>If you specify <span class='text-green-500 font-bold'>www</span>, the application will be redirected (302) from non-www and vice versa.<br><br>To modify the domain, you must first stop the application."
/>
</div>
</div>
<div class="grid grid-cols-2 items-center pb-8">
<Setting
isCenter={false}
bind:setting={dualCerts}
title="Generate SSL for www and non-www?"
description="It will generate certificates for both www and non-www. <br>You need to have <span class='font-bold text-green-500'>both DNS entries</span> set in advance.<br><br>Useful if you expect to have visitors on both.<br>Application must be redeployed."
on:click={() => changeSettings('dualCerts')}
/>
</div>
{#if !staticDeployments.includes(application.buildPack)}
<div class="grid grid-cols-3 items-center">
<label for="port">Port</label>
@ -285,6 +297,7 @@
</div>
</div>
{/if}
{#if !notNodeDeployments.includes(application.buildPack)}
<div class="grid grid-cols-3 items-center">
<label for="installCommand">Install Command</label>
@ -361,7 +374,6 @@
<div class="flex space-x-1 pb-5 font-bold">
<div class="title">Features</div>
</div>
<div class="px-4 pb-10 sm:px-6">
<!-- <ul class="mt-2 divide-y divide-stone-800">
<Setting
bind:setting={forceSSL}
@ -370,21 +382,24 @@
description="Creates a https redirect for all requests from http and also generates a https certificate for the domain through Let's Encrypt."
/>
</ul> -->
<ul class="mt-2 divide-y divide-stone-800">
<div class="px-10 pb-10">
<div class="grid grid-cols-2 items-center">
<Setting
isCenter={false}
bind:setting={previews}
on:click={() => changeSettings('previews')}
title="Enable MR/PR Previews"
description="Creates previews from pull and merge requests."
/>
</ul>
<ul class="mt-2 divide-y divide-stone-800">
</div>
<div class="grid grid-cols-2 items-center">
<Setting
isCenter={false}
bind:setting={debug}
on:click={() => changeSettings('debug')}
title="Debug Logs"
description="Enable debug logs during build phase. <br>(<span class='text-red-500'>sensitive information</span> could be visible in logs)"
/>
</ul>
</div>
</div>
</div>

View File

@ -8,10 +8,10 @@ export const post: RequestHandler = async (event) => {
if (status === 401) return { status, body };
const { id } = event.params;
const { debug, previews } = await event.request.json();
const { debug, previews, dualCerts } = await event.request.json();
try {
await db.setApplicationSettings({ id, debug, previews });
await db.setApplicationSettings({ id, debug, previews, dualCerts });
return { status: 201 };
} catch (error) {
return ErrorHandler(error);

View File

@ -7,9 +7,8 @@
<div class="title">CouchDB</div>
</div>
<div class="px-10">
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="defaultDatabase">Default Database</label>
<div class="col-span-2 ">
<CopyPasswordField
required
readonly={database.defaultDatabase}
@ -20,10 +19,8 @@
bind:value={database.defaultDatabase}
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="dbUser">User</label>
<div class="col-span-2 ">
<CopyPasswordField
readonly
disabled
@ -33,10 +30,8 @@
value={database.dbUser}
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="dbUserPassword">Password</label>
<div class="col-span-2 ">
<CopyPasswordField
readonly
disabled
@ -47,10 +42,8 @@
value={database.dbUserPassword}
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="rootUser">Root User</label>
<div class="col-span-2 ">
<CopyPasswordField
readonly
disabled
@ -60,10 +53,8 @@
value={database.rootUser}
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="rootUserPassword">Root's Password</label>
<div class="col-span-2 ">
<CopyPasswordField
readonly
disabled
@ -74,5 +65,4 @@
value={database.rootUserPassword}
/>
</div>
</div>
</div>

View File

@ -88,9 +88,8 @@
</div>
<div class="grid grid-flow-row gap-2 px-10">
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="name">Name</label>
<div class="col-span-2 ">
<input
readonly={!$session.isAdmin}
name="name"
@ -99,10 +98,8 @@
required
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="destination">Destination</label>
<div class="col-span-2">
{#if database.destinationDockerId}
<div class="no-underline">
<input
@ -115,20 +112,16 @@
</div>
{/if}
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="version">Version</label>
<div class="col-span-2 ">
<input value={database.version} readonly disabled class="bg-transparent " />
</div>
</div>
</div>
<div class="grid grid-flow-row gap-2 px-10">
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="host">Host</label>
<div class="col-span-2 ">
<CopyPasswordField
placeholder="Generated automatically after start"
isPasswordField={false}
@ -139,10 +132,8 @@
value={database.id}
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="publicPort">Port</label>
<div class="col-span-2">
<CopyPasswordField
placeholder="Generated automatically after start"
id="publicPort"
@ -153,7 +144,6 @@
/>
</div>
</div>
</div>
<div class="grid grid-flow-row gap-2">
{#if database.type === 'mysql'}
<MySql bind:database />
@ -166,9 +156,8 @@
{:else if database.type === 'couchdb'}
<CouchDb bind:database />
{/if}
<div class="grid grid-cols-3 items-center px-10 pb-8">
<div class="grid grid-cols-2 items-center px-10 pb-8">
<label for="url">Connection String</label>
<div class="col-span-2 ">
<CopyPasswordField
textarea={true}
placeholder="Generated automatically after start"
@ -181,29 +170,28 @@
/>
</div>
</div>
</div>
</form>
<div class="flex space-x-1 pb-5 font-bold">
<div class="title">Features</div>
</div>
<div class="px-4 pb-10 sm:px-6">
<ul class="mt-2 divide-y divide-stone-800">
<div class="px-10 pb-10">
<div class="grid grid-cols-2 items-center">
<Setting
bind:setting={isPublic}
on:click={() => changeSettings('isPublic')}
title="Set it public"
description="Your database will be reachable over the internet. <br>Take security seriously in this case!"
/>
</ul>
</div>
{#if database.type === 'redis'}
<ul class="mt-2 divide-y divide-stone-800">
<div class="grid grid-cols-2 items-center">
<Setting
bind:setting={appendOnly}
on:click={() => changeSettings('appendOnly')}
title="Change append only mode"
description="Useful if you would like to restore redis data from a backup.<br><span class='font-bold text-white'>Database restart is required.</span>"
/>
</ul>
</div>
{/if}
</div>
</div>

View File

@ -7,9 +7,8 @@
<div class="title">MongoDB</div>
</div>
<div class="px-10">
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="rootUser">Root User</label>
<div class="col-span-2 ">
<CopyPasswordField
placeholder="Generated automatically after start"
id="rootUser"
@ -19,10 +18,8 @@
value={database.rootUser}
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="rootUserPassword">Root's Password</label>
<div class="col-span-2 ">
<CopyPasswordField
placeholder="Generated automatically after start"
isPasswordField={true}
@ -33,5 +30,4 @@
value={database.rootUserPassword}
/>
</div>
</div>
</div>

View File

@ -7,9 +7,8 @@
<div class="title">MySQL</div>
</div>
<div class=" px-10">
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="defaultDatabase">Default Database</label>
<div class="col-span-2 ">
<CopyPasswordField
required
readonly={database.defaultDatabase}
@ -20,10 +19,8 @@
bind:value={database.defaultDatabase}
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="dbUser">User</label>
<div class="col-span-2 ">
<CopyPasswordField
readonly
disabled
@ -33,10 +30,8 @@
value={database.dbUser}
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="dbUserPassword">Password</label>
<div class="col-span-2 ">
<CopyPasswordField
readonly
disabled
@ -47,10 +42,8 @@
value={database.dbUserPassword}
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="rootUser">Root User</label>
<div class="col-span-2 ">
<CopyPasswordField
readonly
disabled
@ -60,10 +53,8 @@
value={database.rootUser}
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="rootUserPassword">Root's Password</label>
<div class="col-span-2 ">
<CopyPasswordField
readonly
disabled
@ -74,5 +65,4 @@
value={database.rootUserPassword}
/>
</div>
</div>
</div>

View File

@ -7,9 +7,8 @@
<div class="title">PostgreSQL</div>
</div>
<div class="px-10">
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="defaultDatabase">Default Database</label>
<div class="col-span-2 ">
<CopyPasswordField
required
readonly={database.defaultDatabase}
@ -20,10 +19,8 @@
bind:value={database.defaultDatabase}
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="dbUser">User</label>
<div class="col-span-2 ">
<CopyPasswordField
readonly
disabled
@ -33,10 +30,8 @@
value={database.dbUser}
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="dbUserPassword">Password</label>
<div class="col-span-2 ">
<CopyPasswordField
readonly
disabled
@ -47,5 +42,4 @@
value={database.dbUserPassword}
/>
</div>
</div>
</div>

View File

@ -7,22 +7,8 @@
<div class="title">Redis</div>
</div>
<div class="px-10">
<!-- <div class="grid grid-cols-3 items-center">
<label for="dbUser">User</label>
<div class="col-span-2 ">
<CopyPasswordField
readonly
disabled
placeholder="Generated automatically after start"
id="dbUser"
name="dbUser"
bind:value={database.dbUser}
/>
</div>
</div> -->
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="dbUserPassword">Password</label>
<div class="col-span-2 ">
<CopyPasswordField
disabled
readonly
@ -33,7 +19,6 @@
value={database.dbUserPassword}
/>
</div>
</div>
<!-- <div class="grid grid-cols-3 items-center">
<label for="rootUser">Root User</label>
<div class="col-span-2 ">

View File

@ -181,13 +181,11 @@
/>
</div>
</div>
<div class="flex justify-start">
<ul class="mt-2 divide-y divide-stone-800">
<div class="grid grid-cols-2 items-center">
<Setting
disabled={cannotDisable}
bind:setting={destination.isCoolifyProxyUsed}
on:click={changeProxySetting}
isPadding={false}
title="Use Coolify Proxy?"
description={`This will install a proxy on the destination to allow you to access your applications and services without any manual configuration. Databases will have their own proxy. <br><br>${
cannotDisable
@ -195,7 +193,6 @@
: ''
}`}
/>
</ul>
</div>
</form>
</div>

View File

@ -7,9 +7,8 @@
<div class="flex space-x-1 py-5 font-bold">
<div class="title">MinIO Server</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="rootUser">Root User</label>
<div class="col-span-2 ">
<input
name="rootUser"
id="rootUser"
@ -18,11 +17,9 @@
disabled
readonly
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="rootUserPassword">Root's Password</label>
<div class="col-span-2 ">
<CopyPasswordField
id="rootUserPassword"
isPasswordField
@ -31,11 +28,9 @@
name="rootUserPassword"
value={service.minio.rootUserPassword}
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center">
<label for="publicPort">API Port</label>
<div class="col-span-2 ">
<input
name="publicPort"
id="publicPort"
@ -44,5 +39,4 @@
readonly
placeholder="Generated automatically after start"
/>
</div>
</div>

View File

@ -7,9 +7,8 @@
<div class="flex space-x-1 py-5 font-bold">
<div class="title">Plausible Analytics</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center px-10">
<label for="email">Email Address</label>
<div class="col-span-2">
<input
name="email"
id="email"
@ -19,11 +18,9 @@
bind:value={service.plausibleAnalytics.email}
required
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center px-10">
<label for="username">Username</label>
<div class="col-span-2">
<CopyPasswordField
name="username"
id="username"
@ -33,11 +30,9 @@
bind:value={service.plausibleAnalytics.username}
required
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center px-10">
<label for="password">Password</label>
<div class="col-span-2 ">
<CopyPasswordField
id="password"
isPasswordField
@ -46,14 +41,12 @@
name="password"
value={service.plausibleAnalytics.password}
/>
</div>
</div>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">PostgreSQL</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center px-10">
<label for="postgresqlUser">Username</label>
<div class="col-span-2 ">
<CopyPasswordField
name="postgresqlUser"
id="postgresqlUser"
@ -61,11 +54,9 @@
readonly
disabled
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center px-10">
<label for="postgresqlPassword">Password</label>
<div class="col-span-2 ">
<CopyPasswordField
id="postgresqlPassword"
isPasswordField
@ -74,11 +65,9 @@
name="postgresqlPassword"
value={service.plausibleAnalytics.postgresqlPassword}
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center px-10">
<label for="postgresqlDatabase">Database</label>
<div class="col-span-2 ">
<CopyPasswordField
name="postgresqlDatabase"
id="postgresqlDatabase"
@ -86,7 +75,6 @@
readonly
disabled
/>
</div>
</div>
<!-- <div class="grid grid-cols-3 items-center">
<label for="postgresqlPublicPort">Public Port</label>

View File

@ -7,6 +7,7 @@
import { post } from '$lib/api';
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
import Explainer from '$lib/components/Explainer.svelte';
import Setting from '$lib/components/Setting.svelte';
import { errorNotification } from '$lib/form';
import { toast } from '@zerodevx/svelte-toast';
import MinIo from './_MinIO.svelte';
@ -18,6 +19,7 @@
let loading = false;
let loadingVerification = false;
let dualCerts = service.dualCerts;
async function handleSubmit() {
loading = true;
@ -42,6 +44,17 @@
loadingVerification = false;
}
}
async function changeSettings(name) {
try {
if (name === 'dualCerts') {
dualCerts = !dualCerts;
}
await post(`/services/${id}/settings.json`, { dualCerts });
return toast.push('Settings saved.');
} catch ({ error }) {
return errorNotification(error);
}
}
</script>
<div class="mx-auto max-w-4xl px-6">
@ -67,10 +80,10 @@
{/if}
</div>
<div class="grid grid-flow-row gap-2 px-10">
<div class="mt-2 grid grid-cols-3 items-center">
<div class="grid grid-flow-row gap-2">
<div class="mt-2 grid grid-cols-2 items-center px-10">
<label for="name">Name</label>
<div class="col-span-2 ">
<div>
<input
readonly={!$session.isAdmin}
name="name"
@ -81,9 +94,9 @@
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center px-10">
<label for="destination">Destination</label>
<div class="col-span-2">
<div>
{#if service.destinationDockerId}
<div class="no-underline">
<input
@ -96,9 +109,9 @@
{/if}
</div>
</div>
<div class="grid grid-cols-3">
<div class="grid grid-cols-2 px-10">
<label for="fqdn" class="pt-2">Domain (FQDN)</label>
<div class="col-span-2 ">
<div>
<CopyPasswordField
placeholder="eg: https://analytics.coollabs.io"
readonly={!$session.isAdmin && !isRunning}
@ -114,6 +127,14 @@
/>
</div>
</div>
<div class="grid grid-cols-2 items-center px-10">
<Setting
bind:setting={dualCerts}
title="Generate SSL for www and non-www?"
description="It will generate certificates for both www and non-www. <br>You need to have <span class='font-bold text-pink-600'>both DNS entries</span> set in advance.<br><br>Service needs to be restarted."
on:click={() => changeSettings('dualCerts')}
/>
</div>
{#if service.type === 'plausibleanalytics'}
<PlausibleAnalytics bind:service {readOnly} />
{:else if service.type === 'minio'}

View File

@ -7,9 +7,8 @@
<div class="flex space-x-1 py-5 font-bold">
<div class="title">VSCode Server</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center px-10">
<label for="password">Password</label>
<div class="col-span-2 ">
<CopyPasswordField
id="password"
isPasswordField
@ -18,5 +17,4 @@
name="password"
value={service.vscodeserver.password}
/>
</div>
</div>

View File

@ -10,9 +10,8 @@
<div class="title">Wordpress</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center px-10">
<label for="extraConfig">Extra Config</label>
<div class="col-span-2 ">
<textarea
disabled={isRunning}
readonly={isRunning}
@ -26,16 +25,14 @@
define('WP_ALLOW_MULTISITE', true);
define('MULTISITE', true);
define('SUBDOMAIN_INSTALL', false);`
: null}>{service.wordpress.extraConfig}</textarea
: null}>{service.wordpress.extraConfig || 'N/A'}</textarea
>
</div>
</div>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">MySQL</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center px-10">
<label for="mysqlDatabase">Database</label>
<div class="col-span-2 ">
<input
name="mysqlDatabase"
id="mysqlDatabase"
@ -45,11 +42,9 @@ define('SUBDOMAIN_INSTALL', false);`
bind:value={service.wordpress.mysqlDatabase}
placeholder="eg: wordpress_db"
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center px-10">
<label for="mysqlRootUser">Root User</label>
<div class="col-span-2 ">
<input
name="mysqlRootUser"
id="mysqlRootUser"
@ -58,11 +53,9 @@ define('SUBDOMAIN_INSTALL', false);`
disabled
readonly
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center px-10">
<label for="mysqlRootUserPassword">Root's Password</label>
<div class="col-span-2 ">
<CopyPasswordField
id="mysqlRootUserPassword"
isPasswordField
@ -71,17 +64,13 @@ define('SUBDOMAIN_INSTALL', false);`
name="mysqlRootUserPassword"
value={service.wordpress.mysqlRootUserPassword}
/>
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center px-10">
<label for="mysqlUser">User</label>
<div class="col-span-2 ">
<input name="mysqlUser" id="mysqlUser" value={service.wordpress.mysqlUser} disabled readonly />
</div>
</div>
<div class="grid grid-cols-3 items-center">
<div class="grid grid-cols-2 items-center px-10">
<label for="mysqlPassword">Password</label>
<div class="col-span-2 ">
<CopyPasswordField
id="mysqlPassword"
isPasswordField
@ -90,5 +79,4 @@ define('SUBDOMAIN_INSTALL', false);`
name="mysqlPassword"
value={service.wordpress.mysqlPassword}
/>
</div>
</div>

View File

@ -30,7 +30,7 @@ export const post: RequestHandler = async (event) => {
const { version } = await event.request.json();
try {
await db.setService({ id, version });
await db.setServiceVersion({ id, version });
return {
status: 201
};

View File

@ -0,0 +1,19 @@
import { getUserDetails } from '$lib/common';
import * as db from '$lib/database';
import { ErrorHandler } from '$lib/database';
import type { RequestHandler } from '@sveltejs/kit';
export const post: RequestHandler = async (event) => {
const { teamId, status, body } = await getUserDetails(event);
if (status === 401) return { status, body };
const { id } = event.params;
const { dualCerts } = await event.request.json();
try {
await db.setServiceSettings({ id, dualCerts });
return { status: 201 };
} catch (error) {
return ErrorHandler(error);
}
};

View File

@ -15,6 +15,7 @@ import {
} from '$lib/haproxy';
import { letsEncrypt } from '$lib/letsencrypt';
import type { RequestHandler } from '@sveltejs/kit';
import dns from 'dns/promises';
export const get: RequestHandler = async (event) => {
const { status, body } = await getUserDetails(event);
@ -45,14 +46,18 @@ export const del: RequestHandler = async (event) => {
if (status === 401) return { status, body };
const { fqdn } = await event.request.json();
const ip = await dns.resolve(event.url.hostname);
try {
const domain = getDomain(fqdn);
await db.prisma.setting.update({ where: { fqdn }, data: { fqdn: null } });
await configureCoolifyProxyOff(fqdn);
await removeWwwRedirection(domain);
return {
status: 201
status: 200,
body: {
message: 'Domain removed',
redirect: `http://${ip[0]}:3000/settings`
}
};
} catch (error) {
return ErrorHandler(error);
@ -69,16 +74,20 @@ export const post: RequestHandler = async (event) => {
};
if (status === 401) return { status, body };
const { fqdn, isRegistrationEnabled } = await event.request.json();
const { fqdn, isRegistrationEnabled, dualCerts } = await event.request.json();
try {
const {
id,
fqdn: oldFqdn,
isRegistrationEnabled: oldIsRegistrationEnabled
isRegistrationEnabled: oldIsRegistrationEnabled,
dualCerts: oldDualCerts
} = await db.listSettings();
if (oldIsRegistrationEnabled !== isRegistrationEnabled) {
await db.prisma.setting.update({ where: { id }, data: { isRegistrationEnabled } });
}
if (oldDualCerts !== dualCerts) {
await db.prisma.setting.update({ where: { id }, data: { dualCerts } });
}
if (oldFqdn && oldFqdn !== fqdn) {
if (oldFqdn) {
const oldDomain = getDomain(oldFqdn);
@ -93,7 +102,7 @@ export const post: RequestHandler = async (event) => {
if (domain) {
await configureCoolifyProxyOn(fqdn);
await setWwwRedirection(fqdn);
if (isHttps && !dev) {
if (isHttps) {
await letsEncrypt({ domain, isCoolify: true });
await forceSSLOnApplication({ domain });
await reloadHaproxy('/var/run/docker.sock');

View File

@ -30,10 +30,13 @@
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
import { browser } from '$app/env';
import { getDomain } from '$lib/components/common';
import { toast } from '@zerodevx/svelte-toast';
let isRegistrationEnabled = settings.isRegistrationEnabled;
let dualCerts = settings.dualCerts;
let fqdn = settings.fqdn;
let isFqdnSet = settings.fqdn;
let isFqdnSet = !!settings.fqdn;
let loading = {
save: false,
remove: false
@ -43,8 +46,8 @@
if (fqdn) {
loading.remove = true;
try {
await del(`/settings.json`, { fqdn });
return window.location.reload();
const { redirect } = await del(`/settings.json`, { fqdn });
return redirect ? window.location.replace(redirect) : window.location.reload();
} catch ({ error }) {
return errorNotification(error);
} finally {
@ -57,7 +60,11 @@
if (name === 'isRegistrationEnabled') {
isRegistrationEnabled = !isRegistrationEnabled;
}
return await post(`/settings.json`, { isRegistrationEnabled });
if (name === 'dualCerts') {
dualCerts = !dualCerts;
}
await post(`/settings.json`, { isRegistrationEnabled, dualCerts });
return toast.push('Settings saved.');
} catch ({ error }) {
return errorNotification(error);
}
@ -82,15 +89,15 @@
<div class="mr-4 text-2xl tracking-tight">Settings</div>
</div>
{#if $session.teamId === '0'}
<div class="mx-auto max-w-2xl">
<div class="mx-auto max-w-4xl px-6">
<form on:submit|preventDefault={handleSubmit}>
<div class="flex space-x-1 p-6 font-bold">
<div class="flex space-x-1 py-6 font-bold">
<div class="title">Global Settings</div>
<button
type="submit"
disabled={loading.save}
class:bg-green-600={!loading.save}
class:hover:bg-green-500={!loading.save}
class:bg-yellow-500={!loading.save}
class:hover:bg-yellow-400={!loading.save}
class="mx-2 ">{loading.save ? 'Saving...' : 'Save'}</button
>
{#if isFqdnSet}
@ -103,10 +110,10 @@
>
{/if}
</div>
<div class="px-4 sm:px-6">
<div class="flex space-x-4 py-4 px-4">
<p class="pt-2 text-base font-bold text-stone-100">Domain (FQDN)</p>
<div class="justify-center">
<div class="grid grid-flow-row gap-2 px-10">
<div class="grid grid-cols-2 items-start">
<div class="pt-2 text-base font-bold text-stone-100">Domain (FQDN)</div>
<div class="justify-start text-left">
<input
bind:value={fqdn}
readonly={!$session.isAdmin || isFqdnSet}
@ -118,23 +125,31 @@
required
/>
<Explainer
text="If you specify <span class='text-green-600 font-bold'>https</span>, Coolify will be accessible only over https. SSL certificate will be generated for you.<br>If you specify <span class='text-green-600 font-bold'>www</span>, Coolify will be redirected (302) from non-www and vice versa."
text="If you specify <span class='text-yellow-500 font-bold'>https</span>, Coolify will be accessible only over https. SSL certificate will be generated for you.<br>If you specify <span class='text-yellow-500 font-bold'>www</span>, Coolify will be redirected (302) from non-www and vice versa."
/>
</div>
</div>
<ul class="mt-2 divide-y divide-stone-800">
<div class="grid grid-cols-2 items-center">
<Setting
disabled={isFqdnSet}
bind:setting={dualCerts}
title="Generate SSL for www and non-www?"
description="It will generate certificates for both www and non-www. <br>You need to have <span class='font-bold text-yellow-400'>both DNS entries</span> set in advance.<br><br>Useful if you expect to have visitors on both."
on:click={() => !isFqdnSet && changeSettings('dualCerts')}
/>
</div>
<div class="grid grid-cols-2 items-center">
<Setting
bind:setting={isRegistrationEnabled}
title="Registration allowed?"
description="Allow further registrations to the application. <br>It's turned off after the first registration. "
on:click={() => changeSettings('isRegistrationEnabled')}
/>
</ul>
</div>
</div>
</form>
<div class="mx-auto max-w-4xl px-6">
<div class="flex space-x-1 pt-5 font-bold">
<div class="title">HAProxy Settings</div>
<div class="flex space-x-1 pt-6 font-bold">
<div class="title">Coolify Proxy Settings</div>
</div>
<Explainer
text={`Credentials for <a class="text-white font-bold" href=${
@ -143,11 +158,9 @@
: browser && 'http://' + window.location.hostname + ':8404'
} target="_blank">stats</a> page.`}
/>
<div class="grid grid-cols-3 items-center px-4 pt-5">
<div class="px-10 py-5">
<div class="grid grid-cols-2 items-center">
<label for="proxyUser">User</label>
<div class="col-span-2 ">
<CopyPasswordField
readonly
disabled
@ -156,10 +169,8 @@
value={settings.proxyUser}
/>
</div>
</div>
<div class="grid grid-cols-3 items-center px-4">
<div class="grid grid-cols-2 items-center">
<label for="proxyPassword">Password</label>
<div class="col-span-2 ">
<CopyPasswordField
readonly
disabled
@ -171,5 +182,4 @@
</div>
</div>
</div>
</div>
{/if}

View File

@ -120,7 +120,7 @@
</div>
<Explainer
maxWidthClass="w-full"
customClass="w-full"
text="<span class='font-bold text-base'>Scopes required:</span>
<br>- api (Access the authenticated user's API)
<br>- read_repository (Allows read-only access to the repository)

View File

@ -99,7 +99,7 @@
<span class="arrow-right-applications px-1 text-cyan-500">></span>
<span class="pr-2">{team.name}</span>
</div>
<div class="mx-auto max-w-2xl">
<div class="mx-auto max-w-4xl">
<form on:submit|preventDefault={handleSubmit}>
<div class="flex space-x-1 p-6 font-bold">
<div class="title">Settings</div>
@ -113,10 +113,10 @@
<input id="name" name="name" placeholder="name" bind:value={team.name} />
</div>
{#if team.id === '0'}
<div class="px-20 pt-4 text-center">
<div class="px-8 pt-4 text-left">
<Explainer
maxWidthClass="w-full"
text="This is the <span class='text-red-500 font-bold'>root</span> team. <br><br>That means members of this group can manage instance wide settings and have all the priviliges in Coolify. (imagine like root user on Linux)"
customClass="w-full"
text="This is the <span class='text-red-500 font-bold'>root</span> team. That means members of this group can manage instance wide settings and have all the priviliges in Coolify (imagine like root user on Linux)."
/>
</div>
{/if}
@ -178,11 +178,19 @@
</div>
</div>
{#if $session.isAdmin}
<div class="mx-auto max-w-2xl pt-8">
<div class="mx-auto max-w-4xl pt-8">
<form on:submit|preventDefault={sendInvitation}>
<div class="flex space-x-1 p-6 font-bold">
<div class="title">Invite new member</div>
<div class="text-center">
<div class="flex space-x-1 p-6">
<div>
<div class="title font-bold">Invite new member</div>
<div class="text-left">
<Explainer
customClass="w-56"
text="You can only invite registered users at the moment - will be extended soon."
/>
</div>
</div>
<div class="pt-1 text-center">
<button class="bg-cyan-600 hover:bg-cyan-500" type="submit">Send invitation</button>
</div>
</div>

View File

@ -22,10 +22,10 @@ @font-face {
}
html {
@apply h-full min-h-full;
@apply h-full min-h-full overflow-y-scroll;
}
body {
@apply min-h-screen overflow-x-hidden bg-coolblack text-sm text-white;
@apply min-h-screen overflow-x-hidden bg-coolblack text-sm text-white scrollbar-w-1 scrollbar-thumb-coollabs scrollbar-track-coolgray-200;
}
main,
@ -170,7 +170,7 @@ [data-tooltip]:after {
padding: 8px;
color: #fff;
content: attr(data-tooltip);
@apply min-w-[100px] rounded bg-coollabs text-center font-normal;
@apply min-w-[100px] rounded bg-coollabs text-center font-medium;
}
/* Directions */

View File

@ -31,7 +31,8 @@ module.exports = {
}
},
variants: {
scrollbar: ['dark'],
extend: {}
},
plugins: []
plugins: [require('tailwindcss-scrollbar')]
};