41 create tenant twilio config (#62)

* add tenant config table

* add encryption/decryption + env vars

* generate secret and validate iv position is number

* expect errors

* remove TWILIO env vars

* settings page impl

* update schema definitions after Mostaphas Tenant impl

* load user env

* just return empty config

* add Settings menu item

* check if settings are present and provide warning if not

* correct form item names

* use correct locals value

* ree

* give twilio its own table

* lock prisma version

* event url is the correct param

* load twilio config from db

* commit migration

* use test script not bun command
This commit is contained in:
Baobeld 2025-01-26 23:36:06 -05:00 committed by GitHub
parent 8006d523c7
commit 8270c53509
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 515 additions and 57 deletions

8
.env
View file

@ -1,9 +1,9 @@
NODE_ENV= NODE_ENV=
# TWILIO # SECRETS
TWILIO_ACCOUNT_SID= SECRETS_PASSWORD=secret_do_not_commit_or_change_this_create_.env.local_instead
TWILIO_AUTH_TOKEN= SECRETS_SALT=secret_do_not_commit_or_change_this_create_.env.local_instead
TWILIO_PHONE_NUMBER= SECRETS_IV_POSITION=secret_do_not_commit_or_change_this_create_.env.local_instead
# PRISMA # PRISMA
DATABASE_URL="postgres://hestia:test123@localhost:5432/hestia" DATABASE_URL="postgres://hestia:test123@localhost:5432/hestia"

View file

@ -24,4 +24,4 @@ jobs:
- name: Prisma Check - name: Prisma Check
run: bun prisma:validate run: bun prisma:validate
- name: Test - name: Test
run: bun test run: 'bun run test:unit'

View file

@ -40,7 +40,7 @@
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.15.3", "@sveltejs/kit": "^2.15.3",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/bun": "^1.1.14", "@types/bun": "^1.1.15",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"daisyui": "^4.12.22", "daisyui": "^4.12.22",
"eslint": "^9.7.0", "eslint": "^9.7.0",
@ -52,7 +52,7 @@
"prettier": "^3.3.2", "prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.6", "prettier-plugin-svelte": "^3.2.6",
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
"prisma": "^6.0.1", "prisma": "6.0.1",
"storybook": "^8.4.7", "storybook": "^8.4.7",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
@ -311,17 +311,17 @@
"@prisma/client": ["@prisma/client@6.0.1", "", { "peerDependencies": { "prisma": "*" }, "optionalPeers": ["prisma"] }, "sha512-60w7kL6bUxz7M6Gs/V+OWMhwy94FshpngVmOY05TmGD0Lhk+Ac0ZgtjlL6Wll9TD4G03t4Sq1wZekNVy+Xdlbg=="], "@prisma/client": ["@prisma/client@6.0.1", "", { "peerDependencies": { "prisma": "*" }, "optionalPeers": ["prisma"] }, "sha512-60w7kL6bUxz7M6Gs/V+OWMhwy94FshpngVmOY05TmGD0Lhk+Ac0ZgtjlL6Wll9TD4G03t4Sq1wZekNVy+Xdlbg=="],
"@prisma/debug": ["@prisma/debug@6.2.1", "", {}, "sha512-0KItvt39CmQxWkEw6oW+RQMD6RZ43SJWgEUnzxN8VC9ixMysa7MzZCZf22LCK5DSooiLNf8vM3LHZm/I/Ni7bQ=="], "@prisma/debug": ["@prisma/debug@6.0.1", "", {}, "sha512-jQylgSOf7ibTVxqBacnAlVGvek6fQxJIYCQOeX2KexsfypNzXjJQSS2o5s+Mjj2Np93iSOQUaw6TvPj8syhG4w=="],
"@prisma/engines": ["@prisma/engines@6.2.1", "", { "dependencies": { "@prisma/debug": "6.2.1", "@prisma/engines-version": "6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69", "@prisma/fetch-engine": "6.2.1", "@prisma/get-platform": "6.2.1" } }, "sha512-lTBNLJBCxVT9iP5I7Mn6GlwqAxTpS5qMERrhebkUhtXpGVkBNd/jHnNJBZQW4kGDCKaQg/r2vlJYkzOHnAb7ZQ=="], "@prisma/engines": ["@prisma/engines@6.0.1", "", { "dependencies": { "@prisma/debug": "6.0.1", "@prisma/engines-version": "5.23.0-27.5dbef10bdbfb579e07d35cc85fb1518d357cb99e", "@prisma/fetch-engine": "6.0.1", "@prisma/get-platform": "6.0.1" } }, "sha512-4hxzI+YQIR2uuDyVsDooFZGu5AtixbvM2psp+iayDZ4hRrAHo/YwgA17N23UWq7G6gRu18NvuNMb48qjP3DPQw=="],
"@prisma/engines-version": ["@prisma/engines-version@6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69", "", {}, "sha512-7tw1qs/9GWSX6qbZs4He09TOTg1ff3gYsB3ubaVNN0Pp1zLm9NC5C5MZShtkz7TyQjx7blhpknB7HwEhlG+PrQ=="], "@prisma/engines-version": ["@prisma/engines-version@5.23.0-27.5dbef10bdbfb579e07d35cc85fb1518d357cb99e", "", {}, "sha512-JmIds0Q2/vsOmnuTJYxY4LE+sajqjYKhLtdOT6y4imojqv5d/aeVEfbBGC74t8Be1uSp0OP8lxIj2OqoKbLsfQ=="],
"@prisma/fetch-engine": ["@prisma/fetch-engine@6.2.1", "", { "dependencies": { "@prisma/debug": "6.2.1", "@prisma/engines-version": "6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69", "@prisma/get-platform": "6.2.1" } }, "sha512-OO7O9d6Mrx2F9i+Gu1LW+DGXXyUFkP7OE5aj9iBfA/2jjDXEJjqa9X0ZmM9NZNo8Uo7ql6zKm6yjDcbAcRrw1A=="], "@prisma/fetch-engine": ["@prisma/fetch-engine@6.0.1", "", { "dependencies": { "@prisma/debug": "6.0.1", "@prisma/engines-version": "5.23.0-27.5dbef10bdbfb579e07d35cc85fb1518d357cb99e", "@prisma/get-platform": "6.0.1" } }, "sha512-T36bWFVGeGYYSyYOj9d+O9G3sBC+pAyMC+jc45iSL63/Haq1GrYjQPgPMxrEj9m739taXrupoysRedQ+VyvM/Q=="],
"@prisma/generator-helper": ["@prisma/generator-helper@6.2.1", "", { "dependencies": { "@prisma/debug": "6.2.1" } }, "sha512-7Ws8DCXGan7hhaFMERXYdmhsudvSzEsrTttJEC7ubZJidvyimS12m3xpM+dLTt+NAShJ7Op7PgF+Mal2jf6xfg=="], "@prisma/generator-helper": ["@prisma/generator-helper@6.2.1", "", { "dependencies": { "@prisma/debug": "6.2.1" } }, "sha512-7Ws8DCXGan7hhaFMERXYdmhsudvSzEsrTttJEC7ubZJidvyimS12m3xpM+dLTt+NAShJ7Op7PgF+Mal2jf6xfg=="],
"@prisma/get-platform": ["@prisma/get-platform@6.2.1", "", { "dependencies": { "@prisma/debug": "6.2.1" } }, "sha512-zp53yvroPl5m5/gXYLz7tGCNG33bhG+JYCm74ohxOq1pPnrL47VQYFfF3RbTZ7TzGWCrR3EtoiYMywUBw7UK6Q=="], "@prisma/get-platform": ["@prisma/get-platform@6.0.1", "", { "dependencies": { "@prisma/debug": "6.0.1" } }, "sha512-zspC9vlxAqx4E6epMPMLLBMED2VD8axDe8sPnquZ8GOsn6tiacWK0oxrGK4UAHYzYUVuMVUApJbdXB2dFpLhvg=="],
"@repeaterjs/repeater": ["@repeaterjs/repeater@3.0.6", "", {}, "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA=="], "@repeaterjs/repeater": ["@repeaterjs/repeater@3.0.6", "", {}, "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA=="],
@ -1251,7 +1251,7 @@
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"prisma": ["prisma@6.2.1", "", { "dependencies": { "@prisma/engines": "6.2.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "prisma": "build/index.js" } }, "sha512-hhyM0H13pQleQ+br4CkzGizS5I0oInoeTw3JfLw1BRZduBSQxPILlJLwi+46wZzj9Je7ndyQEMGw/n5cN2fknA=="], "prisma": ["prisma@6.0.1", "", { "dependencies": { "@prisma/engines": "6.0.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "prisma": "build/index.js" } }, "sha512-CaMNFHkf+DDq8zq3X/JJsQ4Koy7dyWwwtOKibkT/Am9j/tDxcfbg7+lB1Dzhx18G/+RQCMgjPYB61bhRqteNBQ=="],
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
@ -1627,6 +1627,8 @@
"@octokit/request-error/@octokit/types": ["@octokit/types@13.7.0", "", { "dependencies": { "@octokit/openapi-types": "^23.0.1" } }, "sha512-BXfRP+3P3IN6fd4uF3SniaHKOO4UXWBfkdR3vA8mIvaoO/wLjGN5qivUtW0QRitBHHMcfC41SLhNVYIZZE+wkA=="], "@octokit/request-error/@octokit/types": ["@octokit/types@13.7.0", "", { "dependencies": { "@octokit/openapi-types": "^23.0.1" } }, "sha512-BXfRP+3P3IN6fd4uF3SniaHKOO4UXWBfkdR3vA8mIvaoO/wLjGN5qivUtW0QRitBHHMcfC41SLhNVYIZZE+wkA=="],
"@prisma/generator-helper/@prisma/debug": ["@prisma/debug@6.2.1", "", {}, "sha512-0KItvt39CmQxWkEw6oW+RQMD6RZ43SJWgEUnzxN8VC9ixMysa7MzZCZf22LCK5DSooiLNf8vM3LHZm/I/Ni7bQ=="],
"@storybook/blocks/@storybook/csf": ["@storybook/csf@0.1.12", "", { "dependencies": { "type-fest": "^2.19.0" } }, "sha512-9/exVhabisyIVL0VxTCxo01Tdm8wefIXKXfltAPTSr8cbLn5JAxGQ6QV3mjdecLGEOucfoVhAKtJfVHxEK1iqw=="], "@storybook/blocks/@storybook/csf": ["@storybook/csf@0.1.12", "", { "dependencies": { "type-fest": "^2.19.0" } }, "sha512-9/exVhabisyIVL0VxTCxo01Tdm8wefIXKXfltAPTSr8cbLn5JAxGQ6QV3mjdecLGEOucfoVhAKtJfVHxEK1iqw=="],
"@storybook/core/@storybook/csf": ["@storybook/csf@0.1.12", "", { "dependencies": { "type-fest": "^2.19.0" } }, "sha512-9/exVhabisyIVL0VxTCxo01Tdm8wefIXKXfltAPTSr8cbLn5JAxGQ6QV3mjdecLGEOucfoVhAKtJfVHxEK1iqw=="], "@storybook/core/@storybook/csf": ["@storybook/csf@0.1.12", "", { "dependencies": { "type-fest": "^2.19.0" } }, "sha512-9/exVhabisyIVL0VxTCxo01Tdm8wefIXKXfltAPTSr8cbLn5JAxGQ6QV3mjdecLGEOucfoVhAKtJfVHxEK1iqw=="],

View file

@ -21,5 +21,11 @@
"sms_label_phone": "Phone Number", "sms_label_phone": "Phone Number",
"sms_label_message": "Message", "sms_label_message": "Message",
"sms_button_submit": "Send Message", "sms_button_submit": "Send Message",
"settings_title": "Settings",
"settings_category_twilio": "Twilio Config",
"settings_twilio_account_sid": "Account SID",
"settings_twilio_auth_token": "Auth Token",
"settings_twilio_phone_number": "Phone Number",
"settings_save": "Save Settings",
"error_page_go_home": "Go Home" "error_page_go_home": "Go Home"
} }

View file

@ -13,6 +13,7 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .", "format": "prettier --write .",
"generate-secret": "bun ./scripts/generate-secret.ts",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"test:unit": "vitest", "test:unit": "vitest",
"test": "bun run test:unit -- --run && bun run test:e2e", "test": "bun run test:unit -- --run && bun run test:e2e",
@ -44,7 +45,7 @@
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.15.3", "@sveltejs/kit": "^2.15.3",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/bun": "^1.1.14", "@types/bun": "^1.1.15",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"daisyui": "^4.12.22", "daisyui": "^4.12.22",
"eslint": "^9.7.0", "eslint": "^9.7.0",
@ -56,7 +57,7 @@
"prettier": "^3.3.2", "prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.6", "prettier-plugin-svelte": "^3.2.6",
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
"prisma": "^6.0.1", "prisma": "6.0.1",
"storybook": "^8.4.7", "storybook": "^8.4.7",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",

View file

@ -0,0 +1,34 @@
-- CreateTable
CREATE TABLE "TenantConfig" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TenantConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TwilioConfig" (
"id" TEXT NOT NULL,
"tenantConfigId" TEXT NOT NULL,
"accountSID" TEXT NOT NULL,
"authToken" TEXT NOT NULL,
"phoneNumber" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TwilioConfig_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TenantConfig_tenantId_key" ON "TenantConfig"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "TwilioConfig_tenantConfigId_key" ON "TwilioConfig"("tenantConfigId");
-- AddForeignKey
ALTER TABLE "TenantConfig" ADD CONSTRAINT "TenantConfig_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TwilioConfig" ADD CONSTRAINT "TwilioConfig_tenantConfigId_fkey" FOREIGN KEY ("tenantConfigId") REFERENCES "TenantConfig"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View file

@ -32,11 +32,39 @@ model User {
} }
model Tenant { model Tenant {
id String @id @default(uuid()) id String @id @default(uuid())
name String clerkOrganizationId String @unique
slug String @unique
createdAt DateTime @default(now()) users User[]
updatedAt DateTime @updatedAt tenantConfig TenantConfig?
clerkOrganizationId String @unique
users User[] name String
slug String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model TenantConfig {
id String @id @default(uuid())
tenant Tenant @relation(fields: [tenantId], references: [id])
tenantId String @unique
twilioConfig TwilioConfig?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model TwilioConfig {
id String @id @default(uuid())
tenantConfig TenantConfig @relation(fields: [tenantConfigId], references: [id])
tenantConfigId String @unique
accountSID String
authToken String
phoneNumber String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }

View file

@ -0,0 +1,6 @@
import { randomBytes } from 'node:crypto';
console.log('SECRET: ', {
password: randomBytes(16).toString('hex'),
salt: randomBytes(16).toString('hex'),
});

View file

@ -1,12 +1,26 @@
import { PhoneRegex } from '../src/lib/regex/phone';
import { z } from 'zod'; import { z } from 'zod';
const ValidateEnvironment = () => { const ValidateEnvironment = () => {
const { success, error } = z const { success, error } = z
.object({ .object({
TWILIO_ACCOUNT_SID: z.string().min(1), SECRETS_PASSWORD: z.string().length(32),
TWILIO_AUTH_TOKEN: z.string().min(1), SECRETS_SALT: z.string().min(16),
TWILIO_PHONE_NUMBER: z.string().regex(PhoneRegex), SECRETS_IV_POSITION: z.string().transform((val, ctx) => {
const parsed = parseInt(val);
if (isNaN(parsed)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Not a number',
});
// This is a special symbol you can use to
// return early from the transform function.
// It has type `never` so it does not affect the
// inferred return type.
return z.NEVER;
}
return parsed;
}),
}) })
.safeParse(process.env); .safeParse(process.env);

4
src/app.d.ts vendored
View file

@ -1,5 +1,7 @@
// See https://svelte.dev/docs/kit/types#app.d.ts // See https://svelte.dev/docs/kit/types#app.d.ts
import type { Tenant, User } from '@prisma/client';
// for information about these interfaces // for information about these interfaces
declare global { declare global {
namespace App { namespace App {
@ -10,6 +12,8 @@ declare global {
orgId?: string | null; orgId?: string | null;
sessionId?: string; sessionId?: string;
}; };
user: User;
tenant: Tenant;
} }
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}

View file

@ -1,3 +1,5 @@
import { loadUserEnv } from '$lib/server/middleware';
import { sequence } from '@sveltejs/kit/hooks';
import { withClerkHandler } from 'clerk-sveltekit/server'; import { withClerkHandler } from 'clerk-sveltekit/server';
export const handle = withClerkHandler(); export const handle = sequence(withClerkHandler(), loadUserEnv());

View file

@ -0,0 +1,20 @@
import { describe, expect, it, vi } from 'vitest';
import { decrypt, encrypt } from './encryption';
describe('Encryption', () => {
it('should encrypt and decrypt data', () => {
const data = 'aye its ya boi!';
vi.mock('$env/static/private', () => ({
SECRETS_PASSWORD: 'aac7405eb3384e68c285fc252dbf68b2',
SECRETS_SALT: 'c4aeaf8bda72ea45e8c23269ca849013',
SECRETS_IV_POSITION: 9,
}));
const encrypted = encrypt(data);
console.log(encrypted);
const decrypted = decrypt(encrypted);
expect(decrypted).toEqual(data);
});
});

View file

@ -0,0 +1,42 @@
import { SECRETS_PASSWORD, SECRETS_IV_POSITION, SECRETS_SALT } from '$env/static/private';
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
const algorithm = 'aes-256-gcm';
const password = SECRETS_PASSWORD;
const salt = SECRETS_SALT;
const iv_position = Number(SECRETS_IV_POSITION);
function construct(encrypted: string, tag: Buffer, iv: { value: Buffer; position: number }) {
return `${encrypted.slice(0, iv.position)}${iv.value.toString('hex')}${encrypted.slice(iv.position)}${tag.toString('hex')}`;
}
function deconstruct(encrypted: string, position: number): [Buffer, string, Buffer] {
const iv = encrypted.slice(position, position + 16);
const text = `${encrypted.slice(0, position)}${encrypted.slice(position + 16, encrypted.length - 32)}`;
const authTag = encrypted.slice(encrypted.length - 32);
return [Buffer.from(iv, 'hex'), text, Buffer.from(authTag, 'hex')];
}
export function encrypt(value: string): string {
const key = scryptSync(password, salt, 32);
const iv = randomBytes(8);
// @ts-expect-error Bun typing mismatch, but it still works!
const cipher = createCipheriv(algorithm, key, iv);
const encrypted = cipher.update(value, 'utf-8', 'hex') + cipher.final('hex');
const authTag = cipher.getAuthTag();
return construct(encrypted, authTag, { value: iv, position: iv_position });
}
export function decrypt(value: string): string {
const key = scryptSync(password, salt, 32);
const [iv, text, authTag] = deconstruct(value, iv_position);
// @ts-expect-error Bun typing mismatch, but it still works!
const decipher = createDecipheriv(algorithm, key, iv);
// @ts-expect-error Bun typing mismatch, but it still works!
decipher.setAuthTag(authTag);
return decipher.update(text, 'hex', 'utf-8') + decipher.final('utf-8');
}

View file

@ -0,0 +1 @@
export * from './encryption';

View file

@ -0,0 +1 @@
export * from './server';

View file

@ -0,0 +1 @@
export * from './loadUserEnv';

View file

@ -0,0 +1,40 @@
import { logger } from '$lib/server/logger';
import { prisma } from '$lib/server/prisma';
import type { Handle } from '@sveltejs/kit';
const publicRoutes = ['/login'];
export function loadUserEnv(): Handle {
return async ({ event, resolve }) => {
if (event.url !== null && publicRoutes.includes(event.url.pathname)) {
return resolve(event);
}
try {
const tenant = await prisma.tenant.findUniqueOrThrow({
where: {
clerkOrganizationId: event.locals.auth.orgId ?? undefined,
},
});
const user = await prisma.user.findUniqueOrThrow({
where: {
clerkId_tenantId: { clerkId: event.locals.auth.userId!, tenantId: tenant.id },
},
});
event.locals.tenant = tenant;
event.locals.user = user;
} catch (error) {
if (error instanceof Error) {
logger.error(error);
}
return new Response(null, {
status: 307,
headers: {
location: '/login',
},
});
}
return resolve(event);
};
}

View file

@ -1,4 +0,0 @@
import { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN } from '$env/static/private';
import twilio from 'twilio';
export const TwilioClient = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN);

View file

@ -1 +0,0 @@
export * from './client';

View file

@ -5,7 +5,7 @@
import { messages } from '$lib/i18n'; import { messages } from '$lib/i18n';
import 'clerk-sveltekit/client'; import 'clerk-sveltekit/client';
import SignOutButton from 'clerk-sveltekit/client/SignOutButton.svelte'; import SignOutButton from 'clerk-sveltekit/client/SignOutButton.svelte';
import { LogOut, MessageCircleMore } from 'lucide-svelte'; import { Cog, LogOut, MessageCircleMore } from 'lucide-svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
@ -68,11 +68,23 @@
class="menu dropdown-content menu-lg z-[1] mt-4 w-52 rounded-box bg-base-200 p-2 text-right shadow" class="menu dropdown-content menu-lg z-[1] mt-4 w-52 rounded-box bg-base-200 p-2 text-right shadow"
> >
<li> <li>
<button onclick={() => goto('/app/sms')} <button onclick={() => goto('/app/sms')}>
><MessageCircleMore /> {messages.nav_menu_sms()}</button <MessageCircleMore />
> {messages.nav_menu_sms()}
</button>
</li>
<li>
<button onclick={() => goto('/app/settings')}>
<Cog />
{messages.nav_menu_settings()}
</button>
</li>
<li>
<SignOutButton>
<LogOut />
{messages.nav_menu_logout()}
</SignOutButton>
</li> </li>
<li><SignOutButton><LogOut /> {messages.nav_menu_logout()}</SignOutButton></li>
</ul> </ul>
</div> </div>
{/snippet} {/snippet}

View file

@ -0,0 +1,93 @@
import { PhoneRegex } from '$lib/regex';
import { encrypt } from '$lib/server/crypto/encryption.js';
import { logger } from '$lib/server/logger';
import { prisma } from '$lib/server/prisma';
import { fail, type Actions } from '@sveltejs/kit';
import zod from 'zod';
export const load = async (event) => {
const tenantId = event.locals.tenant.id;
const configs = await prisma.tenantConfig.findUnique({
where: { tenantId: tenantId },
select: {
twilioConfig: {
select: {
accountSID: true,
authToken: true,
phoneNumber: true,
},
},
},
});
return {
configs: configs,
};
};
export const actions = {
update: async (event) => {
const form = await event.request.formData();
const tenantId = event.locals.tenant.id;
if (!form.has('twilioAccountSID')) {
return fail(400, { error: 'account_sid_missing' });
}
if (!form.has('twilioAuthToken')) {
return fail(400, { error: 'auth_token_missing' });
}
if (!form.has('twilioPhoneNumber')) {
return fail(400, { error: 'phone_number_missing' });
}
const accountSID = form.get('twilioAccountSID');
if (typeof accountSID !== 'string') {
return fail(400, { error: 'invalid_account_sid' });
}
const authToken = form.get('twilioAuthToken');
if (typeof authToken !== 'string') {
return fail(400, { error: 'invalid_auth_token' });
}
const {
success: phoneSuccess,
data: phoneNumber,
error: phoneError,
} = zod.string().regex(PhoneRegex).safeParse(form.get('twilioPhoneNumber'));
if (!phoneSuccess) {
logger.error(phoneError);
return fail(400, { error: 'invalid_phone_number' });
}
const configs = await prisma.tenantConfig.upsert({
where: {
tenantId: tenantId,
},
create: {
tenantId: tenantId,
twilioConfig: {
create: {
accountSID: encrypt(accountSID),
authToken: encrypt(authToken),
phoneNumber: encrypt(phoneNumber),
},
},
},
update: {
tenantId: tenantId,
twilioConfig: {
update: {
accountSID: accountSID,
authToken: authToken,
phoneNumber: phoneNumber,
},
},
},
select: { twilioConfig: true },
});
return {
configs: configs,
};
},
} satisfies Actions;

View file

@ -0,0 +1,91 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Button } from '$lib/components/Actions';
import { TextInput } from '$lib/components/DataInput';
import { messages } from '$lib/i18n';
import { Fingerprint, KeyRound, PhoneOutgoing } from 'lucide-svelte';
import { fade } from 'svelte/transition';
import type { ActionData, PageData } from './$types';
import Divider from '$lib/components/Layout/Divider.svelte';
type Props = {
data: PageData;
form: ActionData;
};
let { data, form }: Props = $props();
let configs = $derived(form?.configs ?? data.configs);
</script>
<div class="page" transition:fade>
<div class="card w-full max-w-xl bg-base-200 px-4 pt-4 shadow-xl">
<div class="card-title justify-center">
<h2 class="text-2xl font-semibold">{messages.settings_title()}</h2>
{#if form?.error}
<div role="alert" class="alert alert-error absolute -top-20" transition:fade>
<i class="fi fi-bs-octagon-xmark h-6 w-6 shrink-0"></i>
<span>{form.error}</span>
</div>
{/if}
</div>
<form id="sms" method="POST" action="?/update" use:enhance>
<div class="card-body">
<Divider />
<!-- Twilio -->
<h2 class="text-2xl font-semibold">{messages.settings_category_twilio()}</h2>
<TextInput
defaultvalue={configs?.twilioConfig?.accountSID}
name="twilioAccountSID"
placeholder="..."
bordered
fade
>
{#snippet label()}
<div class="flex gap-2">
<Fingerprint size="18" />
{messages.settings_twilio_account_sid()}
</div>
{/snippet}
</TextInput>
<TextInput
defaultvalue={configs?.twilioConfig?.authToken}
name="twilioAuthToken"
placeholder="..."
type="password"
bordered
fade
>
{#snippet label()}
<div class="flex gap-2">
<KeyRound size="18" />
{messages.settings_twilio_auth_token()}
</div>
{/snippet}
</TextInput>
<TextInput
defaultvalue={configs?.twilioConfig?.phoneNumber}
name="twilioPhoneNumber"
placeholder="+1XXX-XXX-XXXX"
bordered
fade
>
{#snippet label()}
<div class="flex gap-2">
<PhoneOutgoing size="18" />
{messages.settings_twilio_phone_number()}
</div>
{/snippet}
</TextInput>
</div>
<div class="card-actions justify-center px-8 pb-4">
<Button type="submit" variant="outline" full>{messages.settings_save()}</Button>
</div>
</form>
</div>
</div>
<style>
.page {
@apply flex flex-col items-center justify-around gap-24 py-[10%];
}
</style>

View file

@ -1,10 +1,41 @@
import { TWILIO_PHONE_NUMBER } from '$env/static/private';
import { PhoneRegex } from '$lib/regex'; import { PhoneRegex } from '$lib/regex';
import { logger } from '$lib/server/logger'; import { logger } from '$lib/server/logger';
import { TwilioClient } from '$lib/server/twilio'; import { prisma } from '$lib/server/prisma/index.js';
import { fail, type Actions } from '@sveltejs/kit'; import { fail, type Actions } from '@sveltejs/kit';
import twilio from 'twilio';
import zod from 'zod'; import zod from 'zod';
export const load = async (event) => {
const tenantId = event.locals.tenant.id;
const configs = await prisma.tenantConfig.findUnique({
where: { tenantId: tenantId },
select: {
twilioConfig: {
select: {
accountSID: true,
authToken: true,
phoneNumber: true,
},
},
},
});
const { success, error: validationError } = zod
.object({
accountSID: zod.string(),
})
.safeParse(configs?.twilioConfig);
if (!success) {
logger.warn(validationError.message);
}
return {
isTwilioConfigured: success,
};
};
export const actions = { export const actions = {
push: async (event) => { push: async (event) => {
const form = await event.request.formData(); const form = await event.request.formData();
@ -31,11 +62,27 @@ export const actions = {
return fail(400, { error: 'invalid_message' }); return fail(400, { error: 'invalid_message' });
} }
const tenant = await prisma.tenantConfig.findUnique({
where: {
tenantId: event.locals.tenant.id,
},
select: {
twilioConfig: true,
},
});
const config = tenant?.twilioConfig;
if (!config) {
return fail(307, { error: 'no_twilio_config' });
}
const client = twilio(config.accountSID, config.authToken);
try { try {
const result = await TwilioClient.messages.create({ const result = await client.messages.create({
to: phone, to: phone,
body: message, body: message,
from: TWILIO_PHONE_NUMBER, from: config.phoneNumber,
}); });
logger.debug(result); logger.debug(result);
} catch (e) { } catch (e) {

View file

@ -1,43 +1,58 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { Button } from '$lib/components/Actions'; import { Button } from '$lib/components/Actions';
import { Textarea, TextInput } from '$lib/components/DataInput'; import { Textarea, TextInput } from '$lib/components/DataInput';
import { fade } from 'svelte/transition'; import { Alert } from '$lib/components/Feedback';
import type { ActionData } from './$types'; import { Link } from '$lib/components/Navigation';
import { messages } from '$lib/i18n'; import { messages } from '$lib/i18n';
import { CircleX, MessageCircleMore, PhoneOutgoing, TriangleAlert } from 'lucide-svelte';
import { fade } from 'svelte/transition';
import type { ActionData, PageData } from './$types';
type Props = { type Props = {
data: PageData;
form: ActionData; form: ActionData;
}; };
let { form }: Props = $props(); let { data, form }: Props = $props();
</script> </script>
{#snippet PhoneLabel()} {#snippet PhoneLabel()}
<i class="fi fi-sr-phone-flip"></i> {messages.sms_label_phone()} <PhoneOutgoing size="18" /> {messages.sms_label_phone()}
{/snippet} {/snippet}
{#snippet MessageLabel()} {#snippet MessageLabel()}
<i class="fi fi-sr-comment-alt"></i> {messages.sms_label_message()} <MessageCircleMore size="18" /> {messages.sms_label_message()}
{/snippet}
{#snippet alert(message: string)}
<div role="alert" class="alert alert-error absolute -top-20" transition:fade>
<i class="fi fi-bs-octagon-xmark h-6 w-6 shrink-0"></i>
<span>{message}</span>
</div>
{/snippet} {/snippet}
<div class="page" transition:fade> <div class="page" transition:fade>
<div class="card bg-base-200 px-4 pt-4 shadow-xl"> <div class="card bg-base-200 px-4 pt-4 shadow-xl">
{#if form?.error}
<Alert status="error">
{#snippet icon()}
<CircleX />
{/snippet}
<span>{form.error}</span>
</Alert>
{:else if !data.isTwilioConfigured}
<Alert status="warning">
{#snippet icon()}
<TriangleAlert />
{/snippet}
<span>
Twilio must be configured on the <Link onclick={() => goto('/app/settings')}
>Settings</Link
> page
</span>
</Alert>
{/if}
<div class="card-title justify-center"> <div class="card-title justify-center">
<h2 class="text-2xl font-semibold">{messages.sms_prompt()}</h2> <h2 class="text-2xl font-semibold">{messages.sms_prompt()}</h2>
{#if form?.error}
{@render alert(form.error)}
{/if}
</div> </div>
<form id="sms" method="POST" action="?/push" use:enhance> <form id="sms" method="POST" action="?/push" use:enhance>
<div class="card-body"> <div class="card-body">
<TextInput <TextInput
disabled={!data.isTwilioConfigured}
type="tel" type="tel"
name="phone" name="phone"
label={PhoneLabel} label={PhoneLabel}
@ -46,6 +61,7 @@
fade fade
/> />
<Textarea <Textarea
disabled={!data.isTwilioConfigured}
label={MessageLabel} label={MessageLabel}
size="lg" size="lg"
error={form?.error} error={form?.error}
@ -55,7 +71,9 @@
/> />
</div> </div>
<div class="card-actions justify-center px-8 pb-4"> <div class="card-actions justify-center px-8 pb-4">
<Button type="submit" variant="outline" full>{messages.sms_button_submit()}</Button> <Button disabled={!data.isTwilioConfigured} type="submit" variant="outline" full>
{messages.sms_button_submit()}
</Button>
</div> </div>
</form> </form>
</div> </div>