diff --git a/app/api/settings/environment/route.ts b/app/api/settings/environment/route.ts new file mode 100644 index 0000000000..4dd1034d61 --- /dev/null +++ b/app/api/settings/environment/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from 'next/server' +import { eq } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { z } from 'zod' +import { hashSecret } from '@/lib/utils' +import { EnvironmentVariable } from '@/stores/settings/environment/types' +import { db } from '@/db' +import { userEnvironment } from '@/db/schema' + +const EnvironmentSchema = z.object({ + userId: z.string(), + data: z.string(), // A JSON stringified object of envvars +}) + +export async function POST(request: Request) { + try { + const body = await request.json() + const { userId, data } = EnvironmentSchema.parse(body) + + // Parse the incoming JSON string + const parsedData = JSON.parse(data) as Record + + // Hash all environment variables with unique salts + const securedData = await Promise.all( + Object.entries(parsedData).map(async ([key, value]) => { + const { hash, salt } = await hashSecret(value.value) + return [key, { key, value: hash, salt }] + }) + ) + + // Store the hashed values + await db + .insert(userEnvironment) + .values({ + id: nanoid(), + userId, + variables: JSON.stringify(Object.fromEntries(securedData)), + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [userEnvironment.userId], + set: { + variables: JSON.stringify(Object.fromEntries(securedData)), + updatedAt: new Date(), + }, + }) + + return NextResponse.json({ success: true }, { status: 200 }) + } catch (error: any) { + console.error('Environment update error:', error) + return NextResponse.json({ error: error.message }, { status: 500 }) + } +} + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url) + const userId = searchParams.get('userId') + + if (!userId) { + return NextResponse.json({ error: 'userId is required' }, { status: 400 }) + } + + const result = await db + .select() + .from(userEnvironment) + .where(eq(userEnvironment.userId, userId)) + .limit(1) + + if (!result.length) { + return NextResponse.json({ data: {} }, { status: 200 }) + } + + // Parse the variables and return just the structure without the hashed values + const variables = JSON.parse(result[0].variables) + const sanitizedVariables = Object.fromEntries( + Object.entries(variables).map(([key, value]: [string, any]) => [ + key, + { key, value: '••••••••' }, // Hide the actual value + ]) + ) + + return NextResponse.json({ data: sanitizedVariables }, { status: 200 }) + } catch (error: any) { + console.error('Environment fetch error:', error) + return NextResponse.json({ error: error.message }, { status: 500 }) + } +} diff --git a/app/api/settings/general/route.ts b/app/api/settings/general/route.ts new file mode 100644 index 0000000000..7a28dcf1b5 --- /dev/null +++ b/app/api/settings/general/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from 'next/server' +import { eq } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { z } from 'zod' +import { db } from '@/db' +import { userSettings } from '@/db/schema' + +const SettingsSchema = z.object({ + userId: z.string(), + isAutoConnectEnabled: z.boolean().default(true), +}) + +export async function POST(request: Request) { + try { + const body = await request.json() + const { userId, isAutoConnectEnabled } = SettingsSchema.parse(body) + + // Store the settings + await db + .insert(userSettings) + .values({ + id: nanoid(), + userId, + isAutoConnectEnabled, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [userSettings.userId], + set: { + isAutoConnectEnabled, + updatedAt: new Date(), + }, + }) + + return NextResponse.json({ success: true }, { status: 200 }) + } catch (error: any) { + console.error('Settings update error:', error) + return NextResponse.json({ error: error.message }, { status: 500 }) + } +} + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url) + const userId = searchParams.get('userId') + + if (!userId) { + return NextResponse.json({ error: 'userId is required' }, { status: 400 }) + } + + const result = await db + .select() + .from(userSettings) + .where(eq(userSettings.userId, userId)) + .limit(1) + + if (!result.length) { + return NextResponse.json( + { + data: { + isAutoConnectEnabled: true, // Return default values + }, + }, + { status: 200 } + ) + } + + return NextResponse.json( + { + data: { + isAutoConnectEnabled: result[0].isAutoConnectEnabled, + }, + }, + { status: 200 } + ) + } catch (error: any) { + console.error('Settings fetch error:', error) + return NextResponse.json({ error: error.message }, { status: 500 }) + } +} diff --git a/db/migrations/0003_smiling_hammerhead.sql b/db/migrations/0003_smiling_hammerhead.sql new file mode 100644 index 0000000000..4b05884dec --- /dev/null +++ b/db/migrations/0003_smiling_hammerhead.sql @@ -0,0 +1,28 @@ +CREATE TABLE "logs" ( + "id" text PRIMARY KEY NOT NULL, + "workflow_id" text NOT NULL, + "execution_id" text, + "level" text NOT NULL, + "message" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_environment" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "variables" text NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "user_environment_user_id_unique" UNIQUE("user_id") +); +--> statement-breakpoint +CREATE TABLE "user_settings" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "is_auto_connect_enabled" boolean DEFAULT true NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "user_settings_user_id_unique" UNIQUE("user_id") +); +--> statement-breakpoint +ALTER TABLE "logs" ADD CONSTRAINT "logs_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_environment" ADD CONSTRAINT "user_environment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_settings" ADD CONSTRAINT "user_settings_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/db/migrations/meta/0003_snapshot.json b/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000000..69671a0b06 --- /dev/null +++ b/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,599 @@ +{ + "id": "3ee25aee-2e62-4bf0-bb64-dc01bf8cad70", + "prevId": "80542c3c-48a3-41e5-b911-fe51152efccc", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.logs": { + "name": "logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "logs_workflow_id_workflow_id_fk": { + "name": "logs_workflow_id_workflow_id_fk", + "tableFrom": "logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_environment": { + "name": "user_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_environment_user_id_user_id_fk": { + "name": "user_environment_user_id_user_id_fk", + "tableFrom": "user_environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_environment_user_id_unique": { + "name": "user_environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_auto_connect_enabled": { + "name": "is_auto_connect_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_settings_user_id_user_id_fk": { + "name": "user_settings_user_id_user_id_fk", + "tableFrom": "user_settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_settings_user_id_unique": { + "name": "user_settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index e3bfcbdf53..13bd0926a7 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1739776634326, "tag": "0002_previous_xavin", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1739818168546, + "tag": "0003_smiling_hammerhead", + "breakpoints": true } ] } diff --git a/db/schema.ts b/db/schema.ts index 7b147bb51b..acdd79f759 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -1,4 +1,4 @@ -import { boolean, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core' +import { boolean, integer, pgTable, text, timestamp, unique } from 'drizzle-orm/pg-core' export const user = pgTable('user', { id: text('id').primaryKey(), @@ -70,3 +70,34 @@ export const waitlist = pgTable('waitlist', { createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }) + +export const consoleLog = pgTable('logs', { + id: text('id').primaryKey(), + workflowId: text('workflow_id') + .notNull() + .references(() => workflow.id, { onDelete: 'cascade' }), + executionId: text('execution_id'), + level: text('level').notNull(), // e.g. "info", "error", etc. + message: text('message').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), +}) + +export const userEnvironment = pgTable('user_environment', { + id: text('id').primaryKey(), // Use the user id as the key + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }) + .unique(), // One environment per user + variables: text('variables').notNull(), // JSON stringified {key: hashedValue} + updatedAt: timestamp('updated_at').notNull().defaultNow(), +}) + +export const userSettings = pgTable('user_settings', { + id: text('id').primaryKey(), // Use the user id as the key + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }) + .unique(), // One settings record per user + isAutoConnectEnabled: boolean('is_auto_connect_enabled').notNull().default(true), + updatedAt: timestamp('updated_at').notNull().defaultNow(), +}) diff --git a/lib/utils.ts b/lib/utils.ts index d32b0fe652..aa791787de 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,6 +1,25 @@ import { type ClassValue, clsx } from 'clsx' +import { createHash } from 'crypto' import { twMerge } from 'tailwind-merge' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +/** + * Hashes a secret using SHA-256 with a salt + * @param secret - The secret to hash + * @param salt - Optional salt to use for hashing. If not provided, a random salt will be generated + * @returns A promise that resolves to an object containing the hashed secret and salt + */ +export async function hashSecret( + secret: string, + salt?: string +): Promise<{ hash: string; salt: string }> { + const useSalt = + salt || createHash('sha256').update(crypto.randomUUID()).digest('hex').slice(0, 16) + const hash = createHash('sha256') + .update(secret + useSalt) + .digest('hex') + return { hash, salt: useSalt } +}