From a42c32f0147b2c154496ea57d0dddfac993c1dc0 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Mon, 6 Jul 2026 21:01:41 -0500 Subject: [PATCH 01/41] Add email via Resend --- .gitignore | 4 +- .vscode/settings.json | 1 + AGENTS.md | 53 ++++++++++++ README.md | 9 --- deploy.md | 12 --- flake.nix | 9 --- nixos/module.nix | 81 ------------------- package-lock.json | 52 +++++++++++- package.json | 3 +- src/lib/server/resend.ts | 56 +++++++++++++ .../(user)}/account/setup/+page.server.ts | 0 .../(user)}/account/setup/+page.svelte | 0 .../v1/automations/create/account/+server.ts | 21 +++++ src/routes/reset-password/+page.server.ts | 0 src/routes/reset-password/+page.svelte | 0 src/routes/send/+server.ts | 25 ++++++ 16 files changed, 210 insertions(+), 116 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 AGENTS.md delete mode 100644 deploy.md delete mode 100644 flake.nix delete mode 100644 nixos/module.nix create mode 100644 src/lib/server/resend.ts rename src/routes/{ => (protected)/(user)}/account/setup/+page.server.ts (100%) rename src/routes/{ => (protected)/(user)}/account/setup/+page.svelte (100%) create mode 100644 src/routes/api/v1/automations/create/account/+server.ts create mode 100644 src/routes/reset-password/+page.server.ts create mode 100644 src/routes/reset-password/+page.svelte create mode 100644 src/routes/send/+server.ts diff --git a/.gitignore b/.gitignore index b2240a9..a122eb5 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,4 @@ Thumbs.db # Vite vite.config.js.timestamp-* -vite.config.ts.timestamp-* - -/../generated/prisma +vite.config.ts.timestamp-* \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d215b3d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,53 @@ +# portal.Rhemawavetech.com + +SvelteKit 2 + Svelte 5 (runes) + Tailwind CSS v4 + Prisma 7 + PostgreSQL + Better-Auth. + +## Quick start + +```sh +npm install # engine-strict=true in .npmrc +npm run prepare # svelte-kit sync (generate types) +npm run dev # vite dev on :5173 +``` + +Requires `.env` with at least `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`. + +## Commands + +| npm run ... | What it does | +|----------------|-------------| +| `check` | `svelte-kit sync && svelte-check` (typecheck) | +| `lint` | `prettier --check . && eslint .` (order matters) | +| `format` | `prettier --write .` | +| `test` | `vitest --run` (single run) | +| `test:unit` | `vitest` (watch mode) | +| `migrate -- --name ` | Create a Prisma migration | +| `deploy` | Apply pending migrations | +| `build` | Production build (adapter-node) | + +## Testing quirks + +- **Two Vitest projects**: `client` (Playwright/Chromium headless, `src/**/*.svelte.{test,spec}.{js,ts}`) and `server` (node env, everything else). Tests co-located beside source files. +- **`expect: { requireAssertions: true }`** — every test must assert something. +- **All route tests mock `$lib/db`** (Prisma client) and `$lib/auth-client`/`$lib/auth` via `vi.mock()`. Stub factories in `src/lib/test-utils.ts`. +- **No `.env.test`** — mocking `$lib/db` makes real DB unnecessary. +- **Known bug**: register page `throw redirect()` is caught by try/catch and returns 400 instead of redirecting (documented in test). + +## Architecture notes + +- **Route groups**: `(protected)/` requires auth + profile; `(user)/` is user-facing; `admin/` requires roleId <= 3 (STAFF+). +- **Svelte-Admin**: custom config-driven CRUD in `src/lib/svelte-admin/`. Models registered in `registry.ts`. Dynamic routes `[model]/[id]`. +- **Prisma client** generated to `src/lib/generated/prisma/` (not `node_modules/.prisma/client`). Lazy singleton via Proxy in `src/lib/db.ts`. +- **Better-Auth** server instance in `src/lib/auth.ts`, browser client in `src/lib/auth-client.ts`. Session fetched in `src/hooks.server.ts`. +- **Roles**: integer hierarchy (SUPER=1, ADMIN=2, STAFF=3, USER=4). `canAccessAdmin` checks roleId <= 3. +- **Payments are stubbed** (`src/lib/payments.ts` — setTimeout + console.log). + +## Formatting conventions + +- Tabs, single quotes, no trailing commas, 100 print width (enforced by Prettier). +- Svelte 5 runes only (`$state`, `$derived`, `$effect`, `$props`). No legacy stores. + +## Generated / build artifacts + +- `.svelte-kit/` — SvelteKit generated types (gitignored) +- `src/lib/generated/prisma/` — Prisma client output (not gitignored; regenerate with `npx prisma generate`) diff --git a/README.md b/README.md index 70f0447..9435861 100644 --- a/README.md +++ b/README.md @@ -1,10 +1 @@ # Rhema Wave Technologies - User Portal - -Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). - -## Deployment - -- Update the Authorized Redirect URIs on Google Cloud Console -- Update the Better Auth URL -- To migrate development changes to the database, run `npm run migarte -- ` -- To run deployment changes to the database, run `npm run deploy` diff --git a/deploy.md b/deploy.md deleted file mode 100644 index 0c50fcd..0000000 --- a/deploy.md +++ /dev/null @@ -1,12 +0,0 @@ -Initial Setup: -1. Add the portal flake input to Webserver-Infra/flake.nix with a Forgejo access token. -- Does this need to be in just the inputs, or the outputs as well? I'm still very much learning nix and these things get confusing. -2. Add services.portal.enable = true; to Webserver-Infra/configuration.nix -3. Remove the existing users.users.portal and groups because they exist in this flake now -4. Clone the repo on the server, place the .env, build, then run nixos-rebuild switch. This is mainly to start the service, I presume. The build is just so that the service has something to - start. - -To update: -1. git pull -2. Apply shell-instance environment variables for placeholder values to then run `npm ci && npm run build && npm ci --omit=dev` -3. Restart the systemctl service (portal.service) to pickup the changes because the service is in charge of the `node build` command. diff --git a/flake.nix b/flake.nix deleted file mode 100644 index e5d870a..0000000 --- a/flake.nix +++ /dev/null @@ -1,9 +0,0 @@ -{ - description = "RhemaWave Portal"; - - inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; - - outputs = { self, nixpkgs }: { - nixosModules.default = import ./nixos/module.nix; - }; -} diff --git a/nixos/module.nix b/nixos/module.nix deleted file mode 100644 index 5978065..0000000 --- a/nixos/module.nix +++ /dev/null @@ -1,81 +0,0 @@ -{ config, lib, pkgs, ... }: - -let - cfg = config.services.portal; -in -{ - options.services.portal = { - enable = lib.mkEnableOption "RhemaWave Portal"; - - directory = lib.mkOption { - type = lib.types.path; - default = "/mnt/volume_nyc3_01/Portal.RhemaWaveTech.com"; - description = "Path to the Portal git clone."; - }; - - envFile = lib.mkOption { - type = lib.types.path; - default = "${cfg.directory}/.env"; - defaultText = lib.literalExpression ''"''${cfg.directory}/.env"''; - description = "Path to the .env file containing secrets."; - }; - - port = lib.mkOption { - type = lib.types.port; - default = 3001; - description = "Port the Portal listens on."; - }; - - host = lib.mkOption { - type = lib.types.str; - default = "0.0.0.0"; - description = "Host address the Portal binds to."; - }; - }; - - config = lib.mkIf cfg.enable { - users.users.portal = { - isSystemUser = true; - group = "portal"; - home = cfg.directory; - }; - - users.groups.portal = { }; - - systemd.services.portal = { - description = "RhemaWave Portal"; - after = [ "network.target" ]; - wantedBy = [ "multi-user.target" ]; - - serviceConfig = { - Type = "simple"; - User = "portal"; - Group = "portal"; - WorkingDirectory = cfg.directory; - EnvironmentFile = cfg.envFile; - ExecStart = "${pkgs.nodejs_22}/bin/node build"; - Restart = "on-failure"; - RestartSec = 5; - }; - - environment = { - HOST = cfg.host; - PORT = toString cfg.port; - DO_CA_CERTIFICATE = "/var/lib/portal/do-ca-certificate.crt"; - NODE_ENV = "production"; - }; - }; - - security.sudo.extraRules = [ - { - users = [ "forgejo-runner" ]; - commands = [ - { - command = "/run/current-system/sw/bin/systemctl restart portal"; - options = [ "NOPASSWD" ]; - } - ]; - } - ]; - }; -} diff --git a/package-lock.json b/package-lock.json index f394660..a9f3407 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "@tiptap/starter-kit": "^3.15.3", "better-auth": "^1.4.10", "dotenv": "^17.2.3", - "pg": "^8.16.3" + "pg": "^8.16.3", + "resend": "^6.17.1" }, "devDependencies": { "@eslint/compat": "^1.4.0", @@ -1853,6 +1854,12 @@ "win32" ] }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -4167,6 +4174,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", @@ -5455,6 +5468,12 @@ "node": ">=14.19.0" } }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -6115,6 +6134,27 @@ "node": ">=0.10.0" } }, + "node_modules/resend": { + "version": "6.17.1", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.17.1.tgz", + "integrity": "sha512-QhHHIRPPM9Tq2uilWmNF8C8kd6nLkUmiFgDQCt1v4eR4o+6p86qrK+Vn12jnAs0jR8Gf6ABdxI18/90LGQ7GVA==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.4", + "standardwebhooks": "1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -6360,6 +6400,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", diff --git a/package.json b/package.json index 9469967..0878ce5 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@tiptap/starter-kit": "^3.15.3", "better-auth": "^1.4.10", "dotenv": "^17.2.3", - "pg": "^8.16.3" + "pg": "^8.16.3", + "resend": "^6.17.1" } } diff --git a/src/lib/server/resend.ts b/src/lib/server/resend.ts new file mode 100644 index 0000000..49b6f50 --- /dev/null +++ b/src/lib/server/resend.ts @@ -0,0 +1,56 @@ +/** src/lib/server/resend.ts + * AUTHOR: Neil Clack + * EMAIL: nclack@radiantwavetech.com + * DATE: 07/06/2027 + * + * A server side library and client for Resend + * Includes the Resend client, as well as functions + * for different email scenarios. + * + */ +import { Resend } from 'resend'; +import { RESEND_API_KEY, EMAIL_FROM, RESEND_TEMPLATE_ID } from '$env/static/private'; + +export const resend = new Resend(RESEND_API_KEY); + +/** + * Send a transactional email using a Resend-hosted template. + * + * @example + * await sendMail({ + * to: 'jane@example.com', + * templateId: 'tmpl_abc123', // optional — defaults to RESEND_TEMPLATE_ID + * subject: 'Your license key is ready', // optional — defaults to 'Template Email' + * data: { // optional — template variables + * firstName: 'Jane', + * licenseKey: 'RWT-7X9K-2M4Q' + * } + * }); + * + * @returns The Resend API response data on success. + * @throws {Error} If the Resend API call fails. + */ +export async function sendMail(options: { + to: string; + subject?: string; + templateId?: string; + data?: Record; +}) { + const { to, subject, templateId, data } = options; + + const result = await resend.emails.send({ + from: EMAIL_FROM, + to: [to], + subject: subject ?? 'Template Email', + template: { + id: templateId ?? RESEND_TEMPLATE_ID, + variables: data ?? {} + } + }); + + if (result.error) { + throw new Error(result.error.message); + } + + return result.data; +} \ No newline at end of file diff --git a/src/routes/account/setup/+page.server.ts b/src/routes/(protected)/(user)/account/setup/+page.server.ts similarity index 100% rename from src/routes/account/setup/+page.server.ts rename to src/routes/(protected)/(user)/account/setup/+page.server.ts diff --git a/src/routes/account/setup/+page.svelte b/src/routes/(protected)/(user)/account/setup/+page.svelte similarity index 100% rename from src/routes/account/setup/+page.svelte rename to src/routes/(protected)/(user)/account/setup/+page.svelte diff --git a/src/routes/api/v1/automations/create/account/+server.ts b/src/routes/api/v1/automations/create/account/+server.ts new file mode 100644 index 0000000..2c0cec4 --- /dev/null +++ b/src/routes/api/v1/automations/create/account/+server.ts @@ -0,0 +1,21 @@ +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async () => { + // Request validation (400) + // 1. Validate and extract information + + // Business rule validation (409) + // 2. Check for existing entries + + // Resource creation (500 on unexpected failures) + // 3. Create new entries + // 5. Create license key + + // External operations (502/503/504 or 500, depending on your API) + // 4. Send profile setup email + // 6. Send license key email + + // Success (200/201) + // 7. Send response + return new Response(JSON.stringify({ "msg": "success" })) +} \ No newline at end of file diff --git a/src/routes/reset-password/+page.server.ts b/src/routes/reset-password/+page.server.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/reset-password/+page.svelte b/src/routes/reset-password/+page.svelte new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/send/+server.ts b/src/routes/send/+server.ts new file mode 100644 index 0000000..4685b8d --- /dev/null +++ b/src/routes/send/+server.ts @@ -0,0 +1,25 @@ +/** src/routes/send/+server.ts + * AUTHOR: Neil Clack + * EMAIL: nclack@radiantwavetech.com + * DATE: 07/06/2027 + * + * An example endpoint, temporarily enabled, to describe + * how to send an email with the Resend API and our custom + * sendMail function. + * + */ + +import { json } from '@sveltejs/kit'; +import { sendMail } from '$lib/server/resend'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ request }) => { + const { to, subject, templateId, data } = await request.json(); + + try { + const result = await sendMail({ to, subject, templateId, data }); + return json(result); + } catch (e) { + return json({ error: (e as Error).message }, { status: 400 }); + } +}; \ No newline at end of file From 92c5ef91c8577897f0b0b3709ab3c0d8eb563b4f Mon Sep 17 00:00:00 2001 From: NeilClack Date: Mon, 6 Jul 2026 21:04:11 -0500 Subject: [PATCH 02/41] Update environment variables --- .env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.example b/.env.example index 99dc697..aaae207 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,6 @@ BETTER_AUTH_SECRET= BETTER_AUTH_URL= GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= +RESEND_API_KEY= +EMAIL_FROM= +RESEND_TEMPLATE_ID= \ No newline at end of file From f566866a2308d235ca84ade98e36047be7e05a8f Mon Sep 17 00:00:00 2001 From: NeilClack Date: Mon, 6 Jul 2026 21:51:59 -0500 Subject: [PATCH 03/41] remove cert file --- digitalocean-database.crt | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 digitalocean-database.crt diff --git a/digitalocean-database.crt b/digitalocean-database.crt deleted file mode 100644 index 8dff6b2..0000000 --- a/digitalocean-database.crt +++ /dev/null @@ -1,26 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIETTCCArWgAwIBAgIUSiKrBdf9KHXL2om54yA9sQTxeawwDQYJKoZIhvcNAQEM -BQAwQDE+MDwGA1UEAww1MTdmNmMxNDktZmQ1Yy00MTRmLTlkNjctYjIxYTc2MWE3 -ZDlhIEdFTiAxIFByb2plY3QgQ0EwHhcNMjUwNDI5MTc0NDA5WhcNMzUwNDI3MTc0 -NDA5WjBAMT4wPAYDVQQDDDUxN2Y2YzE0OS1mZDVjLTQxNGYtOWQ2Ny1iMjFhNzYx -YTdkOWEgR0VOIDEgUHJvamVjdCBDQTCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCC -AYoCggGBANMf/9zhlInHTm5CrsnxHZQcqwy2sr4i4w2aWbpCa9j3rWcSyS7PAd+i -p2u1xTEoboJcbUghZYdmS92epVgejAHsHn4yx8LUsmdQvVF8imNlJfZA5mV09Voj -MpRwmXhQWCxyNqn/pmjMEKtpTm7TCr2GjEPDJC4gHSmPZF1EqJ3wWK+leoeZvRuE -omamd2oP60CxtHxt+8zcI6V80sC4QDze2qqRQ3rv1u2MkymUTAQT8kkb1pGmC/E+ -wanXI1gI5nLqhMcoY55WujA/BOgGLWM1fNCIUWkAoQXUV6G0rM8aYDbTUvuTR0Fk -6LP96/bhlxuhrkTSbezO7kwGngiD0rKL4zKFjdwqBbbL1ZjAgfncmzb9zE6NW6Ad -Z8zcLgGCC0Z74GhXNsqNuxY2Mmqo0B/YUa1ahiFjNeXAk14mMXVGG7pbdu+YUUpU -I3duyxwefZsr3KHvz31HhX2jtcaVFrbOF/Svu23MqRhvj9cCUhiF34h7BqEqMFJP -AeIJ/O8xjwIDAQABoz8wPTAdBgNVHQ4EFgQUQyY3hBxq86mcYi8vRRIkCcDsmkMw -DwYDVR0TBAgwBgEB/wIBADALBgNVHQ8EBAMCAQYwDQYJKoZIhvcNAQEMBQADggGB -AJqSrBQ8Qi6nIT1N0jo4WXQ5J9p9cyxYcaoRKkdOnvtnB9IXYwX6Ea4w7btZI3O5 -eYamItTqNAC9ZZmwc2i16v1Nfpm50vGJjZZUMXYkxx1e9/kzOsUn4h6Q6H6+490Y -PAl7kn4Ngi7xU3bwxt0K80dU5p3a16r0A93y8GYW/WDuGRuGgrmRz6+psic4U8j4 -nzHPFeJqtg9I00XYtzsFIIYrbAZT+jMuViVWCNAHJPyQYip8LkEzAfnlrszo9P9G -ieFN/wZbaPaVdju8wsSXN35yxy/eOsJ1p6gA4vzNdTtOmbMAlTQJ65QLu+HJrPpA -M5gjIf6QDSJyNpvWcjVROn6DiUnhsbe6EhRIPf6e8R0xwTe6y59XiU3sdsSDC0xp -3R2v7khln6IUimCsgs9lFvhOSZp4DmGuMs+CVCbqr8rtPTj5xWkeitkjlmiVSkg/ -z2IItxzG4pGmlPc17TCgKzI6bkNoCZvJ92Wvq85q5LcQegJFQjASZKPFXNOm3SDq -wQ== ------END CERTIFICATE----- From 4d4b2a1497f0a8f981d7d138cd039cdcfd136287 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 7 Jul 2026 08:30:27 -0500 Subject: [PATCH 04/41] fix auth: register redirect, password reset flow, GHL contact ID, session handling - Fix register redirect bug where successful signup returned 400 instead of redirecting to /account/setup (use isRedirect() to re-throw redirects) - Add error display to register page and fix Svelte 5 syntax (on:click) - Add await to Google sign-in on login page and register page - Fix logout to await signOut and redirect to / - Implement full password reset flow: - Add sendResetPassword callback to auth.ts config - Create forgot-password page with email submission - Create reset-password page with one-time token validation - Guard reset page: redirect to / if no valid token in URL - Add ghlContactId field to Profile model for future Go High Level integration - Add Prisma migration for ghl_contact_id column - Update register test to expect redirect instead of broken 400 --- .../migration.sql | 2 + prisma/schema.prisma | 15 +++-- src/lib/auth.ts | 67 +++++++++++++------ src/lib/components/ui/topBar/TopBar.svelte | 3 +- src/routes/forgot-password/+page.server.ts | 27 ++++++++ src/routes/forgot-password/+page.svelte | 42 ++++++++++++ src/routes/login/+page.svelte | 3 +- src/routes/register/+page.server.ts | 60 +++++++++-------- src/routes/register/+page.svelte | 12 +++- src/routes/register/page.server.spec.ts | 12 ++-- src/routes/reset-password/+page.server.ts | 59 ++++++++++++++++ src/routes/reset-password/+page.svelte | 40 +++++++++++ 12 files changed, 271 insertions(+), 71 deletions(-) create mode 100644 prisma/migrations/20260707132629_add_ghl_contact_id/migration.sql create mode 100644 src/routes/forgot-password/+page.server.ts create mode 100644 src/routes/forgot-password/+page.svelte diff --git a/prisma/migrations/20260707132629_add_ghl_contact_id/migration.sql b/prisma/migrations/20260707132629_add_ghl_contact_id/migration.sql new file mode 100644 index 0000000..0ec3afd --- /dev/null +++ b/prisma/migrations/20260707132629_add_ghl_contact_id/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Profile" ADD COLUMN "ghl_contact_id" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 523790d..56ca350 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -36,13 +36,14 @@ model User { // I chose to create a Profile table here because honestly, I didn't want to waste // a bunch of time trying to figure out how to customize the default Better-Auth model. model Profile { - id String @id @default(uuid()) - userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - firstName String - lastName String - phoneNumber String - addresses Address[] + id String @id @default(uuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + firstName String + lastName String + phoneNumber String + ghlContactId String? @map("ghl_contact_id") + addresses Address[] } model Address { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 1af3605..028e5ca 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,24 +1,47 @@ -import { betterAuth } from "better-auth"; -import { prismaAdapter } from "better-auth/adapters/prisma"; -import { prisma } from "$lib/db"; -import { sveltekitCookies } from "better-auth/svelte-kit"; -import { getRequestEvent } from "$app/server"; - +import { betterAuth } from 'better-auth'; +import { prismaAdapter } from 'better-auth/adapters/prisma'; +import { prisma } from '$lib/db'; +import { sveltekitCookies } from 'better-auth/svelte-kit'; +import { getRequestEvent } from '$app/server'; +import { sendMail } from './server/resend'; export const auth = betterAuth({ - baseURL: process.env.BETTER_AUTH_URL, - database: prismaAdapter(prisma, { - provider: "postgresql", - }), - emailAndPassword: { - enabled: true, - }, - socialProviders: { - google: { - prompt: "select_account", - clientId: process.env.GOOGLE_CLIENT_ID as string, - clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, - } - }, - plugins: [sveltekitCookies(getRequestEvent)], -}) \ No newline at end of file + baseURL: process.env.BETTER_AUTH_URL, + database: prismaAdapter(prisma, { + provider: 'postgresql' + }), + emailVerification: { + sendVerificationEmail: async ({ user }) => { + void sendMail({ + to: user.email, + templateId: 'email-verification-email', + data: { + target_email_address: user.email, + verification_link: 'https://google.com' + } + }); + } + }, + emailAndPassword: { + enabled: true, + requireEmailVerification: true, + sendResetPassword: async ({ user, url }) => { + void sendMail({ + to: user.email, + templateId: 'password-reset-email', + data: { + target_email_address: user.email, + reset_link: url + } + }); + } + }, + socialProviders: { + google: { + prompt: 'select_account', + clientId: process.env.GOOGLE_CLIENT_ID as string, + clientSecret: process.env.GOOGLE_CLIENT_SECRET as string + } + }, + plugins: [sveltekitCookies(getRequestEvent)] +}); diff --git a/src/lib/components/ui/topBar/TopBar.svelte b/src/lib/components/ui/topBar/TopBar.svelte index 5f04ed9..2c9a7cc 100644 --- a/src/lib/components/ui/topBar/TopBar.svelte +++ b/src/lib/components/ui/topBar/TopBar.svelte @@ -17,7 +17,8 @@ Dashboard diff --git a/src/routes/forgot-password/+page.server.ts b/src/routes/forgot-password/+page.server.ts new file mode 100644 index 0000000..96582c9 --- /dev/null +++ b/src/routes/forgot-password/+page.server.ts @@ -0,0 +1,27 @@ +import type { Actions } from './$types'; +import { auth } from '$lib/auth'; +import { fail } from '@sveltejs/kit'; + +export const actions: Actions = { + default: async ({ request }) => { + const data = await request.formData(); + const email = data.get('email') as string; + + if (!email) { + return fail(400, { error: 'Email is required.' }); + } + + try { + await auth.api.requestPasswordReset({ + body: { + email, + redirectTo: '/reset-password' + } + }); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to send reset email.'; + return fail(400, { error: message }); + } + } +}; diff --git a/src/routes/forgot-password/+page.svelte b/src/routes/forgot-password/+page.svelte new file mode 100644 index 0000000..f1cfc30 --- /dev/null +++ b/src/routes/forgot-password/+page.svelte @@ -0,0 +1,42 @@ + + +
+

Forgot Password

+ + {#if form?.success} +
+ If an account with that email exists, we've sent a password reset link. +
+ {/if} + + {#if form?.error && !form?.success} +
+ {form.error} +
+ {/if} + +
+ + +
+ + + Back to Login + +
diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 0c3e5d4..502c8d2 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -40,8 +40,7 @@ Or diff --git a/src/routes/register/page.server.spec.ts b/src/routes/register/page.server.spec.ts index 03757df..b7b1c1b 100644 --- a/src/routes/register/page.server.spec.ts +++ b/src/routes/register/page.server.spec.ts @@ -41,12 +41,7 @@ describe('register page server action', () => { expect(signUpEmail).not.toHaveBeenCalled(); }); - // BUG: The `throw redirect(303, '/account/setup')` on line 29 is inside a - // try/catch that catches it and returns `fail(400, err)`. The redirect never - // propagates. Compare with account/setup/+page.server.ts which correctly - // uses `isRedirect()` to re-throw. This test documents the current (broken) - // behaviour — a successful signup should redirect, not fail. - it('returns fail(400) on successful signup instead of redirecting (bug)', async () => { + it('redirects to /account/setup on successful signup', async () => { signUpEmail.mockResolvedValue({ user: { id: 'user-1' } }); const request = createFormRequest({ @@ -57,8 +52,9 @@ describe('register page server action', () => { password2: 'secret123' }); - const result = await invokeDefault(request); - expect(isActionFailure(result)).toBe(true); + await expect(actions.default({ request } as any)).rejects.toMatchObject({ + status: 303 + }); }); it('returns fail(400) when signUpEmail throws', async () => { diff --git a/src/routes/reset-password/+page.server.ts b/src/routes/reset-password/+page.server.ts index e69de29..efe4d56 100644 --- a/src/routes/reset-password/+page.server.ts +++ b/src/routes/reset-password/+page.server.ts @@ -0,0 +1,59 @@ +import type { PageServerLoad, Actions } from './$types'; +import { auth } from '$lib/auth'; +import { prisma } from '$lib/db'; +import { fail, redirect } from '@sveltejs/kit'; + +export const load: PageServerLoad = async ({ url }) => { + const token = url.searchParams.get('token'); + + if (!token) { + throw redirect(302, '/'); + } + + const verification = await prisma.verification.findFirst({ + where: { + value: token, + expiresAt: { gt: new Date() } + } + }); + + if (!verification) { + throw redirect(302, '/'); + } +}; + +export const actions: Actions = { + default: async ({ request, url }) => { + const token = url.searchParams.get('token'); + + if (!token) { + return fail(400, { error: 'Invalid or missing reset token.' }); + } + + const data = await request.formData(); + const password = data.get('password') as string; + const password2 = data.get('password2') as string; + + if (!password || password.length < 8) { + return fail(400, { error: 'Password must be at least 8 characters.' }); + } + + if (password !== password2) { + return fail(400, { error: 'Passwords do not match.' }); + } + + try { + await auth.api.resetPassword({ + body: { + newPassword: password, + token + } + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to reset password.'; + return fail(400, { error: message }); + } + + throw redirect(303, '/login'); + } +}; diff --git a/src/routes/reset-password/+page.svelte b/src/routes/reset-password/+page.svelte index e69de29..6e7d5da 100644 --- a/src/routes/reset-password/+page.svelte +++ b/src/routes/reset-password/+page.svelte @@ -0,0 +1,40 @@ + + +
+

Reset Password

+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + + + + + +
From a72b65be959efba584fcd955db525023913d4390 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 7 Jul 2026 08:51:24 -0500 Subject: [PATCH 05/41] audit: harden better-auth config, fix types, cleanup - Fix email verification link using Better Auth's url parameter (was hardcoded to google.com) - Make App.Locals session/user types nullable to match reality - Add explicit rate limiting config (60s window, 100 max) - Add trusted origins for localhost:5173 and production domain - Add Forgot Password? and Register links to login page - Remove unused dead code from auth-client.ts - Remove outdated known-bug note from AGENTS.md (register redirect is fixed) --- AGENTS.md | 1 - src/app.d.ts | 4 ++-- src/lib/auth-client.ts | 8 +------- src/lib/auth.ts | 13 +++++++++++-- src/routes/login/+page.svelte | 8 ++++++++ 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d215b3d..51ce2f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,6 @@ Requires `.env` with at least `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH - **`expect: { requireAssertions: true }`** — every test must assert something. - **All route tests mock `$lib/db`** (Prisma client) and `$lib/auth-client`/`$lib/auth` via `vi.mock()`. Stub factories in `src/lib/test-utils.ts`. - **No `.env.test`** — mocking `$lib/db` makes real DB unnecessary. -- **Known bug**: register page `throw redirect()` is caught by try/catch and returns 400 instead of redirecting (documented in test). ## Architecture notes diff --git a/src/app.d.ts b/src/app.d.ts index 1168519..27fbade 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -3,8 +3,8 @@ import type { Session, User } from "better-auth"; declare global { namespace App { interface Locals { - session: Session, - user: User, + session: Session | null; + user: User | null; } } } diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 2481aef..c951883 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -1,9 +1,3 @@ import { createAuthClient } from "better-auth/svelte" -export const authClient = createAuthClient({}); - -const signIn = async () => { - const data = await authClient.signIn.social({ - provider: "google", - }); -}; \ No newline at end of file +export const authClient = createAuthClient({}); \ No newline at end of file diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 028e5ca..53b8add 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -11,13 +11,13 @@ export const auth = betterAuth({ provider: 'postgresql' }), emailVerification: { - sendVerificationEmail: async ({ user }) => { + sendVerificationEmail: async ({ user, url }) => { void sendMail({ to: user.email, templateId: 'email-verification-email', data: { target_email_address: user.email, - verification_link: 'https://google.com' + verification_link: url } }); } @@ -43,5 +43,14 @@ export const auth = betterAuth({ clientSecret: process.env.GOOGLE_CLIENT_SECRET as string } }, + rateLimit: { + enabled: true, + window: 60, + max: 100 + }, + trustedOrigins: [ + 'http://localhost:5173', + 'https://portal.rhemawavetech.com' + ], plugins: [sveltekitCookies(getRequestEvent)] }); diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 502c8d2..b31e00e 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -36,6 +36,14 @@ +
Or + {:else} +
+ {#if form?.error} +
+ {form.error} +
+ {/if} + + + + + + + + +

Or

+ - -

Or

- -
+
+ {/if} From 5a13f2516cd560f681307edbab1e2c70f087b25e Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 7 Jul 2026 09:50:57 -0500 Subject: [PATCH 15/41] style: strip to grayscale wireframe - Remap all non-gray colors (blue, green, red, purple, slate) to gray equivalents via Tailwind v4 @theme overrides in layout.css - Zero out all border-radius values (square everything) - Zero out all shadow values (no depth) - Disable all transitions and animations globally - Change theme-color meta tag from purple (#7B2D8E) to gray (#6b7280) - Clean up undefined 'underlined' class and typo bg-grey-200 --- src/routes/+error.svelte | 9 ++-- src/routes/+layout.svelte | 2 +- src/routes/layout.css | 85 +++++++++++++++++++++++++++++++++++ src/routes/login/+page.svelte | 2 +- 4 files changed, 90 insertions(+), 8 deletions(-) diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte index f58b511..d8fbc10 100644 --- a/src/routes/+error.svelte +++ b/src/routes/+error.svelte @@ -6,11 +6,8 @@ {#if $page.status === 403}

Not Authorized

-

- You don't have permission to view this page. Click here to return home. -

+

+ You don't have permission to view this page. Go home. +

{/if}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 160f018..e923d9b 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -10,7 +10,7 @@ Rhema Wave Technologies - + diff --git a/src/routes/layout.css b/src/routes/layout.css index cd67023..b66f9a6 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -1,3 +1,88 @@ @import 'tailwindcss'; @plugin '@tailwindcss/forms'; @plugin '@tailwindcss/typography'; + +/* ------------------------------------------------------------------ */ +/* Wireframe theme — remap all non-gray colors to grayscale */ +/* ------------------------------------------------------------------ */ +@theme { + /* blue → gray */ + --color-blue-50: #f3f4f6; + --color-blue-100: #e5e7eb; + --color-blue-200: #d1d5db; + --color-blue-300: #9ca3af; + --color-blue-400: #6b7280; + --color-blue-500: #6b7280; + --color-blue-600: #4b5563; + --color-blue-700: #374151; + --color-blue-800: #1f2937; + /* green → gray */ + --color-green-50: #f3f4f6; + --color-green-100: #e5e7eb; + --color-green-200: #d1d5db; + --color-green-300: #9ca3af; + --color-green-400: #6b7280; + --color-green-500: #6b7280; + --color-green-600: #4b5563; + --color-green-700: #374151; + --color-green-800: #1f2937; + /* red → gray */ + --color-red-50: #f3f4f6; + --color-red-100: #e5e7eb; + --color-red-200: #d1d5db; + --color-red-300: #9ca3af; + --color-red-400: #6b7280; + --color-red-500: #6b7280; + --color-red-600: #4b5563; + --color-red-700: #374151; + --color-red-800: #1f2937; + /* purple → gray */ + --color-purple-50: #f3f4f6; + --color-purple-100: #e5e7eb; + --color-purple-200: #d1d5db; + --color-purple-300: #9ca3af; + --color-purple-400: #6b7280; + --color-purple-500: #6b7280; + --color-purple-600: #4b5563; + --color-purple-700: #374151; + --color-purple-800: #1f2937; + --color-purple-900: #111827; + /* slate → gray */ + --color-slate-50: #f8fafc; + --color-slate-100: #f1f5f9; + --color-slate-200: #e2e8f0; + --color-slate-300: #cbd5e1; + --color-slate-400: #94a3b8; + --color-slate-500: #64748b; + --color-slate-600: #475569; + --color-slate-700: #334155; + --color-slate-800: #1e293b; + --color-slate-900: #111827; + + /* zero out decorative radii */ + --radius: 0px; + --radius-sm: 0px; + --radius-md: 0px; + --radius-lg: 0px; + --radius-xl: 0px; + --radius-2xl: 0px; + --radius-3xl: 0px; + + /* zero out shadows */ + --shadow-sm: 0 0 #0000; + --shadow: 0 0 #0000; + --shadow-md: 0 0 #0000; + --shadow-lg: 0 0 #0000; + --shadow-xl: 0 0 #0000; + --shadow-2xl: 0 0 #0000; +} + +/* ------------------------------------------------------------------ */ +/* Force-disable transitions, animations, and remaining decoration */ +/* ------------------------------------------------------------------ */ +*, +::before, +::after { + transition: none !important; + animation: none !important; +} diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 79af796..e6e002e 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -39,7 +39,7 @@ {/if}

Login

From 03ecfbd6350e50d9e7ddda7bf6e85ca879510f16 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 7 Jul 2026 09:54:32 -0500 Subject: [PATCH 16/41] feat: add dark/light mode toggle - Add blocking inline script in app.html that reads localStorage 'theme' and applies .dark class before render (no flash) - Invert gray scale in .dark via CSS custom properties so all grayscale colors reverse automatically (light becomes dark) - Add @custom-variant dark to enable dark: utility classes - Add border-button toggle ("Dark"/"Light") in TopBar far right - Persist preference in localStorage --- src/app.html | 10 ++ src/lib/components/ui/topBar/TopBar.svelte | 39 +++++-- src/routes/layout.css | 123 ++++++++++++--------- 3 files changed, 108 insertions(+), 64 deletions(-) diff --git a/src/app.html b/src/app.html index b5bbbd8..3f1e509 100644 --- a/src/app.html +++ b/src/app.html @@ -4,6 +4,16 @@ + %sveltekit.head% diff --git a/src/lib/components/ui/topBar/TopBar.svelte b/src/lib/components/ui/topBar/TopBar.svelte index 2c9a7cc..0e1b2ef 100644 --- a/src/lib/components/ui/topBar/TopBar.svelte +++ b/src/lib/components/ui/topBar/TopBar.svelte @@ -1,28 +1,49 @@ -