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=
|
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"
|
||||||
|
|
|
||||||
2
.github/workflows/pr.yaml
vendored
2
.github/workflows/pr.yaml
vendored
|
|
@ -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'
|
||||||
|
|
|
||||||
18
bun.lock
18
bun.lock
|
|
@ -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=="],
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
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;
|
||||||
|
|
@ -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?
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
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';
|
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
4
src/app.d.ts
vendored
|
|
@ -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 {}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
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 { 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}
|
||||||
|
|
|
||||||
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 { 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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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)