Compare commits

...

10 Commits

Author SHA1 Message Date
igm
c60041a1bb Replace dbmate with direct schema.sql execution in tests
Some checks failed
CI / ci (push) Has been cancelled
Instead of running dbmate migrations, tests now directly execute the
db/schema.sql file on the test database. This is faster and removes
the dbmate dependency from tests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:12:10 +08:00
igm
e43c006bb1 Fix merge conflicts and add withTransaction helper
- Add withTransaction helper that gracefully handles nested transactions
  (reuses existing transaction in tests, starts new one otherwise)
- Update auth procedures to use withTransaction instead of direct .transaction()
- Add email config to all e2e test contexts (required by merged code)
- Remove duplicate verification token code from signup procedure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:07:14 +08:00
igm
8e65c2e698 Merge branch 'transactions-in-procedure' 2026-01-12 15:53:41 +08:00
igm
b085a315be Add transactions to auth procedures and extract DB models
- Wrap multiple DB operations in transactions for atomicity:
  - login-if-completed: device upsert + session + login_request deletion
  - forgot-password: delete old tokens + insert new token
  - signup: session + email_verification creation

- Extract reusable DB model operations to packages/db/src/models/:
  - sessions.ts: insertSession()
  - user-devices.ts: upsertUserDevice(), isDeviceTrusted()

- Update session.ts to use new model functions from @reviq/db
- Fix type narrowing in admin.test.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:52:05 +08:00
igm
1ed41e5c4c Merge branch 'db-coverage' 2026-01-12 15:51:48 +08:00
igm
84644c8bfb Merge branch 'email-cleanup' 2026-01-12 15:51:38 +08:00
igm
5ecf12a1a1 Consolidate duplicate components and create reusable MetricsTable
- Merge two ConfirmDialog components into single shared ui/confirm-dialog
  with consistent API across account and org pages
- Create MetricsTable component to reduce duplication across dashboard
  table components (ad-unit, country, domain, source tables)
- Reduces code duplication by ~200 lines
- Consistent styling and behavior across all confirmation dialogs
- Single source of truth for metrics table structure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:51:29 +08:00
igm
c2b815dd6a Extract emails into separate package with clean interface
- Create packages/emails/ with EmailClient interface abstraction
- Wrap Postmark ServerClient in adapter for clean typing
- Add createLoggingEmailClient for dev mode (logs to console)
- Split email templates into individual files with full test coverage
- Update api-server to use new package via context injection
- Remove EMAIL_DEV_MODE - now uses POSTMARK_API_KEY presence
- Delete apps/api-server/src/utils/email.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:51:12 +08:00
igm
67930d90d5 Simplify apps/cli/ code
- config.ts: Convert arrow functions to function declarations
- api-client.ts: Extract duplicated RPCLink logic into buildClient helper
- format-error.ts: Add centralized ORPCError handling
- complete-login.ts: Remove redundant error handling (now in formatError)
- status.ts: Simplify formatRelativeTime, improve whitespace
- create.ts: Rename validRoles to VALID_ROLES, add as const, early return
- completions.ts: Derive Shell type from SUPPORTED_SHELLS array

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:42:39 +08:00
igm
58ffa68f4c Add tests for @reviq/db package
- token.test.ts: Unit tests for generateToken, parseToken, hashToken
- client.test.ts: Tests for createDb validation and e2e connectivity
- execute-bootstrap.test.ts: Comprehensive e2e tests for bootstrap
  operation including overwrite mode and related record cleanup

Coverage: client.ts 100%, token.ts 100%, execute-bootstrap.ts 98.69%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:35:02 +08:00
78 changed files with 3073 additions and 965 deletions

View File

@@ -12,13 +12,13 @@
"test": "bun test src/ --no-parallel" "test": "bun test src/ --no-parallel"
}, },
"dependencies": { "dependencies": {
"@formatjs/intl-durationformat": "^0.9.2",
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",
"@orpc/experimental-pino": "^1.13.2", "@orpc/experimental-pino": "^1.13.2",
"@orpc/server": "^1.13.2", "@orpc/server": "^1.13.2",
"@reviq/api-contract": "workspace:*", "@reviq/api-contract": "workspace:*",
"@reviq/db": "workspace:*", "@reviq/db": "workspace:*",
"@reviq/db-schema": "workspace:*", "@reviq/db-schema": "workspace:*",
"@reviq/emails": "workspace:*",
"@reviq/server-utils": "workspace:*", "@reviq/server-utils": "workspace:*",
"@scure/base": "^2.0.0", "@scure/base": "^2.0.0",
"@simplewebauthn/server": "^13.2.2", "@simplewebauthn/server": "^13.2.2",

View File

@@ -39,6 +39,7 @@ import {
uniqueTestId, uniqueTestId,
withTestTransaction, withTestTransaction,
} from "@reviq/test-helpers"; } from "@reviq/test-helpers";
import { createLoggingEmailClient } from "@reviq/emails";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.js"; import { hashToken } from "../../utils/crypto.js";
@@ -75,6 +76,11 @@ function createAPIContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }
@@ -1095,15 +1101,17 @@ describeE2E("admin", () => {
expect(org?.display_name).toBe("New Organization"); expect(org?.display_name).toBe("New Organization");
// Verify owner membership // Verify owner membership
if (org) {
const membership = await db const membership = await db
.selectFrom("org_members") .selectFrom("org_members")
.where("org_id", "=", org?.id) .where("org_id", "=", org.id)
.where("user_id", "=", owner.id) .where("user_id", "=", owner.id)
.selectAll() .selectAll()
.executeTakeFirst(); .executeTakeFirst();
expect(membership).toBeDefined(); expect(membership).toBeDefined();
expect(membership?.role).toBe("owner"); expect(membership?.role).toBe("owner");
}
}); });
test("normalizes owner email to lowercase", async () => { test("normalizes owner email to lowercase", async () => {

View File

@@ -50,6 +50,7 @@ import {
uniqueTestId, uniqueTestId,
withTestTransaction, withTestTransaction,
} from "@reviq/test-helpers"; } from "@reviq/test-helpers";
import { createLoggingEmailClient } from "@reviq/emails";
import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
@@ -100,6 +101,11 @@ function createAPIContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }

View File

@@ -32,6 +32,7 @@ import {
uniqueTestId, uniqueTestId,
withTestTransaction, withTestTransaction,
} from "@reviq/test-helpers"; } from "@reviq/test-helpers";
import { createLoggingEmailClient } from "@reviq/emails";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.js"; import { hashToken } from "../../utils/crypto.js";
@@ -82,6 +83,11 @@ function createAPIContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }

View File

@@ -23,6 +23,7 @@ import {
uniqueTestId, uniqueTestId,
withTestTransaction, withTestTransaction,
} from "@reviq/test-helpers"; } from "@reviq/test-helpers";
import { createLoggingEmailClient } from "@reviq/emails";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.js"; import { hashToken } from "../../utils/crypto.js";
@@ -58,6 +59,11 @@ function createAPIContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }

View File

@@ -23,6 +23,7 @@ import {
uniqueTestId, uniqueTestId,
withTestTransaction, withTestTransaction,
} from "@reviq/test-helpers"; } from "@reviq/test-helpers";
import { createLoggingEmailClient } from "@reviq/emails";
import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
@@ -51,6 +52,11 @@ function createAPIContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }
@@ -133,6 +139,11 @@ function createLoginRequestContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }

View File

@@ -36,10 +36,7 @@ export const EMAIL_FROM = Bun.env.EMAIL_FROM ?? "noreply@reviq.io";
/** Base URL for generating email links */ /** Base URL for generating email links */
export const BASE_URL = Bun.env.BASE_URL ?? "http://localhost:6827"; export const BASE_URL = Bun.env.BASE_URL ?? "http://localhost:6827";
/** Dev mode: log emails instead of sending (default: true) */ /** Postmark API key (optional - uses logging client if not set) */
export const EMAIL_DEV_MODE = Bun.env.EMAIL_DEV_MODE !== "false";
/** Postmark API key (required when EMAIL_DEV_MODE is false) */
export const POSTMARK_API_KEY = Bun.env.POSTMARK_API_KEY; export const POSTMARK_API_KEY = Bun.env.POSTMARK_API_KEY;
// ===== Token Expiration Times ===== // ===== Token Expiration Times =====

View File

@@ -3,8 +3,18 @@
*/ */
import type { Database } from "@reviq/db-schema"; import type { Database } from "@reviq/db-schema";
import type { EmailClient } from "@reviq/emails";
import type { Kysely } from "kysely"; import type { Kysely } from "kysely";
/**
* Email configuration for the API
*/
export interface EmailConfig {
client: EmailClient;
fromAddress: string;
baseUrl: string;
}
/** /**
* Base API context available to all handlers * Base API context available to all handlers
*/ */
@@ -23,6 +33,8 @@ export interface APIContext {
resHeaders: Headers; resHeaders: Headers;
/** Client IP address from direct connection (fallback when no proxy headers) */ /** Client IP address from direct connection (fallback when no proxy headers) */
clientIP?: string | null; clientIP?: string | null;
/** Email client and configuration */
email: EmailConfig;
} }
/** /**

View File

@@ -2,10 +2,17 @@ import type { APIContext } from "./context.js";
import { LoggingHandlerPlugin } from "@orpc/experimental-pino"; import { LoggingHandlerPlugin } from "@orpc/experimental-pino";
import { RPCHandler } from "@orpc/server/fetch"; import { RPCHandler } from "@orpc/server/fetch";
import { createDb } from "@reviq/db"; import { createDb } from "@reviq/db";
import {
createLoggingEmailClient,
createPostmarkClient,
} from "@reviq/emails";
import pino from "pino"; import pino from "pino";
import { import {
BASE_URL,
DEFAULT_PORT, DEFAULT_PORT,
DEFAULT_RP_NAME, DEFAULT_RP_NAME,
EMAIL_FROM,
POSTMARK_API_KEY,
getAllowedOrigins, getAllowedOrigins,
} from "./constants.js"; } from "./constants.js";
import { router } from "./router.js"; import { router } from "./router.js";
@@ -24,6 +31,16 @@ if (!databaseUrl) {
throw new Error("DATABASE_URL environment variable is required"); throw new Error("DATABASE_URL environment variable is required");
} }
const db = createDb(databaseUrl); const db = createDb(databaseUrl);
// Create email client - use Postmark if API key is set, otherwise log to console
const emailClient = POSTMARK_API_KEY
? createPostmarkClient(POSTMARK_API_KEY)
: createLoggingEmailClient();
if (!POSTMARK_API_KEY) {
logger.info("POSTMARK_API_KEY not set - emails will be logged to console");
}
const handler = new RPCHandler(router, { const handler = new RPCHandler(router, {
plugins: [ plugins: [
new LoggingHandlerPlugin({ new LoggingHandlerPlugin({
@@ -62,6 +79,11 @@ Bun.serve({
reqHeaders: request.headers, reqHeaders: request.headers,
resHeaders, resHeaders,
clientIP, clientIP,
email: {
client: emailClient,
fromAddress: EMAIL_FROM,
baseUrl: BASE_URL,
},
}; };
const { response } = await handler.handle(request, { const { response } = await handler.handle(request, {

View File

@@ -6,12 +6,13 @@
* This prevents attackers from determining which emails are registered * This prevents attackers from determining which emails are registered
*/ */
import { withTransaction } from "@reviq/db";
import { sendPasswordResetEmail } from "@reviq/emails";
import { TOKEN_DURATIONS } from "../../utils/cookies.js"; import { TOKEN_DURATIONS } from "../../utils/cookies.js";
import { import {
generateExpiry, generateExpiry,
generateSecureBase58Token, generateSecureBase58Token,
} from "../../utils/crypto.js"; } from "../../utils/crypto.js";
import { sendPasswordResetEmail } from "../../utils/email.js";
import { os } from "../base.js"; import { os } from "../base.js";
export const forgotPassword = os.auth.forgotPassword.handler( export const forgotPassword = os.auth.forgotPassword.handler(
@@ -30,19 +31,21 @@ export const forgotPassword = os.auth.forgotPassword.handler(
// If user exists, create password reset token and send email // If user exists, create password reset token and send email
if (user) { if (user) {
// Delete any existing password reset tokens for this user (security measure)
await context.db
.deleteFrom("password_resets")
.where("user_id", "=", user.id)
.execute();
// Generate secure base58 token // Generate secure base58 token
const token = generateSecureBase58Token(); const token = generateSecureBase58Token();
// Create password reset record with 1 hour expiry // Create password reset record with 1 hour expiry
const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET); const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET);
await context.db // Delete old tokens and insert new one in transaction
await withTransaction(context.db, async (trx) => {
// Delete any existing password reset tokens for this user (security measure)
await trx
.deleteFrom("password_resets")
.where("user_id", "=", user.id)
.execute();
await trx
.insertInto("password_resets") .insertInto("password_resets")
.values({ .values({
user_id: user.id, user_id: user.id,
@@ -50,9 +53,17 @@ export const forgotPassword = os.auth.forgotPassword.handler(
expires_at: expiresAt, expires_at: expiresAt,
}) })
.execute(); .execute();
});
// Send password reset email (stubbed) // Send password reset email
await sendPasswordResetEmail(user.email, token); await sendPasswordResetEmail({
client: context.email.client,
fromAddress: context.email.fromAddress,
baseUrl: context.email.baseUrl,
email: user.email,
token,
expiryHours: 1,
});
} }
// Always return success (anti-enumeration) // Always return success (anti-enumeration)

View File

@@ -16,6 +16,7 @@
* e. Return { status: 'completed', redirectTo: '/dashboard' or '/auth/trust-device' } * e. Return { status: 'completed', redirectTo: '/dashboard' or '/auth/trust-device' }
*/ */
import { withTransaction } from "@reviq/db";
import { import {
COOKIE_NAMES, COOKIE_NAMES,
COOKIE_OPTIONS, COOKIE_OPTIONS,
@@ -89,9 +90,13 @@ export const loginIfRequestIsCompleted =
const geo = getGeoInfo(context.reqHeaders, context.clientIP); const geo = getGeoInfo(context.reqHeaders, context.clientIP);
const userAgent = getUserAgent(context.reqHeaders); const userAgent = getUserAgent(context.reqHeaders);
// Create session in transaction (atomic: device upsert + session + login_request delete)
const { session, deviceTrusted } = await withTransaction(
context.db,
async (trx) => {
// Upsert user device // Upsert user device
const deviceId = await upsertUserDevice( const deviceId = await upsertUserDevice(
context.db, trx,
userId, userId,
deviceFingerprint, deviceFingerprint,
geo, geo,
@@ -99,14 +104,10 @@ export const loginIfRequestIsCompleted =
); );
// Check if device is already trusted // Check if device is already trusted
const deviceTrusted = await isDeviceTrusted( const trusted = await isDeviceTrusted(trx, userId, deviceFingerprint);
context.db,
userId,
deviceFingerprint,
);
// Create session with trusted mode = true (email-confirmed login) // Create session with trusted mode = true (email-confirmed login)
const session = await createSession(context.db, { const newSession = await createSession(trx, {
userId, userId,
deviceId, deviceId,
trustedMode: true, trustedMode: true,
@@ -115,11 +116,14 @@ export const loginIfRequestIsCompleted =
}); });
// Delete the login request (it's been consumed) // Delete the login request (it's been consumed)
await context.db await trx
.deleteFrom("login_requests") .deleteFrom("login_requests")
.where("id", "=", loginRequest.id) .where("id", "=", loginRequest.id)
.execute(); .execute();
return { session: newSession, deviceTrusted: trusted };
});
// Set session cookie // Set session cookie
setCookie( setCookie(
context.resHeaders, context.resHeaders,

View File

@@ -4,8 +4,8 @@
*/ */
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { sendLoginConfirmationEmail } from "@reviq/emails";
import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js"; import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js";
import { sendLoginConfirmationEmail } from "../../utils/email.js";
import { verifyPassword } from "../../utils/password.js"; import { verifyPassword } from "../../utils/password.js";
import { isDeviceTrusted } from "../../utils/session.js"; import { isDeviceTrusted } from "../../utils/session.js";
import { os } from "../base.js"; import { os } from "../base.js";
@@ -108,7 +108,14 @@ export const loginPassword = os.auth.loginPassword.handler(
} else { } else {
// Device is untrusted - send confirmation email with existing token // Device is untrusted - send confirmation email with existing token
// The same base58 token is used for both cookie lookup and email confirmation // The same base58 token is used for both cookie lookup and email confirmation
await sendLoginConfirmationEmail(result.email, result.token); await sendLoginConfirmationEmail({
client: context.email.client,
fromAddress: context.email.fromAddress,
baseUrl: context.email.baseUrl,
email: result.email,
token: result.token,
expiryMinutes: 15,
});
} }
return { success: true }; return { success: true };

View File

@@ -10,12 +10,12 @@
* 5. Send verification email (stubbed) * 5. Send verification email (stubbed)
*/ */
import { sendVerificationEmail } from "@reviq/emails";
import { TOKEN_DURATIONS } from "../../utils/cookies.js"; import { TOKEN_DURATIONS } from "../../utils/cookies.js";
import { import {
generateExpiry, generateExpiry,
generateSecureBase58Token, generateSecureBase58Token,
} from "../../utils/crypto.js"; } from "../../utils/crypto.js";
import { sendVerificationEmail } from "../../utils/email.js";
import { authMiddleware, os } from "../base.js"; import { authMiddleware, os } from "../base.js";
export const resendVerificationEmail = os.auth.resendVerificationEmail export const resendVerificationEmail = os.auth.resendVerificationEmail
@@ -47,8 +47,15 @@ export const resendVerificationEmail = os.auth.resendVerificationEmail
}) })
.execute(); .execute();
// Send verification email (stubbed) // Send verification email
await sendVerificationEmail(context.user.email, token); await sendVerificationEmail({
client: context.email.client,
fromAddress: context.email.fromAddress,
baseUrl: context.email.baseUrl,
email: context.user.email,
token,
expiryHours: 24,
});
return { success: true }; return { success: true };
}); });

View File

@@ -10,6 +10,8 @@ import type {
import type { Kysely } from "kysely"; import type { Kysely } from "kysely";
import type { RPInfo } from "../../utils/webauthn.js"; import type { RPInfo } from "../../utils/webauthn.js";
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { withTransaction } from "@reviq/db";
import { sendVerificationEmail } from "@reviq/emails";
import { verifyRegistrationResponse } from "@simplewebauthn/server"; import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { import {
COOKIE_NAMES, COOKIE_NAMES,
@@ -21,7 +23,6 @@ import {
generateExpiry, generateExpiry,
generateSecureBase58Token, generateSecureBase58Token,
} from "../../utils/crypto.js"; } from "../../utils/crypto.js";
import { sendVerificationEmail } from "../../utils/email.js";
import { getGeoInfo, getUserAgent } from "../../utils/geo.js"; import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
import { hashPassword, validatePassword } from "../../utils/password.js"; import { hashPassword, validatePassword } from "../../utils/password.js";
import { createSession } from "../../utils/session.js"; import { createSession } from "../../utils/session.js";
@@ -159,7 +160,7 @@ export async function signupWithPasskey(
// Create user and passkey in a transaction (handle race condition if concurrent signup) // Create user and passkey in a transaction (handle race condition if concurrent signup)
try { try {
const result = await db.transaction().execute(async (trx) => { const result = await withTransaction(db, async (trx) => {
// Create user // Create user
const user = await trx const user = await trx
.insertInto("users") .insertInto("users")
@@ -269,8 +270,16 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
}); });
} }
// Generate verification token
const verificationToken = generateSecureBase58Token();
const verificationExpiresAt = generateExpiry(
TOKEN_DURATIONS.EMAIL_VERIFICATION,
);
// Create session and email verification in transaction
const session = await withTransaction(context.db, async (trx) => {
// Create session (7 days, trusted mode false initially, no device) // Create session (7 days, trusted mode false initially, no device)
const session = await createSession(context.db, { const newSession = await createSession(trx, {
userId, userId,
deviceId: null, deviceId: null,
trustedMode: false, trustedMode: false,
@@ -278,6 +287,19 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
userAgent, userAgent,
}); });
// Store verification token (store raw token, not hash - it's already high-entropy)
await trx
.insertInto("email_verifications")
.values({
user_id: userId,
token: verificationToken,
expires_at: verificationExpiresAt,
})
.execute();
return newSession;
});
// Set session cookie // Set session cookie
setCookie( setCookie(
context.resHeaders, context.resHeaders,
@@ -286,22 +308,15 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
COOKIE_OPTIONS.session, COOKIE_OPTIONS.session,
); );
// Generate verification token // Send verification email
const verificationToken = generateSecureBase58Token(); await sendVerificationEmail({
const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION); client: context.email.client,
fromAddress: context.email.fromAddress,
// Store verification token (store raw token, not hash - it's already high-entropy) baseUrl: context.email.baseUrl,
await context.db email,
.insertInto("email_verifications")
.values({
user_id: userId,
token: verificationToken, token: verificationToken,
expires_at: expiresAt, expiryHours: 24,
}) });
.execute();
// Send verification email (stubbed)
await sendVerificationEmail(email, verificationToken);
return { success: true }; return { success: true };
}); });

View File

@@ -3,12 +3,12 @@
*/ */
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { sendOrgInviteEmail } from "@reviq/emails";
import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js"; import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js";
import { import {
generateExpiry, generateExpiry,
generateSecureBase58Token, generateSecureBase58Token,
} from "../../utils/crypto.js"; } from "../../utils/crypto.js";
import { sendOrgInviteEmail } from "../../utils/email.js";
import { authMiddleware, os } from "../base.js"; import { authMiddleware, os } from "../base.js";
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js"; import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
@@ -122,7 +122,17 @@ export const invitesCreate = os.orgs.invites.create
// Send invitation email // Send invitation email
const inviterName = context.user.displayName ?? context.user.email; const inviterName = context.user.displayName ?? context.user.email;
await sendOrgInviteEmail(email, token, org.displayName, inviterName, role); await sendOrgInviteEmail({
client: context.email.client,
fromAddress: context.email.fromAddress,
baseUrl: context.email.baseUrl,
email,
token,
orgName: org.displayName,
inviterName,
role,
expiryDays: ORG_INVITE_EXPIRY_DAYS,
});
return { success: true }; return { success: true };
}); });

View File

@@ -1,419 +0,0 @@
/**
* Email sending utilities using Postmark
* Implements Workstream G: Email Service (Backend)
*/
import type { OrgRole } from "@reviq/db-schema";
import { DurationFormat } from "@formatjs/intl-durationformat";
import { ServerClient } from "postmark";
import {
BASE_URL,
EMAIL_DEV_MODE,
EMAIL_FROM,
EMAIL_VERIFICATION_EXPIRY_HOURS,
LOGIN_CONFIRMATION_EXPIRY_MINUTES,
ORG_INVITE_EXPIRY_DAYS,
PASSWORD_RESET_EXPIRY_HOURS,
POSTMARK_API_KEY,
} from "../constants.js";
// ===== Types =====
/**
* Email send result
*/
export interface EmailResult {
success: boolean;
messageId?: string;
error?: string;
}
// ===== Postmark Client =====
let postmarkClient: ServerClient | null = null;
const getPostmarkClient = (): ServerClient => {
if (!postmarkClient) {
if (!POSTMARK_API_KEY) {
throw new Error(
"POSTMARK_API_KEY is required when EMAIL_DEV_MODE is false",
);
}
postmarkClient = new ServerClient(POSTMARK_API_KEY);
}
return postmarkClient;
};
// ===== URL Helpers =====
/**
* Build a URL with query parameters using the URL constructor
*/
const buildUrl = (path: string, params: Record<string, string>): string => {
const url = new URL(path, BASE_URL);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
return url.toString();
};
// ===== HTML Escaping =====
/**
* Escape HTML special characters to prevent XSS
*/
const escapeHtml = (unsafe: string): string =>
unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
// ===== Core Email Function =====
interface SendEmailParams {
to: string;
subject: string;
htmlBody: string;
textBody: string;
}
/**
* Send an email via Postmark (or log in dev mode)
*/
const sendEmail = async (params: SendEmailParams): Promise<EmailResult> => {
const { to, subject, htmlBody, textBody } = params;
// Dev mode: log instead of sending
if (EMAIL_DEV_MODE) {
console.log("=== DEV MODE EMAIL ===");
console.log(`To: ${to}`);
console.log(`Subject: ${subject}`);
console.log(`Body:\n${textBody}`);
console.log("======================");
return { success: true, messageId: "dev-mode" };
}
try {
const client = getPostmarkClient();
const result = await client.sendEmail({
From: EMAIL_FROM,
To: to,
Subject: subject,
HtmlBody: htmlBody,
TextBody: textBody,
});
return { success: true, messageId: result.MessageID };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error(`Failed to send email to ${to}:`, message);
return { success: false, error: message };
}
};
// ===== Template Helpers =====
const durationFormatter = new DurationFormat("en", { style: "long" });
const formatExpiryHours = (hours: number): string =>
durationFormatter.format({ hours });
const formatExpiryMinutes = (minutes: number): string =>
durationFormatter.format({ minutes });
const formatExpiryDays = (days: number): string =>
durationFormatter.format({ days });
const roleLabels: Record<OrgRole, string> = {
owner: "Owner",
admin: "Admin",
member: "Member",
};
const formatRoleDisplay = (role: OrgRole): string => roleLabels[role];
/**
* Get the correct article (a/an) for a role
*/
const getArticleForRole = (role: OrgRole): string => {
return role === "owner" || role === "admin" ? "an" : "a";
};
// ===== Email Templates =====
// Common styles
const emailStyles = `font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5;`;
const containerStyles =
"max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; padding: 40px;";
const headingStyles = "margin: 0 0 24px; font-size: 24px; color: #1a1a1a;";
const paragraphStyles =
"margin: 0 0 24px; font-size: 16px; color: #4a4a4a; line-height: 1.5;";
const buttonStyles =
"display: inline-block; background-color: #0066cc; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500;";
const footerStyles = "margin: 24px 0 0; font-size: 14px; color: #6a6a6a;";
// Verification Email
const buildVerificationEmailHtml = (
verifyUrl: string,
expiresIn: string,
): string => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="${emailStyles}">
<div style="${containerStyles}">
<h1 style="${headingStyles}">Verify your email</h1>
<p style="${paragraphStyles}">Please verify your email address by clicking the button below:</p>
<a href="${verifyUrl}" style="${buttonStyles}">Verify Email</a>
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
<p style="${footerStyles}">If you didn't create an account, you can safely ignore this email.</p>
</div>
</body>
</html>
`;
const buildVerificationEmailText = (
verifyUrl: string,
expiresIn: string,
): string =>
`Verify your email
Please verify your email address by clicking the link below:
${verifyUrl}
This link expires in ${expiresIn}.
If you didn't create an account, you can safely ignore this email.
`;
// Password Reset Email
const buildPasswordResetEmailHtml = (
resetUrl: string,
expiresIn: string,
): string => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="${emailStyles}">
<div style="${containerStyles}">
<h1 style="${headingStyles}">Reset your password</h1>
<p style="${paragraphStyles}">We received a request to reset your password. Click the button below to choose a new password:</p>
<a href="${resetUrl}" style="${buttonStyles}">Reset Password</a>
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
<p style="${footerStyles}">If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.</p>
</div>
</body>
</html>
`;
const buildPasswordResetEmailText = (
resetUrl: string,
expiresIn: string,
): string =>
`Reset your password
We received a request to reset your password. Click the link below to choose a new password:
${resetUrl}
This link expires in ${expiresIn}.
If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.
`;
// Login Confirmation Email
const buildLoginConfirmationEmailHtml = (
confirmUrl: string,
expiresIn: string,
): string => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="${emailStyles}">
<div style="${containerStyles}">
<h1 style="${headingStyles}">Confirm your login</h1>
<p style="${paragraphStyles}">Someone is trying to sign in to your account. If this was you, click the button below to confirm:</p>
<a href="${confirmUrl}" style="${buttonStyles}">Confirm Login</a>
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
<p style="${footerStyles}">If you didn't try to sign in, you can safely ignore this email. Someone may have entered your email address by mistake.</p>
</div>
</body>
</html>
`;
const buildLoginConfirmationEmailText = (
confirmUrl: string,
expiresIn: string,
): string =>
`Confirm your login
Someone is trying to sign in to your account. If this was you, click the link below to confirm:
${confirmUrl}
This link expires in ${expiresIn}.
If you didn't try to sign in, you can safely ignore this email. Someone may have entered your email address by mistake.
`;
// Org Invite Email
const buildOrgInviteEmailHtml = (
email: string,
orgName: string,
inviterName: string,
role: OrgRole,
inviteUrl: string,
expiresIn: string,
): string => {
const safeOrgName = escapeHtml(orgName);
const safeInviterName = escapeHtml(inviterName);
const safeEmail = escapeHtml(email);
const roleDisplay = formatRoleDisplay(role);
const article = getArticleForRole(role);
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="${emailStyles}">
<div style="${containerStyles}">
<h1 style="${headingStyles}">You've been invited to join ${safeOrgName}</h1>
<p style="${paragraphStyles}">${safeInviterName} has invited you to join <strong>${safeOrgName}</strong> as ${article} <strong>${roleDisplay}</strong>.</p>
<a href="${inviteUrl}" style="${buttonStyles}">Accept Invitation</a>
<p style="${footerStyles}">This invitation expires in ${expiresIn}.</p>
<p style="${footerStyles}">This invitation was sent to ${safeEmail}. If you weren't expecting this invitation, you can safely ignore this email.</p>
</div>
</body>
</html>
`;
};
const buildOrgInviteEmailText = (
email: string,
orgName: string,
inviterName: string,
role: OrgRole,
inviteUrl: string,
expiresIn: string,
): string => {
const roleDisplay = formatRoleDisplay(role);
const article = getArticleForRole(role);
return `You've been invited to join ${orgName}
${inviterName} has invited you to join ${orgName} as ${article} ${roleDisplay}.
Click the link below to accept the invitation:
${inviteUrl}
This invitation expires in ${expiresIn}.
This invitation was sent to ${email}. If you weren't expecting this invitation, you can safely ignore this email.
`;
};
// ===== Email Helpers =====
/**
* Send verification email to user
*/
export async function sendVerificationEmail(
email: string,
token: string,
): Promise<EmailResult> {
const url = buildUrl("/auth/verify", { token });
const expiresIn = formatExpiryHours(EMAIL_VERIFICATION_EXPIRY_HOURS);
return sendEmail({
to: email,
subject: "Verify your email address",
htmlBody: buildVerificationEmailHtml(url, expiresIn),
textBody: buildVerificationEmailText(url, expiresIn),
});
}
/**
* Send login confirmation email (for untrusted device flow)
*/
export async function sendLoginConfirmationEmail(
email: string,
token: string,
): Promise<EmailResult> {
const url = buildUrl("/auth/confirm", { token });
const expiresIn = formatExpiryMinutes(LOGIN_CONFIRMATION_EXPIRY_MINUTES);
return sendEmail({
to: email,
subject: "Confirm your login",
htmlBody: buildLoginConfirmationEmailHtml(url, expiresIn),
textBody: buildLoginConfirmationEmailText(url, expiresIn),
});
}
/**
* Send password reset email
*/
export async function sendPasswordResetEmail(
email: string,
token: string,
): Promise<EmailResult> {
const url = buildUrl("/auth/reset-password", { token });
const expiresIn = formatExpiryHours(PASSWORD_RESET_EXPIRY_HOURS);
return sendEmail({
to: email,
subject: "Reset your password",
htmlBody: buildPasswordResetEmailHtml(url, expiresIn),
textBody: buildPasswordResetEmailText(url, expiresIn),
});
}
/**
* Send org invite email
*/
export async function sendOrgInviteEmail(
email: string,
token: string,
orgName: string,
inviterName: string,
role: OrgRole,
): Promise<EmailResult> {
const url = buildUrl("/invite/accept", { token });
const expiresIn = formatExpiryDays(ORG_INVITE_EXPIRY_DAYS);
return sendEmail({
to: email,
subject: `You've been invited to join ${orgName}`,
htmlBody: buildOrgInviteEmailHtml(
email,
orgName,
inviterName,
role,
url,
expiresIn,
),
textBody: buildOrgInviteEmailText(
email,
orgName,
inviterName,
role,
url,
expiresIn,
),
});
}

View File

@@ -1,6 +1,11 @@
import type { Database } from "@reviq/db-schema"; import type { Database } from "@reviq/db-schema";
import type { Kysely } from "kysely"; import type { Kysely, Transaction } from "kysely";
import type { GeoInfo } from "./geo.js"; import type { GeoInfo } from "./geo.js";
import {
isDeviceTrusted as dbIsDeviceTrusted,
upsertUserDevice as dbUpsertUserDevice,
insertSession,
} from "@reviq/db";
import { COOKIE_DURATIONS } from "./cookies.js"; import { COOKIE_DURATIONS } from "./cookies.js";
import { generateExpiry, generateSessionToken, hashToken } from "./crypto.js"; import { generateExpiry, generateSessionToken, hashToken } from "./crypto.js";
@@ -23,33 +28,26 @@ export interface SessionResult {
* Returns the raw token (to be sent in cookie) and session details * Returns the raw token (to be sent in cookie) and session details
*/ */
export async function createSession( export async function createSession(
db: Kysely<Database>, db: Kysely<Database> | Transaction<Database>,
options: CreateSessionOptions, options: CreateSessionOptions,
): Promise<SessionResult> { ): Promise<SessionResult> {
const token = generateSessionToken(); const token = generateSessionToken();
const tokenHash = await hashToken(token); const tokenHash = await hashToken(token);
const expiresAt = generateExpiry(COOKIE_DURATIONS.SESSION); const expiresAt = generateExpiry(COOKIE_DURATIONS.SESSION);
const result = await db const result = await insertSession(db, {
.insertInto("sessions") userId: options.userId,
.values({ deviceId: options.deviceId,
user_id: options.userId, tokenHash,
device_id: options.deviceId, trustedMode: options.trustedMode,
token_hash: tokenHash, geo: options.geo,
trusted_mode: options.trustedMode, userAgent: options.userAgent,
ip_address: options.geo.ip, expiresAt,
city: options.geo.city, });
region: options.geo.region,
country: options.geo.country,
user_agent: options.userAgent,
expires_at: expiresAt,
})
.returning(["id"])
.executeTakeFirstOrThrow();
return { return {
token, token,
sessionId: Number(result.id), sessionId: result.sessionId,
expiresAt, expiresAt,
}; };
} }
@@ -60,53 +58,22 @@ export async function createSession(
* Returns the device ID * Returns the device ID
*/ */
export async function upsertUserDevice( export async function upsertUserDevice(
db: Kysely<Database>, db: Kysely<Database> | Transaction<Database>,
userId: number, userId: number,
deviceFingerprint: string, deviceFingerprint: string,
geo: GeoInfo, geo: GeoInfo,
userAgent: string, userAgent: string,
): Promise<number> { ): Promise<number> {
const result = await db return dbUpsertUserDevice(db, userId, deviceFingerprint, geo, userAgent);
.insertInto("user_devices")
.values({
user_id: userId,
device_fingerprint: deviceFingerprint,
user_agent: userAgent,
ip_address: geo.ip,
city: geo.city,
region: geo.region,
country: geo.country,
})
.onConflict((oc) =>
oc.columns(["user_id", "device_fingerprint"]).doUpdateSet({
ip_address: geo.ip,
city: geo.city,
region: geo.region,
country: geo.country,
user_agent: userAgent,
last_used_at: new Date(),
}),
)
.returning(["id"])
.executeTakeFirstOrThrow();
return Number(result.id);
} }
/** /**
* Check if a device is trusted for a user * Check if a device is trusted for a user
*/ */
export async function isDeviceTrusted( export async function isDeviceTrusted(
db: Kysely<Database>, db: Kysely<Database> | Transaction<Database>,
userId: number, userId: number,
deviceFingerprint: string, deviceFingerprint: string,
): Promise<boolean> { ): Promise<boolean> {
const device = await db return dbIsDeviceTrusted(db, userId, deviceFingerprint);
.selectFrom("user_devices")
.select(["is_trusted"])
.where("user_id", "=", userId)
.where("device_fingerprint", "=", deviceFingerprint)
.executeTakeFirst();
return device?.is_trusted ?? false;
} }

View File

@@ -1,5 +1,4 @@
import type { LocalContext } from "../../context.js"; import type { LocalContext } from "../../context.js";
import { ORPCError } from "@orpc/client";
import { buildCommand } from "@stricli/core"; import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js"; import { createApiClient } from "../../utils/api-client.js";
import { formatError } from "../../utils/format-error.js"; import { formatError } from "../../utils/format-error.js";
@@ -21,12 +20,7 @@ async function completeLogin(
console.log(`Completed login request for: ${flags.email}`); console.log(`Completed login request for: ${flags.email}`);
} catch (error) { } catch (error) {
if (error instanceof ORPCError) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- ORPCError.code is typed as any
console.error(`Error [${error.code}]:`, error.message);
} else {
console.error("Error:", formatError(error)); console.error("Error:", formatError(error));
}
this.process.exit(1); this.process.exit(1);
} }
} }

View File

@@ -17,16 +17,16 @@ function formatRelativeTime(date: Date): string {
if (diffDays < 0) { if (diffDays < 0) {
return `${Math.abs(diffDays).toLocaleString()} days ago`; return `${Math.abs(diffDays).toLocaleString()} days ago`;
} }
if (diffDays === 0) { if (diffDays === 0) {
const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
if (diffHours <= 0) { return diffHours <= 0 ? "expired" : `in ${diffHours.toLocaleString()} hours`;
return "expired";
}
return `in ${diffHours.toLocaleString()} hours`;
} }
if (diffDays === 1) { if (diffDays === 1) {
return "tomorrow"; return "tomorrow";
} }
return `in ${diffDays.toLocaleString()} days`; return `in ${diffDays.toLocaleString()} days`;
} }

View File

@@ -1,9 +1,8 @@
import type { LocalContext } from "../context.js"; import type { LocalContext } from "../context.js";
import { buildCommand } from "@stricli/core"; import { buildCommand } from "@stricli/core";
type Shell = "bash" | "zsh" | "fish"; const SUPPORTED_SHELLS = ["bash", "zsh", "fish"] as const;
type Shell = (typeof SUPPORTED_SHELLS)[number];
const SUPPORTED_SHELLS: readonly Shell[] = ["bash", "zsh", "fish"] as const;
function parseShell(value: string): Shell { function parseShell(value: string): Shell {
const shell = value.toLowerCase(); const shell = value.toLowerCase();

View File

@@ -5,20 +5,22 @@ import { formatError } from "../../utils/format-error.js";
type OrgRole = "owner" | "admin" | "member"; type OrgRole = "owner" | "admin" | "member";
const validRoles: OrgRole[] = ["owner", "admin", "member"]; const VALID_ROLES: readonly OrgRole[] = ["owner", "admin", "member"] as const;
function parseRole(role: string | undefined): OrgRole | undefined { function parseRole(role: string | undefined): OrgRole | undefined {
if (!role) { if (!role) {
return undefined; return undefined;
} }
if (validRoles.includes(role as OrgRole)) {
return role as OrgRole; if (!VALID_ROLES.includes(role as OrgRole)) {
}
throw new Error( throw new Error(
`Invalid role: ${role}. Must be one of: ${validRoles.join(", ")}`, `Invalid role: ${role}. Must be one of: ${VALID_ROLES.join(", ")}`,
); );
} }
return role as OrgRole;
}
interface CreateUserFlags { interface CreateUserFlags {
email: string; email: string;
name?: string; name?: string;

View File

@@ -10,6 +10,14 @@ import { readConfig } from "./config.js";
export type ApiClient = ContractRouterClient<typeof contract>; export type ApiClient = ContractRouterClient<typeof contract>;
function buildClient(apiUrl: string, token: string): ApiClient {
const link = new RPCLink({
url: `${apiUrl}/api/v1/rpc`,
headers: { "X-API-Key": token },
});
return createORPCClient(link) as unknown as ApiClient;
}
/** /**
* Create an oRPC API client with provided credentials * Create an oRPC API client with provided credentials
*/ */
@@ -25,18 +33,10 @@ export function createApiClient(
apiUrl?: string, apiUrl?: string,
token?: string, token?: string,
): ApiClient | Promise<ApiClient> { ): ApiClient | Promise<ApiClient> {
// If both arguments are provided, create client directly
if (apiUrl !== undefined && token !== undefined) { if (apiUrl !== undefined && token !== undefined) {
const link = new RPCLink({ return buildClient(apiUrl, token);
url: `${apiUrl}/api/v1/rpc`,
headers: {
"X-API-Key": token,
},
});
return createORPCClient(link) as unknown as ApiClient;
} }
// Otherwise, read from config asynchronously
return (async (): Promise<ApiClient> => { return (async (): Promise<ApiClient> => {
const config = await readConfig(); const config = await readConfig();
if (!config) { if (!config) {
@@ -44,14 +44,6 @@ export function createApiClient(
"Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.", "Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.",
); );
} }
return buildClient(config.apiUrl, config.token);
const link = new RPCLink({
url: `${config.apiUrl}/api/v1/rpc`,
headers: {
"X-API-Key": config.token,
},
});
return createORPCClient(link) as unknown as ApiClient;
})(); })();
} }

View File

@@ -19,40 +19,42 @@ const CONFIG_FILE = join(CONFIG_DIR, "credentials.json");
/** /**
* Get the path to the config file * Get the path to the config file
*/ */
export const getConfigPath = (): string => CONFIG_FILE; export function getConfigPath(): string {
return CONFIG_FILE;
}
/** /**
* Read the config file * Read the config file
* Returns null if the file doesn't exist or is invalid * Returns null if the file doesn't exist or is invalid
*/ */
export const readConfig = async (): Promise<Config | null> => { export async function readConfig(): Promise<Config | null> {
try { try {
const data = await readFile(CONFIG_FILE, "utf-8"); const data = await readFile(CONFIG_FILE, "utf-8");
return JSON.parse(data) as Config; return JSON.parse(data) as Config;
} catch { } catch {
return null; return null;
} }
}; }
/** /**
* Write the config file * Write the config file
* Creates the config directory if it doesn't exist * Creates the config directory if it doesn't exist
*/ */
export const writeConfig = async (config: Config): Promise<void> => { export async function writeConfig(config: Config): Promise<void> {
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 }); await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), { await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), {
mode: 0o600, mode: 0o600,
}); });
}; }
/** /**
* Delete the config file * Delete the config file
* Ignores errors if the file doesn't exist * Ignores errors if the file doesn't exist
*/ */
export const deleteConfig = async (): Promise<void> => { export async function deleteConfig(): Promise<void> {
try { try {
await unlink(CONFIG_FILE); await unlink(CONFIG_FILE);
} catch { } catch {
// Ignore if doesn't exist // Ignore if doesn't exist
} }
}; }

View File

@@ -1,8 +1,14 @@
import { ORPCError } from "@orpc/client";
/** /**
* Format an unknown error value into a string message. * Format an unknown error value into a string message.
* Handles Error instances, strings, and other types safely. * Handles ORPCError, Error instances, strings, and other types safely.
*/ */
export function formatError(error: unknown): string { export function formatError(error: unknown): string {
if (error instanceof ORPCError) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- ORPCError.code is typed as any
return `[${error.code}] ${error.message}`;
}
if (error instanceof Error) { if (error instanceof Error) {
return error.message; return error.message;
} }

View File

@@ -1,7 +1,6 @@
export { default as AccountNav } from "./account-nav.svelte"; export { default as AccountNav } from "./account-nav.svelte";
export { default as AddPasskeyDialog } from "./add-passkey-dialog.svelte"; export { default as AddPasskeyDialog } from "./add-passkey-dialog.svelte";
export { default as ChangePasswordDialog } from "./change-password-dialog.svelte"; export { default as ChangePasswordDialog } from "./change-password-dialog.svelte";
export { default as ConfirmDialog } from "./confirm-dialog.svelte";
export { default as DeleteAccountDialog } from "./delete-account-dialog.svelte"; export { default as DeleteAccountDialog } from "./delete-account-dialog.svelte";
export { default as PasskeyList } from "./passkey-list.svelte"; export { default as PasskeyList } from "./passkey-list.svelte";
export { default as RenamePasskeyDialog } from "./rename-passkey-dialog.svelte"; export { default as RenamePasskeyDialog } from "./rename-passkey-dialog.svelte";

View File

@@ -5,7 +5,7 @@ import { useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import ConfirmDialog from "./confirm-dialog.svelte"; import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import RenamePasskeyDialog from "./rename-passkey-dialog.svelte"; import RenamePasskeyDialog from "./rename-passkey-dialog.svelte";
interface Passkey { interface Passkey {

View File

@@ -1,7 +1,11 @@
<script lang="ts"> <script lang="ts">
import * as Table from "$lib/components/ui/table"; import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
const tableData = [ interface AdUnitRow extends MetricsRow {
name: string;
}
const tableData: AdUnitRow[] = [
{ {
id: 1, id: 1,
name: "/header/leaderboard-728x90", name: "/header/leaderboard-728x90",
@@ -51,58 +55,10 @@ const tableData = [
impPercent: 9.16, impPercent: 9.16,
}, },
]; ];
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</script> </script>
<Table.Root> <MetricsTable data={tableData} labelHeader="Ad unit" showSortIcon>
<Table.Header> {#snippet labelCell({ row })}
<Table.Row class="border-b border-border hover:bg-transparent"> <code class="font-mono text-[13px] text-foreground">{(row as AdUnitRow).name}</code>
<Table.Head class="h-10 w-10 pl-5"></Table.Head> {/snippet}
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Ad unit</Table.Head> </MetricsTable>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">
<div class="flex items-center justify-end gap-1">
% of revenue
<svg class="h-3 w-3 text-muted-foreground/60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m18 15-6-6-6 6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each tableData as row, i (row.id)}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
{i + 1}
</div>
</Table.Cell>
<Table.Cell class="py-3">
<code class="font-mono text-[13px] text-foreground">{row.name}</code>
</Table.Cell>
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
<Table.Cell class="w-32 py-3">
<div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
></div>
</div>
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -1,7 +1,12 @@
<script lang="ts"> <script lang="ts">
import * as Table from "$lib/components/ui/table"; import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
const tableData = [ interface CountryRow extends MetricsRow {
name: string;
code: string;
}
const tableData: CountryRow[] = [
{ {
id: 1, id: 1,
name: "United States", name: "United States",
@@ -57,54 +62,14 @@ const tableData = [
impPercent: 4.68, impPercent: 4.68,
}, },
]; ];
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</script> </script>
<Table.Root> <MetricsTable data={tableData} labelHeader="Country">
<Table.Header> {#snippet labelCell({ row })}
<Table.Row class="border-b border-border hover:bg-transparent"> {@const countryRow = row as CountryRow}
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Country</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">% of revenue</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each tableData as row, i (row.id)}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
{i + 1}
</div>
</Table.Cell>
<Table.Cell class="py-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] font-medium text-muted-foreground">{row.code}</span> <span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] font-medium text-muted-foreground">{countryRow.code}</span>
<span class="text-[13px] font-medium text-foreground">{row.name}</span> <span class="text-[13px] font-medium text-foreground">{countryRow.name}</span>
</div> </div>
</Table.Cell> {/snippet}
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell> </MetricsTable>
<Table.Cell class="w-32 py-3">
<div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
></div>
</div>
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -1,7 +1,11 @@
<script lang="ts"> <script lang="ts">
import * as Table from "$lib/components/ui/table"; import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
const tableData = [ interface DomainRow extends MetricsRow {
name: string;
}
const tableData: DomainRow[] = [
{ {
id: 1, id: 1,
name: "example.com", name: "example.com",
@@ -27,51 +31,10 @@ const tableData = [
impPercent: 18.45, impPercent: 18.45,
}, },
]; ];
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</script> </script>
<Table.Root> <MetricsTable data={tableData} labelHeader="Domain">
<Table.Header> {#snippet labelCell({ row })}
<Table.Row class="border-b border-border hover:bg-transparent"> <span class="text-[13px] font-medium text-foreground">{(row as DomainRow).name}</span>
<Table.Head class="h-10 w-10 pl-5"></Table.Head> {/snippet}
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Domain</Table.Head> </MetricsTable>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">% of revenue</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each tableData as row, i (row.id)}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
{i + 1}
</div>
</Table.Cell>
<Table.Cell class="py-3">
<span class="text-[13px] font-medium text-foreground">{row.name}</span>
</Table.Cell>
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
<Table.Cell class="w-32 py-3">
<div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
></div>
</div>
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -2,4 +2,5 @@ export { default as AdUnitTable } from "./ad-unit-table.svelte";
export { default as CountryTable } from "./country-table.svelte"; export { default as CountryTable } from "./country-table.svelte";
export { default as DomainTable } from "./domain-table.svelte"; export { default as DomainTable } from "./domain-table.svelte";
export { default as KeyValueTable } from "./key-value-table.svelte"; export { default as KeyValueTable } from "./key-value-table.svelte";
export { default as MetricsTable, type MetricsRow } from "./metrics-table.svelte";
export { default as SourceTable } from "./source-table.svelte"; export { default as SourceTable } from "./source-table.svelte";

View File

@@ -1,7 +1,17 @@
<script lang="ts"> <script lang="ts">
import * as Table from "$lib/components/ui/table"; import * as Table from "$lib/components/ui/table";
const tableData = [ interface KeyValueRow {
id: number;
key: string;
value: string;
revenue: string;
revPercent: number;
impressions: string;
impPercent: number;
}
const tableData: KeyValueRow[] = [
{ {
id: 1, id: 1,
key: "device", key: "device",

View File

@@ -0,0 +1,77 @@
<script lang="ts">
import type { Snippet } from "svelte";
import * as Table from "$lib/components/ui/table";
export interface MetricsRow {
id: number;
revenue: string;
revPercent: number;
impressions: string;
impPercent: number;
}
interface Props {
data: MetricsRow[];
labelHeader: string;
labelCell: Snippet<[{ row: MetricsRow; index: number }]>;
showSortIcon?: boolean;
}
let { data, labelHeader, labelCell, showSortIcon = false }: Props = $props();
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = $derived(Math.max(...data.map((d) => d.revPercent)));
</script>
<Table.Root>
<Table.Header>
<Table.Row class="border-b border-border hover:bg-transparent">
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">{labelHeader}</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">
<div class="flex items-center justify-end gap-1">
% of revenue
{#if showSortIcon}
<svg class="h-3 w-3 text-muted-foreground/60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m18 15-6-6-6 6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
</div>
</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data as row, i (row.id)}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
{i + 1}
</div>
</Table.Cell>
<Table.Cell class="py-3">
{@render labelCell({ row, index: i })}
</Table.Cell>
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
<Table.Cell class="w-32 py-3">
<div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
></div>
</div>
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -1,7 +1,11 @@
<script lang="ts"> <script lang="ts">
import * as Table from "$lib/components/ui/table"; import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
const tableData = [ interface SourceRow extends MetricsRow {
name: string;
}
const tableData: SourceRow[] = [
{ {
id: 1, id: 1,
name: "Google AdX", name: "Google AdX",
@@ -43,51 +47,10 @@ const tableData = [
impPercent: 7.28, impPercent: 7.28,
}, },
]; ];
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</script> </script>
<Table.Root> <MetricsTable data={tableData} labelHeader="Source">
<Table.Header> {#snippet labelCell({ row })}
<Table.Row class="border-b border-border hover:bg-transparent"> <span class="text-[13px] font-medium text-foreground">{(row as SourceRow).name}</span>
<Table.Head class="h-10 w-10 pl-5"></Table.Head> {/snippet}
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Source</Table.Head> </MetricsTable>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">% of revenue</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each tableData as row, i (row.id)}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
{i + 1}
</div>
</Table.Cell>
<Table.Cell class="py-3">
<span class="text-[13px] font-medium text-foreground">{row.name}</span>
</Table.Cell>
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
<Table.Cell class="w-32 py-3">
<div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
></div>
</div>
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -1,95 +0,0 @@
<script lang="ts">
import { X } from "@lucide/svelte";
import { Dialog as DialogPrimitive } from "bits-ui";
import { Button } from "$lib/components/ui/button";
import { cn } from "$lib/utils";
interface Props {
open: boolean;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: "destructive" | "default";
loading?: boolean;
onconfirm: () => void;
oncancel: () => void;
}
let {
open = $bindable(false),
title,
description,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
variant = "default",
loading = false,
onconfirm,
oncancel,
}: Props = $props();
function handleCancel() {
open = false;
oncancel();
}
function handleConfirm() {
onconfirm();
}
</script>
<DialogPrimitive.Root bind:open>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<DialogPrimitive.Content
class={cn(
"fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2",
"rounded-lg border bg-background p-6 shadow-lg",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]",
"data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
"duration-200"
)}
>
<!-- Close button -->
<DialogPrimitive.Close
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
onclick={handleCancel}
>
<X class="h-4 w-4" />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
<!-- Header -->
<div class="space-y-2">
<DialogPrimitive.Title class="text-lg font-semibold leading-none tracking-tight">
{title}
</DialogPrimitive.Title>
<DialogPrimitive.Description class="text-sm text-muted-foreground">
{description}
</DialogPrimitive.Description>
</div>
<!-- Actions -->
<div class="mt-6 flex justify-end gap-3">
<Button variant="outline" onclick={handleCancel} disabled={loading}>
{cancelLabel}
</Button>
<Button
variant={variant === "destructive" ? "destructive" : "default"}
onclick={handleConfirm}
disabled={loading}
>
{#if loading}
<span class="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
{/if}
{confirmLabel}
</Button>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>

View File

@@ -1,3 +1,2 @@
export { default as ConfirmDialog } from "./confirm-dialog.svelte";
export { default as OrgAvatar } from "./org-avatar.svelte"; export { default as OrgAvatar } from "./org-avatar.svelte";
export { default as RoleBadge } from "./role-badge.svelte"; export { default as RoleBadge } from "./role-badge.svelte";

View File

@@ -28,7 +28,7 @@ let {
onConfirm, onConfirm,
}: Props = $props(); }: Props = $props();
async function handleConfirm() { async function handleConfirm(): Promise<void> {
await onConfirm(); await onConfirm();
} }
</script> </script>
@@ -54,8 +54,8 @@ async function handleConfirm() {
<LoadingButton <LoadingButton
variant="destructive" variant="destructive"
class="w-full" class="w-full"
loading={loading} {loading}
loadingText={loadingText} {loadingText}
onclick={handleConfirm} onclick={handleConfirm}
> >
{confirmText} {confirmText}
@@ -77,7 +77,7 @@ async function handleConfirm() {
{cancelText} {cancelText}
</Button> </Button>
<LoadingButton <LoadingButton
loading={loading} {loading}
onclick={handleConfirm} onclick={handleConfirm}
> >
{confirmText} {confirmText}

View File

@@ -0,0 +1 @@
export { default as ConfirmDialog } from "./confirm-dialog.svelte";

View File

@@ -14,7 +14,7 @@ import { toast } from "svelte-sonner";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { ConfirmDialog } from "$lib/components/account"; import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Badge } from "$lib/components/ui/badge"; import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";

View File

@@ -12,7 +12,7 @@ import { formatRelativeTime } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { ConfirmDialog } from "$lib/components/account"; import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Badge } from "$lib/components/ui/badge"; import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";

View File

@@ -15,7 +15,7 @@ import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { UAParser } from "ua-parser-js"; import { UAParser } from "ua-parser-js";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { ConfirmDialog } from "$lib/components/account"; import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Badge } from "$lib/components/ui/badge"; import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";

View File

@@ -6,7 +6,7 @@ import { toast } from "svelte-sonner";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client.js"; import { api } from "$lib/api/client.js";
import { AdminLayout } from "$lib/components/layout"; import { AdminLayout } from "$lib/components/layout";
import ConfirmDialog from "$lib/components/org/confirm-dialog.svelte"; import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Button } from "$lib/components/ui/button/index.js"; import { Button } from "$lib/components/ui/button/index.js";
import { import {
Card, Card,
@@ -238,8 +238,7 @@ async function executeConfirmAction() {
title={confirmDialogTitle} title={confirmDialogTitle}
description={confirmDialogDescription} description={confirmDialogDescription}
variant="destructive" variant="destructive"
confirmLabel="Delete" confirmText="Delete"
loading={isConfirmLoading} loading={isConfirmLoading}
onconfirm={executeConfirmAction} onConfirm={executeConfirmAction}
oncancel={() => confirmDialogOpen = false}
/> />

View File

@@ -16,7 +16,8 @@ import { resolve } from "$app/paths";
import { page } from "$app/state"; import { page } from "$app/state";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { AdminLayout } from "$lib/components/layout"; import { AdminLayout } from "$lib/components/layout";
import { ConfirmDialog, OrgAvatar } from "$lib/components/org"; import { OrgAvatar } from "$lib/components/org";
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { import {
@@ -82,7 +83,7 @@ let confirmDialogOpen = $state(false);
let confirmDialogTitle = $state(""); let confirmDialogTitle = $state("");
let confirmDialogDescription = $state(""); let confirmDialogDescription = $state("");
let confirmDialogVariant = $state<"default" | "destructive">("destructive"); let confirmDialogVariant = $state<"default" | "destructive">("destructive");
let confirmDialogConfirmLabel = $state("Confirm"); let confirmDialogConfirmText = $state("Confirm");
let isConfirmLoading = $state(false); let isConfirmLoading = $state(false);
let pendingAction: (() => Promise<void>) | null = $state(null); let pendingAction: (() => Promise<void>) | null = $state(null);
@@ -158,7 +159,7 @@ function handleRemoveSite(domain: string) {
confirmDialogTitle = "Remove Site"; confirmDialogTitle = "Remove Site";
confirmDialogDescription = `Are you sure you want to remove "${domain}" from this organization? This action cannot be undone.`; confirmDialogDescription = `Are you sure you want to remove "${domain}" from this organization? This action cannot be undone.`;
confirmDialogVariant = "destructive"; confirmDialogVariant = "destructive";
confirmDialogConfirmLabel = "Remove Site"; confirmDialogConfirmText = "Remove Site";
pendingAction = async () => { pendingAction = async () => {
try { try {
await api.admin.orgs.removeSite({ slug: slug ?? "", domain }); await api.admin.orgs.removeSite({ slug: slug ?? "", domain });
@@ -180,7 +181,7 @@ function handleDelete() {
confirmDialogTitle = "Delete Organization"; confirmDialogTitle = "Delete Organization";
confirmDialogDescription = `Are you sure you want to delete "${displayName}"? This action cannot be undone. All members, invitations, and sites will be permanently deleted.`; confirmDialogDescription = `Are you sure you want to delete "${displayName}"? This action cannot be undone. All members, invitations, and sites will be permanently deleted.`;
confirmDialogVariant = "destructive"; confirmDialogVariant = "destructive";
confirmDialogConfirmLabel = "Delete Organization"; confirmDialogConfirmText = "Delete Organization";
pendingAction = async () => { pendingAction = async () => {
try { try {
await api.admin.orgs.delete({ slug: slug ?? "" }); await api.admin.orgs.delete({ slug: slug ?? "" });
@@ -452,11 +453,7 @@ async function executeConfirmAction() {
title={confirmDialogTitle} title={confirmDialogTitle}
description={confirmDialogDescription} description={confirmDialogDescription}
variant={confirmDialogVariant} variant={confirmDialogVariant}
confirmLabel={confirmDialogConfirmLabel} confirmText={confirmDialogConfirmText}
loading={isConfirmLoading} loading={isConfirmLoading}
onconfirm={executeConfirmAction} onConfirm={executeConfirmAction}
oncancel={() => {
confirmDialogOpen = false;
pendingAction = null;
}}
/> />

View File

@@ -12,7 +12,8 @@ import { getContext } from "svelte";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { DashboardLayout } from "$lib/components/layout"; import { DashboardLayout } from "$lib/components/layout";
import { ConfirmDialog, RoleBadge } from "$lib/components/org"; import { RoleBadge } from "$lib/components/org";
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { import {
Card, Card,
@@ -464,6 +465,5 @@ const availableInviteRoles = $derived.by(() => {
description={confirmDialogDescription} description={confirmDialogDescription}
variant={confirmDialogVariant} variant={confirmDialogVariant}
loading={isConfirmLoading} loading={isConfirmLoading}
onconfirm={executeConfirmAction} onConfirm={executeConfirmAction}
oncancel={() => confirmDialogOpen = false}
/> />

View File

@@ -14,7 +14,7 @@ import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { SettingsLayout } from "$lib/components/layout"; import { SettingsLayout } from "$lib/components/layout";
import { ConfirmDialog } from "$lib/components/org"; import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { import {
@@ -82,7 +82,7 @@ let confirmDialogOpen = $state(false);
let confirmDialogTitle = $state(""); let confirmDialogTitle = $state("");
let confirmDialogDescription = $state(""); let confirmDialogDescription = $state("");
let confirmDialogVariant = $state<"default" | "destructive">("destructive"); let confirmDialogVariant = $state<"default" | "destructive">("destructive");
let confirmDialogConfirmLabel = $state("Confirm"); let confirmDialogConfirmText = $state("Confirm");
let confirmAction = $state<() => Promise<void>>(() => Promise.resolve()); let confirmAction = $state<() => Promise<void>>(() => Promise.resolve());
let isConfirmLoading = $state(false); let isConfirmLoading = $state(false);
@@ -119,7 +119,7 @@ function handleLeave() {
confirmDialogDescription = confirmDialogDescription =
"Are you sure you want to leave this organization? You will lose access to all resources and will need to be re-invited to rejoin."; "Are you sure you want to leave this organization? You will lose access to all resources and will need to be re-invited to rejoin.";
confirmDialogVariant = "destructive"; confirmDialogVariant = "destructive";
confirmDialogConfirmLabel = "Leave Organization"; confirmDialogConfirmText = "Leave Organization";
confirmAction = async () => { confirmAction = async () => {
try { try {
await api.orgs.leave({ slug }); await api.orgs.leave({ slug });
@@ -142,7 +142,7 @@ function handleDelete() {
confirmDialogTitle = "Delete Organization"; confirmDialogTitle = "Delete Organization";
confirmDialogDescription = `Are you sure you want to delete "${displayName}"? This action cannot be undone. All members, invitations, and sites will be permanently deleted.`; confirmDialogDescription = `Are you sure you want to delete "${displayName}"? This action cannot be undone. All members, invitations, and sites will be permanently deleted.`;
confirmDialogVariant = "destructive"; confirmDialogVariant = "destructive";
confirmDialogConfirmLabel = "Delete Organization"; confirmDialogConfirmText = "Delete Organization";
confirmAction = async () => { confirmAction = async () => {
try { try {
await api.orgs.delete({ slug }); await api.orgs.delete({ slug });
@@ -306,8 +306,7 @@ async function executeConfirmAction() {
title={confirmDialogTitle} title={confirmDialogTitle}
description={confirmDialogDescription} description={confirmDialogDescription}
variant={confirmDialogVariant} variant={confirmDialogVariant}
confirmLabel={confirmDialogConfirmLabel} confirmText={confirmDialogConfirmText}
loading={isConfirmLoading} loading={isConfirmLoading}
onconfirm={executeConfirmAction} onConfirm={executeConfirmAction}
oncancel={() => confirmDialogOpen = false}
/> />

View File

@@ -12,7 +12,8 @@ import { getContext } from "svelte";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { SettingsLayout } from "$lib/components/layout"; import { SettingsLayout } from "$lib/components/layout";
import { ConfirmDialog, RoleBadge } from "$lib/components/org"; import { RoleBadge } from "$lib/components/org";
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { import {
Card, Card,
@@ -464,6 +465,5 @@ const availableInviteRoles = $derived.by(() => {
description={confirmDialogDescription} description={confirmDialogDescription}
variant={confirmDialogVariant} variant={confirmDialogVariant}
loading={isConfirmLoading} loading={isConfirmLoading}
onconfirm={executeConfirmAction} onConfirm={executeConfirmAction}
oncancel={() => confirmDialogOpen = false}
/> />

View File

@@ -15,13 +15,13 @@
"name": "@reviq/api-server", "name": "@reviq/api-server",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@formatjs/intl-durationformat": "^0.9.2",
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",
"@orpc/experimental-pino": "^1.13.2", "@orpc/experimental-pino": "^1.13.2",
"@orpc/server": "^1.13.2", "@orpc/server": "^1.13.2",
"@reviq/api-contract": "workspace:*", "@reviq/api-contract": "workspace:*",
"@reviq/db": "workspace:*", "@reviq/db": "workspace:*",
"@reviq/db-schema": "workspace:*", "@reviq/db-schema": "workspace:*",
"@reviq/emails": "workspace:*",
"@reviq/server-utils": "workspace:*", "@reviq/server-utils": "workspace:*",
"@scure/base": "^2.0.0", "@scure/base": "^2.0.0",
"@simplewebauthn/server": "^13.2.2", "@simplewebauthn/server": "^13.2.2",
@@ -181,6 +181,22 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
}, },
"packages/emails": {
"name": "@reviq/emails",
"version": "0.0.1",
"dependencies": {
"@formatjs/intl-durationformat": "^0.7.0",
"@reviq/db-schema": "workspace:*",
"postmark": "^4.0.5",
},
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@types/bun": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:",
},
},
"packages/frontend-utils": { "packages/frontend-utils": {
"name": "@reviq/frontend-utils", "name": "@reviq/frontend-utils",
"version": "0.0.1", "version": "0.0.1",
@@ -349,13 +365,13 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@3.0.8", "", { "dependencies": { "@formatjs/fast-memoize": "3.0.3", "@formatjs/intl-localematcher": "0.7.5", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-NRiqvxAvhbARZRFSRFPjN0y8txxmVutv2vMYvW2HSdCVf58w9l4osLj6Ujif643vImwZBcbKqhiKE0IOhY+DvA=="], "@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="],
"@formatjs/fast-memoize": ["@formatjs/fast-memoize@3.0.3", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-CArYtQKGLAOruCMeq5/RxCg6vUXFx3OuKBdTm30Wn/+gCefehmZ8Y2xSMxMrO2iel7hRyE3HKfV56t3vAU6D4Q=="], "@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="],
"@formatjs/intl-durationformat": ["@formatjs/intl-durationformat@0.9.2", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.0.8", "@formatjs/intl-localematcher": "0.7.5", "tslib": "^2.8.0" } }, "sha512-/QOJeY96qGj1j9saz32VANfgDYhChbbTRyjWLzjf7dc4OHIEWqGBIO4rQzUKDBVzqtRLJQMh4QKp37Uxkk0d8g=="], "@formatjs/intl-durationformat": ["@formatjs/intl-durationformat@0.7.6", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/intl-localematcher": "0.6.2", "tslib": "^2.8.0" } }, "sha512-jatAN3E84X6aP2UOGK1jTrwD1a7BiG3qWUSEDAhtyNd1BgYeS5wQPtXlnuGF1QRx0DjnwwNOIssyd7oQoRlQeg=="],
"@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.7.5", "", { "dependencies": { "@formatjs/fast-memoize": "3.0.3", "tslib": "^2.8.0" } }, "sha512-7/nd90cn5CT7SVF71/ybUKAcnvBlr9nZlJJp8O8xIZHXFgYOC4SXExZlSdgHv2l6utjw1byidL06QzChvQMHwA=="], "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="],
"@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
@@ -455,6 +471,8 @@
"@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"], "@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"],
"@reviq/emails": ["@reviq/emails@workspace:packages/emails"],
"@reviq/frontend-utils": ["@reviq/frontend-utils@workspace:packages/frontend-utils"], "@reviq/frontend-utils": ["@reviq/frontend-utils@workspace:packages/frontend-utils"],
"@reviq/server-utils": ["@reviq/server-utils@workspace:packages/server-utils"], "@reviq/server-utils": ["@reviq/server-utils@workspace:packages/server-utils"],

View File

@@ -0,0 +1,56 @@
/**
* Tests for the Kysely database client
*/
import { describe, expect, test } from "bun:test";
import { createDb } from "./client.js";
/**
* Skip flag for database-dependent tests.
* Tests are skipped when TEST_DATABASE_URL is not configured.
*/
const SKIP_DB_TESTS = !process.env.TEST_DATABASE_URL;
const describeE2E = describe.skipIf(SKIP_DB_TESTS);
describe("createDb", () => {
test("throws error for empty connection string", () => {
expect(() => createDb("")).toThrow("Database connection string is required");
});
test("throws error for null-ish connection string", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument -- testing edge case
expect(() => createDb(null as any)).toThrow(
"Database connection string is required",
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument -- testing edge case
expect(() => createDb(undefined as any)).toThrow(
"Database connection string is required",
);
});
});
describeE2E("[e2e] createDb with real database", () => {
test("creates working database connection", async () => {
const testUrl = process.env.TEST_DATABASE_URL;
if (!testUrl) {
throw new Error("TEST_DATABASE_URL not set");
}
const db = createDb(testUrl);
try {
// Verify the connection works by executing a simple query
const result = await db
.selectFrom("users")
.select(["id"])
.limit(1)
.execute();
// Should return an array (may be empty)
expect(Array.isArray(result)).toBe(true);
} finally {
await db.destroy();
}
});
});

View File

@@ -0,0 +1,697 @@
/**
* Tests for the bootstrap operation
*
* These tests use a real PostgreSQL database to test the executeBootstrap function.
*/
import type { Database } from "@reviq/db-schema";
import type { Kysely } from "kysely";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { sql } from "kysely";
import { createDb } from "../client.js";
import { executeBootstrap } from "./execute-bootstrap.js";
import { hashToken, parseToken, TOKEN_PREFIX } from "./token.js";
/**
* Skip flag for database-dependent tests.
* Tests are skipped when TEST_DATABASE_URL is not configured.
*/
const SKIP_DB_TESTS = !process.env.TEST_DATABASE_URL;
const describeE2E = describe.skipIf(SKIP_DB_TESTS);
/** Tables to truncate between tests */
const TABLES_TO_TRUNCATE = [
"sessions",
"api_tokens",
"login_requests",
"passkeys",
"user_devices",
"webauthn_challenges",
"email_verifications",
"password_resets",
"org_invites",
"org_sites",
"org_members",
"orgs",
"users",
] as const;
/** Generate unique test ID */
let testCounter = 0;
const uniqueTestId = (): string => {
const timestamp = Date.now();
testCounter++;
return `${timestamp}-${testCounter.toString()}`;
};
/** Truncate all tables */
async function truncateAllTables(db: Kysely<Database>): Promise<void> {
const tableList = TABLES_TO_TRUNCATE.join(", ");
await sql`TRUNCATE ${sql.raw(tableList)} RESTART IDENTITY CASCADE`.execute(
db,
);
}
/** Signal for transaction rollback */
class RollbackSignal extends Error {
constructor() {
super("RollbackSignal");
this.name = "RollbackSignal";
}
}
/** Run test in transaction that auto-rollbacks */
async function withTestTransaction<T>(
db: Kysely<Database>,
testFn: (trx: Kysely<Database>) => Promise<T>,
): Promise<T | undefined> {
let result: T | undefined;
try {
await db.transaction().execute(async (trx) => {
result = await testFn(trx);
throw new RollbackSignal();
});
} catch (e) {
if (!(e instanceof RollbackSignal)) {
throw e;
}
}
return result;
}
describeE2E("[e2e] executeBootstrap", () => {
let db: Kysely<Database>;
beforeAll(async () => {
const testUrl = process.env.TEST_DATABASE_URL;
if (!testUrl) {
throw new Error("TEST_DATABASE_URL not set");
}
db = createDb(testUrl);
await truncateAllTables(db);
});
afterAll(async () => {
if (db) {
await truncateAllTables(db);
await db.destroy();
}
});
test("creates superuser with correct email and password", async () => {
await withTestTransaction(db, async (trx) => {
const result = await executeBootstrap(trx, {
email: "admin@example.com",
password: "password123",
});
expect(result.user.email).toBe("admin@example.com");
// Verify user in database
const user = await trx
.selectFrom("users")
.where("id", "=", result.user.id)
.selectAll()
.executeTakeFirstOrThrow();
expect(user.email).toBe("admin@example.com");
expect(user.is_superuser).toBe(true);
expect(user.email_verified_at).not.toBeNull();
expect(user.password_hash).not.toBeNull();
});
});
test("normalizes email to lowercase", async () => {
await withTestTransaction(db, async (trx) => {
const result = await executeBootstrap(trx, {
email: "ADMIN@EXAMPLE.COM",
password: "password123",
});
expect(result.user.email).toBe("admin@example.com");
});
});
test("creates organization with default slug and name", async () => {
await withTestTransaction(db, async (trx) => {
const result = await executeBootstrap(trx, {
email: "admin@example.com",
password: "password123",
});
expect(result.org.slug).toBe("reviq");
// Verify org in database
const org = await trx
.selectFrom("orgs")
.where("id", "=", result.org.id)
.selectAll()
.executeTakeFirstOrThrow();
expect(org.slug).toBe("reviq");
expect(org.display_name).toBe("RevIQ");
});
});
test("creates organization with custom slug and name", async () => {
await withTestTransaction(db, async (trx) => {
const result = await executeBootstrap(trx, {
email: "admin@example.com",
password: "password123",
orgSlug: "custom-org",
orgDisplayName: "Custom Organization",
});
expect(result.org.slug).toBe("custom-org");
const org = await trx
.selectFrom("orgs")
.where("id", "=", result.org.id)
.selectAll()
.executeTakeFirstOrThrow();
expect(org.slug).toBe("custom-org");
expect(org.display_name).toBe("Custom Organization");
});
});
test("adds user as owner of organization", async () => {
await withTestTransaction(db, async (trx) => {
const result = await executeBootstrap(trx, {
email: "admin@example.com",
password: "password123",
});
const membership = await trx
.selectFrom("org_members")
.where("user_id", "=", result.user.id)
.where("org_id", "=", result.org.id)
.selectAll()
.executeTakeFirstOrThrow();
expect(membership.role).toBe("owner");
});
});
test("creates API token with correct properties", async () => {
await withTestTransaction(db, async (trx) => {
const result = await executeBootstrap(trx, {
email: "admin@example.com",
password: "password123",
});
// Token should be parseable
expect(result.token.startsWith(TOKEN_PREFIX)).toBe(true);
expect(parseToken(result.token)).not.toBeNull();
// Token should be stored as hash in database
const tokenRecord = await trx
.selectFrom("api_tokens")
.where("user_id", "=", result.user.id)
.selectAll()
.executeTakeFirstOrThrow();
expect(tokenRecord.token_hash).toBe(hashToken(result.token));
expect(tokenRecord.name).toBe("CLI bootstrap token");
});
});
test("creates API token with custom name", async () => {
await withTestTransaction(db, async (trx) => {
const result = await executeBootstrap(trx, {
email: "admin@example.com",
password: "password123",
tokenName: "Custom Token Name",
});
const tokenRecord = await trx
.selectFrom("api_tokens")
.where("user_id", "=", result.user.id)
.selectAll()
.executeTakeFirstOrThrow();
expect(tokenRecord.name).toBe("Custom Token Name");
});
});
test("creates API token with custom expiration", async () => {
await withTestTransaction(db, async (trx) => {
const beforeCreate = Date.now();
const result = await executeBootstrap(trx, {
email: "admin@example.com",
password: "password123",
tokenExpirationDays: 30,
});
const tokenRecord = await trx
.selectFrom("api_tokens")
.where("user_id", "=", result.user.id)
.selectAll()
.executeTakeFirstOrThrow();
const expectedMin = beforeCreate + 30 * 24 * 60 * 60 * 1000 - 1000;
const expectedMax = beforeCreate + 30 * 24 * 60 * 60 * 1000 + 5000;
expect(tokenRecord.expires_at.getTime()).toBeGreaterThan(expectedMin);
expect(tokenRecord.expires_at.getTime()).toBeLessThan(expectedMax);
});
});
test("throws error for password less than 8 characters", async () => {
await withTestTransaction(db, async (trx) => {
await expect(
executeBootstrap(trx, {
email: "admin@example.com",
password: "short",
}),
).rejects.toThrow("Password must be at least 8 characters");
});
});
test("throws error for password exactly 7 characters", async () => {
await withTestTransaction(db, async (trx) => {
await expect(
executeBootstrap(trx, {
email: "admin@example.com",
password: "1234567",
}),
).rejects.toThrow("Password must be at least 8 characters");
});
});
test("accepts password exactly 8 characters", async () => {
await withTestTransaction(db, async (trx) => {
const result = await executeBootstrap(trx, {
email: "admin@example.com",
password: "12345678",
});
expect(result.user.email).toBe("admin@example.com");
});
});
test("throws error for invalid email without @", async () => {
await withTestTransaction(db, async (trx) => {
await expect(
executeBootstrap(trx, {
email: "invalidemail",
password: "password123",
}),
).rejects.toThrow("Invalid email address");
});
});
test("accepts email with @ symbol", async () => {
await withTestTransaction(db, async (trx) => {
const result = await executeBootstrap(trx, {
email: "valid@email",
password: "password123",
});
expect(result.user.email).toBe("valid@email");
});
});
test("throws error if user already exists (normal mode)", async () => {
await withTestTransaction(db, async (trx) => {
// Create the first user
await executeBootstrap(trx, {
email: "admin@example.com",
password: "password123",
orgSlug: "org1",
});
// Attempt to create the same user again
await expect(
executeBootstrap(trx, {
email: "admin@example.com",
password: "password123",
orgSlug: "org2",
}),
).rejects.toThrow("User with email admin@example.com already exists");
});
});
test("overwrites existing user in dangerouslyOverwriteExisting mode", async () => {
await withTestTransaction(db, async (trx) => {
// Create the first user
const result1 = await executeBootstrap(trx, {
email: "admin@example.com",
password: "password123",
orgSlug: "original-org",
});
const originalUserId = result1.user.id;
// Overwrite the user
const result2 = await executeBootstrap(trx, {
email: "admin@example.com",
password: "newpassword123",
orgSlug: "new-org",
dangerouslyOverwriteExisting: true,
});
// Should be a different user ID
expect(result2.user.id).not.toBe(originalUserId);
// Original user should be deleted
const originalUser = await trx
.selectFrom("users")
.where("id", "=", originalUserId)
.selectAll()
.executeTakeFirst();
expect(originalUser).toBeUndefined();
// New user should exist
const newUser = await trx
.selectFrom("users")
.where("id", "=", result2.user.id)
.selectAll()
.executeTakeFirst();
expect(newUser).toBeDefined();
});
});
test("deletes existing org in dangerouslyOverwriteExisting mode", async () => {
await withTestTransaction(db, async (trx) => {
// Create the first bootstrap
const result1 = await executeBootstrap(trx, {
email: "admin@example.com",
password: "password123",
orgSlug: "test-org",
});
const originalOrgId = result1.org.id;
// Overwrite with a different email but same org slug
const result2 = await executeBootstrap(trx, {
email: "newadmin@example.com",
password: "password123",
orgSlug: "test-org",
dangerouslyOverwriteExisting: true,
});
// Should be a different org ID
expect(result2.org.id).not.toBe(originalOrgId);
// Original org should be deleted
const originalOrg = await trx
.selectFrom("orgs")
.where("id", "=", originalOrgId)
.selectAll()
.executeTakeFirst();
expect(originalOrg).toBeUndefined();
});
});
test("deletes related user records in overwrite mode", async () => {
const uniqueId = uniqueTestId();
await withTestTransaction(db, async (trx) => {
// Create the first bootstrap
const result1 = await executeBootstrap(trx, {
email: `admin-${uniqueId}@example.com`,
password: "password123",
orgSlug: `org-${uniqueId}`,
});
// Manually add some related records
await trx
.insertInto("sessions")
.values({
user_id: result1.user.id,
token_hash: "test-hash",
ip_address: "127.0.0.1",
user_agent: "test",
expires_at: new Date(Date.now() + 86400000),
trusted_mode: false,
})
.execute();
await trx
.insertInto("email_verifications")
.values({
user_id: result1.user.id,
token: "test-token",
expires_at: new Date(Date.now() + 86400000),
})
.execute();
await trx
.insertInto("login_requests")
.values({
user_id: result1.user.id,
email: `admin-${uniqueId}@example.com`,
token: "login-token",
device_fingerprint: "fingerprint",
expires_at: new Date(Date.now() + 86400000),
})
.execute();
await trx
.insertInto("passkeys")
.values({
user_id: result1.user.id,
credential_id: Buffer.from("credential"),
public_key: Buffer.from("publickey"),
counter: 0,
backup_eligible: false,
backup_status: false,
device_type: "singleDevice",
name: "Test Passkey",
rpid: "localhost",
webauthn_user_id: "test-user-id",
})
.execute();
await trx
.insertInto("password_resets")
.values({
user_id: result1.user.id,
token: "reset-token",
expires_at: new Date(Date.now() + 86400000),
})
.execute();
await trx
.insertInto("user_devices")
.values({
user_id: result1.user.id,
device_fingerprint: "device-fingerprint",
user_agent: "test-agent",
})
.execute();
// Overwrite the user
await executeBootstrap(trx, {
email: `admin-${uniqueId}@example.com`,
password: "newpassword123",
orgSlug: `org-${uniqueId}`,
dangerouslyOverwriteExisting: true,
});
// All related records should be deleted
const sessions = await trx
.selectFrom("sessions")
.where("user_id", "=", result1.user.id)
.selectAll()
.execute();
expect(sessions).toHaveLength(0);
const emailVerifications = await trx
.selectFrom("email_verifications")
.where("user_id", "=", result1.user.id)
.selectAll()
.execute();
expect(emailVerifications).toHaveLength(0);
const loginRequests = await trx
.selectFrom("login_requests")
.where("user_id", "=", result1.user.id)
.selectAll()
.execute();
expect(loginRequests).toHaveLength(0);
const passkeys = await trx
.selectFrom("passkeys")
.where("user_id", "=", result1.user.id)
.selectAll()
.execute();
expect(passkeys).toHaveLength(0);
const passwordResets = await trx
.selectFrom("password_resets")
.where("user_id", "=", result1.user.id)
.selectAll()
.execute();
expect(passwordResets).toHaveLength(0);
const userDevices = await trx
.selectFrom("user_devices")
.where("user_id", "=", result1.user.id)
.selectAll()
.execute();
expect(userDevices).toHaveLength(0);
});
});
test("deletes org invites created by user in overwrite mode", async () => {
const uniqueId = uniqueTestId();
await withTestTransaction(db, async (trx) => {
// Create the first bootstrap
const result1 = await executeBootstrap(trx, {
email: `admin-${uniqueId}@example.com`,
password: "password123",
orgSlug: `org-${uniqueId}`,
});
// Create another org and invite
const [otherOrg] = await trx
.insertInto("orgs")
.values({
slug: `other-org-${uniqueId}`,
display_name: "Other Org",
})
.returning(["id"])
.execute();
await trx
.insertInto("org_invites")
.values({
org_id: otherOrg!.id,
email: "invitee@example.com",
role: "member",
invited_by: result1.user.id,
token: "invite-token",
expires_at: new Date(Date.now() + 86400000),
})
.execute();
// Overwrite the user
await executeBootstrap(trx, {
email: `admin-${uniqueId}@example.com`,
password: "newpassword123",
orgSlug: `new-org-${uniqueId}`,
dangerouslyOverwriteExisting: true,
});
// Invite created by the user should be deleted
const invites = await trx
.selectFrom("org_invites")
.where("invited_by", "=", result1.user.id)
.selectAll()
.execute();
expect(invites).toHaveLength(0);
});
});
test("deletes org related records in overwrite mode", async () => {
const uniqueId = uniqueTestId();
await withTestTransaction(db, async (trx) => {
// Create the first bootstrap
const result1 = await executeBootstrap(trx, {
email: `admin-${uniqueId}@example.com`,
password: "password123",
orgSlug: `org-${uniqueId}`,
});
// Add org sites
await trx
.insertInto("org_sites")
.values({
org_id: result1.org.id,
domain: "example.com",
})
.execute();
// Add org invites (to the org, not by the user)
const [anotherUser] = await trx
.insertInto("users")
.values({
email: `other-${uniqueId}@example.com`,
display_name: "Other User",
})
.returning(["id"])
.execute();
await trx
.insertInto("org_invites")
.values({
org_id: result1.org.id,
email: "invitee@example.com",
role: "member",
invited_by: anotherUser!.id,
token: "invite-token-2",
expires_at: new Date(Date.now() + 86400000),
})
.execute();
// Overwrite with the same org slug
await executeBootstrap(trx, {
email: `newadmin-${uniqueId}@example.com`,
password: "password123",
orgSlug: `org-${uniqueId}`,
dangerouslyOverwriteExisting: true,
});
// Org sites should be deleted
const sites = await trx
.selectFrom("org_sites")
.where("org_id", "=", result1.org.id)
.selectAll()
.execute();
expect(sites).toHaveLength(0);
// Org invites should be deleted
const invites = await trx
.selectFrom("org_invites")
.where("org_id", "=", result1.org.id)
.selectAll()
.execute();
expect(invites).toHaveLength(0);
});
});
test("succeeds when no existing user/org in overwrite mode", async () => {
const uniqueId = uniqueTestId();
await withTestTransaction(db, async (trx) => {
// Should not throw even when nothing exists to overwrite
const result = await executeBootstrap(trx, {
email: `fresh-${uniqueId}@example.com`,
password: "password123",
orgSlug: `fresh-org-${uniqueId}`,
dangerouslyOverwriteExisting: true,
});
expect(result.user.email).toBe(`fresh-${uniqueId}@example.com`);
expect(result.org.slug).toBe(`fresh-org-${uniqueId}`);
});
});
test("returns all expected fields", async () => {
await withTestTransaction(db, async (trx) => {
const result = await executeBootstrap(trx, {
email: "admin@example.com",
password: "password123",
});
// Check user fields
expect(typeof result.user.id).toBe("number");
expect(typeof result.user.email).toBe("string");
// Check org fields
expect(typeof result.org.id).toBe("number");
expect(typeof result.org.slug).toBe("string");
// Check token
expect(typeof result.token).toBe("string");
expect(result.token.startsWith(TOKEN_PREFIX)).toBe(true);
});
});
});

View File

@@ -0,0 +1,136 @@
/**
* Tests for token generation and hashing utilities
*/
import { describe, expect, test } from "bun:test";
import { generateToken, hashToken, parseToken, TOKEN_PREFIX } from "./token.js";
describe("token utilities", () => {
describe("TOKEN_PREFIX", () => {
test("has expected value", () => {
expect(TOKEN_PREFIX).toBe("reviq_");
});
});
describe("generateToken", () => {
test("generates token with correct prefix", () => {
const token = generateToken();
expect(token.startsWith(TOKEN_PREFIX)).toBe(true);
});
test("generates unique tokens", () => {
const tokens = new Set<string>();
for (let i = 0; i < 100; i++) {
tokens.add(generateToken());
}
expect(tokens.size).toBe(100);
});
test("generates token of expected length", () => {
const token = generateToken();
// reviq_ (6 chars) + base58 encoded 32 bytes (~44 chars)
expect(token.length).toBeGreaterThan(40);
expect(token.length).toBeLessThan(60);
});
});
describe("parseToken", () => {
test("parses valid token and returns bytes", () => {
const token = generateToken();
const bytes = parseToken(token);
expect(bytes).not.toBeNull();
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes?.length).toBe(32);
});
test("returns null for token without prefix", () => {
const result = parseToken("invalid_token_without_prefix");
expect(result).toBeNull();
});
test("returns null for empty string", () => {
const result = parseToken("");
expect(result).toBeNull();
});
test("returns null for token with wrong prefix", () => {
const result = parseToken("wrong_prefix_abc123");
expect(result).toBeNull();
});
test("returns null for token with invalid base58", () => {
// Include invalid base58 characters (0, O, I, l)
const result = parseToken(`${TOKEN_PREFIX}invalid0OIl`);
expect(result).toBeNull();
});
test("returns null for token with wrong byte length", () => {
// Create a valid base58 string but with fewer bytes
// base58 encode a 16-byte value (too short)
const result = parseToken(`${TOKEN_PREFIX}2VQr`);
expect(result).toBeNull();
});
test("returns same bytes for same token", () => {
const token = generateToken();
const bytes1 = parseToken(token);
const bytes2 = parseToken(token);
expect(bytes1).toEqual(bytes2);
});
});
describe("hashToken", () => {
test("returns hex string", () => {
const token = generateToken();
const hash = hashToken(token);
// SHA-256 produces 32 bytes = 64 hex chars
expect(hash.length).toBe(64);
expect(/^[0-9a-f]+$/.test(hash)).toBe(true);
});
test("produces deterministic hash", () => {
const token = generateToken();
const hash1 = hashToken(token);
const hash2 = hashToken(token);
expect(hash1).toBe(hash2);
});
test("produces different hashes for different tokens", () => {
const token1 = generateToken();
const token2 = generateToken();
expect(hashToken(token1)).not.toBe(hashToken(token2));
});
test("hashes any string input", () => {
const hash = hashToken("arbitrary string input");
expect(hash.length).toBe(64);
});
test("hashes empty string", () => {
const hash = hashToken("");
// SHA-256 of empty string is a known value
expect(hash).toBe(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
);
});
});
describe("round-trip", () => {
test("generated tokens can be parsed and hashed", () => {
for (let i = 0; i < 10; i++) {
const token = generateToken();
const bytes = parseToken(token);
const hash = hashToken(token);
expect(bytes).not.toBeNull();
expect(bytes?.length).toBe(32);
expect(hash.length).toBe(64);
}
});
});
});

View File

@@ -0,0 +1,34 @@
import type { Database } from "@reviq/db-schema";
import type { Kysely, Transaction } from "kysely";
/**
* Type for a database connection that could be either a Kysely instance or a Transaction
*/
export type DbConnection = Kysely<Database> | Transaction<Database>;
/**
* Execute a callback within a transaction, handling nested transaction scenarios.
*
* If the provided db is already a transaction, the callback is executed directly
* without starting a new transaction (since Kysely doesn't support nested transactions).
*
* If the provided db is a regular Kysely instance, a new transaction is started.
*
* @param db - Database connection (Kysely instance or Transaction)
* @param callback - Function to execute within the transaction
* @returns The result of the callback
*/
export async function withTransaction<T>(
db: DbConnection,
callback: (trx: Transaction<Database>) => Promise<T>,
): Promise<T> {
// Check if db is already a transaction
// Kysely Transaction objects have isTransaction = true
if ("isTransaction" in db && db.isTransaction) {
// Already in a transaction, execute callback directly
return callback(db as Transaction<Database>);
}
// Not in a transaction, start one
return (db as Kysely<Database>).transaction().execute(callback);
}

View File

@@ -33,3 +33,11 @@ export {
parseToken, parseToken,
TOKEN_PREFIX, TOKEN_PREFIX,
} from "./helpers/token.js"; } from "./helpers/token.js";
export {
type DbConnection,
withTransaction,
} from "./helpers/with-transaction.js";
/**
* Export model operations
*/
export * from "./models/index.js";

View File

@@ -0,0 +1,7 @@
/**
* Database model operations
* Reusable database functions organized by table
*/
export * from "./sessions.js";
export * from "./user-devices.js";

View File

@@ -0,0 +1,53 @@
/**
* Database operations for sessions table
*/
import type { Database } from "@reviq/db-schema";
import type { Kysely, Transaction } from "kysely";
import type { DeviceGeoInfo } from "./user-devices.js";
/** Options for inserting a session */
export interface InsertSessionOptions {
userId: number;
deviceId: number | null;
tokenHash: string;
trustedMode: boolean;
geo: DeviceGeoInfo;
userAgent: string;
expiresAt: Date;
}
/** Result of session insertion */
export interface InsertSessionResult {
sessionId: number;
}
/**
* Insert a new session record
* Note: Token generation and hashing should be done by the caller
*/
export async function insertSession(
db: Kysely<Database> | Transaction<Database>,
options: InsertSessionOptions,
): Promise<InsertSessionResult> {
const result = await db
.insertInto("sessions")
.values({
user_id: options.userId,
device_id: options.deviceId,
token_hash: options.tokenHash,
trusted_mode: options.trustedMode,
ip_address: options.geo.ip,
city: options.geo.city,
region: options.geo.region,
country: options.geo.country,
user_agent: options.userAgent,
expires_at: options.expiresAt,
})
.returning(["id"])
.executeTakeFirstOrThrow();
return {
sessionId: Number(result.id),
};
}

View File

@@ -0,0 +1,71 @@
/**
* Database operations for user_devices table
*/
import type { Database } from "@reviq/db-schema";
import type { Kysely, Transaction } from "kysely";
/** Geo information for device tracking */
export interface DeviceGeoInfo {
ip: string | null;
city: string | null;
region: string | null;
country: string | null;
}
/**
* Upsert a user device record
* Creates new device if not exists, updates last_used_at if exists
* @returns The device ID
*/
export async function upsertUserDevice(
db: Kysely<Database> | Transaction<Database>,
userId: number,
deviceFingerprint: string,
geo: DeviceGeoInfo,
userAgent: string,
): Promise<number> {
const result = await db
.insertInto("user_devices")
.values({
user_id: userId,
device_fingerprint: deviceFingerprint,
user_agent: userAgent,
ip_address: geo.ip,
city: geo.city,
region: geo.region,
country: geo.country,
})
.onConflict((oc) =>
oc.columns(["user_id", "device_fingerprint"]).doUpdateSet({
ip_address: geo.ip,
city: geo.city,
region: geo.region,
country: geo.country,
user_agent: userAgent,
last_used_at: new Date(),
}),
)
.returning(["id"])
.executeTakeFirstOrThrow();
return Number(result.id);
}
/**
* Check if a device is trusted for a user
*/
export async function isDeviceTrusted(
db: Kysely<Database> | Transaction<Database>,
userId: number,
deviceFingerprint: string,
): Promise<boolean> {
const device = await db
.selectFrom("user_devices")
.select(["is_trusted"])
.where("user_id", "=", userId)
.where("device_fingerprint", "=", deviceFingerprint)
.executeTakeFirst();
return device?.is_trusted ?? false;
}

View File

@@ -0,0 +1,12 @@
import { configs } from "@macalinao/eslint-config";
export default [
...configs.fast,
{
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
},
},
},
];

View File

@@ -0,0 +1,33 @@
{
"name": "@reviq/emails",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
"lint": "eslint . --cache",
"test": "bun test src/",
"test:cov": "bun test --coverage src/"
},
"dependencies": {
"@formatjs/intl-durationformat": "^0.7.0",
"@reviq/db-schema": "workspace:*",
"postmark": "^4.0.5"
},
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@types/bun": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, it, mock } from "bun:test";
import { createPostmarkClient } from "./client.js";
const mockSendEmail = mock(() =>
Promise.resolve({ MessageID: "test-message-id-123" }),
);
void mock.module("postmark", () => ({
ServerClient: class MockServerClient {
sendEmail = mockSendEmail;
},
}));
describe("createPostmarkClient", () => {
it("should create an EmailClient with sendEmail method", () => {
const client = createPostmarkClient("test-api-key");
expect(typeof client.sendEmail).toBe("function");
});
it("should throw an error if API key is empty", () => {
expect(() => createPostmarkClient("")).toThrow(
"Postmark API key is required",
);
});
it("should convert our interface to Postmark format and return converted result", async () => {
const client = createPostmarkClient("test-api-key");
const result = await client.sendEmail({
from: "sender@example.com",
to: "recipient@example.com",
subject: "Test Subject",
htmlBody: "<p>HTML</p>",
textBody: "Text",
});
expect(mockSendEmail).toHaveBeenCalledWith({
From: "sender@example.com",
To: "recipient@example.com",
Subject: "Test Subject",
HtmlBody: "<p>HTML</p>",
TextBody: "Text",
});
expect(result.messageId).toBe("test-message-id-123");
});
});

View File

@@ -0,0 +1,27 @@
import { ServerClient } from "postmark";
import type {
ClientSendParams,
ClientSendResult,
EmailClient,
} from "./types.js";
export function createPostmarkClient(apiKey: string): EmailClient {
if (!apiKey) {
throw new Error("Postmark API key is required");
}
const serverClient = new ServerClient(apiKey);
return {
sendEmail: async (params: ClientSendParams): Promise<ClientSendResult> => {
const result = await serverClient.sendEmail({
From: params.from,
To: params.to,
Subject: params.subject,
HtmlBody: params.htmlBody,
TextBody: params.textBody,
});
return { messageId: result.MessageID };
},
};
}

View File

@@ -0,0 +1,39 @@
export {
buildLoginConfirmationEmailHtml,
buildLoginConfirmationEmailText,
sendLoginConfirmationEmail,
} from "./login-confirmation.js";
export type {
LoginConfirmationEmailParams,
SendLoginConfirmationEmailParams,
} from "./login-confirmation.js";
export {
buildOrgInviteEmailHtml,
buildOrgInviteEmailText,
sendOrgInviteEmail,
} from "./org-invite.js";
export type {
OrgInviteEmailParams,
SendOrgInviteEmailParams,
} from "./org-invite.js";
export {
buildPasswordResetEmailHtml,
buildPasswordResetEmailText,
sendPasswordResetEmail,
} from "./password-reset.js";
export type {
PasswordResetEmailParams,
SendPasswordResetEmailParams,
} from "./password-reset.js";
export {
buildVerificationEmailHtml,
buildVerificationEmailText,
sendVerificationEmail,
} from "./verification.js";
export type {
SendVerificationEmailParams,
VerificationEmailParams,
} from "./verification.js";

View File

@@ -0,0 +1,108 @@
import { describe, expect, it, mock, beforeEach } from "bun:test";
import type { EmailClient } from "../types.js";
import {
buildLoginConfirmationEmailHtml,
buildLoginConfirmationEmailText,
sendLoginConfirmationEmail,
} from "./login-confirmation.js";
describe("buildLoginConfirmationEmailHtml", () => {
const params = {
confirmUrl: "https://example.com/auth/confirm?token=def456",
expiresIn: "15 minutes",
};
it("should include the confirm URL", () => {
const html = buildLoginConfirmationEmailHtml(params);
expect(html).toContain(
'href="https://example.com/auth/confirm?token=def456"',
);
});
it("should include the expiry time", () => {
const html = buildLoginConfirmationEmailHtml(params);
expect(html).toContain("This link expires in 15 minutes.");
});
it("should include the heading", () => {
const html = buildLoginConfirmationEmailHtml(params);
expect(html).toContain("Confirm your login");
});
it("should include confirm button text", () => {
const html = buildLoginConfirmationEmailHtml(params);
expect(html).toContain(">Confirm Login</a>");
});
it("should include warning about unauthorized access", () => {
const html = buildLoginConfirmationEmailHtml(params);
expect(html).toContain("Someone is trying to sign in to your account.");
});
});
describe("buildLoginConfirmationEmailText", () => {
const params = {
confirmUrl: "https://example.com/auth/confirm?token=def456",
expiresIn: "15 minutes",
};
it("should include the confirm URL", () => {
const text = buildLoginConfirmationEmailText(params);
expect(text).toContain("https://example.com/auth/confirm?token=def456");
});
it("should include the expiry time", () => {
const text = buildLoginConfirmationEmailText(params);
expect(text).toContain("This link expires in 15 minutes.");
});
});
describe("sendLoginConfirmationEmail", () => {
const createMockClient = () => {
const sendEmailMock = mock(() =>
Promise.resolve({ messageId: "test-message-id" }),
);
return {
sendEmail: sendEmailMock,
} as EmailClient & { sendEmail: ReturnType<typeof mock> };
};
let mockClient: ReturnType<typeof createMockClient>;
beforeEach(() => {
mockClient = createMockClient();
});
it("should send login confirmation email with correct URL and expiry", async () => {
const result = await sendLoginConfirmationEmail({
client: mockClient,
fromAddress: "noreply@example.com",
baseUrl: "https://app.example.com",
email: "user@example.com",
token: "confirm-token-456",
expiryMinutes: 15,
});
expect(result.success).toBe(true);
expect(mockClient.sendEmail).toHaveBeenCalledTimes(1);
const call = mockClient.sendEmail.mock.calls[0];
const params = call?.[0] as {
from: string;
to: string;
subject: string;
htmlBody: string;
textBody: string;
};
expect(params.to).toBe("user@example.com");
expect(params.subject).toBe("Confirm your login");
expect(params.htmlBody).toContain(
"https://app.example.com/auth/confirm?token=confirm-token-456",
);
expect(params.htmlBody).toContain("15 minutes");
expect(params.textBody).toContain(
"https://app.example.com/auth/confirm?token=confirm-token-456",
);
});
});

View File

@@ -0,0 +1,84 @@
import { buildUrl, formatExpiryMinutes } from "../helpers.js";
import { sendEmail } from "../send.js";
import {
buttonStyles,
containerStyles,
emailStyles,
footerStyles,
headingStyles,
paragraphStyles,
} from "../styles.js";
import type { EmailClient, EmailResult } from "../types.js";
export interface LoginConfirmationEmailParams {
confirmUrl: string;
expiresIn: string;
}
export function buildLoginConfirmationEmailHtml({
confirmUrl,
expiresIn,
}: LoginConfirmationEmailParams): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="${emailStyles}">
<div style="${containerStyles}">
<h1 style="${headingStyles}">Confirm your login</h1>
<p style="${paragraphStyles}">Someone is trying to sign in to your account. If this was you, click the button below to confirm:</p>
<a href="${confirmUrl}" style="${buttonStyles}">Confirm Login</a>
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
<p style="${footerStyles}">If you didn't try to sign in, you can safely ignore this email. Someone may have entered your email address by mistake.</p>
</div>
</body>
</html>
`;
}
export function buildLoginConfirmationEmailText({
confirmUrl,
expiresIn,
}: LoginConfirmationEmailParams): string {
return `Confirm your login
Someone is trying to sign in to your account. If this was you, click the link below to confirm:
${confirmUrl}
This link expires in ${expiresIn}.
If you didn't try to sign in, you can safely ignore this email. Someone may have entered your email address by mistake.
`;
}
export interface SendLoginConfirmationEmailParams {
client: EmailClient;
fromAddress: string;
baseUrl: string;
email: string;
token: string;
expiryMinutes: number;
}
export async function sendLoginConfirmationEmail({
client,
fromAddress,
baseUrl,
email,
token,
expiryMinutes,
}: SendLoginConfirmationEmailParams): Promise<EmailResult> {
const confirmUrl = buildUrl(baseUrl, "/auth/confirm", { token });
const expiresIn = formatExpiryMinutes(expiryMinutes);
return sendEmail(client, fromAddress, {
to: email,
subject: "Confirm your login",
htmlBody: buildLoginConfirmationEmailHtml({ confirmUrl, expiresIn }),
textBody: buildLoginConfirmationEmailText({ confirmUrl, expiresIn }),
});
}

View File

@@ -0,0 +1,195 @@
import { describe, expect, it, mock, beforeEach } from "bun:test";
import type { EmailClient } from "../types.js";
import {
buildOrgInviteEmailHtml,
buildOrgInviteEmailText,
sendOrgInviteEmail,
} from "./org-invite.js";
describe("buildOrgInviteEmailHtml", () => {
const params = {
email: "user@example.com",
orgName: "Acme Corp",
inviterName: "John Doe",
role: "admin" as const,
inviteUrl: "https://example.com/invite/accept?token=invite123",
expiresIn: "7 days",
};
it("should include the invite URL", () => {
const html = buildOrgInviteEmailHtml(params);
expect(html).toContain(
'href="https://example.com/invite/accept?token=invite123"',
);
});
it("should include the organization name", () => {
const html = buildOrgInviteEmailHtml(params);
expect(html).toContain("Acme Corp");
});
it("should include the inviter name", () => {
const html = buildOrgInviteEmailHtml(params);
expect(html).toContain("John Doe");
});
it("should include the role with correct article", () => {
const html = buildOrgInviteEmailHtml(params);
expect(html).toContain("an <strong>Admin</strong>");
});
it("should include the expiry time", () => {
const html = buildOrgInviteEmailHtml(params);
expect(html).toContain("This invitation expires in 7 days.");
});
it("should include the recipient email", () => {
const html = buildOrgInviteEmailHtml(params);
expect(html).toContain("user@example.com");
});
it("should escape HTML in organization name", () => {
const paramsWithXss = {
...params,
orgName: '<script>alert("xss")</script>',
};
const html = buildOrgInviteEmailHtml(paramsWithXss);
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;");
});
it("should escape HTML in inviter name", () => {
const paramsWithXss = {
...params,
inviterName: '<img src="x" onerror="alert(1)">',
};
const html = buildOrgInviteEmailHtml(paramsWithXss);
expect(html).not.toContain("<img");
expect(html).toContain("&lt;img");
});
it("should use 'a' article for member role", () => {
const memberParams = { ...params, role: "member" as const };
const html = buildOrgInviteEmailHtml(memberParams);
expect(html).toContain("a <strong>Member</strong>");
});
it("should use 'an' article for owner role", () => {
const ownerParams = { ...params, role: "owner" as const };
const html = buildOrgInviteEmailHtml(ownerParams);
expect(html).toContain("an <strong>Owner</strong>");
});
});
describe("buildOrgInviteEmailText", () => {
const params = {
email: "user@example.com",
orgName: "Acme Corp",
inviterName: "John Doe",
role: "admin" as const,
inviteUrl: "https://example.com/invite/accept?token=invite123",
expiresIn: "7 days",
};
it("should include the invite URL", () => {
const text = buildOrgInviteEmailText(params);
expect(text).toContain(
"https://example.com/invite/accept?token=invite123",
);
});
it("should include the organization name", () => {
const text = buildOrgInviteEmailText(params);
expect(text).toContain("Acme Corp");
});
it("should include the inviter name", () => {
const text = buildOrgInviteEmailText(params);
expect(text).toContain("John Doe");
});
it("should include the role with correct article", () => {
const text = buildOrgInviteEmailText(params);
expect(text).toContain("an Admin");
});
it("should include the expiry time", () => {
const text = buildOrgInviteEmailText(params);
expect(text).toContain("This invitation expires in 7 days.");
});
});
describe("sendOrgInviteEmail", () => {
const createMockClient = () => {
const sendEmailMock = mock(() =>
Promise.resolve({ messageId: "test-message-id" }),
);
return {
sendEmail: sendEmailMock,
} as EmailClient & { sendEmail: ReturnType<typeof mock> };
};
let mockClient: ReturnType<typeof createMockClient>;
beforeEach(() => {
mockClient = createMockClient();
});
it("should send org invite email with correct details", async () => {
const result = await sendOrgInviteEmail({
client: mockClient,
fromAddress: "noreply@example.com",
baseUrl: "https://app.example.com",
email: "newuser@example.com",
token: "invite-token-abc",
orgName: "Acme Corp",
inviterName: "Jane Doe",
role: "admin",
expiryDays: 7,
});
expect(result.success).toBe(true);
expect(mockClient.sendEmail).toHaveBeenCalledTimes(1);
const call = mockClient.sendEmail.mock.calls[0];
const params = call?.[0] as {
from: string;
to: string;
subject: string;
htmlBody: string;
textBody: string;
};
expect(params.to).toBe("newuser@example.com");
expect(params.subject).toBe("You've been invited to join Acme Corp");
expect(params.htmlBody).toContain(
"https://app.example.com/invite/accept?token=invite-token-abc",
);
expect(params.htmlBody).toContain("Acme Corp");
expect(params.htmlBody).toContain("Jane Doe");
expect(params.htmlBody).toContain("Admin");
expect(params.htmlBody).toContain("7 days");
expect(params.textBody).toContain("Acme Corp");
expect(params.textBody).toContain("Jane Doe");
expect(params.textBody).toContain("an Admin");
});
it("should use correct article for member role", async () => {
await sendOrgInviteEmail({
client: mockClient,
fromAddress: "noreply@example.com",
baseUrl: "https://app.example.com",
email: "newuser@example.com",
token: "invite-token-abc",
orgName: "Acme Corp",
inviterName: "Jane Doe",
role: "member",
expiryDays: 7,
});
const call = mockClient.sendEmail.mock.calls[0];
const params = call?.[0] as { textBody: string };
expect(params.textBody).toContain("a Member");
});
});

View File

@@ -0,0 +1,134 @@
import type { OrgRole } from "@reviq/db-schema";
import {
buildUrl,
escapeHtml,
formatExpiryDays,
formatRoleDisplay,
getArticleForRole,
} from "../helpers.js";
import { sendEmail } from "../send.js";
import {
buttonStyles,
containerStyles,
emailStyles,
footerStyles,
headingStyles,
paragraphStyles,
} from "../styles.js";
import type { EmailClient, EmailResult } from "../types.js";
export interface OrgInviteEmailParams {
email: string;
orgName: string;
inviterName: string;
role: OrgRole;
inviteUrl: string;
expiresIn: string;
}
export function buildOrgInviteEmailHtml({
email,
orgName,
inviterName,
role,
inviteUrl,
expiresIn,
}: OrgInviteEmailParams): string {
const safeOrgName = escapeHtml(orgName);
const safeInviterName = escapeHtml(inviterName);
const safeEmail = escapeHtml(email);
const roleDisplay = formatRoleDisplay(role);
const article = getArticleForRole(role);
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="${emailStyles}">
<div style="${containerStyles}">
<h1 style="${headingStyles}">You've been invited to join ${safeOrgName}</h1>
<p style="${paragraphStyles}">${safeInviterName} has invited you to join <strong>${safeOrgName}</strong> as ${article} <strong>${roleDisplay}</strong>.</p>
<a href="${inviteUrl}" style="${buttonStyles}">Accept Invitation</a>
<p style="${footerStyles}">This invitation expires in ${expiresIn}.</p>
<p style="${footerStyles}">This invitation was sent to ${safeEmail}. If you weren't expecting this invitation, you can safely ignore this email.</p>
</div>
</body>
</html>
`;
}
export function buildOrgInviteEmailText({
email,
orgName,
inviterName,
role,
inviteUrl,
expiresIn,
}: OrgInviteEmailParams): string {
const roleDisplay = formatRoleDisplay(role);
const article = getArticleForRole(role);
return `You've been invited to join ${orgName}
${inviterName} has invited you to join ${orgName} as ${article} ${roleDisplay}.
Click the link below to accept the invitation:
${inviteUrl}
This invitation expires in ${expiresIn}.
This invitation was sent to ${email}. If you weren't expecting this invitation, you can safely ignore this email.
`;
}
export interface SendOrgInviteEmailParams {
client: EmailClient;
fromAddress: string;
baseUrl: string;
email: string;
token: string;
orgName: string;
inviterName: string;
role: OrgRole;
expiryDays: number;
}
export async function sendOrgInviteEmail({
client,
fromAddress,
baseUrl,
email,
token,
orgName,
inviterName,
role,
expiryDays,
}: SendOrgInviteEmailParams): Promise<EmailResult> {
const inviteUrl = buildUrl(baseUrl, "/invite/accept", { token });
const expiresIn = formatExpiryDays(expiryDays);
return sendEmail(client, fromAddress, {
to: email,
subject: `You've been invited to join ${orgName}`,
htmlBody: buildOrgInviteEmailHtml({
email,
orgName,
inviterName,
role,
inviteUrl,
expiresIn,
}),
textBody: buildOrgInviteEmailText({
email,
orgName,
inviterName,
role,
inviteUrl,
expiresIn,
}),
});
}

View File

@@ -0,0 +1,112 @@
import { describe, expect, it, mock, beforeEach } from "bun:test";
import type { EmailClient } from "../types.js";
import {
buildPasswordResetEmailHtml,
buildPasswordResetEmailText,
sendPasswordResetEmail,
} from "./password-reset.js";
describe("buildPasswordResetEmailHtml", () => {
const params = {
resetUrl: "https://example.com/auth/reset-password?token=xyz789",
expiresIn: "1 hour",
};
it("should include the reset URL", () => {
const html = buildPasswordResetEmailHtml(params);
expect(html).toContain(
'href="https://example.com/auth/reset-password?token=xyz789"',
);
});
it("should include the expiry time", () => {
const html = buildPasswordResetEmailHtml(params);
expect(html).toContain("This link expires in 1 hour.");
});
it("should include the heading", () => {
const html = buildPasswordResetEmailHtml(params);
expect(html).toContain("Reset your password");
});
it("should include reset button text", () => {
const html = buildPasswordResetEmailHtml(params);
expect(html).toContain(">Reset Password</a>");
});
it("should include ignore message", () => {
const html = buildPasswordResetEmailHtml(params);
expect(html).toContain(
"If you didn't request a password reset, you can safely ignore this email.",
);
});
});
describe("buildPasswordResetEmailText", () => {
const params = {
resetUrl: "https://example.com/auth/reset-password?token=xyz789",
expiresIn: "1 hour",
};
it("should include the reset URL", () => {
const text = buildPasswordResetEmailText(params);
expect(text).toContain(
"https://example.com/auth/reset-password?token=xyz789",
);
});
it("should include the expiry time", () => {
const text = buildPasswordResetEmailText(params);
expect(text).toContain("This link expires in 1 hour.");
});
});
describe("sendPasswordResetEmail", () => {
const createMockClient = () => {
const sendEmailMock = mock(() =>
Promise.resolve({ messageId: "test-message-id" }),
);
return {
sendEmail: sendEmailMock,
} as EmailClient & { sendEmail: ReturnType<typeof mock> };
};
let mockClient: ReturnType<typeof createMockClient>;
beforeEach(() => {
mockClient = createMockClient();
});
it("should send password reset email with correct URL and expiry", async () => {
const result = await sendPasswordResetEmail({
client: mockClient,
fromAddress: "noreply@example.com",
baseUrl: "https://app.example.com",
email: "user@example.com",
token: "reset-token-789",
expiryHours: 1,
});
expect(result.success).toBe(true);
expect(mockClient.sendEmail).toHaveBeenCalledTimes(1);
const call = mockClient.sendEmail.mock.calls[0];
const params = call?.[0] as {
from: string;
to: string;
subject: string;
htmlBody: string;
textBody: string;
};
expect(params.to).toBe("user@example.com");
expect(params.subject).toBe("Reset your password");
expect(params.htmlBody).toContain(
"https://app.example.com/auth/reset-password?token=reset-token-789",
);
expect(params.htmlBody).toContain("1 hour");
expect(params.textBody).toContain(
"https://app.example.com/auth/reset-password?token=reset-token-789",
);
});
});

View File

@@ -0,0 +1,84 @@
import { buildUrl, formatExpiryHours } from "../helpers.js";
import { sendEmail } from "../send.js";
import {
buttonStyles,
containerStyles,
emailStyles,
footerStyles,
headingStyles,
paragraphStyles,
} from "../styles.js";
import type { EmailClient, EmailResult } from "../types.js";
export interface PasswordResetEmailParams {
resetUrl: string;
expiresIn: string;
}
export function buildPasswordResetEmailHtml({
resetUrl,
expiresIn,
}: PasswordResetEmailParams): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="${emailStyles}">
<div style="${containerStyles}">
<h1 style="${headingStyles}">Reset your password</h1>
<p style="${paragraphStyles}">We received a request to reset your password. Click the button below to choose a new password:</p>
<a href="${resetUrl}" style="${buttonStyles}">Reset Password</a>
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
<p style="${footerStyles}">If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.</p>
</div>
</body>
</html>
`;
}
export function buildPasswordResetEmailText({
resetUrl,
expiresIn,
}: PasswordResetEmailParams): string {
return `Reset your password
We received a request to reset your password. Click the link below to choose a new password:
${resetUrl}
This link expires in ${expiresIn}.
If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.
`;
}
export interface SendPasswordResetEmailParams {
client: EmailClient;
fromAddress: string;
baseUrl: string;
email: string;
token: string;
expiryHours: number;
}
export async function sendPasswordResetEmail({
client,
fromAddress,
baseUrl,
email,
token,
expiryHours,
}: SendPasswordResetEmailParams): Promise<EmailResult> {
const resetUrl = buildUrl(baseUrl, "/auth/reset-password", { token });
const expiresIn = formatExpiryHours(expiryHours);
return sendEmail(client, fromAddress, {
to: email,
subject: "Reset your password",
htmlBody: buildPasswordResetEmailHtml({ resetUrl, expiresIn }),
textBody: buildPasswordResetEmailText({ resetUrl, expiresIn }),
});
}

View File

@@ -0,0 +1,115 @@
import { describe, expect, it, mock, beforeEach } from "bun:test";
import type { EmailClient } from "../types.js";
import {
buildVerificationEmailHtml,
buildVerificationEmailText,
sendVerificationEmail,
} from "./verification.js";
describe("buildVerificationEmailHtml", () => {
const params = {
verifyUrl: "https://example.com/auth/verify?token=abc123",
expiresIn: "24 hours",
};
it("should include the verify URL", () => {
const html = buildVerificationEmailHtml(params);
expect(html).toContain('href="https://example.com/auth/verify?token=abc123"');
});
it("should include the expiry time", () => {
const html = buildVerificationEmailHtml(params);
expect(html).toContain("This link expires in 24 hours.");
});
it("should include the heading", () => {
const html = buildVerificationEmailHtml(params);
expect(html).toContain("Verify your email");
});
it("should include verify button text", () => {
const html = buildVerificationEmailHtml(params);
expect(html).toContain(">Verify Email</a>");
});
it("should include ignore message", () => {
const html = buildVerificationEmailHtml(params);
expect(html).toContain(
"If you didn't create an account, you can safely ignore this email.",
);
});
});
describe("buildVerificationEmailText", () => {
const params = {
verifyUrl: "https://example.com/auth/verify?token=abc123",
expiresIn: "24 hours",
};
it("should include the verify URL", () => {
const text = buildVerificationEmailText(params);
expect(text).toContain("https://example.com/auth/verify?token=abc123");
});
it("should include the expiry time", () => {
const text = buildVerificationEmailText(params);
expect(text).toContain("This link expires in 24 hours.");
});
it("should include the heading", () => {
const text = buildVerificationEmailText(params);
expect(text).toContain("Verify your email");
});
});
describe("sendVerificationEmail", () => {
const createMockClient = () => {
const sendEmailMock = mock(() =>
Promise.resolve({ messageId: "test-message-id" }),
);
return {
sendEmail: sendEmailMock,
} as EmailClient & { sendEmail: ReturnType<typeof mock> };
};
let mockClient: ReturnType<typeof createMockClient>;
beforeEach(() => {
mockClient = createMockClient();
});
it("should send verification email with correct URL and expiry", async () => {
const result = await sendVerificationEmail({
client: mockClient,
fromAddress: "noreply@example.com",
baseUrl: "https://app.example.com",
email: "user@example.com",
token: "verify-token-123",
expiryHours: 24,
});
expect(result.success).toBe(true);
expect(mockClient.sendEmail).toHaveBeenCalledTimes(1);
const call = mockClient.sendEmail.mock.calls[0];
const params = call?.[0] as {
from: string;
to: string;
subject: string;
htmlBody: string;
textBody: string;
};
expect(params.to).toBe("user@example.com");
expect(params.from).toBe("noreply@example.com");
expect(params.subject).toBe("Verify your email address");
expect(params.htmlBody).toContain(
"https://app.example.com/auth/verify?token=verify-token-123",
);
expect(params.htmlBody).toContain("24 hours");
expect(params.textBody).toContain(
"https://app.example.com/auth/verify?token=verify-token-123",
);
expect(params.textBody).toContain("24 hours");
});
});

View File

@@ -0,0 +1,84 @@
import { buildUrl, formatExpiryHours } from "../helpers.js";
import { sendEmail } from "../send.js";
import {
buttonStyles,
containerStyles,
emailStyles,
footerStyles,
headingStyles,
paragraphStyles,
} from "../styles.js";
import type { EmailClient, EmailResult } from "../types.js";
export interface VerificationEmailParams {
verifyUrl: string;
expiresIn: string;
}
export function buildVerificationEmailHtml({
verifyUrl,
expiresIn,
}: VerificationEmailParams): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="${emailStyles}">
<div style="${containerStyles}">
<h1 style="${headingStyles}">Verify your email</h1>
<p style="${paragraphStyles}">Please verify your email address by clicking the button below:</p>
<a href="${verifyUrl}" style="${buttonStyles}">Verify Email</a>
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
<p style="${footerStyles}">If you didn't create an account, you can safely ignore this email.</p>
</div>
</body>
</html>
`;
}
export function buildVerificationEmailText({
verifyUrl,
expiresIn,
}: VerificationEmailParams): string {
return `Verify your email
Please verify your email address by clicking the link below:
${verifyUrl}
This link expires in ${expiresIn}.
If you didn't create an account, you can safely ignore this email.
`;
}
export interface SendVerificationEmailParams {
client: EmailClient;
fromAddress: string;
baseUrl: string;
email: string;
token: string;
expiryHours: number;
}
export async function sendVerificationEmail({
client,
fromAddress,
baseUrl,
email,
token,
expiryHours,
}: SendVerificationEmailParams): Promise<EmailResult> {
const verifyUrl = buildUrl(baseUrl, "/auth/verify", { token });
const expiresIn = formatExpiryHours(expiryHours);
return sendEmail(client, fromAddress, {
to: email,
subject: "Verify your email address",
htmlBody: buildVerificationEmailHtml({ verifyUrl, expiresIn }),
textBody: buildVerificationEmailText({ verifyUrl, expiresIn }),
});
}

View File

@@ -0,0 +1,140 @@
import { describe, expect, it } from "bun:test";
import {
buildUrl,
escapeHtml,
formatExpiryDays,
formatExpiryHours,
formatExpiryMinutes,
formatRoleDisplay,
getArticleForRole,
} from "./helpers.js";
describe("buildUrl", () => {
it("should build a URL with query parameters", () => {
const result = buildUrl("https://example.com", "/auth/verify", {
token: "abc123",
});
expect(result).toBe("https://example.com/auth/verify?token=abc123");
});
it("should handle multiple query parameters", () => {
const result = buildUrl("https://example.com", "/page", {
foo: "bar",
baz: "qux",
});
expect(result).toBe("https://example.com/page?foo=bar&baz=qux");
});
it("should encode special characters in query parameters", () => {
const result = buildUrl("https://example.com", "/search", {
q: "hello world",
});
expect(result).toBe("https://example.com/search?q=hello+world");
});
it("should handle empty params", () => {
const result = buildUrl("https://example.com", "/page", {});
expect(result).toBe("https://example.com/page");
});
it("should handle base URL with trailing slash", () => {
const result = buildUrl("https://example.com/", "/auth/verify", {
token: "abc",
});
expect(result).toBe("https://example.com/auth/verify?token=abc");
});
});
describe("escapeHtml", () => {
it("should escape ampersands", () => {
expect(escapeHtml("foo & bar")).toBe("foo &amp; bar");
});
it("should escape less than signs", () => {
expect(escapeHtml("<script>")).toBe("&lt;script&gt;");
});
it("should escape greater than signs", () => {
expect(escapeHtml("a > b")).toBe("a &gt; b");
});
it("should escape double quotes", () => {
expect(escapeHtml('say "hello"')).toBe("say &quot;hello&quot;");
});
it("should escape single quotes", () => {
expect(escapeHtml("it's")).toBe("it&#039;s");
});
it("should escape all characters in combination", () => {
expect(escapeHtml('<a href="test">O\'Reilly & Co</a>')).toBe(
"&lt;a href=&quot;test&quot;&gt;O&#039;Reilly &amp; Co&lt;/a&gt;",
);
});
it("should return empty string unchanged", () => {
expect(escapeHtml("")).toBe("");
});
it("should return safe strings unchanged", () => {
expect(escapeHtml("hello world")).toBe("hello world");
});
});
describe("formatExpiryHours", () => {
it("should format 1 hour", () => {
expect(formatExpiryHours(1)).toBe("1 hour");
});
it("should format multiple hours", () => {
expect(formatExpiryHours(24)).toBe("24 hours");
});
});
describe("formatExpiryMinutes", () => {
it("should format 1 minute", () => {
expect(formatExpiryMinutes(1)).toBe("1 minute");
});
it("should format multiple minutes", () => {
expect(formatExpiryMinutes(15)).toBe("15 minutes");
});
});
describe("formatExpiryDays", () => {
it("should format 1 day", () => {
expect(formatExpiryDays(1)).toBe("1 day");
});
it("should format multiple days", () => {
expect(formatExpiryDays(7)).toBe("7 days");
});
});
describe("formatRoleDisplay", () => {
it("should format owner role", () => {
expect(formatRoleDisplay("owner")).toBe("Owner");
});
it("should format admin role", () => {
expect(formatRoleDisplay("admin")).toBe("Admin");
});
it("should format member role", () => {
expect(formatRoleDisplay("member")).toBe("Member");
});
});
describe("getArticleForRole", () => {
it("should return 'an' for owner", () => {
expect(getArticleForRole("owner")).toBe("an");
});
it("should return 'an' for admin", () => {
expect(getArticleForRole("admin")).toBe("an");
});
it("should return 'a' for member", () => {
expect(getArticleForRole("member")).toBe("a");
});
});

View File

@@ -0,0 +1,54 @@
import { DurationFormat } from "@formatjs/intl-durationformat";
import type { OrgRole } from "@reviq/db-schema";
export function buildUrl(
baseUrl: string,
path: string,
params: Record<string, string>,
): string {
const url = new URL(path, baseUrl);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
return url.toString();
}
export function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
const durationFormatter = new DurationFormat("en", { style: "long" });
export function formatExpiryHours(hours: number): string {
return durationFormatter.format({ hours });
}
export function formatExpiryMinutes(minutes: number): string {
return durationFormatter.format({ minutes });
}
export function formatExpiryDays(days: number): string {
return durationFormatter.format({ days });
}
const roleLabels: Record<OrgRole, string> = {
owner: "Owner",
admin: "Admin",
member: "Member",
};
export function formatRoleDisplay(role: OrgRole): string {
return roleLabels[role];
}
export function getArticleForRole(role: OrgRole): string {
if (role === "owner" || role === "admin") {
return "an";
}
return "a";
}

View File

@@ -0,0 +1,26 @@
// Client factories
export { createPostmarkClient } from "./client.js";
export { createLoggingEmailClient } from "./logging-client.js";
// Core types
export type { EmailClient, EmailResult } from "./types.js";
// Base send function
export { sendEmail } from "./send.js";
export type { SendEmailParams } from "./send.js";
// Email-specific send functions
export {
sendLoginConfirmationEmail,
sendOrgInviteEmail,
sendPasswordResetEmail,
sendVerificationEmail,
} from "./emails/index.js";
// Email param types
export type {
SendLoginConfirmationEmailParams,
SendOrgInviteEmailParams,
SendPasswordResetEmailParams,
SendVerificationEmailParams,
} from "./emails/index.js";

View File

@@ -0,0 +1,61 @@
import { describe, expect, it, mock, beforeEach, afterEach } from "bun:test";
import { createLoggingEmailClient } from "./logging-client.js";
describe("createLoggingEmailClient", () => {
let originalLog: typeof console.log;
let logOutput: string[];
beforeEach(() => {
logOutput = [];
originalLog = console.log;
console.log = mock((...args: unknown[]) => {
logOutput.push(args.join(" "));
});
});
afterEach(() => {
console.log = originalLog;
});
it("should create a client that logs emails to console", async () => {
const client = createLoggingEmailClient();
const result = await client.sendEmail({
from: "noreply@example.com",
to: "user@example.com",
subject: "Test Subject",
htmlBody: "<p>Hello</p>",
textBody: "Hello",
});
expect(result.messageId).toMatch(/^dev-mode-\d+$/);
expect(logOutput).toContain("=== DEV MODE EMAIL ===");
expect(logOutput.some((line) => line.includes("From: noreply@example.com"))).toBe(true);
expect(logOutput.some((line) => line.includes("To: user@example.com"))).toBe(true);
expect(logOutput.some((line) => line.includes("Subject: Test Subject"))).toBe(true);
expect(logOutput.some((line) => line.includes("Hello"))).toBe(true);
expect(logOutput).toContain("======================");
});
it("should generate unique message IDs", async () => {
const client = createLoggingEmailClient();
const result1 = await client.sendEmail({
from: "a@example.com",
to: "b@example.com",
subject: "Test 1",
htmlBody: "",
textBody: "",
});
const result2 = await client.sendEmail({
from: "a@example.com",
to: "b@example.com",
subject: "Test 2",
htmlBody: "",
textBody: "",
});
expect(result1.messageId).not.toBe(result2.messageId);
});
});

View File

@@ -0,0 +1,23 @@
import type {
ClientSendParams,
ClientSendResult,
EmailClient,
} from "./types.js";
let messageIdCounter = 0;
export function createLoggingEmailClient(): EmailClient {
return {
sendEmail: (params: ClientSendParams): Promise<ClientSendResult> => {
console.log("=== DEV MODE EMAIL ===");
console.log(`From: ${params.from}`);
console.log(`To: ${params.to}`);
console.log(`Subject: ${params.subject}`);
console.log(`Text Body:\n${params.textBody}`);
console.log("======================");
messageIdCounter++;
return Promise.resolve({ messageId: `dev-mode-${messageIdCounter.toString()}` });
},
};
}

View File

@@ -0,0 +1,69 @@
import { describe, expect, it, mock } from "bun:test";
import type { EmailClient } from "./types.js";
import { sendEmail } from "./send.js";
describe("sendEmail", () => {
const createMockClient = () => {
const sendEmailMock = mock(() =>
Promise.resolve({ messageId: "test-message-id" }),
);
return {
sendEmail: sendEmailMock,
} as EmailClient & { sendEmail: ReturnType<typeof mock> };
};
it("should send email successfully", async () => {
const mockClient = createMockClient();
const result = await sendEmail(mockClient, "noreply@example.com", {
to: "user@example.com",
subject: "Test Subject",
htmlBody: "<p>Hello</p>",
textBody: "Hello",
});
expect(result.success).toBe(true);
expect(result.messageId).toBe("test-message-id");
expect(mockClient.sendEmail).toHaveBeenCalledTimes(1);
expect(mockClient.sendEmail).toHaveBeenCalledWith({
from: "noreply@example.com",
to: "user@example.com",
subject: "Test Subject",
htmlBody: "<p>Hello</p>",
textBody: "Hello",
});
});
it("should handle errors gracefully", async () => {
const mockClient = {
sendEmail: mock(() => Promise.reject(new Error("API Error"))),
} as EmailClient;
const result = await sendEmail(mockClient, "noreply@example.com", {
to: "user@example.com",
subject: "Test",
htmlBody: "<p>Test</p>",
textBody: "Test",
});
expect(result.success).toBe(false);
expect(result.error).toBe("API Error");
});
it("should handle non-Error exceptions", async () => {
const mockClient = {
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
sendEmail: mock(() => Promise.reject("String error")),
} as unknown as EmailClient;
const result = await sendEmail(mockClient, "noreply@example.com", {
to: "user@example.com",
subject: "Test",
htmlBody: "<p>Test</p>",
textBody: "Test",
});
expect(result.success).toBe(false);
expect(result.error).toBe("Unknown error");
});
});

View File

@@ -0,0 +1,31 @@
import type { EmailClient, EmailResult } from "./types.js";
export interface SendEmailParams {
to: string;
subject: string;
htmlBody: string;
textBody: string;
}
export async function sendEmail(
client: EmailClient,
fromAddress: string,
params: SendEmailParams,
): Promise<EmailResult> {
const { to, subject, htmlBody, textBody } = params;
try {
const result = await client.sendEmail({
from: fromAddress,
to,
subject,
htmlBody,
textBody,
});
return { success: true, messageId: result.messageId };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error(`Failed to send email to ${to}:`, message);
return { success: false, error: message };
}
}

View File

@@ -0,0 +1,12 @@
export const emailStyles =
"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5;";
export const containerStyles =
"max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; padding: 40px;";
export const headingStyles =
"margin: 0 0 24px; font-size: 24px; color: #1a1a1a;";
export const paragraphStyles =
"margin: 0 0 24px; font-size: 16px; color: #4a4a4a; line-height: 1.5;";
export const buttonStyles =
"display: inline-block; background-color: #0066cc; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500;";
export const footerStyles =
"margin: 24px 0 0; font-size: 14px; color: #6a6a6a;";

View File

@@ -0,0 +1,21 @@
export interface EmailResult {
success: boolean;
messageId?: string;
error?: string;
}
export interface ClientSendParams {
from: string;
to: string;
subject: string;
htmlBody: string;
textBody: string;
}
export interface ClientSendResult {
messageId: string;
}
export interface EmailClient {
sendEmail(params: ClientSendParams): Promise<ClientSendResult>;
}

View File

@@ -0,0 +1,6 @@
{
"extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": {
"types": ["bun"]
}
}

View File

@@ -142,7 +142,7 @@ async function ensureTestDatabaseExists(): Promise<void> {
} }
/** /**
* Finds the repository root by looking for db/migrations directory. * Finds the repository root by looking for db/schema.sql file.
* Walks up from the current directory until found. * Walks up from the current directory until found.
* *
* @throws Error if repo root cannot be found * @throws Error if repo root cannot be found
@@ -152,8 +152,8 @@ function findRepoRoot(): string {
// Walk up to 10 levels to find the repo root // Walk up to 10 levels to find the repo root
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
const migrationsPath = join(current, "db", "migrations"); const schemaPath = join(current, "db", "schema.sql");
if (existsSync(migrationsPath)) { if (existsSync(schemaPath)) {
return current; return current;
} }
const parent = join(current, ".."); const parent = join(current, "..");
@@ -164,13 +164,13 @@ function findRepoRoot(): string {
} }
throw new Error( throw new Error(
"Could not find repository root (looking for db/migrations directory)", "Could not find repository root (looking for db/schema.sql file)",
); );
} }
/** /**
* Runs database migrations using dbmate CLI. * Applies the database schema from db/schema.sql.
* Creates the database if it doesn't exist. * Creates the database if it doesn't exist, then runs the schema.
* Should be called once before running test suite. * Should be called once before running test suite.
*/ */
export async function runMigrations(): Promise<void> { export async function runMigrations(): Promise<void> {
@@ -180,21 +180,34 @@ export async function runMigrations(): Promise<void> {
await ensureTestDatabaseExists(); await ensureTestDatabaseExists();
const repoRoot = findRepoRoot(); const repoRoot = findRepoRoot();
const schemaPath = join(repoRoot, "db", "schema.sql");
const proc = Bun.spawn(["dbmate", "up"], { // Read the schema file
env: { ...process.env, DATABASE_URL: testDbUrl }, const schemaFile = Bun.file(schemaPath);
cwd: repoRoot, const schemaSql = await schemaFile.text();
stdout: "pipe",
stderr: "pipe", // Connect to the test database and run the schema
const { host, port, user, password, database } = parsePostgresUrl(testDbUrl);
const client = new Client({
host,
port,
user,
password,
database,
}); });
const exitCode = await proc.exited; try {
await client.connect();
if (exitCode !== 0) { await client.query(schemaSql);
const stderr = await new Response(proc.stderr).text(); } catch (error) {
throw new Error( // Ignore "already exists" errors - schema may have already been applied
`Migration failed with code ${exitCode.toString()}: ${stderr}`, const message = error instanceof Error ? error.message : String(error);
); if (!message.includes("already exists")) {
throw new Error(`Schema application failed: ${message}`);
}
} finally {
await client.end();
} }
} }