Compare commits
21 Commits
5a2e0297e5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
8da4379583
|
|||
|
1f6d5a4a9f
|
|||
|
d8397dfb38
|
|||
|
73ef3df01f
|
|||
|
25c8bab741
|
|||
|
b48012c1f6
|
|||
|
bd4053f952
|
|||
|
ce5a27d014
|
|||
|
665092464a
|
|||
|
b78064caeb
|
|||
|
c60041a1bb
|
|||
|
40d743c8c2
|
|||
|
e43c006bb1
|
|||
|
8e65c2e698
|
|||
|
b085a315be
|
|||
|
1ed41e5c4c
|
|||
|
84644c8bfb
|
|||
|
5ecf12a1a1
|
|||
|
c2b815dd6a
|
|||
|
67930d90d5
|
|||
|
58ffa68f4c
|
@@ -55,7 +55,7 @@ publisher-dashboard/
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Bun](https://bun.sh/) v1.1.42+
|
- [Bun](https://bun.sh/) v1.3.5+
|
||||||
- [devenv](https://devenv.sh/) for development environment management
|
- [devenv](https://devenv.sh/) for development environment management
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type { Kysely } from "kysely";
|
|||||||
import type { APIContext } from "../../context.js";
|
import type { APIContext } from "../../context.js";
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { call } from "@orpc/server";
|
import { call } from "@orpc/server";
|
||||||
|
import { createLoggingEmailClient } from "@reviq/emails";
|
||||||
import {
|
import {
|
||||||
createTestUser,
|
createTestUser,
|
||||||
describeE2E,
|
describeE2E,
|
||||||
@@ -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
|
||||||
const membership = await db
|
if (org) {
|
||||||
.selectFrom("org_members")
|
const membership = await db
|
||||||
.where("org_id", "=", org?.id)
|
.selectFrom("org_members")
|
||||||
.where("user_id", "=", owner.id)
|
.where("org_id", "=", org.id)
|
||||||
.selectAll()
|
.where("user_id", "=", owner.id)
|
||||||
.executeTakeFirst();
|
.selectAll()
|
||||||
|
.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 () => {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import type { Kysely } from "kysely";
|
|||||||
import type { APIContext } from "../../context.js";
|
import type { APIContext } from "../../context.js";
|
||||||
import { beforeAll, describe, expect, test } from "bun:test";
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { call } from "@orpc/server";
|
import { call } from "@orpc/server";
|
||||||
|
import { createLoggingEmailClient } from "@reviq/emails";
|
||||||
import {
|
import {
|
||||||
createTestUser,
|
createTestUser,
|
||||||
describeE2E,
|
describeE2E,
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type { Kysely } from "kysely";
|
|||||||
import type { APIContext } from "../../context.js";
|
import type { APIContext } from "../../context.js";
|
||||||
import { beforeAll, describe, expect, test } from "bun:test";
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { call } from "@orpc/server";
|
import { call } from "@orpc/server";
|
||||||
|
import { createLoggingEmailClient } from "@reviq/emails";
|
||||||
import {
|
import {
|
||||||
createTestUser,
|
createTestUser,
|
||||||
describeE2E,
|
describeE2E,
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { Kysely } from "kysely";
|
|||||||
import type { APIContext } from "../../context.js";
|
import type { APIContext } from "../../context.js";
|
||||||
import { beforeAll, describe, expect, test } from "bun:test";
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { call } from "@orpc/server";
|
import { call } from "@orpc/server";
|
||||||
|
import { createLoggingEmailClient } from "@reviq/emails";
|
||||||
import {
|
import {
|
||||||
createTestUser,
|
createTestUser,
|
||||||
describeE2E,
|
describeE2E,
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { Kysely } from "kysely";
|
|||||||
import type { APIContext } from "../../context.js";
|
import type { APIContext } from "../../context.js";
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { call } from "@orpc/server";
|
import { call } from "@orpc/server";
|
||||||
|
import { createLoggingEmailClient } from "@reviq/emails";
|
||||||
import {
|
import {
|
||||||
createTestUser,
|
createTestUser,
|
||||||
describeE2E,
|
describeE2E,
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 =====
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,3 +115,34 @@ export interface SuperuserContext extends AuthenticatedContext {
|
|||||||
/** User with superuser privileges */
|
/** User with superuser privileges */
|
||||||
user: SessionUser & { isSuperuser: true };
|
user: SessionUser & { isSuperuser: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organization info in context
|
||||||
|
*/
|
||||||
|
export interface OrgInfo {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
displayName: string;
|
||||||
|
logoUrl: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's membership in an org
|
||||||
|
*/
|
||||||
|
export interface OrgMembership {
|
||||||
|
id: number;
|
||||||
|
role: "owner" | "admin" | "member";
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Org member context for org-scoped procedures
|
||||||
|
* Requires user to be a member of the org
|
||||||
|
*/
|
||||||
|
export interface OrgMemberContext extends AuthenticatedContext {
|
||||||
|
/** The organization */
|
||||||
|
org: OrgInfo;
|
||||||
|
/** User's membership in the org */
|
||||||
|
membership: OrgMembership;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ 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,
|
||||||
getAllowedOrigins,
|
getAllowedOrigins,
|
||||||
|
POSTMARK_API_KEY,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
import { router } from "./router.js";
|
import { router } from "./router.js";
|
||||||
|
|
||||||
@@ -24,6 +28,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 +76,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, {
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
/**
|
|
||||||
* Authentication middleware for oRPC server
|
|
||||||
*
|
|
||||||
* Handles authentication via:
|
|
||||||
* - Session cookie (rev.session_token) - for browser clients
|
|
||||||
* - API key header (x-api-key) - for CLI and programmatic access
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
APIContext,
|
|
||||||
AuthenticatedContext,
|
|
||||||
AuthInfo,
|
|
||||||
Session,
|
|
||||||
SessionUser,
|
|
||||||
} from "../context.js";
|
|
||||||
import { ORPCError } from "@orpc/server";
|
|
||||||
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
|
|
||||||
import { hashToken } from "../utils/crypto.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the auth middleware function
|
|
||||||
* This returns a middleware handler that can be used with oRPC procedures
|
|
||||||
*/
|
|
||||||
export const createAuthMiddleware = () => {
|
|
||||||
return async ({
|
|
||||||
context,
|
|
||||||
next,
|
|
||||||
}: {
|
|
||||||
context: APIContext;
|
|
||||||
next: (opts: {
|
|
||||||
context: Omit<AuthenticatedContext, keyof APIContext>;
|
|
||||||
}) => Promise<unknown>;
|
|
||||||
}) => {
|
|
||||||
const { db, reqHeaders } = context;
|
|
||||||
|
|
||||||
// Try session cookie first
|
|
||||||
let tokenHash: string | undefined;
|
|
||||||
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
|
||||||
if (sessionToken) {
|
|
||||||
tokenHash = await hashToken(sessionToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to API key header (for CLI)
|
|
||||||
const apiKey = reqHeaders.get("x-api-key");
|
|
||||||
if (!tokenHash && apiKey) {
|
|
||||||
tokenHash = await hashToken(apiKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tokenHash) {
|
|
||||||
throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look up session (check not expired and not revoked)
|
|
||||||
const session = await db
|
|
||||||
.selectFrom("sessions")
|
|
||||||
.where("token_hash", "=", tokenHash)
|
|
||||||
.where("expires_at", ">", new Date())
|
|
||||||
.where("revoked_at", "is", null)
|
|
||||||
.selectAll()
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
// Fall back to API token if no session found
|
|
||||||
const apiToken = !session
|
|
||||||
? await db
|
|
||||||
.selectFrom("api_tokens")
|
|
||||||
.where("token_hash", "=", tokenHash)
|
|
||||||
.where("expires_at", ">", new Date())
|
|
||||||
.selectAll()
|
|
||||||
.executeTakeFirst()
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const userId = session?.user_id ?? apiToken?.user_id;
|
|
||||||
if (!userId) {
|
|
||||||
throw new ORPCError("UNAUTHORIZED", {
|
|
||||||
message: "Invalid or expired token",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last_used_at for API tokens
|
|
||||||
if (apiToken) {
|
|
||||||
await db
|
|
||||||
.updateTable("api_tokens")
|
|
||||||
.set({ last_used_at: new Date() })
|
|
||||||
.where("id", "=", apiToken.id)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch user details
|
|
||||||
const user = await db
|
|
||||||
.selectFrom("users")
|
|
||||||
.where("id", "=", userId)
|
|
||||||
.select([
|
|
||||||
"id",
|
|
||||||
"email",
|
|
||||||
"display_name",
|
|
||||||
"email_verified_at",
|
|
||||||
"is_superuser",
|
|
||||||
])
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new ORPCError("UNAUTHORIZED", {
|
|
||||||
message: "User not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionUser: SessionUser = {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
displayName: user.display_name,
|
|
||||||
emailVerifiedAt: user.email_verified_at,
|
|
||||||
isSuperuser: user.is_superuser,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build session and auth info based on authentication method
|
|
||||||
let sessionInfo: Session;
|
|
||||||
let authInfo: AuthInfo;
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
sessionInfo = {
|
|
||||||
id: session.id,
|
|
||||||
trustedMode: session.trusted_mode,
|
|
||||||
createdAt: session.created_at,
|
|
||||||
};
|
|
||||||
authInfo = {
|
|
||||||
method: "session",
|
|
||||||
sessionId: session.id,
|
|
||||||
expiresAt: session.expires_at,
|
|
||||||
createdAt: session.created_at,
|
|
||||||
};
|
|
||||||
} else if (apiToken) {
|
|
||||||
sessionInfo = {
|
|
||||||
// For API token auth, create a synthetic session object
|
|
||||||
id: "0",
|
|
||||||
trustedMode: true,
|
|
||||||
createdAt: apiToken.created_at,
|
|
||||||
};
|
|
||||||
authInfo = {
|
|
||||||
method: "api_token",
|
|
||||||
tokenId: apiToken.id,
|
|
||||||
tokenName: apiToken.name,
|
|
||||||
expiresAt: apiToken.expires_at,
|
|
||||||
lastUsedAt: apiToken.last_used_at,
|
|
||||||
createdAt: apiToken.created_at,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// This should never happen since we checked userId above
|
|
||||||
throw new ORPCError("UNAUTHORIZED", {
|
|
||||||
message: "Invalid authentication state",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return next({
|
|
||||||
context: {
|
|
||||||
user: sessionUser,
|
|
||||||
session: sessionInfo,
|
|
||||||
auth: authInfo,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to require superuser access
|
|
||||||
*/
|
|
||||||
export const createSuperuserMiddleware = () => {
|
|
||||||
return async ({
|
|
||||||
context,
|
|
||||||
next,
|
|
||||||
}: {
|
|
||||||
context: AuthenticatedContext;
|
|
||||||
next: () => Promise<unknown>;
|
|
||||||
}) => {
|
|
||||||
if (!context.user.isSuperuser) {
|
|
||||||
throw new ORPCError("FORBIDDEN", {
|
|
||||||
message: "Superuser access required",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return next();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
138
apps/api-server/src/middlewares/auth.ts
Normal file
138
apps/api-server/src/middlewares/auth.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Auth middleware - validates session/API token and adds user to context
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AuthInfo, Session, SessionUser } from "../context.js";
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
|
||||||
|
import { hashToken } from "../utils/crypto.js";
|
||||||
|
import { os } from "./os.js";
|
||||||
|
|
||||||
|
export const authMiddleware = os.middleware(async ({ context, next }) => {
|
||||||
|
const { db, reqHeaders } = context;
|
||||||
|
|
||||||
|
// Try session cookie first
|
||||||
|
let tokenHash: string | undefined;
|
||||||
|
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||||
|
if (sessionToken) {
|
||||||
|
tokenHash = await hashToken(sessionToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to API key header (for CLI)
|
||||||
|
const apiKey = reqHeaders.get("x-api-key");
|
||||||
|
if (!tokenHash && apiKey) {
|
||||||
|
tokenHash = await hashToken(apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tokenHash) {
|
||||||
|
throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up session (check not expired and not revoked)
|
||||||
|
const session = await db
|
||||||
|
.selectFrom("sessions")
|
||||||
|
.where("token_hash", "=", tokenHash)
|
||||||
|
.where("expires_at", ">", new Date())
|
||||||
|
.where("revoked_at", "is", null)
|
||||||
|
.selectAll()
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
// Fall back to API token if no session found
|
||||||
|
const apiToken = !session
|
||||||
|
? await db
|
||||||
|
.selectFrom("api_tokens")
|
||||||
|
.where("token_hash", "=", tokenHash)
|
||||||
|
.where("expires_at", ">", new Date())
|
||||||
|
.selectAll()
|
||||||
|
.executeTakeFirst()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const userId = session?.user_id ?? apiToken?.user_id;
|
||||||
|
if (!userId) {
|
||||||
|
throw new ORPCError("UNAUTHORIZED", {
|
||||||
|
message: "Invalid or expired token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last_used_at for API tokens
|
||||||
|
if (apiToken) {
|
||||||
|
await db
|
||||||
|
.updateTable("api_tokens")
|
||||||
|
.set({ last_used_at: new Date() })
|
||||||
|
.where("id", "=", apiToken.id)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch user details
|
||||||
|
const user = await db
|
||||||
|
.selectFrom("users")
|
||||||
|
.where("id", "=", userId)
|
||||||
|
.select([
|
||||||
|
"id",
|
||||||
|
"email",
|
||||||
|
"display_name",
|
||||||
|
"email_verified_at",
|
||||||
|
"is_superuser",
|
||||||
|
])
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ORPCError("UNAUTHORIZED", {
|
||||||
|
message: "User not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionUser: SessionUser = {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.display_name,
|
||||||
|
emailVerifiedAt: user.email_verified_at,
|
||||||
|
isSuperuser: user.is_superuser,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build session and auth info based on authentication method
|
||||||
|
let sessionInfo: Session;
|
||||||
|
let authInfo: AuthInfo;
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
sessionInfo = {
|
||||||
|
id: session.id,
|
||||||
|
trustedMode: session.trusted_mode,
|
||||||
|
createdAt: session.created_at,
|
||||||
|
};
|
||||||
|
authInfo = {
|
||||||
|
method: "session",
|
||||||
|
sessionId: session.id,
|
||||||
|
expiresAt: session.expires_at,
|
||||||
|
createdAt: session.created_at,
|
||||||
|
};
|
||||||
|
} else if (apiToken) {
|
||||||
|
sessionInfo = {
|
||||||
|
// For API token auth, create a synthetic session object
|
||||||
|
id: "0",
|
||||||
|
trustedMode: true,
|
||||||
|
createdAt: apiToken.created_at,
|
||||||
|
};
|
||||||
|
authInfo = {
|
||||||
|
method: "api_token",
|
||||||
|
tokenId: apiToken.id,
|
||||||
|
tokenName: apiToken.name,
|
||||||
|
expiresAt: apiToken.expires_at,
|
||||||
|
lastUsedAt: apiToken.last_used_at,
|
||||||
|
createdAt: apiToken.created_at,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// This should never happen since we checked userId above
|
||||||
|
throw new ORPCError("UNAUTHORIZED", {
|
||||||
|
message: "Invalid authentication state",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return next({
|
||||||
|
context: {
|
||||||
|
user: sessionUser,
|
||||||
|
session: sessionInfo,
|
||||||
|
auth: authInfo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
8
apps/api-server/src/middlewares/index.ts
Normal file
8
apps/api-server/src/middlewares/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Middleware exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { authMiddleware } from "./auth.js";
|
||||||
|
export { loginRequestMiddleware } from "./login-request.js";
|
||||||
|
export { os } from "./os.js";
|
||||||
|
export { superuserMiddleware } from "./superuser.js";
|
||||||
64
apps/api-server/src/middlewares/login-request.ts
Normal file
64
apps/api-server/src/middlewares/login-request.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Login request middleware - validates login request token from cookie
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SessionUser } from "../context.js";
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
|
||||||
|
import { os } from "./os.js";
|
||||||
|
|
||||||
|
export const loginRequestMiddleware = os.middleware(
|
||||||
|
async ({ context, next }) => {
|
||||||
|
const { db, reqHeaders } = context;
|
||||||
|
|
||||||
|
// Read login request token from cookie
|
||||||
|
const loginRequestToken = getCookie(
|
||||||
|
reqHeaders,
|
||||||
|
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!loginRequestToken) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "No login request found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch login request with user data by token
|
||||||
|
const result = await db
|
||||||
|
.selectFrom("login_requests")
|
||||||
|
.innerJoin("users", "users.id", "login_requests.user_id")
|
||||||
|
.select([
|
||||||
|
"login_requests.id",
|
||||||
|
"login_requests.user_id",
|
||||||
|
"login_requests.expires_at",
|
||||||
|
"users.email",
|
||||||
|
"users.display_name",
|
||||||
|
"users.email_verified_at",
|
||||||
|
"users.is_superuser",
|
||||||
|
])
|
||||||
|
.where("login_requests.token", "=", loginRequestToken)
|
||||||
|
.where("login_requests.expires_at", ">", new Date())
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "Login request expired or not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionUser: SessionUser = {
|
||||||
|
id: result.user_id,
|
||||||
|
email: result.email,
|
||||||
|
displayName: result.display_name,
|
||||||
|
emailVerifiedAt: result.email_verified_at,
|
||||||
|
isSuperuser: result.is_superuser,
|
||||||
|
};
|
||||||
|
|
||||||
|
return next({
|
||||||
|
context: {
|
||||||
|
loginRequestId: Number(result.id),
|
||||||
|
user: sessionUser,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
10
apps/api-server/src/middlewares/os.ts
Normal file
10
apps/api-server/src/middlewares/os.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Base implementer with typed APIContext
|
||||||
|
* All procedures and middlewares should derive from this
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { APIContext } from "../context.js";
|
||||||
|
import { implement } from "@orpc/server";
|
||||||
|
import { contract } from "@reviq/api-contract";
|
||||||
|
|
||||||
|
export const os = implement(contract).$context<APIContext>();
|
||||||
19
apps/api-server/src/middlewares/superuser.ts
Normal file
19
apps/api-server/src/middlewares/superuser.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Superuser middleware - authenticates and requires superuser access
|
||||||
|
*
|
||||||
|
* This middleware chains authMiddleware first, then checks for superuser.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { authMiddleware } from "./auth.js";
|
||||||
|
|
||||||
|
export const superuserMiddleware = authMiddleware.concat(
|
||||||
|
async ({ context, next }) => {
|
||||||
|
if (!context.user.isSuperuser) {
|
||||||
|
throw new ORPCError("FORBIDDEN", {
|
||||||
|
message: "Superuser access required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -3,49 +3,49 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
|
|
||||||
export const adminAuthCompleteLogin = os.admin.auth.completeLogin
|
export const adminAuthCompleteLogin =
|
||||||
.use(authMiddleware)
|
superuserProcedure.admin.auth.completeLogin.handler(
|
||||||
.use(superuserMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
const email = input.email.toLowerCase();
|
||||||
const email = input.email.toLowerCase();
|
|
||||||
|
|
||||||
// First check if any login request exists for this email
|
// First check if any login request exists for this email
|
||||||
const anyRequest = await context.db
|
const anyRequest = await context.db
|
||||||
.selectFrom("login_requests")
|
.selectFrom("login_requests")
|
||||||
.where("email", "=", email)
|
.where("email", "=", email)
|
||||||
.orderBy("created_at", "desc")
|
.orderBy("created_at", "desc")
|
||||||
.select(["id", "completed_at", "expires_at"])
|
.select(["id", "completed_at", "expires_at"])
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!anyRequest) {
|
if (!anyRequest) {
|
||||||
throw new ORPCError("NOT_FOUND", {
|
throw new ORPCError("NOT_FOUND", {
|
||||||
message: `No login request found for ${email}`,
|
message: `No login request found for ${email}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already completed
|
// Check if already completed
|
||||||
if (anyRequest.completed_at) {
|
if (anyRequest.completed_at) {
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
message: "Login request already completed",
|
message: "Login request already completed",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if expired
|
// Check if expired
|
||||||
if (new Date(anyRequest.expires_at) < new Date()) {
|
if (new Date(anyRequest.expires_at) < new Date()) {
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
message:
|
message:
|
||||||
"Login request expired (15 min limit). Start a new login flow.",
|
"Login request expired (15 min limit). Start a new login flow.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete the login request
|
// Complete the login request
|
||||||
await context.db
|
await context.db
|
||||||
.updateTable("login_requests")
|
.updateTable("login_requests")
|
||||||
.set({ completed_at: new Date() })
|
.set({ completed_at: new Date() })
|
||||||
.where("id", "=", anyRequest.id)
|
.where("id", "=", anyRequest.id)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
|
|
||||||
export const adminOrgsCreate = os.admin.orgs.create
|
export const adminOrgsCreate = superuserProcedure.admin.orgs.create.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug, displayName, ownerEmail } = input;
|
const { slug, displayName, ownerEmail } = input;
|
||||||
|
|
||||||
// Find owner user by email (outside transaction - read-only)
|
// Find owner user by email (outside transaction - read-only)
|
||||||
@@ -55,4 +53,5 @@ export const adminOrgsCreate = os.admin.orgs.create
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { slug };
|
return { slug };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
|
|
||||||
export const adminOrgsDelete = os.admin.orgs.delete
|
export const adminOrgsDelete = superuserProcedure.admin.orgs.delete.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug } = input;
|
const { slug } = input;
|
||||||
|
|
||||||
// Delete org and related records in transaction
|
// Delete org and related records in transaction
|
||||||
@@ -35,4 +33,5 @@ export const adminOrgsDelete = os.admin.orgs.delete
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,13 +3,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
import { toOrgResponse } from "../helpers.js";
|
import { toOrgResponse } from "../helpers.js";
|
||||||
|
|
||||||
export const adminOrgsGet = os.admin.orgs.get
|
export const adminOrgsGet = superuserProcedure.admin.orgs.get.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const org = await context.db
|
const org = await context.db
|
||||||
.selectFrom("orgs")
|
.selectFrom("orgs")
|
||||||
.where("slug", "=", input.slug)
|
.where("slug", "=", input.slug)
|
||||||
@@ -19,4 +17,5 @@ export const adminOrgsGet = os.admin.orgs.get
|
|||||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||||
}
|
}
|
||||||
return toOrgResponse(org);
|
return toOrgResponse(org);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,13 +2,12 @@
|
|||||||
* admin.orgs.list - List all organizations
|
* admin.orgs.list - List all organizations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
import { toOrgResponse } from "../helpers.js";
|
import { toOrgResponse } from "../helpers.js";
|
||||||
|
|
||||||
export const adminOrgsList = os.admin.orgs.list
|
export const adminOrgsList = superuserProcedure.admin.orgs.list.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ context }) => {
|
|
||||||
const orgs = await context.db.selectFrom("orgs").selectAll().execute();
|
const orgs = await context.db.selectFrom("orgs").selectAll().execute();
|
||||||
return orgs.map(toOrgResponse);
|
return orgs.map(toOrgResponse);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -4,37 +4,35 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
import { toSiteResponse } from "../helpers.js";
|
import { toSiteResponse } from "../helpers.js";
|
||||||
|
|
||||||
export const adminOrgsListSites = os.admin.orgs.listSites
|
export const adminOrgsListSites =
|
||||||
.use(authMiddleware)
|
superuserProcedure.admin.orgs.listSites.handler(
|
||||||
.use(superuserMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
const { slug } = input;
|
||||||
const { slug } = input;
|
|
||||||
|
|
||||||
const org = await context.db
|
const org = await context.db
|
||||||
.selectFrom("orgs")
|
.selectFrom("orgs")
|
||||||
.where("slug", "=", slug)
|
.where("slug", "=", slug)
|
||||||
.select(["id"])
|
.select(["id"])
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!org) {
|
if (!org) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const sites = await context.db
|
const sites = await context.db
|
||||||
.selectFrom("org_sites")
|
.selectFrom("org_sites")
|
||||||
.where("org_id", "=", org.id)
|
.where("org_id", "=", org.id)
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return sites.map(toSiteResponse);
|
return sites.map(toSiteResponse);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const adminOrgsAddSite = os.admin.orgs.addSite
|
export const adminOrgsAddSite = superuserProcedure.admin.orgs.addSite.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug, domain } = input;
|
const { slug, domain } = input;
|
||||||
|
|
||||||
// Use transaction to prevent race condition on site creation
|
// Use transaction to prevent race condition on site creation
|
||||||
@@ -70,32 +68,33 @@ export const adminOrgsAddSite = os.admin.orgs.addSite
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const adminOrgsRemoveSite = os.admin.orgs.removeSite
|
export const adminOrgsRemoveSite =
|
||||||
.use(authMiddleware)
|
superuserProcedure.admin.orgs.removeSite.handler(
|
||||||
.use(superuserMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
const { slug, domain } = input;
|
||||||
const { slug, domain } = input;
|
|
||||||
|
|
||||||
const org = await context.db
|
const org = await context.db
|
||||||
.selectFrom("orgs")
|
.selectFrom("orgs")
|
||||||
.where("slug", "=", slug)
|
.where("slug", "=", slug)
|
||||||
.select(["id"])
|
.select(["id"])
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!org) {
|
if (!org) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await context.db
|
const result = await context.db
|
||||||
.deleteFrom("org_sites")
|
.deleteFrom("org_sites")
|
||||||
.where("org_id", "=", org.id)
|
.where("org_id", "=", org.id)
|
||||||
.where("domain", "=", domain)
|
.where("domain", "=", domain)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!result.numDeletedRows || result.numDeletedRows === 0n) {
|
if (!result.numDeletedRows || result.numDeletedRows === 0n) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: "Site not found" });
|
throw new ORPCError("NOT_FOUND", { message: "Site not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
|
|
||||||
export const adminOrgsUpdate = os.admin.orgs.update
|
export const adminOrgsUpdate = superuserProcedure.admin.orgs.update.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug, displayName, logoUrl } = input;
|
const { slug, displayName, logoUrl } = input;
|
||||||
|
|
||||||
// Check if there are actual updates to make
|
// Check if there are actual updates to make
|
||||||
@@ -49,4 +47,5 @@ export const adminOrgsUpdate = os.admin.orgs.update
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,24 +3,24 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
|
|
||||||
export const adminUsersConfirmEmail = os.admin.users.confirmEmail
|
export const adminUsersConfirmEmail =
|
||||||
.use(authMiddleware)
|
superuserProcedure.admin.users.confirmEmail.handler(
|
||||||
.use(superuserMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
const result = await context.db
|
||||||
const result = await context.db
|
.updateTable("users")
|
||||||
.updateTable("users")
|
.set({
|
||||||
.set({
|
email_verified_at: new Date(),
|
||||||
email_verified_at: new Date(),
|
updated_at: new Date(),
|
||||||
updated_at: new Date(),
|
})
|
||||||
})
|
.where("email", "=", input.email.toLowerCase())
|
||||||
.where("email", "=", input.email.toLowerCase())
|
.executeTakeFirst();
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
|
|
||||||
export const adminUsersCreate = os.admin.users.create
|
export const adminUsersCreate = superuserProcedure.admin.users.create.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { email, name, orgSlug, orgRole } = input;
|
const { email, name, orgSlug, orgRole } = input;
|
||||||
const normalizedEmail = email.toLowerCase();
|
const normalizedEmail = email.toLowerCase();
|
||||||
|
|
||||||
@@ -62,4 +60,5 @@ export const adminUsersCreate = os.admin.users.create
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,13 +3,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
import { toUserResponse } from "../helpers.js";
|
import { toUserResponse } from "../helpers.js";
|
||||||
|
|
||||||
export const adminUsersGet = os.admin.users.get
|
export const adminUsersGet = superuserProcedure.admin.users.get.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const user = await context.db
|
const user = await context.db
|
||||||
.selectFrom("users")
|
.selectFrom("users")
|
||||||
.where("email", "=", input.email.toLowerCase())
|
.where("email", "=", input.email.toLowerCase())
|
||||||
@@ -19,4 +17,5 @@ export const adminUsersGet = os.admin.users.get
|
|||||||
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||||
}
|
}
|
||||||
return toUserResponse(user);
|
return toUserResponse(user);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,13 +2,12 @@
|
|||||||
* admin.users.list - List all users
|
* admin.users.list - List all users
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
import { toUserResponse } from "../helpers.js";
|
import { toUserResponse } from "../helpers.js";
|
||||||
|
|
||||||
export const adminUsersList = os.admin.users.list
|
export const adminUsersList = superuserProcedure.admin.users.list.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ context }) => {
|
|
||||||
const users = await context.db.selectFrom("users").selectAll().execute();
|
const users = await context.db.selectFrom("users").selectAll().execute();
|
||||||
return users.map(toUserResponse);
|
return users.map(toUserResponse);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
|
|
||||||
export const adminUsersUpdate = os.admin.users.update
|
export const adminUsersUpdate = superuserProcedure.admin.users.update.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { email, isSuperuser } = input;
|
const { email, isSuperuser } = input;
|
||||||
const normalizedEmail = email.toLowerCase();
|
const normalizedEmail = email.toLowerCase();
|
||||||
|
|
||||||
@@ -47,4 +45,5 @@ export const adminUsersUpdate = os.admin.users.update
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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,29 +31,39 @@ 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
|
||||||
.insertInto("password_resets")
|
await withTransaction(context.db, async (trx) => {
|
||||||
.values({
|
// Delete any existing password reset tokens for this user (security measure)
|
||||||
user_id: user.id,
|
await trx
|
||||||
token,
|
.deleteFrom("password_resets")
|
||||||
expires_at: expiresAt,
|
.where("user_id", "=", user.id)
|
||||||
})
|
.execute();
|
||||||
.execute();
|
|
||||||
|
|
||||||
// Send password reset email (stubbed)
|
await trx
|
||||||
await sendPasswordResetEmail(user.email, token);
|
.insertInto("password_resets")
|
||||||
|
.values({
|
||||||
|
user_id: user.id,
|
||||||
|
token,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send password reset email
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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,37 +90,41 @@ 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);
|
||||||
|
|
||||||
// Upsert user device
|
// Create session in transaction (atomic: device upsert + session + login_request delete)
|
||||||
const deviceId = await upsertUserDevice(
|
const { session, deviceTrusted } = await withTransaction(
|
||||||
context.db,
|
context.db,
|
||||||
userId,
|
async (trx) => {
|
||||||
deviceFingerprint,
|
// Upsert user device
|
||||||
geo,
|
const deviceId = await upsertUserDevice(
|
||||||
userAgent,
|
trx,
|
||||||
|
userId,
|
||||||
|
deviceFingerprint,
|
||||||
|
geo,
|
||||||
|
userAgent,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if device is already trusted
|
||||||
|
const trusted = await isDeviceTrusted(trx, userId, deviceFingerprint);
|
||||||
|
|
||||||
|
// Create session with trusted mode = true (email-confirmed login)
|
||||||
|
const newSession = await createSession(trx, {
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
trustedMode: true,
|
||||||
|
geo,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete the login request (it's been consumed)
|
||||||
|
await trx
|
||||||
|
.deleteFrom("login_requests")
|
||||||
|
.where("id", "=", loginRequest.id)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return { session: newSession, deviceTrusted: trusted };
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if device is already trusted
|
|
||||||
const deviceTrusted = await isDeviceTrusted(
|
|
||||||
context.db,
|
|
||||||
userId,
|
|
||||||
deviceFingerprint,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create session with trusted mode = true (email-confirmed login)
|
|
||||||
const session = await createSession(context.db, {
|
|
||||||
userId,
|
|
||||||
deviceId,
|
|
||||||
trustedMode: true,
|
|
||||||
geo,
|
|
||||||
userAgent,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete the login request (it's been consumed)
|
|
||||||
await context.db
|
|
||||||
.deleteFrom("login_requests")
|
|
||||||
.where("id", "=", loginRequest.id)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// Set session cookie
|
// Set session cookie
|
||||||
setCookie(
|
setCookie(
|
||||||
context.resHeaders,
|
context.resHeaders,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
|
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { authedProcedure } from "../base.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout handler
|
* Logout handler
|
||||||
@@ -11,9 +11,8 @@ import { authMiddleware, os } from "../base.js";
|
|||||||
* - Revokes the current session by setting revoked_at to now()
|
* - Revokes the current session by setting revoked_at to now()
|
||||||
* - Clears the session cookie from the response
|
* - Clears the session cookie from the response
|
||||||
*/
|
*/
|
||||||
export const logout = os.auth.logout
|
export const logout = authedProcedure.auth.logout.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.handler(async ({ context }) => {
|
|
||||||
// Revoke the current session
|
// Revoke the current session
|
||||||
await context.db
|
await context.db
|
||||||
.updateTable("sessions")
|
.updateTable("sessions")
|
||||||
@@ -25,4 +24,5 @@ export const logout = os.auth.logout
|
|||||||
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -10,17 +10,16 @@
|
|||||||
* 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 { authedProcedure } from "../base.js";
|
||||||
import { authMiddleware, os } from "../base.js";
|
|
||||||
|
|
||||||
export const resendVerificationEmail = os.auth.resendVerificationEmail
|
export const resendVerificationEmail =
|
||||||
.use(authMiddleware)
|
authedProcedure.auth.resendVerificationEmail.handler(async ({ context }) => {
|
||||||
.handler(async ({ context }) => {
|
|
||||||
// Check if email is already verified
|
// Check if email is already verified
|
||||||
if (context.user.emailVerifiedAt !== null) {
|
if (context.user.emailVerifiedAt !== null) {
|
||||||
// Email already verified, return early
|
// Email already verified, return early
|
||||||
@@ -47,8 +46,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 };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,13 +270,34 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session (7 days, trusted mode false initially, no device)
|
// Generate verification token
|
||||||
const session = await createSession(context.db, {
|
const verificationToken = generateSecureBase58Token();
|
||||||
userId,
|
const verificationExpiresAt = generateExpiry(
|
||||||
deviceId: null,
|
TOKEN_DURATIONS.EMAIL_VERIFICATION,
|
||||||
trustedMode: false,
|
);
|
||||||
geo,
|
|
||||||
userAgent,
|
// 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)
|
||||||
|
const newSession = await createSession(trx, {
|
||||||
|
userId,
|
||||||
|
deviceId: null,
|
||||||
|
trustedMode: false,
|
||||||
|
geo,
|
||||||
|
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
|
||||||
@@ -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")
|
token: verificationToken,
|
||||||
.values({
|
expiryHours: 24,
|
||||||
user_id: userId,
|
});
|
||||||
token: verificationToken,
|
|
||||||
expires_at: expiresAt,
|
|
||||||
})
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// Send verification email (stubbed)
|
|
||||||
await sendVerificationEmail(email, verificationToken);
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,227 +8,22 @@
|
|||||||
import type {
|
import type {
|
||||||
APIContext,
|
APIContext,
|
||||||
AuthenticatedContext,
|
AuthenticatedContext,
|
||||||
AuthInfo,
|
|
||||||
LoginRequestContext,
|
LoginRequestContext,
|
||||||
Session,
|
|
||||||
SessionUser,
|
|
||||||
} from "../context.js";
|
} from "../context.js";
|
||||||
import { implement, ORPCError } from "@orpc/server";
|
import {
|
||||||
import { contract } from "@reviq/api-contract";
|
authMiddleware,
|
||||||
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
|
loginRequestMiddleware,
|
||||||
import { hashToken } from "../utils/crypto.js";
|
os,
|
||||||
|
superuserMiddleware,
|
||||||
|
} from "../middlewares/index.js";
|
||||||
|
|
||||||
/**
|
// Re-export middlewares and os
|
||||||
* Base implementer with typed APIContext
|
export { authMiddleware, loginRequestMiddleware, os, superuserMiddleware };
|
||||||
* All procedures should be derived from this
|
|
||||||
*/
|
|
||||||
export const os = implement(contract).$context<APIContext>();
|
|
||||||
|
|
||||||
/**
|
// Pre-configured procedures with middleware applied
|
||||||
* Auth middleware - validates session/API token and adds user to context
|
export const authedProcedure = os.use(authMiddleware);
|
||||||
* Use with os.use(authMiddleware) to create authenticated procedures
|
export const superuserProcedure = os.use(superuserMiddleware);
|
||||||
*/
|
export const loginRequestProcedure = os.use(loginRequestMiddleware);
|
||||||
export const authMiddleware = os.middleware(async ({ context, next }) => {
|
|
||||||
const { db, reqHeaders } = context;
|
|
||||||
|
|
||||||
// Try session cookie first
|
|
||||||
let tokenHash: string | undefined;
|
|
||||||
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
|
||||||
if (sessionToken) {
|
|
||||||
tokenHash = await hashToken(sessionToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to API key header (for CLI)
|
|
||||||
const apiKey = reqHeaders.get("x-api-key");
|
|
||||||
if (!tokenHash && apiKey) {
|
|
||||||
tokenHash = await hashToken(apiKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tokenHash) {
|
|
||||||
throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look up session (check not expired and not revoked)
|
|
||||||
const session = await db
|
|
||||||
.selectFrom("sessions")
|
|
||||||
.where("token_hash", "=", tokenHash)
|
|
||||||
.where("expires_at", ">", new Date())
|
|
||||||
.where("revoked_at", "is", null)
|
|
||||||
.selectAll()
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
// Fall back to API token if no session found
|
|
||||||
const apiToken = !session
|
|
||||||
? await db
|
|
||||||
.selectFrom("api_tokens")
|
|
||||||
.where("token_hash", "=", tokenHash)
|
|
||||||
.where("expires_at", ">", new Date())
|
|
||||||
.selectAll()
|
|
||||||
.executeTakeFirst()
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const userId = session?.user_id ?? apiToken?.user_id;
|
|
||||||
if (!userId) {
|
|
||||||
throw new ORPCError("UNAUTHORIZED", {
|
|
||||||
message: "Invalid or expired token",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last_used_at for API tokens
|
|
||||||
if (apiToken) {
|
|
||||||
await db
|
|
||||||
.updateTable("api_tokens")
|
|
||||||
.set({ last_used_at: new Date() })
|
|
||||||
.where("id", "=", apiToken.id)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch user details
|
|
||||||
const user = await db
|
|
||||||
.selectFrom("users")
|
|
||||||
.where("id", "=", userId)
|
|
||||||
.select([
|
|
||||||
"id",
|
|
||||||
"email",
|
|
||||||
"display_name",
|
|
||||||
"email_verified_at",
|
|
||||||
"is_superuser",
|
|
||||||
])
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new ORPCError("UNAUTHORIZED", {
|
|
||||||
message: "User not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionUser: SessionUser = {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
displayName: user.display_name,
|
|
||||||
emailVerifiedAt: user.email_verified_at,
|
|
||||||
isSuperuser: user.is_superuser,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build session and auth info based on authentication method
|
|
||||||
let sessionInfo: Session;
|
|
||||||
let authInfo: AuthInfo;
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
sessionInfo = {
|
|
||||||
id: session.id,
|
|
||||||
trustedMode: session.trusted_mode,
|
|
||||||
createdAt: session.created_at,
|
|
||||||
};
|
|
||||||
authInfo = {
|
|
||||||
method: "session",
|
|
||||||
sessionId: session.id,
|
|
||||||
expiresAt: session.expires_at,
|
|
||||||
createdAt: session.created_at,
|
|
||||||
};
|
|
||||||
} else if (apiToken) {
|
|
||||||
sessionInfo = {
|
|
||||||
// For API token auth, create a synthetic session object
|
|
||||||
id: "0",
|
|
||||||
trustedMode: true,
|
|
||||||
createdAt: apiToken.created_at,
|
|
||||||
};
|
|
||||||
authInfo = {
|
|
||||||
method: "api_token",
|
|
||||||
tokenId: apiToken.id,
|
|
||||||
tokenName: apiToken.name,
|
|
||||||
expiresAt: apiToken.expires_at,
|
|
||||||
lastUsedAt: apiToken.last_used_at,
|
|
||||||
createdAt: apiToken.created_at,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// This should never happen since we checked userId above
|
|
||||||
throw new ORPCError("UNAUTHORIZED", {
|
|
||||||
message: "Invalid authentication state",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return next({
|
|
||||||
context: {
|
|
||||||
user: sessionUser,
|
|
||||||
session: sessionInfo,
|
|
||||||
auth: authInfo,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Login request middleware - validates login request token from cookie
|
|
||||||
*/
|
|
||||||
export const loginRequestMiddleware = os.middleware(
|
|
||||||
async ({ context, next }) => {
|
|
||||||
const { db, reqHeaders } = context;
|
|
||||||
|
|
||||||
// Read login request token from cookie
|
|
||||||
const loginRequestToken = getCookie(
|
|
||||||
reqHeaders,
|
|
||||||
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!loginRequestToken) {
|
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
|
||||||
message: "No login request found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch login request with user data by token
|
|
||||||
const result = await db
|
|
||||||
.selectFrom("login_requests")
|
|
||||||
.innerJoin("users", "users.id", "login_requests.user_id")
|
|
||||||
.select([
|
|
||||||
"login_requests.id",
|
|
||||||
"login_requests.user_id",
|
|
||||||
"login_requests.expires_at",
|
|
||||||
"users.email",
|
|
||||||
"users.display_name",
|
|
||||||
"users.email_verified_at",
|
|
||||||
"users.is_superuser",
|
|
||||||
])
|
|
||||||
.where("login_requests.token", "=", loginRequestToken)
|
|
||||||
.where("login_requests.expires_at", ">", new Date())
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
|
||||||
message: "Login request expired or not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionUser: SessionUser = {
|
|
||||||
id: result.user_id,
|
|
||||||
email: result.email,
|
|
||||||
displayName: result.display_name,
|
|
||||||
emailVerifiedAt: result.email_verified_at,
|
|
||||||
isSuperuser: result.is_superuser,
|
|
||||||
};
|
|
||||||
|
|
||||||
return next({
|
|
||||||
context: {
|
|
||||||
loginRequestId: Number(result.id),
|
|
||||||
user: sessionUser,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Superuser middleware - requires admin access (must be used after authMiddleware)
|
|
||||||
*/
|
|
||||||
export const superuserMiddleware = os.middleware(async ({ context, next }) => {
|
|
||||||
// This middleware should be used after authMiddleware
|
|
||||||
const ctx = context as AuthenticatedContext;
|
|
||||||
if (!ctx.user.isSuperuser) {
|
|
||||||
throw new ORPCError("FORBIDDEN", {
|
|
||||||
message: "Superuser access required",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Type exports for use in procedure files
|
// Type exports for use in procedure files
|
||||||
export type { APIContext, AuthenticatedContext, LoginRequestContext };
|
export type { APIContext, AuthenticatedContext, LoginRequestContext };
|
||||||
|
|||||||
7
apps/api-server/src/procedures/me/_base.ts
Normal file
7
apps/api-server/src/procedures/me/_base.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Base route for me procedures with auth middleware applied
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { authedProcedure } from "../base.js";
|
||||||
|
|
||||||
|
export const meRoute = authedProcedure.me;
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
hashToken,
|
hashToken,
|
||||||
TOKEN_PREFIX,
|
TOKEN_PREFIX,
|
||||||
} from "../../utils/crypto.js";
|
} from "../../utils/crypto.js";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
/** Token expiration: 365 days */
|
/** Token expiration: 365 days */
|
||||||
const TOKEN_EXPIRATION_DAYS = 365;
|
const TOKEN_EXPIRATION_DAYS = 365;
|
||||||
@@ -18,9 +18,8 @@ const TOKEN_EXPIRATION_DAYS = 365;
|
|||||||
* List all API tokens for the current user
|
* List all API tokens for the current user
|
||||||
* Returns token metadata (not the actual token values)
|
* Returns token metadata (not the actual token values)
|
||||||
*/
|
*/
|
||||||
export const listApiTokens = os.me.apiTokens.list
|
export const listApiTokens = meRoute.apiTokens.list.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.handler(async ({ context }) => {
|
|
||||||
const tokens = await context.db
|
const tokens = await context.db
|
||||||
.selectFrom("api_tokens")
|
.selectFrom("api_tokens")
|
||||||
.select(["id", "name", "last_used_at", "created_at", "expires_at"])
|
.select(["id", "name", "last_used_at", "created_at", "expires_at"])
|
||||||
@@ -35,15 +34,15 @@ export const listApiTokens = os.me.apiTokens.list
|
|||||||
createdAt: token.created_at.toISOString(),
|
createdAt: token.created_at.toISOString(),
|
||||||
expiresAt: token.expires_at.toISOString(),
|
expiresAt: token.expires_at.toISOString(),
|
||||||
}));
|
}));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new API token
|
* Create a new API token
|
||||||
* Requires superuser status and trusted session
|
* Requires superuser status and trusted session
|
||||||
*/
|
*/
|
||||||
export const createApiToken = os.me.apiTokens.create
|
export const createApiToken = meRoute.apiTokens.create.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
// Require superuser status
|
// Require superuser status
|
||||||
if (!context.user.isSuperuser) {
|
if (!context.user.isSuperuser) {
|
||||||
throw new ORPCError("FORBIDDEN", {
|
throw new ORPCError("FORBIDDEN", {
|
||||||
@@ -85,14 +84,14 @@ export const createApiToken = os.me.apiTokens.create
|
|||||||
token,
|
token,
|
||||||
expiresAt: expiresAt.toISOString(),
|
expiresAt: expiresAt.toISOString(),
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete an API token
|
* Delete an API token
|
||||||
*/
|
*/
|
||||||
export const deleteApiToken = os.me.apiTokens.delete
|
export const deleteApiToken = meRoute.apiTokens.delete.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const result = await context.db
|
const result = await context.db
|
||||||
.deleteFrom("api_tokens")
|
.deleteFrom("api_tokens")
|
||||||
.where("id", "=", input.tokenId.toString())
|
.where("id", "=", input.tokenId.toString())
|
||||||
@@ -106,4 +105,5 @@ export const deleteApiToken = os.me.apiTokens.delete
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,40 +2,38 @@
|
|||||||
* Get current user auth status
|
* Get current user auth status
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
export const meAuthStatus = os.me.authStatus
|
export const meAuthStatus = meRoute.authStatus.handler(async ({ context }) => {
|
||||||
.use(authMiddleware)
|
const user = await context.db
|
||||||
.handler(async ({ context }) => {
|
.selectFrom("users")
|
||||||
const user = await context.db
|
.select([
|
||||||
.selectFrom("users")
|
"id",
|
||||||
.select([
|
"email",
|
||||||
"id",
|
"display_name",
|
||||||
"email",
|
"full_name",
|
||||||
"display_name",
|
"phone_number",
|
||||||
"full_name",
|
"avatar_url",
|
||||||
"phone_number",
|
"email_verified_at",
|
||||||
"avatar_url",
|
"is_superuser",
|
||||||
"email_verified_at",
|
"password_hash",
|
||||||
"is_superuser",
|
])
|
||||||
"password_hash",
|
.where("id", "=", context.user.id)
|
||||||
])
|
.executeTakeFirstOrThrow();
|
||||||
.where("id", "=", context.user.id)
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
displayName: user.display_name,
|
displayName: user.display_name,
|
||||||
fullName: user.full_name,
|
fullName: user.full_name,
|
||||||
phoneNumber: user.phone_number,
|
phoneNumber: user.phone_number,
|
||||||
avatarUrl: user.avatar_url,
|
avatarUrl: user.avatar_url,
|
||||||
emailVerified: user.email_verified_at !== null,
|
emailVerified: user.email_verified_at !== null,
|
||||||
needsSetup: user.display_name === null,
|
needsSetup: user.display_name === null,
|
||||||
isSuperuser: user.is_superuser,
|
isSuperuser: user.is_superuser,
|
||||||
hasPassword: user.password_hash !== null,
|
hasPassword: user.password_hash !== null,
|
||||||
},
|
},
|
||||||
auth: context.auth,
|
auth: context.auth,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
|
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
|
||||||
import { verifyPassword } from "../../utils/password.js";
|
import { verifyPassword } from "../../utils/password.js";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete account handler
|
* Delete account handler
|
||||||
@@ -14,39 +14,37 @@ import { authMiddleware, os } from "../base.js";
|
|||||||
* - Deletes user record (cascades to sessions, devices, passkeys, etc.)
|
* - Deletes user record (cascades to sessions, devices, passkeys, etc.)
|
||||||
* - Clears session cookie
|
* - Clears session cookie
|
||||||
*/
|
*/
|
||||||
export const meDelete = os.me.delete
|
export const meDelete = meRoute.delete.handler(async ({ input, context }) => {
|
||||||
.use(authMiddleware)
|
const { password } = input;
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { password } = input;
|
|
||||||
|
|
||||||
// Fetch user with password hash
|
// Fetch user with password hash
|
||||||
const user = await context.db
|
const user = await context.db
|
||||||
.selectFrom("users")
|
.selectFrom("users")
|
||||||
.select(["password_hash"])
|
.select(["password_hash"])
|
||||||
.where("id", "=", context.user.id)
|
.where("id", "=", context.user.id)
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
// Verify password (required for account deletion)
|
// Verify password (required for account deletion)
|
||||||
if (!user.password_hash) {
|
if (!user.password_hash) {
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
message:
|
message:
|
||||||
"Cannot delete account without a password. Please set a password first.",
|
"Cannot delete account without a password. Please set a password first.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await verifyPassword(password, user.password_hash);
|
const valid = await verifyPassword(password, user.password_hash);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
throw new ORPCError("BAD_REQUEST", { message: "Incorrect password" });
|
throw new ORPCError("BAD_REQUEST", { message: "Incorrect password" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete user (cascades to sessions, devices, passkeys, etc.)
|
// Delete user (cascades to sessions, devices, passkeys, etc.)
|
||||||
await context.db
|
await context.db
|
||||||
.deleteFrom("users")
|
.deleteFrom("users")
|
||||||
.where("id", "=", context.user.id)
|
.where("id", "=", context.user.id)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Clear session cookie
|
// Clear session cookie
|
||||||
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
import { defaultDeviceName, requireDeviceFingerprint } from "./helpers.js";
|
import { defaultDeviceName, requireDeviceFingerprint } from "./helpers.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,9 +13,8 @@ import { defaultDeviceName, requireDeviceFingerprint } from "./helpers.js";
|
|||||||
* @throws BAD_REQUEST if no device fingerprint found
|
* @throws BAD_REQUEST if no device fingerprint found
|
||||||
* @throws NOT_FOUND if device doesn't exist
|
* @throws NOT_FOUND if device doesn't exist
|
||||||
*/
|
*/
|
||||||
export const getDeviceInfo = os.me.devices.getInfo
|
export const getDeviceInfo = meRoute.devices.getInfo.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.handler(async ({ context }) => {
|
|
||||||
const fingerprint = requireDeviceFingerprint(context.reqHeaders);
|
const fingerprint = requireDeviceFingerprint(context.reqHeaders);
|
||||||
|
|
||||||
const device = await context.db
|
const device = await context.db
|
||||||
@@ -39,7 +38,8 @@ export const getDeviceInfo = os.me.devices.getInfo
|
|||||||
lastUsedAt: device.last_used_at,
|
lastUsedAt: device.last_used_at,
|
||||||
isTrusted: device.is_trusted,
|
isTrusted: device.is_trusted,
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trust device handler
|
* Trust device handler
|
||||||
@@ -48,9 +48,8 @@ export const getDeviceInfo = os.me.devices.getInfo
|
|||||||
* @throws BAD_REQUEST if no device fingerprint found
|
* @throws BAD_REQUEST if no device fingerprint found
|
||||||
* @throws NOT_FOUND if device doesn't exist
|
* @throws NOT_FOUND if device doesn't exist
|
||||||
*/
|
*/
|
||||||
export const trustDevice = os.me.devices.trust
|
export const trustDevice = meRoute.devices.trust.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { name } = input;
|
const { name } = input;
|
||||||
const fingerprint = requireDeviceFingerprint(context.reqHeaders);
|
const fingerprint = requireDeviceFingerprint(context.reqHeaders);
|
||||||
|
|
||||||
@@ -66,16 +65,16 @@ export const trustDevice = os.me.devices.trust
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List trusted devices handler
|
* List trusted devices handler
|
||||||
* - Requires authentication
|
* - Requires authentication
|
||||||
* - Returns all trusted devices for the current user
|
* - Returns all trusted devices for the current user
|
||||||
*/
|
*/
|
||||||
export const listTrustedDevices = os.me.devices.listTrusted
|
export const listTrustedDevices = meRoute.devices.listTrusted.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.handler(async ({ context }) => {
|
|
||||||
const devices = await context.db
|
const devices = await context.db
|
||||||
.selectFrom("user_devices")
|
.selectFrom("user_devices")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
@@ -94,7 +93,8 @@ export const listTrustedDevices = os.me.devices.listTrusted
|
|||||||
lastUsedAt: d.last_used_at,
|
lastUsedAt: d.last_used_at,
|
||||||
isTrusted: d.is_trusted,
|
isTrusted: d.is_trusted,
|
||||||
}));
|
}));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Untrust device handler
|
* Untrust device handler
|
||||||
@@ -102,9 +102,8 @@ export const listTrustedDevices = os.me.devices.listTrusted
|
|||||||
* - Marks device as untrusted by ID
|
* - Marks device as untrusted by ID
|
||||||
* @throws NOT_FOUND if device doesn't exist
|
* @throws NOT_FOUND if device doesn't exist
|
||||||
*/
|
*/
|
||||||
export const untrustDevice = os.me.devices.untrust
|
export const untrustDevice = meRoute.devices.untrust.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const result = await context.db
|
const result = await context.db
|
||||||
.updateTable("user_devices")
|
.updateTable("user_devices")
|
||||||
.set({ is_trusted: false })
|
.set({ is_trusted: false })
|
||||||
@@ -117,16 +116,16 @@ export const untrustDevice = os.me.devices.untrust
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revoke all trusted devices handler
|
* Revoke all trusted devices handler
|
||||||
* - Requires authentication
|
* - Requires authentication
|
||||||
* - Marks all devices as untrusted
|
* - Marks all devices as untrusted
|
||||||
*/
|
*/
|
||||||
export const revokeAllTrustedDevices = os.me.devices.revokeAll
|
export const revokeAllTrustedDevices = meRoute.devices.revokeAll.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.handler(async ({ context }) => {
|
|
||||||
await context.db
|
await context.db
|
||||||
.updateTable("user_devices")
|
.updateTable("user_devices")
|
||||||
.set({ is_trusted: false })
|
.set({ is_trusted: false })
|
||||||
@@ -134,4 +133,5 @@ export const revokeAllTrustedDevices = os.me.devices.revokeAll
|
|||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,37 +2,35 @@
|
|||||||
* Get current user profile
|
* Get current user profile
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
export const meGet = os.me.get
|
export const meGet = meRoute.get.handler(async ({ context }) => {
|
||||||
.use(authMiddleware)
|
const user = await context.db
|
||||||
.handler(async ({ context }) => {
|
.selectFrom("users")
|
||||||
const user = await context.db
|
.select([
|
||||||
.selectFrom("users")
|
"id",
|
||||||
.select([
|
"email",
|
||||||
"id",
|
"display_name",
|
||||||
"email",
|
"full_name",
|
||||||
"display_name",
|
"phone_number",
|
||||||
"full_name",
|
"avatar_url",
|
||||||
"phone_number",
|
"email_verified_at",
|
||||||
"avatar_url",
|
"is_superuser",
|
||||||
"email_verified_at",
|
"password_hash",
|
||||||
"is_superuser",
|
])
|
||||||
"password_hash",
|
.where("id", "=", context.user.id)
|
||||||
])
|
.executeTakeFirstOrThrow();
|
||||||
.where("id", "=", context.user.id)
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
displayName: user.display_name,
|
displayName: user.display_name,
|
||||||
fullName: user.full_name,
|
fullName: user.full_name,
|
||||||
phoneNumber: user.phone_number,
|
phoneNumber: user.phone_number,
|
||||||
avatarUrl: user.avatar_url,
|
avatarUrl: user.avatar_url,
|
||||||
emailVerified: user.email_verified_at !== null,
|
emailVerified: user.email_verified_at !== null,
|
||||||
needsSetup: user.display_name === null,
|
needsSetup: user.display_name === null,
|
||||||
isSuperuser: user.is_superuser,
|
isSuperuser: user.is_superuser,
|
||||||
hasPassword: user.password_hash !== null,
|
hasPassword: user.password_hash !== null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,64 +3,61 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List pending invites for the current user
|
* List pending invites for the current user
|
||||||
* Only returns invites where the user's email matches and email is verified
|
* Only returns invites where the user's email matches and email is verified
|
||||||
*/
|
*/
|
||||||
export const listInvites = os.me.invites.list
|
export const listInvites = meRoute.invites.list.handler(async ({ context }) => {
|
||||||
.use(authMiddleware)
|
// Only show invites if email is verified
|
||||||
.handler(async ({ context }) => {
|
if (!context.user.emailVerifiedAt) {
|
||||||
// Only show invites if email is verified
|
return [];
|
||||||
if (!context.user.emailVerifiedAt) {
|
}
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get non-expired invites matching user's email
|
// Get non-expired invites matching user's email
|
||||||
const invites = await context.db
|
const invites = await context.db
|
||||||
.selectFrom("org_invites")
|
.selectFrom("org_invites")
|
||||||
.innerJoin("orgs", "orgs.id", "org_invites.org_id")
|
.innerJoin("orgs", "orgs.id", "org_invites.org_id")
|
||||||
.innerJoin("users", "users.id", "org_invites.invited_by")
|
.innerJoin("users", "users.id", "org_invites.invited_by")
|
||||||
.where("org_invites.email", "=", context.user.email.toLowerCase())
|
.where("org_invites.email", "=", context.user.email.toLowerCase())
|
||||||
.where("org_invites.expires_at", ">", new Date())
|
.where("org_invites.expires_at", ">", new Date())
|
||||||
.select([
|
.select([
|
||||||
"org_invites.id",
|
"org_invites.id",
|
||||||
"org_invites.role",
|
"org_invites.role",
|
||||||
"org_invites.created_at",
|
"org_invites.created_at",
|
||||||
"org_invites.expires_at",
|
"org_invites.expires_at",
|
||||||
"orgs.id as org_id",
|
"orgs.id as org_id",
|
||||||
"orgs.slug as org_slug",
|
"orgs.slug as org_slug",
|
||||||
"orgs.display_name as org_display_name",
|
"orgs.display_name as org_display_name",
|
||||||
"orgs.logo_url as org_logo_url",
|
"orgs.logo_url as org_logo_url",
|
||||||
"users.display_name as inviter_name",
|
"users.display_name as inviter_name",
|
||||||
"users.email as inviter_email",
|
"users.email as inviter_email",
|
||||||
])
|
])
|
||||||
.orderBy("org_invites.created_at", "desc")
|
.orderBy("org_invites.created_at", "desc")
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return invites.map((i) => ({
|
return invites.map((i) => ({
|
||||||
id: i.id,
|
id: i.id,
|
||||||
org: {
|
org: {
|
||||||
id: i.org_id,
|
id: i.org_id,
|
||||||
slug: i.org_slug,
|
slug: i.org_slug,
|
||||||
displayName: i.org_display_name,
|
displayName: i.org_display_name,
|
||||||
logoUrl: i.org_logo_url,
|
logoUrl: i.org_logo_url,
|
||||||
},
|
},
|
||||||
role: i.role,
|
role: i.role,
|
||||||
invitedBy: i.inviter_name ?? i.inviter_email,
|
invitedBy: i.inviter_name ?? i.inviter_email,
|
||||||
createdAt: i.created_at,
|
createdAt: i.created_at,
|
||||||
expiresAt: i.expires_at,
|
expiresAt: i.expires_at,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a specific invite by ID
|
* Get a specific invite by ID
|
||||||
* Only returns if the invite belongs to the current user's email
|
* Only returns if the invite belongs to the current user's email
|
||||||
*/
|
*/
|
||||||
export const getInvite = os.me.invites.get
|
export const getInvite = meRoute.invites.get.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { inviteId } = input;
|
const { inviteId } = input;
|
||||||
|
|
||||||
// Only show invite if email is verified
|
// Only show invite if email is verified
|
||||||
@@ -111,15 +108,15 @@ export const getInvite = os.me.invites.get
|
|||||||
createdAt: invite.created_at,
|
createdAt: invite.created_at,
|
||||||
expiresAt: invite.expires_at,
|
expiresAt: invite.expires_at,
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept an invite by ID
|
* Accept an invite by ID
|
||||||
* Adds user to org and deletes the invite
|
* Adds user to org and deletes the invite
|
||||||
*/
|
*/
|
||||||
export const acceptInvite = os.me.invites.accept
|
export const acceptInvite = meRoute.invites.accept.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { inviteId } = input;
|
const { inviteId } = input;
|
||||||
|
|
||||||
// Only allow accepting if email is verified
|
// Only allow accepting if email is verified
|
||||||
@@ -183,15 +180,15 @@ export const acceptInvite = os.me.invites.accept
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decline an invite
|
* Decline an invite
|
||||||
* Deletes the invite if it belongs to the current user's email
|
* Deletes the invite if it belongs to the current user's email
|
||||||
*/
|
*/
|
||||||
export const declineInvite = os.me.invites.decline
|
export const declineInvite = meRoute.invites.decline.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { inviteId } = input;
|
const { inviteId } = input;
|
||||||
|
|
||||||
// Delete the invite only if it matches user's email
|
// Delete the invite only if it matches user's email
|
||||||
@@ -208,4 +205,5 @@ export const declineInvite = os.me.invites.decline
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -4,16 +4,15 @@
|
|||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { getUserPasskeys } from "../../utils/webauthn.js";
|
import { getUserPasskeys } from "../../utils/webauthn.js";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List passkeys handler
|
* List passkeys handler
|
||||||
* - Requires authentication
|
* - Requires authentication
|
||||||
* - Returns all passkeys for the current user
|
* - Returns all passkeys for the current user
|
||||||
*/
|
*/
|
||||||
export const listPasskeys = os.me.passkeys.list
|
export const listPasskeys = meRoute.passkeys.list.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.handler(async ({ context }) => {
|
|
||||||
const passkeys = await getUserPasskeys(context.db, context.user.id);
|
const passkeys = await getUserPasskeys(context.db, context.user.id);
|
||||||
|
|
||||||
return passkeys.map((p) => ({
|
return passkeys.map((p) => ({
|
||||||
@@ -22,7 +21,8 @@ export const listPasskeys = os.me.passkeys.list
|
|||||||
createdAt: p.createdAt,
|
createdAt: p.createdAt,
|
||||||
lastUsedAt: p.lastUsedAt,
|
lastUsedAt: p.lastUsedAt,
|
||||||
}));
|
}));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rename passkey handler
|
* Rename passkey handler
|
||||||
@@ -30,9 +30,8 @@ export const listPasskeys = os.me.passkeys.list
|
|||||||
* - Updates passkey name
|
* - Updates passkey name
|
||||||
* @throws NOT_FOUND if passkey doesn't exist
|
* @throws NOT_FOUND if passkey doesn't exist
|
||||||
*/
|
*/
|
||||||
export const renamePasskey = os.me.passkeys.rename
|
export const renamePasskey = meRoute.passkeys.rename.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { passkeyId, name } = input;
|
const { passkeyId, name } = input;
|
||||||
|
|
||||||
const result = await context.db
|
const result = await context.db
|
||||||
@@ -47,7 +46,8 @@ export const renamePasskey = os.me.passkeys.rename
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete passkey handler
|
* Delete passkey handler
|
||||||
@@ -57,9 +57,8 @@ export const renamePasskey = os.me.passkeys.rename
|
|||||||
* @throws NOT_FOUND if passkey doesn't exist
|
* @throws NOT_FOUND if passkey doesn't exist
|
||||||
* @throws BAD_REQUEST if trying to delete last passkey without password
|
* @throws BAD_REQUEST if trying to delete last passkey without password
|
||||||
*/
|
*/
|
||||||
export const deletePasskey = os.me.passkeys.delete
|
export const deletePasskey = meRoute.passkeys.delete.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { passkeyId } = input;
|
const { passkeyId } = input;
|
||||||
|
|
||||||
// Use transaction to prevent race condition when checking last passkey
|
// Use transaction to prevent race condition when checking last passkey
|
||||||
@@ -96,4 +95,5 @@ export const deletePasskey = os.me.passkeys.delete
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List sessions handler
|
* List sessions handler
|
||||||
@@ -11,9 +11,8 @@ import { authMiddleware, os } from "../base.js";
|
|||||||
* - Returns all sessions for the current user
|
* - Returns all sessions for the current user
|
||||||
* - Includes isCurrent flag to identify active session
|
* - Includes isCurrent flag to identify active session
|
||||||
*/
|
*/
|
||||||
export const listSessions = os.me.sessions.list
|
export const listSessions = meRoute.sessions.list.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.handler(async ({ context }) => {
|
|
||||||
const sessions = await context.db
|
const sessions = await context.db
|
||||||
.selectFrom("sessions")
|
.selectFrom("sessions")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
@@ -33,7 +32,8 @@ export const listSessions = os.me.sessions.list
|
|||||||
isCurrent: s.id === context.session.id,
|
isCurrent: s.id === context.session.id,
|
||||||
revokedAt: s.revoked_at,
|
revokedAt: s.revoked_at,
|
||||||
}));
|
}));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revoke session handler
|
* Revoke session handler
|
||||||
@@ -42,9 +42,8 @@ export const listSessions = os.me.sessions.list
|
|||||||
* @throws NOT_FOUND if session doesn't exist
|
* @throws NOT_FOUND if session doesn't exist
|
||||||
* @throws BAD_REQUEST if trying to revoke current session
|
* @throws BAD_REQUEST if trying to revoke current session
|
||||||
*/
|
*/
|
||||||
export const revokeSession = os.me.sessions.revoke
|
export const revokeSession = meRoute.sessions.revoke.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { sessionId } = input;
|
const { sessionId } = input;
|
||||||
|
|
||||||
// Prevent revoking current session (use logout instead)
|
// Prevent revoking current session (use logout instead)
|
||||||
@@ -67,16 +66,16 @@ export const revokeSession = os.me.sessions.revoke
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revoke all sessions handler
|
* Revoke all sessions handler
|
||||||
* - Requires authentication
|
* - Requires authentication
|
||||||
* - Revokes all sessions except current
|
* - Revokes all sessions except current
|
||||||
*/
|
*/
|
||||||
export const revokeAllSessions = os.me.sessions.revokeAll
|
export const revokeAllSessions = meRoute.sessions.revokeAll.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.handler(async ({ context }) => {
|
|
||||||
// Revoke all sessions except current
|
// Revoke all sessions except current
|
||||||
await context.db
|
await context.db
|
||||||
.updateTable("sessions")
|
.updateTable("sessions")
|
||||||
@@ -87,4 +86,5 @@ export const revokeAllSessions = os.me.sessions.revokeAll
|
|||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
validatePassword,
|
validatePassword,
|
||||||
verifyPassword,
|
verifyPassword,
|
||||||
} from "../../utils/password.js";
|
} from "../../utils/password.js";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set password handler
|
* Set password handler
|
||||||
@@ -16,9 +16,8 @@ import { authMiddleware, os } from "../base.js";
|
|||||||
* - If user has existing password, currentPassword is required
|
* - If user has existing password, currentPassword is required
|
||||||
* - Validates new password strength using zxcvbn
|
* - Validates new password strength using zxcvbn
|
||||||
*/
|
*/
|
||||||
export const setPassword = os.me.setPassword
|
export const setPassword = meRoute.setPassword.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { currentPassword, newPassword } = input;
|
const { currentPassword, newPassword } = input;
|
||||||
|
|
||||||
// Fetch current password hash
|
// Fetch current password hash
|
||||||
@@ -60,4 +59,5 @@ export const setPassword = os.me.setPassword
|
|||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
* Setup user profile (initial setup after signup)
|
* Setup user profile (initial setup after signup)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
export const setupProfile = os.me.setupProfile
|
export const setupProfile = meRoute.setupProfile.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { displayName, fullName, phoneNumber } = input;
|
const { displayName, fullName, phoneNumber } = input;
|
||||||
|
|
||||||
await context.db
|
await context.db
|
||||||
@@ -21,4 +20,5 @@ export const setupProfile = os.me.setupProfile
|
|||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ProfileUpdate } from "./helpers.js";
|
import type { ProfileUpdate } from "./helpers.js";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update profile handler
|
* Update profile handler
|
||||||
@@ -11,9 +11,8 @@ import { authMiddleware, os } from "../base.js";
|
|||||||
* - Allows partial updates to display_name, full_name, phone_number, avatar_url
|
* - Allows partial updates to display_name, full_name, phone_number, avatar_url
|
||||||
* - Automatically sets updated_at timestamp
|
* - Automatically sets updated_at timestamp
|
||||||
*/
|
*/
|
||||||
export const updateProfile = os.me.updateProfile
|
export const updateProfile = meRoute.updateProfile.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const updates: Partial<ProfileUpdate> = {};
|
const updates: Partial<ProfileUpdate> = {};
|
||||||
if (input.displayName !== undefined) {
|
if (input.displayName !== undefined) {
|
||||||
updates.display_name = input.displayName;
|
updates.display_name = input.displayName;
|
||||||
@@ -38,4 +37,5 @@ export const updateProfile = os.me.updateProfile
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,15 +3,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { authedProcedure } from "../base.js";
|
||||||
import { getMembership, lookupOrgBySlug } from "./helpers.js";
|
import { getMembership, lookupOrgBySlug } from "./helpers.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all orgs the current user is a member of
|
* List all orgs the current user is a member of
|
||||||
*/
|
*/
|
||||||
export const orgsList = os.orgs.list
|
export const orgsList = authedProcedure.orgs.list.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.handler(async ({ context }) => {
|
|
||||||
const orgs = await context.db
|
const orgs = await context.db
|
||||||
.selectFrom("org_members")
|
.selectFrom("org_members")
|
||||||
.innerJoin("orgs", "orgs.id", "org_members.org_id")
|
.innerJoin("orgs", "orgs.id", "org_members.org_id")
|
||||||
@@ -33,15 +32,15 @@ export const orgsList = os.orgs.list
|
|||||||
logoUrl: o.logo_url,
|
logoUrl: o.logo_url,
|
||||||
createdAt: o.created_at,
|
createdAt: o.created_at,
|
||||||
}));
|
}));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new org
|
* Create a new org
|
||||||
* The creating user becomes the owner
|
* The creating user becomes the owner
|
||||||
*/
|
*/
|
||||||
export const orgsCreate = os.orgs.create
|
export const orgsCreate = authedProcedure.orgs.create.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug, displayName } = input;
|
const { slug, displayName } = input;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -75,15 +74,15 @@ export const orgsCreate = os.orgs.create
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single org by slug
|
* Get a single org by slug
|
||||||
* Requires membership
|
* Requires membership
|
||||||
*/
|
*/
|
||||||
export const orgsGet = os.orgs.get
|
export const orgsGet = authedProcedure.orgs.get.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug } = input;
|
const { slug } = input;
|
||||||
|
|
||||||
// Lookup org and verify membership
|
// Lookup org and verify membership
|
||||||
@@ -97,4 +96,5 @@ export const orgsGet = os.orgs.get
|
|||||||
logoUrl: org.logoUrl,
|
logoUrl: org.logoUrl,
|
||||||
createdAt: org.createdAt,
|
createdAt: org.createdAt,
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -5,25 +5,11 @@
|
|||||||
|
|
||||||
import type { DB, OrgRole } from "@reviq/db-schema";
|
import type { DB, OrgRole } from "@reviq/db-schema";
|
||||||
import type { Kysely } from "kysely";
|
import type { Kysely } from "kysely";
|
||||||
|
import type { OrgInfo, OrgMembership } from "../../context.js";
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
|
|
||||||
// ===== Types =====
|
// Re-export types for convenience
|
||||||
|
export type { OrgInfo, OrgMembership };
|
||||||
/** Org info returned from lookup */
|
|
||||||
export interface OrgInfo {
|
|
||||||
id: number;
|
|
||||||
slug: string;
|
|
||||||
displayName: string;
|
|
||||||
logoUrl: string | null;
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** User's membership in an org */
|
|
||||||
export interface OrgMembership {
|
|
||||||
id: number;
|
|
||||||
role: OrgRole;
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Role Hierarchy =====
|
// ===== Role Hierarchy =====
|
||||||
|
|
||||||
|
|||||||
@@ -3,22 +3,21 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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 { authedProcedure } from "../base.js";
|
||||||
import { authMiddleware, os } from "../base.js";
|
|
||||||
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
|
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List pending invites for an org
|
* List pending invites for an org
|
||||||
* Requires admin or owner role
|
* Requires admin or owner role
|
||||||
*/
|
*/
|
||||||
export const invitesList = os.orgs.invites.list
|
export const invitesList = authedProcedure.orgs.invites.list.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug } = input;
|
const { slug } = input;
|
||||||
|
|
||||||
// Lookup org and verify admin+ role
|
// Lookup org and verify admin+ role
|
||||||
@@ -52,16 +51,16 @@ export const invitesList = os.orgs.invites.list
|
|||||||
createdAt: i.created_at,
|
createdAt: i.created_at,
|
||||||
expiresAt: i.expires_at,
|
expiresAt: i.expires_at,
|
||||||
}));
|
}));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an invite for a new member
|
* Create an invite for a new member
|
||||||
* Requires admin or owner role
|
* Requires admin or owner role
|
||||||
* Only owners can invite new owners (privilege escalation prevention)
|
* Only owners can invite new owners (privilege escalation prevention)
|
||||||
*/
|
*/
|
||||||
export const invitesCreate = os.orgs.invites.create
|
export const invitesCreate = authedProcedure.orgs.invites.create.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug, email: rawEmail, role } = input;
|
const { slug, email: rawEmail, role } = input;
|
||||||
const email = rawEmail.toLowerCase();
|
const email = rawEmail.toLowerCase();
|
||||||
|
|
||||||
@@ -122,18 +121,28 @@ 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 };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel a pending invite
|
* Cancel a pending invite
|
||||||
* Requires admin or owner role
|
* Requires admin or owner role
|
||||||
*/
|
*/
|
||||||
export const invitesCancel = os.orgs.invites.cancel
|
export const invitesCancel = authedProcedure.orgs.invites.cancel.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug, inviteId } = input;
|
const { slug, inviteId } = input;
|
||||||
|
|
||||||
// Lookup org and verify admin+ role
|
// Lookup org and verify admin+ role
|
||||||
@@ -153,16 +162,16 @@ export const invitesCancel = os.orgs.invites.cancel
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept an invitation
|
* Accept an invitation
|
||||||
* Token-based lookup, requires auth but no org membership
|
* Token-based lookup, requires auth but no org membership
|
||||||
* Handles race condition if user is already a member
|
* Handles race condition if user is already a member
|
||||||
*/
|
*/
|
||||||
export const invitesAccept = os.orgs.invites.accept
|
export const invitesAccept = authedProcedure.orgs.invites.accept.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { token } = input;
|
const { token } = input;
|
||||||
|
|
||||||
// Find the invite by token (must not be expired)
|
// Find the invite by token (must not be expired)
|
||||||
@@ -225,4 +234,5 @@ export const invitesAccept = os.orgs.invites.accept
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { authedProcedure } from "../base.js";
|
||||||
import {
|
import {
|
||||||
countOwners,
|
countOwners,
|
||||||
getMembership,
|
getMembership,
|
||||||
@@ -15,9 +15,8 @@ import {
|
|||||||
* Update org details
|
* Update org details
|
||||||
* Requires admin or owner role
|
* Requires admin or owner role
|
||||||
*/
|
*/
|
||||||
export const orgsUpdate = os.orgs.update
|
export const orgsUpdate = authedProcedure.orgs.update.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug, displayName, logoUrl } = input;
|
const { slug, displayName, logoUrl } = input;
|
||||||
|
|
||||||
// Lookup org and verify membership with admin+ role
|
// Lookup org and verify membership with admin+ role
|
||||||
@@ -41,16 +40,16 @@ export const orgsUpdate = os.orgs.update
|
|||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete an org
|
* Delete an org
|
||||||
* Requires owner role
|
* Requires owner role
|
||||||
* FK CASCADE handles deleting members, invites, and sites
|
* FK CASCADE handles deleting members, invites, and sites
|
||||||
*/
|
*/
|
||||||
export const orgsDelete = os.orgs.delete
|
export const orgsDelete = authedProcedure.orgs.delete.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug } = input;
|
const { slug } = input;
|
||||||
|
|
||||||
// Lookup org and verify ownership
|
// Lookup org and verify ownership
|
||||||
@@ -61,16 +60,16 @@ export const orgsDelete = os.orgs.delete
|
|||||||
await context.db.deleteFrom("orgs").where("id", "=", org.id).execute();
|
await context.db.deleteFrom("orgs").where("id", "=", org.id).execute();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leave an org
|
* Leave an org
|
||||||
* Cannot leave if you're the only owner
|
* Cannot leave if you're the only owner
|
||||||
* Uses transaction to prevent race condition where multiple owners leave simultaneously
|
* Uses transaction to prevent race condition where multiple owners leave simultaneously
|
||||||
*/
|
*/
|
||||||
export const orgsLeave = os.orgs.leave
|
export const orgsLeave = authedProcedure.orgs.leave.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug } = input;
|
const { slug } = input;
|
||||||
|
|
||||||
// Lookup org and get membership
|
// Lookup org and get membership
|
||||||
@@ -98,4 +97,5 @@ export const orgsLeave = os.orgs.leave
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { authedProcedure } from "../base.js";
|
||||||
import {
|
import {
|
||||||
countOwners,
|
countOwners,
|
||||||
getMembership,
|
getMembership,
|
||||||
@@ -15,9 +15,8 @@ import {
|
|||||||
* List all members of an org
|
* List all members of an org
|
||||||
* Any member can view the member list
|
* Any member can view the member list
|
||||||
*/
|
*/
|
||||||
export const membersList = os.orgs.members.list
|
export const membersList = authedProcedure.orgs.members.list.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug } = input;
|
const { slug } = input;
|
||||||
|
|
||||||
// Lookup org and verify membership
|
// Lookup org and verify membership
|
||||||
@@ -48,65 +47,70 @@ export const membersList = os.orgs.members.list
|
|||||||
role: m.role,
|
role: m.role,
|
||||||
createdAt: m.created_at,
|
createdAt: m.created_at,
|
||||||
}));
|
}));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a member's role
|
* Update a member's role
|
||||||
* Only owners can change roles
|
* Only owners can change roles
|
||||||
* Uses transaction to prevent race condition when demoting owners
|
* Uses transaction to prevent race condition when demoting owners
|
||||||
*/
|
*/
|
||||||
export const membersUpdateRole = os.orgs.members.updateRole
|
export const membersUpdateRole =
|
||||||
.use(authMiddleware)
|
authedProcedure.orgs.members.updateRole.handler(
|
||||||
.handler(async ({ input, context }) => {
|
async ({ input, context }) => {
|
||||||
const { slug, userId, role: newRole } = input;
|
const { slug, userId, role: newRole } = input;
|
||||||
|
|
||||||
// Lookup org and verify ownership
|
// Lookup org and verify ownership
|
||||||
const org = await lookupOrgBySlug(context.db, slug);
|
const org = await lookupOrgBySlug(context.db, slug);
|
||||||
const membership = await getMembership(context.db, org.id, context.user.id);
|
const membership = await getMembership(
|
||||||
requireRole(membership, "owner");
|
context.db,
|
||||||
|
org.id,
|
||||||
|
context.user.id,
|
||||||
|
);
|
||||||
|
requireRole(membership, "owner");
|
||||||
|
|
||||||
await context.db.transaction().execute(async (trx) => {
|
await context.db.transaction().execute(async (trx) => {
|
||||||
// Get the target member's current membership
|
// Get the target member's current membership
|
||||||
const targetMember = await trx
|
const targetMember = await trx
|
||||||
.selectFrom("org_members")
|
.selectFrom("org_members")
|
||||||
.select(["id", "role"])
|
.select(["id", "role"])
|
||||||
.where("org_id", "=", org.id)
|
.where("org_id", "=", org.id)
|
||||||
.where("user_id", "=", userId)
|
.where("user_id", "=", userId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!targetMember) {
|
if (!targetMember) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: "Member not found" });
|
throw new ORPCError("NOT_FOUND", { message: "Member not found" });
|
||||||
}
|
|
||||||
|
|
||||||
// If demoting an owner, check if they're the last one
|
|
||||||
if (targetMember.role === "owner" && newRole !== "owner") {
|
|
||||||
const ownerCount = await countOwners(trx, org.id);
|
|
||||||
if (ownerCount === 1) {
|
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
|
||||||
message: "Cannot demote the only owner",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Update the role
|
// If demoting an owner, check if they're the last one
|
||||||
await trx
|
if (targetMember.role === "owner" && newRole !== "owner") {
|
||||||
.updateTable("org_members")
|
const ownerCount = await countOwners(trx, org.id);
|
||||||
.set({ role: newRole })
|
if (ownerCount === 1) {
|
||||||
.where("id", "=", targetMember.id)
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
.execute();
|
message: "Cannot demote the only owner",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
// Update the role
|
||||||
});
|
await trx
|
||||||
|
.updateTable("org_members")
|
||||||
|
.set({ role: newRole })
|
||||||
|
.where("id", "=", targetMember.id)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a member from an org
|
* Remove a member from an org
|
||||||
* Owners can remove anyone, admins can only remove members
|
* Owners can remove anyone, admins can only remove members
|
||||||
* Uses transaction to prevent race condition when removing owners
|
* Uses transaction to prevent race condition when removing owners
|
||||||
*/
|
*/
|
||||||
export const membersRemove = os.orgs.members.remove
|
export const membersRemove = authedProcedure.orgs.members.remove.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug, userId } = input;
|
const { slug, userId } = input;
|
||||||
|
|
||||||
// Lookup org and verify membership
|
// Lookup org and verify membership
|
||||||
@@ -159,4 +163,5 @@ export const membersRemove = os.orgs.members.remove
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,16 +2,15 @@
|
|||||||
* Org sites procedures - list
|
* Org sites procedures - list
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { authedProcedure } from "../base.js";
|
||||||
import { getMembership, lookupOrgBySlug } from "./helpers.js";
|
import { getMembership, lookupOrgBySlug } from "./helpers.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all sites for an org
|
* List all sites for an org
|
||||||
* Any member can view the site list
|
* Any member can view the site list
|
||||||
*/
|
*/
|
||||||
export const sitesList = os.orgs.sites.list
|
export const sitesList = authedProcedure.orgs.sites.list.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug } = input;
|
const { slug } = input;
|
||||||
|
|
||||||
// Lookup org and verify membership
|
// Lookup org and verify membership
|
||||||
@@ -31,4 +30,5 @@ export const sitesList = os.orgs.sites.list
|
|||||||
domain: s.domain,
|
domain: s.domain,
|
||||||
createdAt: s.created_at,
|
createdAt: s.created_at,
|
||||||
}));
|
}));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* Authentication utilities for token handling
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Database } from "@reviq/db-schema";
|
|
||||||
import type { Kysely } from "kysely";
|
|
||||||
import { hashToken } from "./crypto.js";
|
|
||||||
|
|
||||||
export interface AuthenticatedUser {
|
|
||||||
id: number;
|
|
||||||
email: string;
|
|
||||||
isSuperuser: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticate a request using session token or API key
|
|
||||||
* Returns the authenticated user or null if not authenticated
|
|
||||||
*/
|
|
||||||
export const authenticateRequest = async (
|
|
||||||
db: Kysely<Database>,
|
|
||||||
sessionToken?: string,
|
|
||||||
apiKey?: string,
|
|
||||||
): Promise<AuthenticatedUser | null> => {
|
|
||||||
// Try session cookie first, then API key
|
|
||||||
const token = sessionToken ?? apiKey;
|
|
||||||
if (!token) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenHash = await hashToken(token);
|
|
||||||
|
|
||||||
// Check sessions table
|
|
||||||
const session = await db
|
|
||||||
.selectFrom("sessions")
|
|
||||||
.innerJoin("users", "users.id", "sessions.user_id")
|
|
||||||
.where("sessions.token_hash", "=", tokenHash)
|
|
||||||
.where("sessions.expires_at", ">", new Date())
|
|
||||||
.where("sessions.revoked_at", "is", null)
|
|
||||||
.select(["users.id", "users.email", "users.is_superuser"])
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
return {
|
|
||||||
id: session.id,
|
|
||||||
email: session.email,
|
|
||||||
isSuperuser: session.is_superuser,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check API tokens table
|
|
||||||
const apiToken = await db
|
|
||||||
.selectFrom("api_tokens")
|
|
||||||
.innerJoin("users", "users.id", "api_tokens.user_id")
|
|
||||||
.where("api_tokens.token_hash", "=", tokenHash)
|
|
||||||
.where("api_tokens.expires_at", ">", new Date())
|
|
||||||
.select(["users.id", "users.email", "users.is_superuser"])
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
if (apiToken) {
|
|
||||||
// Update last_used_at
|
|
||||||
await db
|
|
||||||
.updateTable("api_tokens")
|
|
||||||
.set({ last_used_at: new Date() })
|
|
||||||
.where("token_hash", "=", tokenHash)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: apiToken.id,
|
|
||||||
email: apiToken.email,
|
|
||||||
isSuperuser: apiToken.is_superuser,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
@@ -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, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
|
|
||||||
// ===== 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,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
console.error("Error:", formatError(error));
|
||||||
// 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));
|
|
||||||
}
|
|
||||||
this.process.exit(1);
|
this.process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,16 +17,18 @@ 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
|
||||||
return "expired";
|
? "expired"
|
||||||
}
|
: `in ${diffHours.toLocaleString()} hours`;
|
||||||
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`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -45,7 +44,6 @@ function completions(
|
|||||||
_flags: Record<string, never>,
|
_flags: Record<string, never>,
|
||||||
shell: Shell,
|
shell: Shell,
|
||||||
): void {
|
): void {
|
||||||
// biome-ignore lint/nursery/noUnnecessaryConditions: switch on union type is valid
|
|
||||||
switch (shell) {
|
switch (shell) {
|
||||||
case "bash":
|
case "bash":
|
||||||
console.log("To enable bash completions for reviq, run:\n");
|
console.log("To enable bash completions for reviq, run:\n");
|
||||||
|
|||||||
@@ -5,18 +5,20 @@ 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(
|
||||||
|
`Invalid role: ${role}. Must be one of: ${VALID_ROLES.join(", ")}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw new Error(
|
|
||||||
`Invalid role: ${role}. Must be one of: ${validRoles.join(", ")}`,
|
return role as OrgRole;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateUserFlags {
|
interface CreateUserFlags {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
<div class="flex items-center gap-2">
|
||||||
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Country</Table.Head>
|
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] font-medium text-muted-foreground">{countryRow.code}</span>
|
||||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
|
<span class="text-[13px] font-medium text-foreground">{countryRow.name}</span>
|
||||||
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">% of revenue</Table.Head>
|
</div>
|
||||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
|
{/snippet}
|
||||||
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
|
</MetricsTable>
|
||||||
</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">
|
|
||||||
<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="text-[13px] font-medium text-foreground">{row.name}</span>
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -2,4 +2,8 @@ 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";
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as ConfirmDialog } from "./confirm-dialog.svelte";
|
||||||
@@ -14,7 +14,6 @@ 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 { 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";
|
||||||
@@ -25,6 +24,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "$lib/components/ui/card";
|
} from "$lib/components/ui/card";
|
||||||
|
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { Label } from "$lib/components/ui/label";
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ 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 { 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";
|
||||||
@@ -23,6 +22,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "$lib/components/ui/card";
|
} from "$lib/components/ui/card";
|
||||||
|
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ 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 { 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";
|
||||||
@@ -26,6 +25,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "$lib/components/ui/card";
|
} from "$lib/components/ui/card";
|
||||||
|
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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 { Button } from "$lib/components/ui/button/index.js";
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -14,6 +13,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "$lib/components/ui/card/index.js";
|
} from "$lib/components/ui/card/index.js";
|
||||||
|
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
||||||
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
|
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ 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 { 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 {
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "$lib/components/ui/card";
|
} from "$lib/components/ui/card";
|
||||||
|
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { Label } from "$lib/components/ui/label";
|
import { Label } from "$lib/components/ui/label";
|
||||||
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;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ 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 { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "$lib/components/ui/card";
|
} from "$lib/components/ui/card";
|
||||||
|
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { Label } from "$lib/components/ui/label";
|
import { Label } from "$lib/components/ui/label";
|
||||||
import {
|
import {
|
||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ 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 { 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 {
|
||||||
@@ -24,6 +23,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "$lib/components/ui/card";
|
} from "$lib/components/ui/card";
|
||||||
|
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { Label } from "$lib/components/ui/label";
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
|
||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ 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 { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "$lib/components/ui/card";
|
} from "$lib/components/ui/card";
|
||||||
|
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { Label } from "$lib/components/ui/label";
|
import { Label } from "$lib/components/ui/label";
|
||||||
import {
|
import {
|
||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
|
|||||||
28
bun.lock
28
bun.lock
@@ -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"],
|
||||||
|
|||||||
47
packages/db/src/client.test.ts
Normal file
47
packages/db/src/client.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
699
packages/db/src/helpers/execute-bootstrap.test.ts
Normal file
699
packages/db/src/helpers/execute-bootstrap.test.ts
Normal file
@@ -0,0 +1,699 @@
|
|||||||
|
/**
|
||||||
|
* 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.toString()}-${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 () => {
|
||||||
|
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) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression
|
||||||
|
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) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression
|
||||||
|
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) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression
|
||||||
|
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
|
||||||
|
// eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression
|
||||||
|
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"])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
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"])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
136
packages/db/src/helpers/token.test.ts
Normal file
136
packages/db/src/helpers/token.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
34
packages/db/src/helpers/with-transaction.ts
Normal file
34
packages/db/src/helpers/with-transaction.ts
Normal 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.transaction().execute(callback);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
7
packages/db/src/models/index.ts
Normal file
7
packages/db/src/models/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Database model operations
|
||||||
|
* Reusable database functions organized by table
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./sessions.js";
|
||||||
|
export * from "./user-devices.js";
|
||||||
53
packages/db/src/models/sessions.ts
Normal file
53
packages/db/src/models/sessions.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
71
packages/db/src/models/user-devices.ts
Normal file
71
packages/db/src/models/user-devices.ts
Normal 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;
|
||||||
|
}
|
||||||
12
packages/emails/eslint.config.js
Normal file
12
packages/emails/eslint.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { configs } from "@macalinao/eslint-config";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...configs.fast,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
33
packages/emails/package.json
Normal file
33
packages/emails/package.json
Normal 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:"
|
||||||
|
}
|
||||||
|
}
|
||||||
46
packages/emails/src/client.test.ts
Normal file
46
packages/emails/src/client.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
27
packages/emails/src/client.ts
Normal file
27
packages/emails/src/client.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type {
|
||||||
|
ClientSendParams,
|
||||||
|
ClientSendResult,
|
||||||
|
EmailClient,
|
||||||
|
} from "./types.js";
|
||||||
|
import { ServerClient } from "postmark";
|
||||||
|
|
||||||
|
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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
36
packages/emails/src/emails/index.ts
Normal file
36
packages/emails/src/emails/index.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export type {
|
||||||
|
LoginConfirmationEmailParams,
|
||||||
|
SendLoginConfirmationEmailParams,
|
||||||
|
} from "./login-confirmation.js";
|
||||||
|
export type {
|
||||||
|
OrgInviteEmailParams,
|
||||||
|
SendOrgInviteEmailParams,
|
||||||
|
} from "./org-invite.js";
|
||||||
|
export type {
|
||||||
|
PasswordResetEmailParams,
|
||||||
|
SendPasswordResetEmailParams,
|
||||||
|
} from "./password-reset.js";
|
||||||
|
export type {
|
||||||
|
SendVerificationEmailParams,
|
||||||
|
VerificationEmailParams,
|
||||||
|
} from "./verification.js";
|
||||||
|
export {
|
||||||
|
buildLoginConfirmationEmailHtml,
|
||||||
|
buildLoginConfirmationEmailText,
|
||||||
|
sendLoginConfirmationEmail,
|
||||||
|
} from "./login-confirmation.js";
|
||||||
|
export {
|
||||||
|
buildOrgInviteEmailHtml,
|
||||||
|
buildOrgInviteEmailText,
|
||||||
|
sendOrgInviteEmail,
|
||||||
|
} from "./org-invite.js";
|
||||||
|
export {
|
||||||
|
buildPasswordResetEmailHtml,
|
||||||
|
buildPasswordResetEmailText,
|
||||||
|
sendPasswordResetEmail,
|
||||||
|
} from "./password-reset.js";
|
||||||
|
export {
|
||||||
|
buildVerificationEmailHtml,
|
||||||
|
buildVerificationEmailText,
|
||||||
|
sendVerificationEmail,
|
||||||
|
} from "./verification.js";
|
||||||
108
packages/emails/src/emails/login-confirmation.test.ts
Normal file
108
packages/emails/src/emails/login-confirmation.test.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import type { EmailClient } from "../types.js";
|
||||||
|
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
84
packages/emails/src/emails/login-confirmation.ts
Normal file
84
packages/emails/src/emails/login-confirmation.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import type { EmailClient, EmailResult } from "../types.js";
|
||||||
|
import { buildUrl, formatExpiryMinutes } from "../helpers.js";
|
||||||
|
import { sendEmail } from "../send.js";
|
||||||
|
import {
|
||||||
|
buttonStyles,
|
||||||
|
containerStyles,
|
||||||
|
emailStyles,
|
||||||
|
footerStyles,
|
||||||
|
headingStyles,
|
||||||
|
paragraphStyles,
|
||||||
|
} from "../styles.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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user