diff --git a/.env b/.env index 1ed8778..5a40385 100644 --- a/.env +++ b/.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" diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 864c420..ba0826f 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -24,4 +24,4 @@ jobs: - name: Prisma Check run: bun prisma:validate - name: Test - run: bun test + run: 'bun run test:unit' diff --git a/bun.lock b/bun.lock index ceb661c..1b028e3 100644 --- a/bun.lock +++ b/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=="], diff --git a/messages/en.json b/messages/en.json index cc9ea3b..26ac9c8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" } diff --git a/package.json b/package.json index b7fe197..e7a321d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20250127041354_tenant_config/migration.sql b/prisma/migrations/20250127041354_tenant_config/migration.sql new file mode 100644 index 0000000..7952e81 --- /dev/null +++ b/prisma/migrations/20250127041354_tenant_config/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4f15db1..68d49c9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,11 +32,39 @@ model User { } model Tenant { - id String @id @default(uuid()) - name String - slug String @unique - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - clerkOrganizationId String @unique - users User[] + 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? + + 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 +} \ No newline at end of file diff --git a/scripts/generate-secret.ts b/scripts/generate-secret.ts new file mode 100644 index 0000000..2dce9de --- /dev/null +++ b/scripts/generate-secret.ts @@ -0,0 +1,6 @@ +import { randomBytes } from 'node:crypto'; + +console.log('SECRET: ', { + password: randomBytes(16).toString('hex'), + salt: randomBytes(16).toString('hex'), +}); diff --git a/scripts/validate-env.ts b/scripts/validate-env.ts index da28326..18a7b77 100644 --- a/scripts/validate-env.ts +++ b/scripts/validate-env.ts @@ -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); diff --git a/src/app.d.ts b/src/app.d.ts index 086138d..5aa6da4 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -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 {} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index e182571..5d5908e 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -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()); diff --git a/src/lib/server/crypto/encryption.test.ts b/src/lib/server/crypto/encryption.test.ts new file mode 100644 index 0000000..3d421ae --- /dev/null +++ b/src/lib/server/crypto/encryption.test.ts @@ -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); + }); +}); diff --git a/src/lib/server/crypto/encryption.ts b/src/lib/server/crypto/encryption.ts new file mode 100644 index 0000000..1872f59 --- /dev/null +++ b/src/lib/server/crypto/encryption.ts @@ -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'); +} diff --git a/src/lib/server/crypto/index.ts b/src/lib/server/crypto/index.ts new file mode 100644 index 0000000..73ebae8 --- /dev/null +++ b/src/lib/server/crypto/index.ts @@ -0,0 +1 @@ +export * from './encryption'; diff --git a/src/lib/server/middleware/index.ts b/src/lib/server/middleware/index.ts new file mode 100644 index 0000000..0ce5251 --- /dev/null +++ b/src/lib/server/middleware/index.ts @@ -0,0 +1 @@ +export * from './server'; diff --git a/src/lib/server/middleware/server/index.ts b/src/lib/server/middleware/server/index.ts new file mode 100644 index 0000000..2bcd448 --- /dev/null +++ b/src/lib/server/middleware/server/index.ts @@ -0,0 +1 @@ +export * from './loadUserEnv'; diff --git a/src/lib/server/middleware/server/loadUserEnv.ts b/src/lib/server/middleware/server/loadUserEnv.ts new file mode 100644 index 0000000..cf20a81 --- /dev/null +++ b/src/lib/server/middleware/server/loadUserEnv.ts @@ -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); + }; +} diff --git a/src/lib/server/twilio/client.ts b/src/lib/server/twilio/client.ts deleted file mode 100644 index 40bdee2..0000000 --- a/src/lib/server/twilio/client.ts +++ /dev/null @@ -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); diff --git a/src/lib/server/twilio/index.ts b/src/lib/server/twilio/index.ts deleted file mode 100644 index 4f1cce4..0000000 --- a/src/lib/server/twilio/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './client'; diff --git a/src/routes/app/+layout.svelte b/src/routes/app/+layout.svelte index 4ea34d0..a28013b 100644 --- a/src/routes/app/+layout.svelte +++ b/src/routes/app/+layout.svelte @@ -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" >