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:
parent
8006d523c7
commit
8270c53509
24 changed files with 515 additions and 57 deletions
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
Add a link
Reference in a new issue