41 create tenant twilio config #62
24 changed files with 515 additions and 57 deletions
8
.env
8
.env
|
|
@ -1,9 +1,9 @@
|
|||
NODE_ENV=
|
||||
|
||||
# TWILIO
|
||||
TWILIO_ACCOUNT_SID=
|
||||
TWILIO_AUTH_TOKEN=
|
||||
TWILIO_PHONE_NUMBER=
|
||||
# SECRETS
|
||||
SECRETS_PASSWORD=secret_do_not_commit_or_change_this_create_.env.local_instead
|
||||
SECRETS_SALT=secret_do_not_commit_or_change_this_create_.env.local_instead
|
||||
SECRETS_IV_POSITION=secret_do_not_commit_or_change_this_create_.env.local_instead
|
||||
|
||||
# PRISMA
|
||||
DATABASE_URL="postgres://hestia:test123@localhost:5432/hestia"
|
||||
|
|
|
|||
2
.github/workflows/pr.yaml
vendored
2
.github/workflows/pr.yaml
vendored
|
|
@ -24,4 +24,4 @@ jobs:
|
|||
- name: Prisma Check
|
||||
run: bun prisma:validate
|
||||
- name: Test
|
||||
run: bun test
|
||||
run: 'bun run test:unit'
|
||||
|
|
|
|||
18
bun.lock
18
bun.lock
|
|
@ -40,7 +40,7 @@
|
|||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.15.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@types/bun": "^1.1.14",
|
||||
"@types/bun": "^1.1.15",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"daisyui": "^4.12.22",
|
||||
"eslint": "^9.7.0",
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"prisma": "^6.0.1",
|
||||
"prisma": "6.0.1",
|
||||
"storybook": "^8.4.7",
|
||||
"svelte": "^5.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/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/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=="],
|
||||
|
||||
|
|
@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
|
|
@ -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=="],
|
||||
|
||||
"@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/core/@storybook/csf": ["@storybook/csf@0.1.12", "", { "dependencies": { "type-fest": "^2.19.0" } }, "sha512-9/exVhabisyIVL0VxTCxo01Tdm8wefIXKXfltAPTSr8cbLn5JAxGQ6QV3mjdecLGEOucfoVhAKtJfVHxEK1iqw=="],
|
||||
|
|
|
|||
|
|
@ -21,5 +21,11 @@
|
|||
"sms_label_phone": "Phone Number",
|
||||
"sms_label_message": "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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"generate-secret": "bun ./scripts/generate-secret.ts",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"test:unit": "vitest",
|
||||
"test": "bun run test:unit -- --run && bun run test:e2e",
|
||||
|
|
@ -44,7 +45,7 @@
|
|||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.15.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@types/bun": "^1.1.14",
|
||||
"@types/bun": "^1.1.15",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"daisyui": "^4.12.22",
|
||||
"eslint": "^9.7.0",
|
||||
|
|
@ -56,7 +57,7 @@
|
|||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"prisma": "^6.0.1",
|
||||
"prisma": "6.0.1",
|
||||
"storybook": "^8.4.7",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
|
|
|
|||
34
prisma/migrations/20250127041354_tenant_config/migration.sql
Normal file
34
prisma/migrations/20250127041354_tenant_config/migration.sql
Normal 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;
|
||||
|
|
@ -33,10 +33,38 @@ model User {
|
|||
|
||||
model Tenant {
|
||||
id String @id @default(uuid())
|
||||
clerkOrganizationId String @unique
|
||||
|
||||
users User[]
|
||||
tenantConfig TenantConfig?
|
||||
|
||||
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?
|
||||
|
||||
|
and use Zod for validation and use Zod for validation
|
||||
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
|
||||
clerkOrganizationId String @unique
|
||||
users User[]
|
||||
}
|
||||
6
scripts/generate-secret.ts
Normal file
6
scripts/generate-secret.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
console.log('SECRET: ', {
|
||||
password: randomBytes(16).toString('hex'),
|
||||
salt: randomBytes(16).toString('hex'),
|
||||
});
|
||||
|
|
@ -1,12 +1,26 @@
|
|||
import { PhoneRegex } from '../src/lib/regex/phone';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ValidateEnvironment = () => {
|
||||
const { success, error } = z
|
||||
.object({
|
||||
TWILIO_ACCOUNT_SID: z.string().min(1),
|
||||
TWILIO_AUTH_TOKEN: z.string().min(1),
|
||||
TWILIO_PHONE_NUMBER: z.string().regex(PhoneRegex),
|
||||
SECRETS_PASSWORD: z.string().length(32),
|
||||
SECRETS_SALT: z.string().min(16),
|
||||
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);
|
||||
|
||||
|
|
|
|||
4
src/app.d.ts
vendored
4
src/app.d.ts
vendored
|
|
@ -1,5 +1,7 @@
|
|||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
|
||||
import type { Tenant, User } from '@prisma/client';
|
||||
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
|
|
@ -10,6 +12,8 @@ declare global {
|
|||
orgId?: string | null;
|
||||
sessionId?: string;
|
||||
};
|
||||
user: User;
|
||||
tenant: Tenant;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { loadUserEnv } from '$lib/server/middleware';
|
||||
import { sequence } from '@sveltejs/kit/hooks';
|
||||
import { withClerkHandler } from 'clerk-sveltekit/server';
|
||||
|
||||
export const handle = withClerkHandler();
|
||||
export const handle = sequence(withClerkHandler(), loadUserEnv());
|
||||
|
|
|
|||
20
src/lib/server/crypto/encryption.test.ts
Normal file
20
src/lib/server/crypto/encryption.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
42
src/lib/server/crypto/encryption.ts
Normal file
42
src/lib/server/crypto/encryption.ts
Normal 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');
|
||||
}
|
||||
1
src/lib/server/crypto/index.ts
Normal file
1
src/lib/server/crypto/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './encryption';
|
||||
1
src/lib/server/middleware/index.ts
Normal file
1
src/lib/server/middleware/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './server';
|
||||
1
src/lib/server/middleware/server/index.ts
Normal file
1
src/lib/server/middleware/server/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './loadUserEnv';
|
||||
40
src/lib/server/middleware/server/loadUserEnv.ts
Normal file
40
src/lib/server/middleware/server/loadUserEnv.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from './client';
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
import { messages } from '$lib/i18n';
|
||||
import 'clerk-sveltekit/client';
|
||||
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 { onMount } from 'svelte';
|
||||
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"
|
||||
>
|
||||
<li>
|
||||
<button onclick={() => goto('/app/sms')}
|
||||
><MessageCircleMore /> {messages.nav_menu_sms()}</button
|
||||
>
|
||||
<button onclick={() => goto('/app/sms')}>
|
||||
<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><SignOutButton><LogOut /> {messages.nav_menu_logout()}</SignOutButton></li>
|
||||
</ul>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
|
|
|||
93
src/routes/app/settings/+page.server.ts
Normal file
93
src/routes/app/settings/+page.server.ts
Normal 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;
|
||||
91
src/routes/app/settings/+page.svelte
Normal file
91
src/routes/app/settings/+page.svelte
Normal 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>
|
||||
|
|
@ -1,10 +1,41 @@
|
|||
import { TWILIO_PHONE_NUMBER } from '$env/static/private';
|
||||
import { PhoneRegex } from '$lib/regex';
|
||||
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 twilio from 'twilio';
|
||||
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 = {
|
||||
push: async (event) => {
|
||||
const form = await event.request.formData();
|
||||
|
|
@ -31,11 +62,27 @@ export const actions = {
|
|||
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 {
|
||||
const result = await TwilioClient.messages.create({
|
||||
const result = await client.messages.create({
|
||||
to: phone,
|
||||
body: message,
|
||||
from: TWILIO_PHONE_NUMBER,
|
||||
from: config.phoneNumber,
|
||||
});
|
||||
logger.debug(result);
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -1,43 +1,58 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button } from '$lib/components/Actions';
|
||||
import { Textarea, TextInput } from '$lib/components/DataInput';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { ActionData } from './$types';
|
||||
import { Alert } from '$lib/components/Feedback';
|
||||
import { Link } from '$lib/components/Navigation';
|
||||
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 = {
|
||||
data: PageData;
|
||||
form: ActionData;
|
||||
};
|
||||
let { form }: Props = $props();
|
||||
let { data, form }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#snippet PhoneLabel()}
|
||||
<i class="fi fi-sr-phone-flip"></i> {messages.sms_label_phone()}
|
||||
<PhoneOutgoing size="18" /> {messages.sms_label_phone()}
|
||||
{/snippet}
|
||||
|
||||
{#snippet MessageLabel()}
|
||||
<i class="fi fi-sr-comment-alt"></i> {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>
|
||||
<MessageCircleMore size="18" /> {messages.sms_label_message()}
|
||||
{/snippet}
|
||||
|
||||
<div class="page" transition:fade>
|
||||
<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">
|
||||
<h2 class="text-2xl font-semibold">{messages.sms_prompt()}</h2>
|
||||
{#if form?.error}
|
||||
{@render alert(form.error)}
|
||||
{/if}
|
||||
</div>
|
||||
<form id="sms" method="POST" action="?/push" use:enhance>
|
||||
<div class="card-body">
|
||||
<TextInput
|
||||
disabled={!data.isTwilioConfigured}
|
||||
type="tel"
|
||||
name="phone"
|
||||
label={PhoneLabel}
|
||||
|
|
@ -46,6 +61,7 @@
|
|||
fade
|
||||
/>
|
||||
<Textarea
|
||||
disabled={!data.isTwilioConfigured}
|
||||
label={MessageLabel}
|
||||
size="lg"
|
||||
error={form?.error}
|
||||
|
|
@ -55,7 +71,9 @@
|
|||
/>
|
||||
</div>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue
Not a fan of calling this TenantConfig while it contains twillio specific fields, if you have a new integration like sendgrid, will you just add a new column here? It doesn't feel scalable, I think you could either rename this to TwillioTenantConfig, if you don't wanna deal with it now or have a generic way to store integration credentials. Like have two column, integrationName, integrationValues (JSON column)