diff --git a/api/bun.lockb b/api/bun.lockb index 4478f52..06c8d45 100644 Binary files a/api/bun.lockb and b/api/bun.lockb differ diff --git a/api/package.json b/api/package.json index 255bb08..9099af1 100644 --- a/api/package.json +++ b/api/package.json @@ -7,6 +7,7 @@ }, "dependencies": { "@elysiajs/cors": "^1.0.2", + "@elysiajs/jwt": "^1.0.2", "@it-incubator/prettier-config": "^0.1.2", "elysia": "latest", "postgres": "^3.4.4" diff --git a/api/src/drivers/driver.interface.ts b/api/src/drivers/driver.interface.ts new file mode 100644 index 0000000..cc60795 --- /dev/null +++ b/api/src/drivers/driver.interface.ts @@ -0,0 +1,51 @@ +export type WithSort = T & { sortField?: string; sortDesc?: boolean }; +export type WithPagination = T & { perPage: number; page: number }; +export type WithSortPagination = WithPagination>; + +export type Credentials = + | { + username: string; + password: string; + host: string; + type: string; + port: string; + database: string; + ssl: string; + } + | { + connectionString: string; + }; + +export interface Driver { + getAllDatabases(credentials: Credentials): Promise; + getAllTables( + credentials: Credentials, + args: WithSort<{ dbName: string }>, + ): Promise; + getTableData( + credentials: Credentials, + args: WithSortPagination<{ tableName: string; dbName: string }>, + ): Promise<{ + count: number; + data: Record[]; + }>; + getTableColumns( + credentials: Credentials, + args: { dbName: string; tableName: string }, + ): Promise; + getTableIndexes( + credentials: Credentials, + args: { dbName: string; tableName: string }, + ): Promise; + getTableForeignKeys( + credentials: Credentials, + args: { dbName: string; tableName: string }, + ): Promise; + executeQuery( + credentials: Credentials, + query: string, + ): Promise<{ + count: number; + data: Record[]; + }>; +} diff --git a/api/src/drivers/postgres.ts b/api/src/drivers/postgres.ts new file mode 100644 index 0000000..70b0e86 --- /dev/null +++ b/api/src/drivers/postgres.ts @@ -0,0 +1,352 @@ +import postgres, { type Options } from "postgres"; +import type { + Credentials, + Driver, + WithSort, + WithSortPagination, +} from "./driver.interface"; + +export class PostgresDriver implements Driver { + private parseSsl(ssl: string) { + switch (ssl) { + case "false": + return false; + case "true": + return true; + default: + return ssl as Options["ssl"]; + } + } + + parseCredentials({ + username, + password, + host, + type, + port, + database, + ssl, + }: { + username: string; + password: string; + host: string; + type: string; + port: string; + database: string; + ssl: string; + }) { + return { + username, + password, + host, + type, + port: Number.parseInt(port, 10), + database, + ssl: this.parseSsl(ssl), + }; + } + + private async queryRunner(credentials: Credentials) { + let sql: postgres.Sql; + if ("connectionString" in credentials) { + sql = postgres(credentials.connectionString); + } else { + sql = postgres("", this.parseCredentials(credentials)); + } + + return sql; + } + private getActions(matchString: string) { + const onActions = "RESTRICT|NO ACTION|CASCADE|SET NULL|SET DEFAULT"; + const onDeleteRegex = new RegExp(`ON DELETE (${onActions})`); + const onUpdateRegex = new RegExp(`ON UPDATE (${onActions})`); + + const onDeleteMatch = matchString.match(onDeleteRegex); + const onUpdateMatch = matchString.match(onUpdateRegex); + + const onDeleteAction = onDeleteMatch ? onDeleteMatch[1] : "NO ACTION"; + const onUpdateAction = onUpdateMatch ? onUpdateMatch[1] : "NO ACTION"; + + return { + onDelete: onDeleteAction, + onUpdate: onUpdateAction, + }; + } + async getAllDatabases(credentials: Credentials) { + const sql = await this.queryRunner(credentials); + + const result = await sql` + SELECT nspname + FROM pg_catalog.pg_namespace;`; + + void sql.end(); + + return result.map(({ nspname }) => nspname); + } + + async getAllTables( + credentials: Credentials, + { sortDesc, sortField, dbName }: WithSort<{ dbName: string }>, + ) { + const sql = await this.queryRunner(credentials); + + const tables = await sql` + WITH primary_keys AS (SELECT pg_class.relname AS table_name, + pg_namespace.nspname AS schema_name, + pg_attribute.attname AS primary_key + FROM pg_index + JOIN + pg_class ON pg_class.oid = pg_index.indrelid + JOIN + pg_attribute ON pg_attribute.attrelid = pg_class.oid AND + pg_attribute.attnum = ANY (pg_index.indkey) + JOIN + pg_namespace ON pg_namespace.oid = pg_class.relnamespace + WHERE pg_index.indisprimary) + SELECT t.schemaname AS schema_name, + t.tablename AS table_name, + pg_total_relation_size(quote_ident(t.schemaname) || '.' || quote_ident(t.tablename)) AS total_size, + pg_relation_size(quote_ident(t.schemaname) || '.' || quote_ident(t.tablename)) AS table_size, + pg_total_relation_size(quote_ident(t.schemaname) || '.' || quote_ident(t.tablename)) - + pg_relation_size(quote_ident(t.schemaname) || '.' || quote_ident(t.tablename)) AS index_size, + COALESCE( + (SELECT obj_description((quote_ident(t.schemaname) || '.' || quote_ident(t.tablename))::regclass)), + '' + ) AS comments, + (SELECT reltuples::bigint + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = t.tablename + AND n.nspname = t.schemaname) AS row_count, + (SELECT string_agg(indexname, ', ') + FROM pg_indexes + WHERE tablename = t.tablename + AND schemaname = t.schemaname) AS indexes, + t.tableowner AS owner, + COALESCE( + (SELECT string_agg(pk.primary_key, ', ') + FROM primary_keys pk + WHERE pk.schema_name = t.schemaname + AND pk.table_name = t.tablename), + '' + ) AS primary_key + FROM pg_tables t + WHERE t.schemaname = ${dbName} + + ORDER BY ${ + sortField + ? sql`${sql(sortField)} + ${sortDesc ? sql`DESC` : sql`ASC`}` + : sql`t.schemaname, t.tablename` + }`; + + void sql.end(); + return tables.map((table) => ({ + ...table, + total_size: Number.parseInt(table.total_size, 10), + table_size: Number.parseInt(table.table_size, 10), + index_size: Number.parseInt(table.index_size, 10), + row_count: Number.parseInt(table.row_count, 10), + })); + } + + async getTableData( + credentials: Credentials, + { + tableName, + dbName, + perPage, + page, + sortDesc, + sortField, + }: WithSortPagination<{ tableName: string; dbName: string }>, + ) { + const sql = await this.queryRunner(credentials); + + const offset = (perPage * page).toString(); + const rows = sql` + SELECT COUNT(*) + FROM ${sql(dbName)}.${sql(tableName)}`; + + const tables = sql` + SELECT * + FROM ${sql(dbName)}.${sql(tableName)} + ${sortField ? sql`ORDER BY ${sql(sortField)} ${sortDesc ? sql`DESC` : sql`ASC`}` : sql``} + LIMIT ${perPage} OFFSET ${offset} + `; + + const [[count], data] = await Promise.all([rows, tables]); + + void sql.end(); + + return { + count: count.count, + data, + }; + } + + async getTableColumns( + credentials: Credentials, + { tableName, dbName }: { dbName: string; tableName: string }, + ) { + const sql = await this.queryRunner(credentials); + + const result = await sql` + SELECT + cols.column_name, + cols.data_type, + cols.udt_name, + pgd.description AS column_comment + FROM + information_schema.columns AS cols + LEFT JOIN + pg_catalog.pg_statio_all_tables AS st ON st.relname = cols.table_name + LEFT JOIN + pg_catalog.pg_description AS pgd ON pgd.objoid = st.relid AND pgd.objsubid = cols.ordinal_position + WHERE + cols.table_name = ${tableName} + AND cols.table_schema = ${dbName} + + ORDER BY + cols.ordinal_position; + `; + + void sql.end(); + + return result; + } + + async getTableIndexes( + credentials: Credentials, + { dbName, tableName }: { dbName: string; tableName: string }, + ) { + const sql = await this.queryRunner(credentials); + + const [tableOidResult] = await sql` + SELECT oid + FROM pg_class + WHERE relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = ${dbName}) + AND relname = ${tableName} + `; + + const tableOid = tableOidResult.oid; + + const columnsResult = await sql` + SELECT attnum, attname + FROM pg_attribute + WHERE attrelid = ${tableOid} + AND attnum > 0 + `; + + const columns = {}; + columnsResult.forEach((row) => { + columns[row.attnum] = row.attname; + }); + + const indexResult = await sql` + SELECT + relname, + indisunique::int, + indisprimary::int, + indkey, + (indpred IS NOT NULL)::int as indispartial + FROM pg_index i + JOIN pg_class ci ON ci.oid = i.indexrelid + WHERE i.indrelid = ${tableOid} + `; + + void sql.end(); + + return indexResult.map((row) => { + return { + relname: row.relname, + key: row.relname, + type: row.indispartial + ? "INDEX" + : row.indisprimary + ? "PRIMARY" + : row.indisunique + ? "UNIQUE" + : "INDEX", + columns: row.indkey.split(" ").map((indkey) => columns[indkey]), + }; + }); + } + + async getTableForeignKeys( + credentials: Credentials, + { dbName, tableName }: { dbName: string; tableName: string }, + ) { + const sql = await this.queryRunner(credentials); + + const result = await sql` + SELECT + conname, + condeferrable::int AS deferrable, + pg_get_constraintdef(oid) AS definition + FROM + pg_constraint + WHERE + conrelid = ( + SELECT pc.oid + FROM pg_class AS pc + INNER JOIN pg_namespace AS pn ON (pn.oid = pc.relnamespace) + WHERE pc.relname = ${tableName} + AND pn.nspname = ${dbName} + ) + AND contype = 'f'::char + ORDER BY conkey, conname + `; + + void sql.end(); + + return result.map((row) => { + const match = row.definition.match( + /FOREIGN KEY\s*\((.+)\)\s*REFERENCES (.+)\((.+)\)(.*)$/iy, + ); + if (match) { + const sourceColumns = match[1] + .split(",") + .map((col) => col.replaceAll('"', "").trim()); + const targetTableMatch = match[2].match( + /^(("([^"]|"")+"|[^"]+)\.)?"?("([^"]|"")+"|[^"]+)$/, + ); + const targetTable = targetTableMatch + ? targetTableMatch[0].trim() + : null; + const targetColumns = match[3] + .split(",") + .map((col) => col.replaceAll('"', "").trim()); + const { onDelete, onUpdate } = this.getActions(match[4]); + return { + conname: row.conname, + deferrable: Boolean(row.deferrable), + definition: row.definition, + source: sourceColumns, + ns: targetTableMatch + ? targetTableMatch[0].replaceAll('"', "").trim() + : null, + table: targetTable.replaceAll('"', ""), + target: targetColumns, + on_delete: onDelete ?? "NO ACTION", + on_update: onUpdate ?? "NO ACTION", + }; + } + }); + } + + async executeQuery(credentials: Credentials, query: string) { + const sql = await this.queryRunner(credentials); + + const result = await sql.unsafe(query); + + void sql.end(); + + return { + count: result.length, + data: result, + }; + } +} + +export const postgresDriver = new PostgresDriver(); diff --git a/api/src/index.ts b/api/src/index.ts index 5a7beb5..7b9836b 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,39 +1,90 @@ import cors from "@elysiajs/cors"; +import { jwt } from "@elysiajs/jwt"; import { Elysia, t } from "elysia"; -import postgres from "postgres"; - -const DB_URL = Bun.env.DB_URL; - -if (!DB_URL) { - console.error("❗DB_URL not found in environment variables"); - process.exit(1); -} - -const sql = postgres(DB_URL); - -const [{ version }] = await sql`SELECT version()`; -console.log("pg version: ", version); +import { postgresDriver } from "./drivers/postgres"; +const credentialsSchema = t.Union([ + t.Object({ + username: t.String(), + password: t.String(), + host: t.String(), + type: t.String(), + port: t.String(), + database: t.String(), + ssl: t.String(), + }), + t.Object({ + connectionString: t.String(), + }), +]); const app = new Elysia({ prefix: "/api" }) + .use( + jwt({ + name: "jwt", + secret: "Fischl von Luftschloss Narfidort", + schema: credentialsSchema, + }), + ) .get("/", () => "Hello Elysia") - .get("/databases", async () => { - const databases = await getDatabases(); + .post( + "/auth/login", + async ({ body, jwt, cookie: { auth } }) => { + const databases = await postgresDriver.getAllDatabases(body); + + auth.set({ + value: await jwt.sign(body), + httpOnly: true, + }); + + return { success: true, databases }; + }, + { body: credentialsSchema }, + ) + .get("/databases", async ({ jwt, set, cookie: { auth } }) => { + const credentials = await jwt.verify(auth.value); + + if (!credentials) { + set.status = 401; + return "Unauthorized"; + } + + const databases = await postgresDriver.getAllDatabases(credentials); return new Response(JSON.stringify(databases, null, 2)).json(); }) - .get("/databases/:dbName/tables", async ({ query, params }) => { - const { sortField, sortDesc } = query; - const { dbName } = params; + .get( + "/databases/:dbName/tables", + async ({ query, params, jwt, set, cookie: { auth } }) => { + const { sortField, sortDesc } = query; + const { dbName } = params; + const credentials = await jwt.verify(auth.value); - const tables = await getTables(dbName, sortField, sortDesc === "true"); + if (!credentials) { + set.status = 401; + return "Unauthorized"; + } - return new Response(JSON.stringify(tables, null, 2)).json(); - }) + const tables = await postgresDriver.getAllTables(credentials, { + dbName, + sortField, + sortDesc: sortDesc === "true", + }); + + return new Response(JSON.stringify(tables, null, 2)).json(); + }, + ) .get( "databases/:dbName/tables/:tableName/data", - async ({ params, query }) => { + async ({ query, params, jwt, set, cookie: { auth } }) => { const { tableName, dbName } = params; const { perPage = "50", page = "0", sortField, sortDesc } = query; - return getTableData({ + const credentials = await jwt.verify(auth.value); + + if (!credentials) { + set.status = 401; + return "Unauthorized"; + } + + return postgresDriver.getTableData(credentials, { tableName, dbName, perPage: Number.parseInt(perPage, 10), @@ -45,37 +96,74 @@ const app = new Elysia({ prefix: "/api" }) ) .get( "databases/:dbName/tables/:tableName/columns", - async ({ params, query }) => { + async ({ params, jwt, set, cookie: { auth } }) => { const { tableName, dbName } = params; + const credentials = await jwt.verify(auth.value); - const columns = await getColumns(dbName, tableName); + if (!credentials) { + set.status = 401; + return "Unauthorized"; + } + + const columns = await postgresDriver.getTableColumns(credentials, { + dbName, + tableName, + }); return new Response(JSON.stringify(columns, null, 2)).json(); }, ) .get( "databases/:dbName/tables/:tableName/indexes", - async ({ params, query }) => { + async ({ params, jwt, set, cookie: { auth } }) => { const { tableName, dbName } = params; + const credentials = await jwt.verify(auth.value); - const indexes = await getIndexes(dbName, tableName); + if (!credentials) { + set.status = 401; + return "Unauthorized"; + } + + const indexes = await postgresDriver.getTableIndexes(credentials, { + dbName, + tableName, + }); return new Response(JSON.stringify(indexes, null, 2)).json(); }, ) .get( "databases/:dbName/tables/:tableName/foreign-keys", - async ({ params, query }) => { + async ({ params, jwt, set, cookie: { auth } }) => { const { tableName, dbName } = params; + const credentials = await jwt.verify(auth.value); + + if (!credentials) { + set.status = 401; + return "Unauthorized"; + } + + const foreignKeys = await postgresDriver.getTableForeignKeys( + credentials, + { + dbName, + tableName, + }, + ); - const foreignKeys = await getForeignKeys(dbName, tableName); return new Response(JSON.stringify(foreignKeys, null, 2)).json(); }, ) .post( "raw", - async ({ body }) => { + async ({ body, jwt, set, cookie: { auth } }) => { + const credentials = await jwt.verify(auth.value); + + if (!credentials) { + set.status = 401; + return "Unauthorized"; + } + const { query } = body; - const result = await sql.unsafe(query); - return new Response(JSON.stringify(result, null, 2)).json(); + return await postgresDriver.executeQuery(credentials, query); }, { body: t.Object({ @@ -83,262 +171,14 @@ const app = new Elysia({ prefix: "/api" }) }), }, ) - .use(cors()) + .use( + cors({ + origin: ["localhost:5173"], + allowedHeaders: ["Content-Type", "Authorization"], + }), + ) .listen(3000); console.log( `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`, ); - -async function getIndexes(dbName: string, tableName: string) { - const [tableOidResult] = await sql` - SELECT oid - FROM pg_class - WHERE relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = ${dbName}) - AND relname = ${tableName} - `; - - const tableOid = tableOidResult.oid; - - const columnsResult = await sql` - SELECT attnum, attname - FROM pg_attribute - WHERE attrelid = ${tableOid} - AND attnum > 0 - `; - - const columns = {}; - columnsResult.forEach((row) => { - columns[row.attnum] = row.attname; - }); - - const indexResult = await sql` - SELECT - relname, - indisunique::int, - indisprimary::int, - indkey, - (indpred IS NOT NULL)::int as indispartial - FROM pg_index i - JOIN pg_class ci ON ci.oid = i.indexrelid - WHERE i.indrelid = ${tableOid} - `; - - return indexResult.map((row) => { - return { - relname: row.relname, - key: row.relname, - type: row.indispartial - ? "INDEX" - : row.indisprimary - ? "PRIMARY" - : row.indisunique - ? "UNIQUE" - : "INDEX", - columns: row.indkey.split(" ").map((indkey) => columns[indkey]), - }; - }); -} - -async function getColumns(dbName: string, tableName: string) { - return await sql` - SELECT - cols.column_name, - cols.data_type, - cols.udt_name, - pgd.description AS column_comment - FROM - information_schema.columns AS cols - LEFT JOIN - pg_catalog.pg_statio_all_tables AS st ON st.relname = cols.table_name - LEFT JOIN - pg_catalog.pg_description AS pgd ON pgd.objoid = st.relid AND pgd.objsubid = cols.ordinal_position - WHERE - cols.table_name = ${tableName} - AND cols.table_schema = ${dbName} - - ORDER BY - cols.ordinal_position; - `; -} - -async function getForeignKeys(dbName: string, tableName: string) { - const result = await sql` - SELECT - conname, - condeferrable::int AS deferrable, - pg_get_constraintdef(oid) AS definition - FROM - pg_constraint - WHERE - conrelid = ( - SELECT pc.oid - FROM pg_class AS pc - INNER JOIN pg_namespace AS pn ON (pn.oid = pc.relnamespace) - WHERE pc.relname = ${tableName} - AND pn.nspname = ${dbName} - ) - AND contype = 'f'::char - ORDER BY conkey, conname - `; - - return result.map((row) => { - const match = row.definition.match( - /FOREIGN KEY\s*\((.+)\)\s*REFERENCES (.+)\((.+)\)(.*)$/iy, - ); - if (match) { - const sourceColumns = match[1] - .split(",") - .map((col) => col.replaceAll('"', "").trim()); - const targetTableMatch = match[2].match( - /^(("([^"]|"")+"|[^"]+)\.)?"?("([^"]|"")+"|[^"]+)$/, - ); - const targetTable = targetTableMatch ? targetTableMatch[0].trim() : null; - const targetColumns = match[3] - .split(",") - .map((col) => col.replaceAll('"', "").trim()); - const { onDelete, onUpdate } = getActions(match[4]); - return { - conname: row.conname, - deferrable: Boolean(row.deferrable), - definition: row.definition, - source: sourceColumns, - ns: targetTableMatch - ? targetTableMatch[0].replaceAll('"', "").trim() - : null, - table: targetTable.replaceAll('"', ""), - target: targetColumns, - on_delete: onDelete ?? "NO ACTION", - on_update: onUpdate ?? "NO ACTION", - }; - } - }); -} - -function getActions(matchString: string) { - const onActions = "RESTRICT|NO ACTION|CASCADE|SET NULL|SET DEFAULT"; - const onDeleteRegex = new RegExp(`ON DELETE (${onActions})`); - const onUpdateRegex = new RegExp(`ON UPDATE (${onActions})`); - - const onDeleteMatch = matchString.match(onDeleteRegex); - const onUpdateMatch = matchString.match(onUpdateRegex); - - const onDeleteAction = onDeleteMatch ? onDeleteMatch[1] : "NO ACTION"; - const onUpdateAction = onUpdateMatch ? onUpdateMatch[1] : "NO ACTION"; - - return { - onDelete: onDeleteAction, - onUpdate: onUpdateAction, - }; -} - -async function getTables( - dbName: string, - sortField?: string, - sortDesc?: boolean, -) { - const tables = await sql` - WITH primary_keys AS (SELECT pg_class.relname AS table_name, - pg_namespace.nspname AS schema_name, - pg_attribute.attname AS primary_key - FROM pg_index - JOIN - pg_class ON pg_class.oid = pg_index.indrelid - JOIN - pg_attribute ON pg_attribute.attrelid = pg_class.oid AND - pg_attribute.attnum = ANY (pg_index.indkey) - JOIN - pg_namespace ON pg_namespace.oid = pg_class.relnamespace - WHERE pg_index.indisprimary) - SELECT t.schemaname AS schema_name, - t.tablename AS table_name, - pg_total_relation_size(quote_ident(t.schemaname) || '.' || quote_ident(t.tablename)) AS total_size, - pg_relation_size(quote_ident(t.schemaname) || '.' || quote_ident(t.tablename)) AS table_size, - pg_total_relation_size(quote_ident(t.schemaname) || '.' || quote_ident(t.tablename)) - - pg_relation_size(quote_ident(t.schemaname) || '.' || quote_ident(t.tablename)) AS index_size, - COALESCE( - (SELECT obj_description((quote_ident(t.schemaname) || '.' || quote_ident(t.tablename))::regclass)), - '' - ) AS comments, - (SELECT reltuples::bigint - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname = t.tablename - AND n.nspname = t.schemaname) AS row_count, - (SELECT string_agg(indexname, ', ') - FROM pg_indexes - WHERE tablename = t.tablename - AND schemaname = t.schemaname) AS indexes, - t.tableowner AS owner, - COALESCE( - (SELECT string_agg(pk.primary_key, ', ') - FROM primary_keys pk - WHERE pk.schema_name = t.schemaname - AND pk.table_name = t.tablename), - '' - ) AS primary_key - FROM pg_tables t - WHERE t.schemaname = ${dbName} - - ORDER BY ${ - sortField - ? sql`${sql(sortField)} - ${sortDesc ? sql`DESC` : sql`ASC`}` - : sql`t.schemaname, t.tablename` - }`; - - return tables.map((table) => ({ - ...table, - total_size: Number.parseInt(table.total_size, 10), - table_size: Number.parseInt(table.table_size, 10), - index_size: Number.parseInt(table.index_size, 10), - row_count: Number.parseInt(table.row_count, 10), - })); -} - -async function getDatabases() { - const result = await sql` - SELECT nspname - FROM pg_catalog.pg_namespace;`; - - return result.map(({ nspname }) => nspname); -} - -async function getTableData({ - tableName, - dbName, - perPage, - page, - sortDesc, - sortField, -}: { - tableName: string; - dbName: string; - perPage: number; - page: number; - sortField?: string; - sortDesc?: boolean; -}) { - const offset = (perPage * page).toString(); - const rows = sql` - SELECT COUNT(*) - FROM ${sql(dbName)}.${sql(tableName)}`; - - const tables = sql` - SELECT * - FROM ${sql(dbName)}.${sql(tableName)} - ${ - sortField - ? sql`ORDER BY ${sql(sortField)} ${sortDesc ? sql`DESC` : sql`ASC`}` - : sql`` - } - LIMIT ${perPage} OFFSET ${offset} - `; - - const [[count], data] = await Promise.all([rows, tables]); - - return { - count: count.count, - data, - }; -} diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 5ca93d9..f6465b6 100644 Binary files a/frontend/bun.lockb and b/frontend/bun.lockb differ diff --git a/frontend/package.json b/frontend/package.json index 250c409..03d1f77 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,9 @@ "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-toggle": "^1.1.0", + "@radix-ui/react-toggle-group": "^1.1.0", "@tanstack/react-query": "^5.50.1", "@tanstack/react-router": "^1.43.12", "@tanstack/react-table": "^8.19.2", diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..afa13ec --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index eba1ad7..41f0a57 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -1,4 +1,5 @@ export * from "./button"; +export * from "./card"; export * from "./data-table-pagination"; export * from "./dialog"; export * from "./dropdown-menu"; @@ -11,3 +12,6 @@ export * from "./sql-data-table"; export * from "./sql-data-table-cell"; export * from "./switch"; export * from "./table"; +export * from "./tabs"; +export * from "./toggle"; +export * from "./toggle-group"; diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx index 677d05f..a5e556f 100644 --- a/frontend/src/components/ui/input.tsx +++ b/frontend/src/components/ui/input.tsx @@ -1,25 +1,24 @@ -import * as React from "react" +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; export interface InputProps extends React.InputHTMLAttributes {} const Input = React.forwardRef( - ({ className, type, ...props }, ref) => { + ({ className, ...props }, ref) => { return ( - ) - } -) -Input.displayName = "Input" + ); + }, +); +Input.displayName = "Input"; -export { Input } +export { Input }; diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..f57fffd --- /dev/null +++ b/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/frontend/src/components/ui/toggle-group.tsx b/frontend/src/components/ui/toggle-group.tsx new file mode 100644 index 0000000..4e4eed6 --- /dev/null +++ b/frontend/src/components/ui/toggle-group.tsx @@ -0,0 +1,59 @@ +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"; +import type { VariantProps } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { toggleVariants } from "./toggle"; + +const ToggleGroupContext = React.createContext< + VariantProps +>({ + size: "default", + variant: "default", +}); + +const ToggleGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, children, ...props }, ref) => ( + + + {children} + + +)); + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName; + +const ToggleGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, children, variant, size, ...props }, ref) => { + const context = React.useContext(ToggleGroupContext); + + return ( + + {children} + + ); +}); + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName; + +export { ToggleGroup, ToggleGroupItem }; diff --git a/frontend/src/components/ui/toggle.tsx b/frontend/src/components/ui/toggle.tsx new file mode 100644 index 0000000..9ecac28 --- /dev/null +++ b/frontend/src/components/ui/toggle.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import * as TogglePrimitive from "@radix-ui/react-toggle" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const toggleVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-10 px-3", + sm: "h-9 px-2.5", + lg: "h-11 px-5", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const Toggle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, ...props }, ref) => ( + +)) + +Toggle.displayName = TogglePrimitive.Root.displayName + +export { Toggle, toggleVariants } diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 1566629..de6ddf6 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as rootRoute } from './routes/__root' import { Route as IndexImport } from './routes/index' import { Route as RawIndexImport } from './routes/raw/index' +import { Route as AuthLoginImport } from './routes/auth/login' import { Route as DbDbNameTablesIndexImport } from './routes/db/$dbName/tables/index' import { Route as DbDbNameTablesTableNameIndexImport } from './routes/db/$dbName/tables/$tableName/index' import { Route as DbDbNameTablesTableNameDataImport } from './routes/db/$dbName/tables/$tableName/data' @@ -29,6 +30,11 @@ const RawIndexRoute = RawIndexImport.update({ getParentRoute: () => rootRoute, } as any) +const AuthLoginRoute = AuthLoginImport.update({ + path: '/auth/login', + getParentRoute: () => rootRoute, +} as any) + const DbDbNameTablesIndexRoute = DbDbNameTablesIndexImport.update({ path: '/db/$dbName/tables/', getParentRoute: () => rootRoute, @@ -57,6 +63,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexImport parentRoute: typeof rootRoute } + '/auth/login': { + id: '/auth/login' + path: '/auth/login' + fullPath: '/auth/login' + preLoaderRoute: typeof AuthLoginImport + parentRoute: typeof rootRoute + } '/raw/': { id: '/raw/' path: '/raw' @@ -92,6 +105,7 @@ declare module '@tanstack/react-router' { export const routeTree = rootRoute.addChildren({ IndexRoute, + AuthLoginRoute, RawIndexRoute, DbDbNameTablesIndexRoute, DbDbNameTablesTableNameDataRoute, @@ -107,6 +121,7 @@ export const routeTree = rootRoute.addChildren({ "filePath": "__root.tsx", "children": [ "/", + "/auth/login", "/raw/", "/db/$dbName/tables/", "/db/$dbName/tables/$tableName/data", @@ -116,6 +131,9 @@ export const routeTree = rootRoute.addChildren({ "/": { "filePath": "index.tsx" }, + "/auth/login": { + "filePath": "auth/login.tsx" + }, "/raw/": { "filePath": "raw/index.tsx" }, diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index fb48c19..845bc9f 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -12,6 +12,7 @@ import { import { cn } from "@/lib/utils"; import { useDatabasesListQuery, useTablesListQuery } from "@/services/db"; import { useUiStore } from "@/state"; +import { useSessionStore } from "@/state/db-session-store"; import { Link, Outlet, @@ -29,6 +30,8 @@ export const Route = createRootRoute({ function Root() { const showSidebar = useUiStore.use.showSidebar(); const toggleSidebar = useUiStore.use.toggleSidebar(); + const sessions = useSessionStore.use.sessions(); + const currentSessionId = useSessionStore.use.currentSessionId(); const { data } = useDatabasesListQuery(); const params = useParams({ strict: false }); @@ -40,7 +43,6 @@ function Root() { }; const { data: tables } = useTablesListQuery({ dbName }); - return ( <>
{showSidebar && ( <> + {sessions.length > 0 && ( + + )} + + + + + Postgres + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + ) : ( +
+ + +
+ )} + + + + + + +
+ ); +} diff --git a/frontend/src/services/db/db.hooks.ts b/frontend/src/services/db/db.hooks.ts index 4e2c000..559556a 100644 --- a/frontend/src/services/db/db.hooks.ts +++ b/frontend/src/services/db/db.hooks.ts @@ -9,9 +9,14 @@ import type { GetTableForeignKeysArgs, GetTableIndexesArgs, GetTablesListArgs, - QueryRawSqlArgs, } from "./db.types"; +export const useLoginMutation = () => { + return useMutation({ + mutationFn: dbService.login, + }); +}; + export const useDatabasesListQuery = () => { return useQuery({ queryKey: [DB_QUERY_KEYS.DATABASES.ALL], @@ -90,7 +95,6 @@ export const useQueryRawSqlMutation = () => { } toast.error(error.message); }, - mutationFn: ({ query }: QueryRawSqlArgs) => - dbService.queryRawSql({ query }), + mutationFn: dbService.queryRawSql, }); }; diff --git a/frontend/src/services/db/db.instance.ts b/frontend/src/services/db/db.instance.ts index 14b827c..9963a17 100644 --- a/frontend/src/services/db/db.instance.ts +++ b/frontend/src/services/db/db.instance.ts @@ -1,5 +1,6 @@ -import ky from 'ky' +import ky from "ky"; export const dbInstance = ky.create({ - prefixUrl: 'http://localhost:3000' -}) + credentials: "include", + prefixUrl: "http://localhost:3000", +}); diff --git a/frontend/src/services/db/db.service.ts b/frontend/src/services/db/db.service.ts index 8e3b1a1..e6a03b9 100644 --- a/frontend/src/services/db/db.service.ts +++ b/frontend/src/services/db/db.service.ts @@ -9,6 +9,8 @@ import type { GetTableIndexesArgs, GetTablesListArgs, GetTablesListResponse, + LoginArgs, + LoginResponse, QueryRawSqlArgs, QueryRawSqlResponse, TableColumns, @@ -17,6 +19,12 @@ import type { } from "@/services/db/db.types"; class DbService { + login(data: LoginArgs) { + return dbInstance + .post("api/auth/login", { json: data }) + .json(); + } + getDatabasesList() { return dbInstance.get("api/databases").json(); } diff --git a/frontend/src/services/db/db.types.ts b/frontend/src/services/db/db.types.ts index 27fc082..af66d15 100644 --- a/frontend/src/services/db/db.types.ts +++ b/frontend/src/services/db/db.types.ts @@ -1,3 +1,21 @@ +export type LoginArgs = + | { + username: string; + password: string; + host: string; + type: string; + port: string; + ssl: string; + database: string; + } + | { + connectionString: string; + }; + +export type LoginResponse = { + success: boolean; +}; + export type DatabasesResponse = Array; // Tables List diff --git a/frontend/src/state/db-session-store.ts b/frontend/src/state/db-session-store.ts new file mode 100644 index 0000000..0964371 --- /dev/null +++ b/frontend/src/state/db-session-store.ts @@ -0,0 +1,82 @@ +import { createSelectors } from "@/lib/create-selectors"; +import { toast } from "sonner"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +type SessionFields = { + username: string; + password: string; + host: string; + type: string; + port: string; + database: string; + ssl: string; + id: number; +}; +type SessionConnectionString = { + id: number; + connectionString: string; +}; +type Session = SessionFields | SessionConnectionString; + +type SesionState = { + sessions: Session[]; + currentSessionId: number | null; + setCurrentSessionId: (sessionId: number | null) => void; + addSession: ( + session: Omit | Omit, + ) => void; + removeSession: (sessionId: number) => void; +}; + +const useSessionStoreBase = create()( + persist( + (set) => { + return { + currentSessionId: null, + setCurrentSessionId: (sessionId) => { + set(() => ({ currentSessionId: sessionId })); + }, + sessions: [], + addSession: (session) => { + set((state) => { + const id = state.sessions.length + ? Math.max(...state.sessions.map((s) => s.id)) + 1 + : 1; + let isExisting = false; + for (const s of state.sessions) { + if ( + "connectionString" in s && + "connectionString" in session && + s.connectionString === session.connectionString + ) { + isExisting = true; + break; + } + if ("host" in s && "host" in session && s.host === session.host) + isExisting = true; + } + if (isExisting) { + toast.error("Session already exists"); + return state; + } + return { + sessions: [...state.sessions, { ...session, id }], + currentSessionId: id, + }; + }); + }, + removeSession: (sessionId) => { + set((state) => ({ + sessions: state.sessions.filter((s) => s.id !== sessionId), + })); + }, + }; + }, + { + name: "db-session-storage", + }, + ), +); + +export const useSessionStore = createSelectors(useSessionStoreBase);