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

4
src/app.d.ts vendored
View file

@ -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 {}

View file

@ -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());

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 '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}

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 { 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) {

View file

@ -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>