feat: added otel logging to axiom
This commit is contained in:
@@ -60,6 +60,7 @@ services:
|
||||
volumes:
|
||||
- ./server:/app/server
|
||||
- shared-dist:/app/shared/dist
|
||||
- server-node-modules:/app/server/node_modules
|
||||
- root-node-modules:/app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
@@ -78,6 +79,7 @@ services:
|
||||
# Mount server source for hot reload (workers use server code)
|
||||
- ./server:/app/server
|
||||
- shared-dist:/app/shared/dist
|
||||
- server-node-modules:/app/server/node_modules
|
||||
- root-node-modules:/app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
|
||||
@@ -46,5 +46,5 @@ CMD ["pnpm", "run", "dev"]
|
||||
FROM base AS workers
|
||||
COPY server/ ./server/
|
||||
WORKDIR /app/server
|
||||
RUN pnpm install
|
||||
# RUN pnpm install
|
||||
CMD ["pnpm", "run", "workers"]
|
||||
1232
pnpm-lock.yaml
generated
1232
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,15 @@
|
||||
"@date-fns/utc": "^2.1.0",
|
||||
"@hyperdx/node-opentelemetry": "^0.8.2",
|
||||
"@logtail/node": "^0.5.2",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.60.1",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.202.0",
|
||||
"@opentelemetry/resources": "^2.0.1",
|
||||
"@opentelemetry/sdk-metrics": "^2.0.1",
|
||||
"@opentelemetry/sdk-node": "^0.202.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.0.1",
|
||||
"@opentelemetry/sdk-trace-node": "^2.0.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||
"@react-email/components": "^0.0.42",
|
||||
"@sentry/node": "^9.30.0",
|
||||
"@supabase/supabase-js": "^2.46.2",
|
||||
|
||||
@@ -37,7 +37,7 @@ export const initLogger = () => {
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "UTC:yyyy-mm-dd HH:MM:ss",
|
||||
ignore: "pid,hostname,res,context,req,statusCode,worker",
|
||||
ignore: "pid,hostname,res,statusCode,worker,context,req",
|
||||
customColors: {
|
||||
default: "white",
|
||||
60: "bgRed",
|
||||
@@ -55,18 +55,18 @@ export const initLogger = () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.AXIOM_TOKEN) {
|
||||
streams.push({
|
||||
level: "info",
|
||||
stream: pino.transport({
|
||||
target: "@axiomhq/pino",
|
||||
options: {
|
||||
dataset: "express",
|
||||
token: "xaat-32eb7e2b-8291-40c3-a5bf-c3a0b233fcf8",
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
// if (process.env.AXIOM_TOKEN) {
|
||||
// streams.push({
|
||||
// level: "info",
|
||||
// stream: pino.transport({
|
||||
// target: "@axiomhq/pino",
|
||||
// options: {
|
||||
// dataset: "express",
|
||||
// token: "xaat-32eb7e2b-8291-40c3-a5bf-c3a0b233fcf8",
|
||||
// },
|
||||
// }),
|
||||
// });
|
||||
// }
|
||||
|
||||
const logger = pino.default(
|
||||
{
|
||||
|
||||
1
server/src/external/logtail/logtailUtils.ts
vendored
1
server/src/external/logtail/logtailUtils.ts
vendored
@@ -80,7 +80,6 @@ export const createLogger = () => {
|
||||
info: createLogMethod(basePinoLogger.info.bind(basePinoLogger)),
|
||||
warn: createLogMethod(basePinoLogger.warn.bind(basePinoLogger)),
|
||||
error: createLogMethod(basePinoLogger.error.bind(basePinoLogger)),
|
||||
|
||||
child: ({
|
||||
context,
|
||||
onlyProd = false,
|
||||
|
||||
@@ -2,18 +2,11 @@ import {
|
||||
AppEnv,
|
||||
CollectionMethod,
|
||||
CusProductStatus,
|
||||
ErrCode,
|
||||
Organization,
|
||||
} from "@autumn/shared";
|
||||
|
||||
import { createStripeCli } from "../utils.js";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
|
||||
import {
|
||||
getWebhookLock,
|
||||
releaseWebhookLock,
|
||||
} from "@/external/redis/stripeWebhookLocks.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { SubService } from "@/internal/subscriptions/SubService.js";
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { config } from "dotenv";
|
||||
config();
|
||||
|
||||
import "./instrumentation";
|
||||
import { trace, context } from "@opentelemetry/api";
|
||||
|
||||
import http from "http";
|
||||
import cluster from "cluster";
|
||||
import os from "os";
|
||||
import mainRouter from "./internal/mainRouter.js";
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import chalk from "chalk";
|
||||
|
||||
import webhooksRouter from "./external/webhooks/webhooksRouter.js";
|
||||
|
||||
import { apiRouter } from "./internal/api/apiRouter.js";
|
||||
import { QueueManager } from "./queue/QueueManager.js";
|
||||
import { AppEnv, AuthType } from "@autumn/shared";
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import { CacheManager } from "./external/caching/CacheManager.js";
|
||||
import { logger } from "./external/logtail/logtailUtils.js";
|
||||
import { createPosthogCli } from "./external/posthog/createPosthogCli.js";
|
||||
@@ -25,6 +27,8 @@ import { auth } from "./utils/auth.js";
|
||||
import { checkEnvVars } from "./utils/initUtils.js";
|
||||
import { initLogger } from "./errors/logger.js";
|
||||
|
||||
const tracer = trace.getTracer("express");
|
||||
|
||||
checkEnvVars();
|
||||
|
||||
const init = async () => {
|
||||
@@ -75,7 +79,7 @@ const init = async () => {
|
||||
|
||||
subscribeToOrgUpdates({ db });
|
||||
|
||||
app.use((req: any, res: any, next: any) => {
|
||||
app.use(async (req: any, res: any, next: any) => {
|
||||
req.env = req.env = req.headers["app_env"] || AppEnv.Sandbox;
|
||||
req.db = db;
|
||||
// req.logtailAll = logtailAll;
|
||||
@@ -83,49 +87,61 @@ const init = async () => {
|
||||
req.id = req.headers["rndr-id"] || generateId("local_req");
|
||||
req.timestamp = Date.now();
|
||||
|
||||
const reqContext = {
|
||||
id: req.id,
|
||||
env: req.headers["app_env"] || undefined,
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
body: req.body,
|
||||
timestamp: req.timestamp,
|
||||
};
|
||||
|
||||
// Create span
|
||||
const spanName = `${req.method} ${req.originalUrl} - ${req.id}`;
|
||||
const span = tracer.startSpan(spanName);
|
||||
span.setAttributes({
|
||||
req_id: req.id,
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
});
|
||||
|
||||
// Store span on request for potential use in other middleware/handlers
|
||||
req.span = span;
|
||||
|
||||
req.logtail = logger.child({
|
||||
context: {
|
||||
req: {
|
||||
id: req.id,
|
||||
env: req.env,
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
body: req.body,
|
||||
timestamp: req.timestamp,
|
||||
},
|
||||
req: reqContext,
|
||||
},
|
||||
});
|
||||
|
||||
next();
|
||||
// End span when response finishes
|
||||
res.on("finish", () => {
|
||||
span.setAttributes({
|
||||
"http.response.status_code": res.statusCode,
|
||||
"http.response.body.size": res.get("content-length") || 0,
|
||||
"http.response.duration": Date.now() - req.timestamp,
|
||||
});
|
||||
span.end();
|
||||
});
|
||||
|
||||
// Run the rest of the request processing within the span's context
|
||||
context.with(trace.setSpan(context.active(), span), () => {
|
||||
next();
|
||||
});
|
||||
});
|
||||
|
||||
app.use("/webhooks", webhooksRouter);
|
||||
|
||||
app.use(express.json());
|
||||
app.use((req: any, res: any, next: any) => {
|
||||
req.logtail.info(`${req.method} ${req.originalUrl}`);
|
||||
req.logtail.info(`${req.method} ${req.originalUrl}`, {
|
||||
context: {
|
||||
body: req.body,
|
||||
},
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// app.use((req: any, res, next) => {
|
||||
// const method = req.method;
|
||||
// const path = req.url;
|
||||
// const methodToColor: any = {
|
||||
// GET: chalk.green,
|
||||
// POST: chalk.yellow,
|
||||
// PUT: chalk.blue,
|
||||
// DELETE: chalk.red,
|
||||
// PATCH: chalk.magenta,
|
||||
// };
|
||||
|
||||
// const methodColor: any = methodToColor[method] || chalk.gray;
|
||||
|
||||
// console.log(`${methodColor(method).padEnd(18)} ${path}`);
|
||||
|
||||
// next();
|
||||
// });
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
app.use(mainRouter);
|
||||
app.use("/v1", apiRouter);
|
||||
|
||||
|
||||
35
server/src/instrumentation.ts
Normal file
35
server/src/instrumentation.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import "dotenv/config";
|
||||
import { NodeSDK } from "@opentelemetry/sdk-node";
|
||||
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
|
||||
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
||||
import { resourceFromAttributes } from "@opentelemetry/resources";
|
||||
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
||||
|
||||
// Initialize OTLP trace exporter with the endpoint URL and headers
|
||||
|
||||
if (process.env.AXIOM_TOKEN) {
|
||||
const traceExporter = new OTLPTraceExporter({
|
||||
url: "https://api.axiom.co/v1/traces",
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.AXIOM_TOKEN}`,
|
||||
"X-Axiom-Dataset": "express_otel",
|
||||
},
|
||||
});
|
||||
|
||||
// Creating a resource to identify your service in traces
|
||||
const resource = resourceFromAttributes({
|
||||
[ATTR_SERVICE_NAME]: "express",
|
||||
});
|
||||
|
||||
// Configuring the OpenTelemetry Node SDK
|
||||
const sdk = new NodeSDK({
|
||||
spanProcessor: new BatchSpanProcessor(traceExporter),
|
||||
resource: resource,
|
||||
instrumentations: [getNodeAutoInstrumentations()],
|
||||
});
|
||||
|
||||
// Starting the OpenTelemetry SDK to begin collecting telemetry data
|
||||
console.log("Starting OpenTelemetry");
|
||||
sdk.start();
|
||||
}
|
||||
22
server/src/internal/analytics/tracer/spanUtils.ts
Normal file
22
server/src/internal/analytics/tracer/spanUtils.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { context, trace } from "@opentelemetry/api";
|
||||
|
||||
const tracer = trace.getTracer("express");
|
||||
|
||||
export const withSpan = <T>({
|
||||
name,
|
||||
attributes,
|
||||
fn,
|
||||
}: {
|
||||
name: string;
|
||||
attributes: Record<string, any>;
|
||||
fn: () => Promise<T>;
|
||||
}) => {
|
||||
const span = tracer.startSpan(name);
|
||||
span.setAttributes(attributes);
|
||||
|
||||
return context.with(trace.setSpan(context.active(), span), async () => {
|
||||
const result = await fn();
|
||||
span.end();
|
||||
return result;
|
||||
});
|
||||
};
|
||||
@@ -66,7 +66,6 @@ export const getBooleanEntitledResult = async ({
|
||||
allowed,
|
||||
balance: undefined,
|
||||
feature,
|
||||
raw: false,
|
||||
cusProducts,
|
||||
allFeatures,
|
||||
})
|
||||
|
||||
@@ -233,7 +233,7 @@ const getCusEntsAndFeatures = async ({
|
||||
|
||||
if (!feature) {
|
||||
throw new RecaseError({
|
||||
message: "Feature not found",
|
||||
message: `feature with id ${feature_id} not found`,
|
||||
code: ErrCode.FeatureNotFound,
|
||||
statusCode: StatusCodes.NOT_FOUND,
|
||||
});
|
||||
@@ -298,7 +298,7 @@ entitledRouter.post("", async (req: any, res: any) => {
|
||||
|
||||
if (!customer_id) {
|
||||
throw new RecaseError({
|
||||
message: "Customer ID is required",
|
||||
message: "`customer_id` is required",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
});
|
||||
@@ -306,7 +306,7 @@ entitledRouter.post("", async (req: any, res: any) => {
|
||||
|
||||
if (!feature_id && !product_id) {
|
||||
throw new RecaseError({
|
||||
message: "Feature ID or product ID is required",
|
||||
message: "`feature_id` or `product_id` is required",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
});
|
||||
@@ -315,7 +315,7 @@ entitledRouter.post("", async (req: any, res: any) => {
|
||||
if (feature_id && product_id) {
|
||||
throw new RecaseError({
|
||||
message:
|
||||
"Provide either feature_id or product_id. Not allowed to provide both.",
|
||||
"Provide either feature_id or product_id. Not allowed to provide both",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
});
|
||||
@@ -442,15 +442,6 @@ entitledRouter.post("", async (req: any, res: any) => {
|
||||
// 3. If with preview, get preview
|
||||
let preview = undefined;
|
||||
if (req.body.with_preview) {
|
||||
// let withPreview = notNullish(req.body.with_preview);
|
||||
// if (withPreview !== "raw" && withPreview !== "formatted") {
|
||||
// throw new RecaseError({
|
||||
// message: "with_preview must be 'raw' or 'formatted'",
|
||||
// code: ErrCode.InvalidRequest,
|
||||
// statusCode: StatusCodes.BAD_REQUEST,
|
||||
// });
|
||||
// }
|
||||
|
||||
try {
|
||||
preview = await getCheckPreview({
|
||||
db,
|
||||
@@ -458,7 +449,6 @@ entitledRouter.post("", async (req: any, res: any) => {
|
||||
balance: balanceObj?.balance,
|
||||
feature: featureToUse!,
|
||||
cusProducts,
|
||||
raw: req.body.with_preview === "raw",
|
||||
allFeatures,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -21,7 +21,6 @@ export const getCheckPreview = async ({
|
||||
balance,
|
||||
feature,
|
||||
cusProducts,
|
||||
raw = false,
|
||||
allFeatures,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
@@ -29,7 +28,6 @@ export const getCheckPreview = async ({
|
||||
balance?: number;
|
||||
feature: Feature;
|
||||
cusProducts: FullCusProduct[];
|
||||
raw?: boolean;
|
||||
allFeatures: Feature[];
|
||||
}) => {
|
||||
if (allowed) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { addTaskToQueue } from "@/queue/queueUtils.js";
|
||||
import { getEventTimestamp } from "./eventUtils.js";
|
||||
import { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import { runUpdateUsageTask } from "@/trigger/updateUsageTask.js";
|
||||
import { logger } from "@/external/logtail/logtailUtils.js";
|
||||
|
||||
export const eventsRouter: Router = Router();
|
||||
export const usageRouter: Router = Router();
|
||||
@@ -39,17 +40,15 @@ const getCusFeatureAndOrg = async ({
|
||||
// 1. Get customer
|
||||
const { org, features } = req;
|
||||
|
||||
let [customer] = await Promise.all([
|
||||
getOrCreateCustomer({
|
||||
req,
|
||||
customerId,
|
||||
customerData,
|
||||
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
|
||||
let customer = await getOrCreateCustomer({
|
||||
req,
|
||||
customerId,
|
||||
customerData,
|
||||
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
|
||||
|
||||
entityId,
|
||||
entityData: req.body.entity_data,
|
||||
}),
|
||||
]);
|
||||
entityId,
|
||||
entityData: req.body.entity_data,
|
||||
});
|
||||
|
||||
let feature = features.find((f) => f.id == featureId);
|
||||
let creditSystems = features.filter(
|
||||
@@ -136,6 +135,8 @@ export const handleUsageEvent = async ({
|
||||
entity_id,
|
||||
idempotency_key,
|
||||
} = req.body;
|
||||
const { logtail: logger } = req;
|
||||
|
||||
if (!customer_id || !feature_id) {
|
||||
throw new RecaseError({
|
||||
message: "customer_id and feature_id are required",
|
||||
@@ -146,6 +147,8 @@ export const handleUsageEvent = async ({
|
||||
|
||||
properties = properties || {};
|
||||
|
||||
logger.info(`/track: customer ${customer_id}, feature ${feature_id}`);
|
||||
const startTime = Date.now();
|
||||
const { customer, org, feature, creditSystems } = await getCusFeatureAndOrg({
|
||||
req,
|
||||
customerId: customer_id,
|
||||
@@ -153,6 +156,8 @@ export const handleUsageEvent = async ({
|
||||
customerData: customer_data,
|
||||
entityId: entity_id,
|
||||
});
|
||||
logger.info(`/track: get customer took ${Date.now() - startTime}ms`);
|
||||
const startTime2 = Date.now();
|
||||
|
||||
let newEvent = await createAndInsertEvent({
|
||||
req,
|
||||
@@ -163,6 +168,7 @@ export const handleUsageEvent = async ({
|
||||
properties,
|
||||
idempotencyKey: idempotency_key,
|
||||
});
|
||||
logger.info(`/track: insert event took ${Date.now() - startTime2}ms`);
|
||||
|
||||
const features = [feature, ...creditSystems];
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@ import { StatusCodes } from "http-status-codes";
|
||||
import { and, eq, or, sql } from "drizzle-orm";
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { getFullCusQuery } from "./getFullCusQuery.js";
|
||||
import { flipProductResults } from "./cusUtils/cusUtils.js";
|
||||
import { trace } from "@opentelemetry/api";
|
||||
import { withSpan } from "../analytics/tracer/spanUtils.js";
|
||||
|
||||
const tracer = trace.getTracer("express");
|
||||
|
||||
export class CusService {
|
||||
static async getFull({
|
||||
db,
|
||||
@@ -47,51 +51,61 @@ export class CusService {
|
||||
const includeInvoices = expand?.includes(CusExpand.Invoices) || false;
|
||||
const withTrialsUsed = expand?.includes(CusExpand.TrialsUsed) || false;
|
||||
|
||||
const query = getFullCusQuery(
|
||||
idOrInternalId,
|
||||
orgId,
|
||||
env,
|
||||
inStatuses,
|
||||
includeInvoices,
|
||||
withEntities,
|
||||
withTrialsUsed,
|
||||
withSubs,
|
||||
entityId,
|
||||
);
|
||||
return withSpan<FullCustomer>({
|
||||
name: "CusService.getFull",
|
||||
attributes: {
|
||||
idOrInternalId,
|
||||
entityId,
|
||||
orgId,
|
||||
env,
|
||||
inStatuses,
|
||||
withEntities,
|
||||
withSubs,
|
||||
},
|
||||
fn: async () => {
|
||||
const query = getFullCusQuery(
|
||||
idOrInternalId,
|
||||
orgId,
|
||||
env,
|
||||
inStatuses,
|
||||
includeInvoices,
|
||||
withEntities,
|
||||
withTrialsUsed,
|
||||
withSubs,
|
||||
entityId,
|
||||
);
|
||||
|
||||
let result = await db.execute(query);
|
||||
let result = await db.execute(query);
|
||||
|
||||
if (!result || result.length == 0) {
|
||||
if (allowNotFound) {
|
||||
// @ts-ignore
|
||||
return null;
|
||||
}
|
||||
if (!result || result.length == 0) {
|
||||
if (allowNotFound) {
|
||||
// @ts-ignore
|
||||
return null as FullCustomer;
|
||||
}
|
||||
|
||||
throw new RecaseError({
|
||||
message: `Customer ${idOrInternalId} not found`,
|
||||
code: ErrCode.CustomerNotFound,
|
||||
statusCode: StatusCodes.NOT_FOUND,
|
||||
});
|
||||
}
|
||||
throw new RecaseError({
|
||||
message: `Customer ${idOrInternalId} not found`,
|
||||
code: ErrCode.CustomerNotFound,
|
||||
statusCode: StatusCodes.NOT_FOUND,
|
||||
});
|
||||
}
|
||||
|
||||
let data = result[0];
|
||||
data.created_at = Number(data.created_at);
|
||||
let data = result[0];
|
||||
data.created_at = Number(data.created_at);
|
||||
|
||||
for (const product of data.customer_products as FullCusProduct[]) {
|
||||
if (!product.customer_prices) {
|
||||
product.customer_prices = [];
|
||||
}
|
||||
for (const product of data.customer_products as FullCusProduct[]) {
|
||||
if (!product.customer_prices) {
|
||||
product.customer_prices = [];
|
||||
}
|
||||
|
||||
if (!product.customer_entitlements) {
|
||||
product.customer_entitlements = [];
|
||||
}
|
||||
}
|
||||
if (!product.customer_entitlements) {
|
||||
product.customer_entitlements = [];
|
||||
}
|
||||
}
|
||||
|
||||
// data.invoices = data.invoices || [];
|
||||
// data.subscriptions = data.subscriptions || [];
|
||||
// data.trials_used = data.trials_used || [];
|
||||
|
||||
return data as FullCustomer;
|
||||
return data as FullCustomer;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async get({
|
||||
|
||||
@@ -52,6 +52,10 @@ export const getOrCreateCustomer = async ({
|
||||
|
||||
const { db, org, features, env, logtail: logger } = req;
|
||||
|
||||
if (!withEntities) {
|
||||
withEntities = expand?.includes(CusExpand.Entities) || false;
|
||||
}
|
||||
|
||||
if (!skipGet) {
|
||||
customer = await CusService.getFull({
|
||||
db,
|
||||
|
||||
@@ -2,31 +2,19 @@ import { CusService } from "@/internal/customers/CusService.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import {
|
||||
AppEnv,
|
||||
AttachScenario,
|
||||
BillingInterval,
|
||||
CreateCustomer,
|
||||
CreateCustomerSchema,
|
||||
CusProductStatus,
|
||||
Customer,
|
||||
ErrCode,
|
||||
FullProduct,
|
||||
Organization,
|
||||
} from "@autumn/shared";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { generateId, notNullish } from "@/utils/genUtils.js";
|
||||
import { createStripeCli } from "@/external/stripe/utils.js";
|
||||
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
|
||||
import { handleAddProduct } from "@/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.js";
|
||||
import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js";
|
||||
import { ProductService } from "@/internal/products/ProductService.js";
|
||||
import {
|
||||
initProductInStripe,
|
||||
isFreeProduct,
|
||||
} from "@/internal/products/productUtils.js";
|
||||
import { notNullish } from "@/utils/genUtils.js";
|
||||
import { initProductInStripe } from "@/internal/products/productUtils.js";
|
||||
import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js";
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { ActionRequest, ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import { addCustomerCreatedTask } from "@/internal/analytics/handlers/handleCustomerCreated.js";
|
||||
import { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import { createNewCustomer } from "../cusUtils/createNewCustomer.js";
|
||||
|
||||
export const initStripeCusAndProducts = async ({
|
||||
|
||||
@@ -14,30 +14,29 @@ export const handleGetCustomer = async (req: any, res: any) =>
|
||||
action: "get customer",
|
||||
handler: async () => {
|
||||
let customerId = req.params.customer_id;
|
||||
let { orgId, env, db } = req;
|
||||
let { env, db, logtail: logger, org, features } = req;
|
||||
let { expand } = req.query;
|
||||
|
||||
let expandArray = parseCusExpand(expand);
|
||||
|
||||
const [features, org, customer] = await Promise.all([
|
||||
FeatureService.getFromReq(req),
|
||||
OrgService.getFromReq(req),
|
||||
CusService.getFull({
|
||||
db,
|
||||
idOrInternalId: customerId,
|
||||
orgId: orgId,
|
||||
env: env,
|
||||
inStatuses: [
|
||||
CusProductStatus.Active,
|
||||
CusProductStatus.PastDue,
|
||||
CusProductStatus.Scheduled,
|
||||
],
|
||||
withEntities: true,
|
||||
// withEntities: expandArray.includes(CusExpand.Entities),
|
||||
expand: expandArray,
|
||||
allowNotFound: true,
|
||||
}),
|
||||
]);
|
||||
logger.info(`getting customer ${customerId} for org ${org.slug}`);
|
||||
const startTime = Date.now();
|
||||
const customer = await CusService.getFull({
|
||||
db,
|
||||
idOrInternalId: customerId,
|
||||
orgId: org.id,
|
||||
env: env,
|
||||
inStatuses: [
|
||||
CusProductStatus.Active,
|
||||
CusProductStatus.PastDue,
|
||||
CusProductStatus.Scheduled,
|
||||
],
|
||||
withEntities: true,
|
||||
// withEntities: expandArray.includes(CusExpand.Entities),
|
||||
expand: expandArray,
|
||||
allowNotFound: true,
|
||||
});
|
||||
logger.info(`get customer took ${Date.now() - startTime}ms`);
|
||||
|
||||
if (!customer) {
|
||||
req.logtail.warn(
|
||||
|
||||
@@ -60,6 +60,7 @@ mainRouter.use(
|
||||
headers: {
|
||||
cookie: req.headers.cookie,
|
||||
"Content-Type": "application/json",
|
||||
origin: req.get("origin"),
|
||||
},
|
||||
});
|
||||
return client as any;
|
||||
|
||||
@@ -50,6 +50,15 @@ export const analyticsMiddleware = async (req: any, res: any, next: any) => {
|
||||
req?.body?.customer_id || parseCustomerIdFromUrl(req.originalUrl),
|
||||
};
|
||||
|
||||
if (req.span) {
|
||||
req.span.setAttributes({
|
||||
org_id: req.org?.id,
|
||||
org_slug: req.org?.slug,
|
||||
env: req.env,
|
||||
customer_id: reqContext.customer_id,
|
||||
});
|
||||
}
|
||||
|
||||
req.logtail = req.logtail.child({
|
||||
context: {
|
||||
context: reqContext,
|
||||
|
||||
@@ -3,6 +3,27 @@ import { verifyKey } from "@/internal/dev/api-keys/apiKeyUtils.js";
|
||||
import { verifyBearerPublishableKey } from "./publicAuthMiddleware.js";
|
||||
import { AuthType, ErrCode } from "@autumn/shared";
|
||||
import { floatToVersion } from "@/utils/versionUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { dashboardOrigins } from "@/utils/constants.js";
|
||||
|
||||
const verifyApiVersion = (version: string) => {
|
||||
let versionFloat = parseFloat(version);
|
||||
let apiVersion = floatToVersion(versionFloat);
|
||||
|
||||
if (isNaN(versionFloat) || !apiVersion) {
|
||||
throw new RecaseError({
|
||||
message: `${version} is not a valid API version`,
|
||||
code: ErrCode.InvalidApiVersion,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return apiVersion;
|
||||
};
|
||||
|
||||
const maskApiKey = (apiKey: string) => {
|
||||
return apiKey.slice(0, 15) + apiKey.slice(15).replace(/./g, "*");
|
||||
};
|
||||
|
||||
export const verifySecretKey = async (req: any, res: any, next: any) => {
|
||||
const authHeader =
|
||||
@@ -12,159 +33,92 @@ export const verifySecretKey = async (req: any, res: any, next: any) => {
|
||||
const version = req.headers["x-api-version"];
|
||||
|
||||
if (version) {
|
||||
let versionFloat = parseFloat(version);
|
||||
|
||||
if (isNaN(versionFloat)) {
|
||||
return {
|
||||
error: ErrCode.InvalidApiVersion,
|
||||
fallback: false,
|
||||
statusCode: 400,
|
||||
};
|
||||
}
|
||||
|
||||
let apiVersion = floatToVersion(versionFloat);
|
||||
if (!apiVersion) {
|
||||
return {
|
||||
error: ErrCode.InvalidApiVersion,
|
||||
fallback: false,
|
||||
statusCode: 400,
|
||||
};
|
||||
}
|
||||
|
||||
req.apiVersion = apiVersion;
|
||||
req.apiVersion = verifyApiVersion(version);
|
||||
}
|
||||
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
return {
|
||||
error: ErrCode.NoAuthHeader,
|
||||
fallback: true,
|
||||
};
|
||||
let origin = req.get("origin");
|
||||
if (dashboardOrigins.includes(origin)) {
|
||||
return withOrgAuth(req, res, next);
|
||||
} else {
|
||||
throw new RecaseError({
|
||||
message: "Secret key not found in Authorization header",
|
||||
code: ErrCode.NoSecretKey,
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = authHeader.split(" ")[1];
|
||||
|
||||
if (!apiKey.startsWith("am_")) {
|
||||
return {
|
||||
error: ErrCode.InvalidAuthHeader,
|
||||
fallback: true,
|
||||
statusCode: null,
|
||||
};
|
||||
throw new RecaseError({
|
||||
message: "Invalid secret key",
|
||||
code: ErrCode.InvalidSecretKey,
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
if (apiKey.startsWith("am_pk")) {
|
||||
return await verifyBearerPublishableKey(apiKey, req, res, next);
|
||||
}
|
||||
|
||||
// Try verify via Autumn
|
||||
const { valid, data } = await verifyKey({
|
||||
db: req.db,
|
||||
key: apiKey,
|
||||
});
|
||||
|
||||
try {
|
||||
const { valid, data } = await verifyKey({
|
||||
db: req.db,
|
||||
key: apiKey,
|
||||
if (!valid || !data) {
|
||||
throw new RecaseError({
|
||||
message: "Invalid secret key",
|
||||
code: ErrCode.InvalidSecretKey,
|
||||
statusCode: 401,
|
||||
});
|
||||
|
||||
if (valid && data) {
|
||||
let { org, features, env } = data;
|
||||
req.orgId = org.id;
|
||||
req.env = env;
|
||||
req.minOrg = {
|
||||
id: org.id,
|
||||
slug: org.slug,
|
||||
};
|
||||
req.org = org;
|
||||
req.features = features;
|
||||
req.authType = AuthType.SecretKey;
|
||||
|
||||
next();
|
||||
|
||||
return {
|
||||
error: null,
|
||||
fallback: null,
|
||||
statusCode: null,
|
||||
};
|
||||
} else {
|
||||
logger.info(`Autumn API verification failed`);
|
||||
return {
|
||||
error: ErrCode.FailedToVerifySecretKey,
|
||||
fallback: true,
|
||||
statusCode: 401,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
logger.error("Failed to fetch key from Autumn", error);
|
||||
} catch (error) {
|
||||
console.error("(log failed) Failed to fetch key from Autumn", error);
|
||||
}
|
||||
return {
|
||||
error: ErrCode.FailedToFetchKeyFromAutumn,
|
||||
fallback: true,
|
||||
statusCode: 500,
|
||||
};
|
||||
}
|
||||
|
||||
let { org, features, env } = data;
|
||||
req.orgId = org.id;
|
||||
req.env = env;
|
||||
req.minOrg = {
|
||||
id: org.id,
|
||||
slug: org.slug,
|
||||
};
|
||||
req.org = org;
|
||||
req.features = features;
|
||||
req.authType = AuthType.SecretKey;
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export const apiAuthMiddleware = async (req: any, res: any, next: any) => {
|
||||
// 1. Verify secret key
|
||||
const logger = req.logtail;
|
||||
try {
|
||||
const { error, fallback, statusCode } = await verifySecretKey(
|
||||
req,
|
||||
res,
|
||||
next,
|
||||
);
|
||||
await verifySecretKey(req, res, next);
|
||||
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error && !fallback) {
|
||||
res.status(statusCode).json({
|
||||
message: error,
|
||||
code: error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
return;
|
||||
} catch (error: any) {
|
||||
try {
|
||||
let logger = req.logtail;
|
||||
logger.error("Error: verifySecretKey failed", error);
|
||||
} catch (error) {
|
||||
console.error("(log failed) Error: verifySecretKey failed", error);
|
||||
if (error instanceof RecaseError) {
|
||||
if (error.code === ErrCode.InvalidSecretKey) {
|
||||
let apiKey = req.headers["authorization"]?.split(" ")[1];
|
||||
error.message = `Invalid secret key: ${maskApiKey(apiKey)}`;
|
||||
}
|
||||
|
||||
logger.warn(`auth warning: ${error.message}`);
|
||||
|
||||
res.status(error.statusCode).json({
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
} else {
|
||||
logger.error(`auth error: ${error.message}`, {
|
||||
error,
|
||||
});
|
||||
res.status(500).json({
|
||||
message: `Failed to verify secret key: ${error.message}`,
|
||||
code: ErrCode.InternalError,
|
||||
});
|
||||
}
|
||||
res.status(500).json({
|
||||
message: "Failed to verify secret key -- internal server error",
|
||||
code: ErrCode.FailedToVerifySecretKey,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
withOrgAuth(req, res, next);
|
||||
|
||||
// // 2. Verify publishable key (through x-publishable-key header)
|
||||
// try {
|
||||
// const { error, fallback, statusCode } = await verifyPublishableKey(
|
||||
// req,
|
||||
// res,
|
||||
// next
|
||||
// );
|
||||
|
||||
// if (!error) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// if (error && !fallback) {
|
||||
// res.status(statusCode).json({
|
||||
// message: error,
|
||||
// code: error,
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.log("Error: verifyPublishableKey failed", error);
|
||||
// res.status(500).json({
|
||||
// message: "Failed to verify publishable key -- internal server error",
|
||||
// code: ErrCode.FailedToVerifyPublishableKey,
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
};
|
||||
|
||||
@@ -13,4 +13,7 @@ export const ADMIN_USER_IDs =
|
||||
? ["user_2tMgAiPsQzX8JTHjZZh9m0VdvUv", "user_2sB3tBXsnVVLlTKliQIqvvM2xfB"]
|
||||
: ["user_2rypooIKyMQx81vMS8FFGx24UHU"];
|
||||
|
||||
// console.log(ADMIN_USER_IDs);
|
||||
export const dashboardOrigins = [
|
||||
"http://localhost:3000",
|
||||
"https://app.useautumn.com",
|
||||
];
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export const ErrCode = {
|
||||
// Auth
|
||||
InvalidApiVersion: "invalid_api_version",
|
||||
NoSecretKey: "no_secret_key",
|
||||
InvalidSecretKey: "invalid_secret_key",
|
||||
|
||||
NoAuthHeader: "no_auth_header",
|
||||
InvalidAuthHeader: "invalid_auth_header",
|
||||
FailedToVerifySecretKey: "failed_to_verify_secret_key",
|
||||
@@ -10,7 +14,6 @@ export const ErrCode = {
|
||||
GetOrgFromPublishableKeyFailed: "get_org_from_publishable_key_failed",
|
||||
EndpointNotPublic: "endpoint_not_public",
|
||||
FailedToVerifyPublishableKey: "failed_to_verify_publishable_key",
|
||||
InvalidApiVersion: "invalid_api_version",
|
||||
|
||||
// General
|
||||
InvalidInputs: "invalid_inputs",
|
||||
|
||||
@@ -13,12 +13,17 @@ import { ArrowUpRightFromSquare, PanelLeft, PanelRight } from "lucide-react";
|
||||
import { AutumnProvider } from "autumn-js/react";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { CustomToaster } from "@/components/general/CustomToaster";
|
||||
import { SidebarContext, useSidebarContext } from "@/views/main-sidebar/SidebarContext";
|
||||
import {
|
||||
SidebarContext,
|
||||
useSidebarContext,
|
||||
} from "@/views/main-sidebar/SidebarContext";
|
||||
|
||||
export function MainLayout() {
|
||||
const env = useEnv();
|
||||
const { data, isPending } = useSession();
|
||||
const [sidebarState, setSidebarState] = useState<"expanded" | "collapsed">("expanded");
|
||||
const [sidebarState, setSidebarState] = useState<"expanded" | "collapsed">(
|
||||
"expanded",
|
||||
);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const posthog = usePostHog();
|
||||
@@ -39,7 +44,9 @@ export function MainLayout() {
|
||||
// 1. If not loaded, show loading screen
|
||||
if (isPending) {
|
||||
return (
|
||||
<SidebarContext.Provider value={{ state: sidebarState, setState: setSidebarState }}>
|
||||
<SidebarContext.Provider
|
||||
value={{ state: sidebarState, setState: setSidebarState }}
|
||||
>
|
||||
<div className="w-screen h-screen flex bg-stone-100">
|
||||
<MainSidebar />
|
||||
<div className="w-full h-screen flex flex-col overflow-hidden py-3 pr-3">
|
||||
@@ -88,7 +95,9 @@ export function MainLayout() {
|
||||
|
||||
return (
|
||||
<AutumnProvider backendUrl={import.meta.env.VITE_BACKEND_URL}>
|
||||
<SidebarContext.Provider value={{ state: sidebarState, setState: setSidebarState }}>
|
||||
<SidebarContext.Provider
|
||||
value={{ state: sidebarState, setState: setSidebarState }}
|
||||
>
|
||||
<main className="w-screen h-screen flex bg-stone-100">
|
||||
<CustomToaster />
|
||||
<MainSidebar />
|
||||
@@ -104,23 +113,11 @@ const MainContent = () => {
|
||||
const navigate = useNavigate();
|
||||
const { state, setState } = useSidebarContext();
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setState(state === "expanded" ? "collapsed" : "expanded");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen flex flex-col justify-center overflow-hidden py-3 pr-3 relative">
|
||||
<div className="w-full h-full flex flex-col overflow-hidden rounded-lg border">
|
||||
{/* Toggle Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleSidebar}
|
||||
className="absolute top-4 left-2 z-10 border-none border-0 shadow-none bg-stone-50 hover:bg-stone-100 text-stone-600 hover:text-stone-800 focus:ring-0 focus:outline-none"
|
||||
>
|
||||
{state === "expanded" ? <PanelLeft size={16} /> : <PanelRight size={16} />}
|
||||
</Button>
|
||||
|
||||
|
||||
{env === AppEnv.Sandbox && (
|
||||
<div className="w-full min-h-10 h-10 bg-amber-100 text-sm flex items-center justify-center relative px-4 text-amber-500 ">
|
||||
<p className="font-medium font-mono">You're in sandbox</p>
|
||||
|
||||
@@ -29,7 +29,7 @@ import CopyButton from "@/components/general/CopyButton";
|
||||
|
||||
const CustomerWithProductsSchema = CustomerSchema.extend({
|
||||
customer_products: z.array(
|
||||
CusProductSchema.extend({ product: ProductSchema })
|
||||
CusProductSchema.extend({ product: ProductSchema }),
|
||||
),
|
||||
});
|
||||
type CustomerWithProducts = z.infer<typeof CustomerWithProductsSchema>;
|
||||
@@ -53,7 +53,7 @@ export const CustomersTable = ({
|
||||
|
||||
// Filter out expired products first
|
||||
const activeProducts = customer.customer_products.filter(
|
||||
(cusProduct) => cusProduct.status !== CusProductStatus.Expired
|
||||
(cusProduct) => cusProduct.status !== CusProductStatus.Expired,
|
||||
);
|
||||
|
||||
if (activeProducts.length === 0) {
|
||||
@@ -67,7 +67,7 @@ export const CustomersTable = ({
|
||||
const versionCount = versionCounts[cusProduct.product.id];
|
||||
const version = cusProduct.product.version;
|
||||
|
||||
let prodName = (
|
||||
const prodName = (
|
||||
<>
|
||||
{name}
|
||||
{versionCount > 1 && (
|
||||
@@ -172,7 +172,7 @@ export const CustomersTable = ({
|
||||
<Link
|
||||
to={getRedirectUrl(
|
||||
`/customers/${customer.id || customer.internal_id}`,
|
||||
env
|
||||
env,
|
||||
)}
|
||||
key={index}
|
||||
className="grid grid-cols-16 gap-2 items-center px-10 w-full text-sm h-8 cursor-default hover:bg-primary/5 text-t2 whitespace-nowrap"
|
||||
@@ -218,7 +218,7 @@ export const CustomTableCell = ({
|
||||
className={cn(
|
||||
colSpan ? `col-span-${colSpan}` : "col-span-3",
|
||||
"overflow-hidden text-ellipsis pr-1",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -227,24 +227,23 @@ function CustomersView({ env }: { env: AppEnv }) {
|
||||
<CustomersTable customers={data.customers} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-t3 text-sm w-full min-h-[60vh] gap-4">
|
||||
<img
|
||||
src="./customer.png"
|
||||
alt="No customers"
|
||||
className="w-48 h-48 opacity-60 filter grayscale"
|
||||
// className="w-48 h-48 opacity-80 filter brightness-0 invert" // this is for dark mode
|
||||
/>
|
||||
<span>
|
||||
{
|
||||
// Show loading state during search transitions to prevent flash of incorrect message
|
||||
(paginationLoading || searching)
|
||||
? "Loading..."
|
||||
: searchParams.get("q")?.trim()
|
||||
? "No matching results found. Try a different search."
|
||||
: "No customers found... yet 😉"
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col px-10 mt-3 text-t3 text-sm w-full min-h-[60vh] gap-4">
|
||||
{/* <img
|
||||
src="./customer.png"
|
||||
alt="No customers"
|
||||
className="w-48 h-48 opacity-60 filter grayscale"
|
||||
// className="w-48 h-48 opacity-80 filter brightness-0 invert" // this is for dark mode
|
||||
/> */}
|
||||
<span>
|
||||
{
|
||||
// Show loading state during search transitions to prevent flash of incorrect message
|
||||
|
||||
searchParams.get("q")?.trim()
|
||||
? "No matching results found. Try a different search."
|
||||
: "Create your first customer by interacting with an Autumn function via the API."
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* <div className="shrink-0 sticky bottom-0">
|
||||
|
||||
@@ -17,15 +17,17 @@ export const ApiKeysView = ({ apiKeys }: any) => {
|
||||
{apiKeys.length > 0 ? (
|
||||
<APIKeyTable apiKeys={apiKeys} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-t3 text-sm w-full min-h-[60vh] gap-4">
|
||||
<img
|
||||
<div
|
||||
className="flex flex-col px-10 center text-t3 text-sm w-full
|
||||
min-h-[60vh] gap-4 mt-3"
|
||||
>
|
||||
{/* <img
|
||||
src="./secret.png"
|
||||
alt="No API Keys"
|
||||
className="w-48 h-48 opacity-60 filter grayscale"
|
||||
// className="w-48 h-48 opacity-80 filter brightness-0 invert" // this is for dark mode
|
||||
|
||||
/>
|
||||
<p className="text-sm text-t3 text-center max-w-2xl px-4">
|
||||
/> */}
|
||||
<p className="text-sm text-t3">
|
||||
API keys are used to securely authenticate your requests from your
|
||||
server. Learn more{" "}
|
||||
<a
|
||||
@@ -38,7 +40,6 @@ export const ApiKeysView = ({ apiKeys }: any) => {
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,17 +4,27 @@ import { useEnv } from "@/utils/envUtils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSidebarContext } from "./SidebarContext";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { Code, Package, Shield, User } from "lucide-react";
|
||||
import {
|
||||
Code,
|
||||
Package,
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
Shield,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { EnvDropdown } from "./EnvDropdown";
|
||||
import { OrgDropdown } from "./components/OrgDropdown";
|
||||
import { AdminOnly } from "../admin/components/AdminOnly";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export const MainSidebar = () => {
|
||||
const env = useEnv();
|
||||
const { state, setState } = useSidebarContext();
|
||||
|
||||
useHotkeys(["meta+b", "ctrl+b"], () => {
|
||||
setState((prev: "expanded" | "collapsed") => (prev == "expanded" ? "collapsed" : "expanded"));
|
||||
setState((prev: "expanded" | "collapsed") =>
|
||||
prev == "expanded" ? "collapsed" : "expanded",
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -26,9 +36,29 @@ export const MainSidebar = () => {
|
||||
: "min-w-[50px] max-w-[50px]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-6 relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setState(state === "expanded" ? "collapsed" : "expanded");
|
||||
}}
|
||||
className={cn(
|
||||
"absolute top-1 right-4 text-t3 hover:bg-stone-200 w-5 h-5 p-0 border-none border-0 shadow-none bg-transparent",
|
||||
state == "expanded"
|
||||
? "opacity-100 transition-opacity duration-100"
|
||||
: "opacity-0 transition-opacity duration-100",
|
||||
// state == "expanded" ? "top-4" : "top-2",
|
||||
)}
|
||||
>
|
||||
<PanelLeft size={14} />
|
||||
{/* {state === "expanded" ? (
|
||||
) : (
|
||||
<PanelRight size={14} />
|
||||
)} */}
|
||||
</Button>
|
||||
<OrgDropdown />
|
||||
{/* <SidebarTop /> */}
|
||||
|
||||
<EnvDropdown env={env} />
|
||||
<div className="flex flex-col px-4">
|
||||
<NavButton
|
||||
|
||||
@@ -1,115 +1,9 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { OrganizationSwitcher, useUser } from "@clerk/clerk-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { useEnv } from "@/utils/envUtils";
|
||||
import { useSidebarContext } from "./SidebarContext";
|
||||
|
||||
import { Check, ChevronLeft, ChevronRight, Copy } from "lucide-react";
|
||||
import { AdminHover } from "@/components/general/AdminHover";
|
||||
import { OrgDropdown } from "./components/OrgDropdown";
|
||||
|
||||
export const SidebarTop = () => {
|
||||
const { state, setState } = useSidebarContext();
|
||||
|
||||
return (
|
||||
<div className="px-2">
|
||||
<OrgDropdown />
|
||||
{/* <div
|
||||
className={cn(
|
||||
"flex items-center w-full",
|
||||
state == "expanded" ? "justify-between" : "justify-center",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setState((prev: string) =>
|
||||
prev == "expanded" ? "collapsed" : "expanded",
|
||||
);
|
||||
}}
|
||||
variant="ghost"
|
||||
className="p-0 w-5 h-5 text-t3 m-0"
|
||||
>
|
||||
{state == "expanded" ? (
|
||||
<ChevronLeft size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
</Button>
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CopyText = ({ text }: { text: string }) => {
|
||||
const [isHover, setIsHover] = useState(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<p
|
||||
onMouseEnter={() => setIsHover(true)}
|
||||
onMouseLeave={() => setIsHover(false)}
|
||||
className="flex items-center gap-1 font-mono hover:underline cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
navigator.clipboard.writeText(text);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 1000);
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
{(isCopied || isHover) && (
|
||||
<div
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setIsCopied(true);
|
||||
}}
|
||||
>
|
||||
{isCopied ? <Check size={10} /> : <Copy size={10} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// {
|
||||
// state == "expanded" && (
|
||||
// <div className="flex flex-col">
|
||||
// <div className="flex relative w-full h-7">
|
||||
// <OrganizationSwitcher
|
||||
// appearance={{
|
||||
// elements: {
|
||||
// organizationSwitcherTrigger: "flex !pl- pr-1 max-w-[160px]",
|
||||
// },
|
||||
// }}
|
||||
// hidePersonal={true}
|
||||
// skipInvitationScreen={true}
|
||||
// afterCreateOrganizationUrl="/sandbox/onboarding"
|
||||
// />
|
||||
// {organization && (
|
||||
// <AdminHover
|
||||
// texts={[
|
||||
// {
|
||||
// key: "id",
|
||||
// value: organization.id,
|
||||
// },
|
||||
// {
|
||||
// key: "slug",
|
||||
// value: organization.slug || "N/A",
|
||||
// },
|
||||
// ]}
|
||||
// >
|
||||
|
||||
// </AdminHover>
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
@@ -28,6 +28,7 @@ import { DropdownMenuGroup } from "@radix-ui/react-dropdown-menu";
|
||||
import {
|
||||
ChevronDown,
|
||||
LogOut,
|
||||
PanelRight,
|
||||
Plus,
|
||||
Settings,
|
||||
Shield,
|
||||
@@ -45,9 +46,7 @@ import { useMemberships } from "../org-dropdown/hooks/useMemberships";
|
||||
import { useSidebarContext } from "../SidebarContext";
|
||||
import { OrgLogo } from "../org-dropdown/components/OrgLogo";
|
||||
import { AdminHover } from "@/components/general/AdminHover";
|
||||
import { NavButton } from "../NavButton";
|
||||
import { AdminOnly } from "@/views/admin/components/AdminOnly";
|
||||
import { getBackendErr, notNullish } from "@/utils/genUtils";
|
||||
|
||||
import { AdminDropdownItems } from "./AdminDropdownItems";
|
||||
|
||||
export const OrgDropdown = () => {
|
||||
@@ -155,6 +154,19 @@ export const OrgDropdown = () => {
|
||||
<Plus size={14} />
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
{state === "collapsed" && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
setState("expanded");
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between w-full items-center gap-2 text-t2">
|
||||
<span>Open Sidebar</span>
|
||||
<PanelRight size={14} />
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuSub>
|
||||
|
||||
@@ -16,7 +16,7 @@ export const OrgLogo = ({ org }: { org: FrontendOrg }) => {
|
||||
{org.logo ? (
|
||||
<img src={org.logo} alt={org.name} className="w-full h-full" />
|
||||
) : (
|
||||
<span className="w-full h-full flex items-center justify-center bg-gradient-to-r from-purple-600 via-purple-500 to-[#6f47ff] text-white text-xs">
|
||||
<span className="w-5 h-5 flex items-center justify-center bg-gradient-to-r from-purple-600 via-purple-500 to-[#6f47ff] text-white text-xs">
|
||||
{firstLetter}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -47,19 +47,19 @@ export const ProductsTable = ({
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col justify-center items-center h-10 px-10 text-t3 min-h-[60vh] gap-4",
|
||||
"justify-start items-start mt-3",
|
||||
onboarding && "px-2 mt-4",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
{/* <img
|
||||
src="./product.png"
|
||||
alt="Products"
|
||||
className="w-48 h-48 opacity-60 filter grayscale"
|
||||
// className="w-48 h-48 opacity-80 filter brightness-0 invert" // this is for dark mode
|
||||
/>
|
||||
<span className="text-center">
|
||||
Products define the features your customers can access and how much{" "}
|
||||
<br />
|
||||
they cost. Create your first product to get started ☝️.
|
||||
/> */}
|
||||
<span>
|
||||
Products define the features your customers can access and how
|
||||
much they cost. Create your first product to get started ☝️.
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -64,7 +64,6 @@ export const FeatureConfig = () => {
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
<AdvancedItemConfig />
|
||||
{isFeature && (
|
||||
<div className="flex w-full justify-start transition-all duration-300 ease-in-out overflow-hidden">
|
||||
<Button
|
||||
@@ -77,6 +76,7 @@ export const FeatureConfig = () => {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<AdvancedItemConfig />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useState } from "react";
|
||||
import { useProductContext } from "../../../../ProductContext";
|
||||
import { useProductItemContext } from "../../../ProductItemContext";
|
||||
import { Feature, FeatureItemSchema, TierInfinite } from "@autumn/shared";
|
||||
@@ -15,9 +13,6 @@ export default function FeaturePrice() {
|
||||
const { item, setItem } = useProductItemContext();
|
||||
|
||||
const feature = features.find((f: Feature) => f.id == item.feature_id);
|
||||
const featureName = feature?.name;
|
||||
|
||||
const [editBillingUnits, setEditBillingUnits] = useState(false);
|
||||
|
||||
const setUsageTier = (index: number, key: string, value: string | number) => {
|
||||
const newUsageTiers = [...item.tiers];
|
||||
|
||||
Reference in New Issue
Block a user