nobalmako 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +112 -0
  2. package/components.json +22 -0
  3. package/dist/nobalmako.js +272 -0
  4. package/drizzle/0000_pink_spiral.sql +126 -0
  5. package/drizzle/meta/0000_snapshot.json +1027 -0
  6. package/drizzle/meta/_journal.json +13 -0
  7. package/drizzle.config.ts +10 -0
  8. package/eslint.config.mjs +18 -0
  9. package/next.config.ts +7 -0
  10. package/package.json +80 -0
  11. package/postcss.config.mjs +7 -0
  12. package/public/file.svg +1 -0
  13. package/public/globe.svg +1 -0
  14. package/public/next.svg +1 -0
  15. package/public/vercel.svg +1 -0
  16. package/public/window.svg +1 -0
  17. package/server/index.ts +118 -0
  18. package/src/app/api/api-keys/[id]/route.ts +147 -0
  19. package/src/app/api/api-keys/route.ts +151 -0
  20. package/src/app/api/audit-logs/route.ts +84 -0
  21. package/src/app/api/auth/forgot-password/route.ts +47 -0
  22. package/src/app/api/auth/login/route.ts +99 -0
  23. package/src/app/api/auth/logout/route.ts +15 -0
  24. package/src/app/api/auth/me/route.ts +23 -0
  25. package/src/app/api/auth/mfa/setup/route.ts +33 -0
  26. package/src/app/api/auth/mfa/verify/route.ts +45 -0
  27. package/src/app/api/auth/register/route.ts +140 -0
  28. package/src/app/api/auth/reset-password/route.ts +52 -0
  29. package/src/app/api/auth/update/route.ts +71 -0
  30. package/src/app/api/auth/verify/route.ts +39 -0
  31. package/src/app/api/environments/route.ts +227 -0
  32. package/src/app/api/team-members/route.ts +385 -0
  33. package/src/app/api/teams/route.ts +217 -0
  34. package/src/app/api/variable-history/route.ts +218 -0
  35. package/src/app/api/variables/route.ts +476 -0
  36. package/src/app/api/webhooks/route.ts +77 -0
  37. package/src/app/api-keys/APIKeysClient.tsx +316 -0
  38. package/src/app/api-keys/page.tsx +10 -0
  39. package/src/app/api-reference/page.tsx +324 -0
  40. package/src/app/audit-log/AuditLogClient.tsx +229 -0
  41. package/src/app/audit-log/page.tsx +10 -0
  42. package/src/app/auth/forgot-password/page.tsx +121 -0
  43. package/src/app/auth/login/LoginForm.tsx +145 -0
  44. package/src/app/auth/login/page.tsx +11 -0
  45. package/src/app/auth/register/RegisterForm.tsx +156 -0
  46. package/src/app/auth/register/page.tsx +16 -0
  47. package/src/app/auth/reset-password/page.tsx +160 -0
  48. package/src/app/dashboard/DashboardClient.tsx +219 -0
  49. package/src/app/dashboard/page.tsx +11 -0
  50. package/src/app/docs/page.tsx +251 -0
  51. package/src/app/favicon.ico +0 -0
  52. package/src/app/globals.css +123 -0
  53. package/src/app/layout.tsx +35 -0
  54. package/src/app/page.tsx +231 -0
  55. package/src/app/profile/ProfileClient.tsx +230 -0
  56. package/src/app/profile/page.tsx +10 -0
  57. package/src/app/project/[id]/ProjectDetailsClient.tsx +512 -0
  58. package/src/app/project/[id]/page.tsx +17 -0
  59. package/src/bin/nobalmako.ts +341 -0
  60. package/src/components/ApiKeysManager.tsx +529 -0
  61. package/src/components/AppLayout.tsx +193 -0
  62. package/src/components/BulkActions.tsx +138 -0
  63. package/src/components/CreateEnvironmentDialog.tsx +207 -0
  64. package/src/components/CreateTeamDialog.tsx +174 -0
  65. package/src/components/CreateVariableDialog.tsx +311 -0
  66. package/src/components/DeleteEnvironmentDialog.tsx +104 -0
  67. package/src/components/DeleteTeamDialog.tsx +112 -0
  68. package/src/components/DeleteVariableDialog.tsx +103 -0
  69. package/src/components/EditEnvironmentDialog.tsx +202 -0
  70. package/src/components/EditMemberDialog.tsx +143 -0
  71. package/src/components/EditTeamDialog.tsx +178 -0
  72. package/src/components/EditVariableDialog.tsx +231 -0
  73. package/src/components/ImportVariablesDialog.tsx +347 -0
  74. package/src/components/InviteMemberDialog.tsx +191 -0
  75. package/src/components/LeaveProjectDialog.tsx +111 -0
  76. package/src/components/MFASettings.tsx +136 -0
  77. package/src/components/ProjectDiff.tsx +123 -0
  78. package/src/components/Providers.tsx +24 -0
  79. package/src/components/RemoveMemberDialog.tsx +112 -0
  80. package/src/components/SearchDialog.tsx +276 -0
  81. package/src/components/SecurityOverview.tsx +92 -0
  82. package/src/components/TeamMembersManager.tsx +103 -0
  83. package/src/components/VariableHistoryDialog.tsx +265 -0
  84. package/src/components/WebhooksManager.tsx +169 -0
  85. package/src/components/ui/alert-dialog.tsx +160 -0
  86. package/src/components/ui/alert.tsx +59 -0
  87. package/src/components/ui/avatar.tsx +53 -0
  88. package/src/components/ui/badge.tsx +46 -0
  89. package/src/components/ui/button.tsx +62 -0
  90. package/src/components/ui/card.tsx +92 -0
  91. package/src/components/ui/checkbox.tsx +32 -0
  92. package/src/components/ui/dialog.tsx +143 -0
  93. package/src/components/ui/dropdown-menu.tsx +257 -0
  94. package/src/components/ui/input.tsx +21 -0
  95. package/src/components/ui/label.tsx +24 -0
  96. package/src/components/ui/select.tsx +190 -0
  97. package/src/components/ui/separator.tsx +28 -0
  98. package/src/components/ui/sonner.tsx +37 -0
  99. package/src/components/ui/switch.tsx +31 -0
  100. package/src/components/ui/table.tsx +117 -0
  101. package/src/components/ui/tabs.tsx +66 -0
  102. package/src/components/ui/textarea.tsx +18 -0
  103. package/src/hooks/use-api-keys.ts +95 -0
  104. package/src/hooks/use-audit-logs.ts +58 -0
  105. package/src/hooks/use-auth.tsx +121 -0
  106. package/src/hooks/use-environments.ts +33 -0
  107. package/src/hooks/use-project-permissions.ts +49 -0
  108. package/src/hooks/use-team-members.ts +30 -0
  109. package/src/hooks/use-teams.ts +33 -0
  110. package/src/hooks/use-variables.ts +38 -0
  111. package/src/lib/audit.ts +36 -0
  112. package/src/lib/auth.ts +108 -0
  113. package/src/lib/crypto.ts +39 -0
  114. package/src/lib/db.ts +15 -0
  115. package/src/lib/dynamic-providers.ts +19 -0
  116. package/src/lib/email.ts +110 -0
  117. package/src/lib/mail.ts +51 -0
  118. package/src/lib/permissions.ts +51 -0
  119. package/src/lib/schema.ts +240 -0
  120. package/src/lib/seed.ts +107 -0
  121. package/src/lib/utils.ts +6 -0
  122. package/src/lib/webhooks.ts +42 -0
  123. package/tsconfig.json +34 -0
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1769191495696,
9
+ "tag": "0000_pink_spiral",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'drizzle-kit'
2
+
3
+ export default defineConfig({
4
+ schema: './src/lib/schema.ts',
5
+ out: './drizzle',
6
+ dialect: 'postgresql',
7
+ dbCredentials: {
8
+ url: process.env.DATABASE_URL!,
9
+ },
10
+ })
@@ -0,0 +1,18 @@
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
package/next.config.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ /* config options here */
5
+ };
6
+
7
+ export default nextConfig;
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "nobalmako",
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "bin": {
6
+ "nobalmako": "./dist/nobalmako.js"
7
+ },
8
+ "scripts": {
9
+ "dev": "next dev",
10
+ "build": "next build",
11
+ "build:cli": "esbuild src/bin/nobalmako.ts --bundle --platform=node --outfile=dist/nobalmako.js --external:dotenv --external:commander --external:fs --external:path --external:os --external:enquirer",
12
+ "start": "next start",
13
+ "lint": "next lint",
14
+ "db:generate": "drizzle-kit generate",
15
+ "db:push": "drizzle-kit push",
16
+ "db:migrate": "drizzle-kit migrate",
17
+ "db:studio": "drizzle-kit studio",
18
+ "db:seed": "tsx src/lib/seed.ts",
19
+ "cli": "tsx src/bin/nobalmako.ts"
20
+ },
21
+ "dependencies": {
22
+ "@hookform/resolvers": "^5.2.2",
23
+ "@neondatabase/serverless": "^1.0.2",
24
+ "@radix-ui/react-alert-dialog": "^1.1.15",
25
+ "@radix-ui/react-avatar": "^1.1.11",
26
+ "@radix-ui/react-checkbox": "^1.3.3",
27
+ "@radix-ui/react-dialog": "^1.1.15",
28
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
29
+ "@radix-ui/react-label": "^2.1.8",
30
+ "@radix-ui/react-popover": "^1.1.15",
31
+ "@radix-ui/react-progress": "^1.1.8",
32
+ "@radix-ui/react-select": "^2.2.6",
33
+ "@radix-ui/react-separator": "^1.1.8",
34
+ "@radix-ui/react-slot": "^1.2.4",
35
+ "@radix-ui/react-switch": "^1.2.6",
36
+ "@radix-ui/react-tabs": "^1.1.13",
37
+ "@radix-ui/react-toast": "^1.2.15",
38
+ "@radix-ui/react-tooltip": "^1.2.8",
39
+ "@tanstack/react-query": "^5.90.19",
40
+ "@types/jsonwebtoken": "^9.0.10",
41
+ "@types/qrcode": "^1.5.6",
42
+ "@types/speakeasy": "^2.0.10",
43
+ "bcryptjs": "^3.0.3",
44
+ "class-variance-authority": "^0.7.1",
45
+ "clsx": "^2.1.1",
46
+ "commander": "^14.0.3",
47
+ "date-fns": "^4.1.0",
48
+ "dotenv": "^17.2.3",
49
+ "drizzle-kit": "^0.31.8",
50
+ "drizzle-orm": "^0.45.1",
51
+ "enquirer": "^2.4.1",
52
+ "jsonwebtoken": "^9.0.3",
53
+ "lucide-react": "^0.562.0",
54
+ "next": "16.1.3",
55
+ "nodemailer": "^7.0.13",
56
+ "postgres": "^3.4.8",
57
+ "qrcode": "^1.5.4",
58
+ "react": "19.2.3",
59
+ "react-dom": "19.2.3",
60
+ "react-hook-form": "^7.71.1",
61
+ "sonner": "^2.0.7",
62
+ "speakeasy": "^2.0.0",
63
+ "tailwind-merge": "^3.4.0",
64
+ "zod": "^4.3.5"
65
+ },
66
+ "devDependencies": {
67
+ "@tailwindcss/postcss": "^4",
68
+ "@types/node": "^20",
69
+ "@types/nodemailer": "^7.0.9",
70
+ "@types/react": "^19",
71
+ "@types/react-dom": "^19",
72
+ "esbuild": "^0.27.2",
73
+ "eslint": "^9",
74
+ "eslint-config-next": "16.1.3",
75
+ "tailwindcss": "^4",
76
+ "tsx": "^4.21.0",
77
+ "tw-animate-css": "^1.4.0",
78
+ "typescript": "^5"
79
+ }
80
+ }
@@ -0,0 +1,7 @@
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
@@ -0,0 +1 @@
1
+ <svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
@@ -0,0 +1 @@
1
+ <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
@@ -0,0 +1 @@
1
+ <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
@@ -0,0 +1 @@
1
+ <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
@@ -0,0 +1,118 @@
1
+ import express, { type Request, Response, NextFunction } from "express";
2
+ import { registerRoutes } from "./routes";
3
+ import { serveStatic } from "./static";
4
+ import { createServer } from "http";
5
+ import path from "path";
6
+ import { fileURLToPath } from "url";
7
+ import mime from "mime-types";
8
+ import { createServer as createViteServer } from "vite";
9
+ import { config } from "dotenv";
10
+
11
+ config(); // Load environment variables from .env file
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ const app = express();
16
+ const httpServer = createServer(app);
17
+
18
+ declare module "http" {
19
+ interface IncomingMessage {
20
+ rawBody: unknown;
21
+ }
22
+ }
23
+
24
+ app.use(
25
+ express.json({
26
+ verify: (req, _res, buf) => {
27
+ req.rawBody = buf;
28
+ },
29
+ }),
30
+ );
31
+
32
+ app.use(express.urlencoded({ extended: false }));
33
+
34
+ export function log(message: string, source = "express") {
35
+ const formattedTime = new Date().toLocaleTimeString("en-US", {
36
+ hour: "numeric",
37
+ minute: "2-digit",
38
+ second: "2-digit",
39
+ hour12: true,
40
+ });
41
+
42
+ console.log(`${formattedTime} [${source}] ${message}`);
43
+ }
44
+
45
+ app.use((req, res, next) => {
46
+ const start = Date.now();
47
+ const path = req.path;
48
+ let capturedJsonResponse: Record<string, any> | undefined = undefined;
49
+
50
+ const originalResJson = res.json;
51
+ res.json = function (bodyJson, ...args) {
52
+ capturedJsonResponse = bodyJson;
53
+ return originalResJson.apply(res, [bodyJson, ...args]);
54
+ };
55
+
56
+ res.on("finish", () => {
57
+ const duration = Date.now() - start;
58
+ if (path.startsWith("/api")) {
59
+ let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`;
60
+ if (capturedJsonResponse) {
61
+ logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`;
62
+ }
63
+
64
+ log(logLine);
65
+ }
66
+ });
67
+
68
+ next();
69
+ });
70
+
71
+ (async () => {
72
+ await registerRoutes(httpServer, app);
73
+
74
+ app.use((err: any, _req: Request, res: Response, next: NextFunction) => {
75
+ const status = err.status || err.statusCode || 500;
76
+ const message = err.message || "Internal Server Error";
77
+
78
+ console.error("Internal Server Error:", err);
79
+
80
+ if (res.headersSent) {
81
+ return next(err);
82
+ }
83
+
84
+ return res.status(status).json({ message });
85
+ });
86
+
87
+ // importantly only setup vite in development and after
88
+ // setting up all the other routes so the catch-all route
89
+ // doesn't interfere with the other routes
90
+ if (process.env.NODE_ENV === "production") {
91
+ serveStatic(app);
92
+ } else {
93
+ // Create Vite server in middleware mode for development
94
+ const vite = await createViteServer({
95
+ server: { middlewareMode: true },
96
+ appType: 'spa'
97
+ });
98
+
99
+ // Use vite's connect instance as middleware
100
+ app.use(vite.middlewares);
101
+ }
102
+
103
+ // ALWAYS serve the app on the port specified in the environment variable PORT
104
+ // Other ports are firewalled. Default to 5000 if not specified.
105
+ // this serves both the API and the client.
106
+ // It is the only port that is not firewalled.
107
+ const port = parseInt(process.env.PORT || "5000", 10);
108
+ httpServer.listen(
109
+ {
110
+ port,
111
+ host: "0.0.0.0",
112
+ reusePort: true,
113
+ },
114
+ () => {
115
+ log(`serving on port ${port}`);
116
+ },
117
+ );
118
+ })();
@@ -0,0 +1,147 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { eq, and } from 'drizzle-orm'
3
+ import { db } from '@/lib/db'
4
+ import { apiKeys, teamMembers } from '@/lib/schema'
5
+ import { createAuditLog } from '@/lib/audit'
6
+ import { z } from 'zod'
7
+ import { getUserFromToken } from '@/lib/auth'
8
+
9
+ const updateApiKeySchema = z.object({
10
+ name: z.string().min(1).max(100).optional(),
11
+ permissions: z.array(z.string()).optional(),
12
+ expiresAt: z.string().optional(),
13
+ })
14
+
15
+ // PUT /api/api-keys/[id] - Update an API key
16
+ export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
17
+ try {
18
+ const user = await getUserFromToken()
19
+ if (!user) {
20
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
21
+ }
22
+
23
+ const userId = user.id
24
+ const { id: apiKeyId } = await params
25
+ const body = await request.json()
26
+
27
+ const validation = updateApiKeySchema.safeParse(body)
28
+ if (!validation.success) {
29
+ return NextResponse.json({ error: 'Invalid input', details: validation.error.issues }, { status: 400 })
30
+ }
31
+
32
+ const updates = validation.data
33
+
34
+ // Find the API key and verify ownership
35
+ const apiKey = await db
36
+ .select()
37
+ .from(apiKeys)
38
+ .where(eq(apiKeys.id, apiKeyId))
39
+ .limit(1)
40
+
41
+ if (apiKey.length === 0) {
42
+ return NextResponse.json({ error: 'API key not found' }, { status: 404 })
43
+ }
44
+
45
+ const key = apiKey[0]
46
+
47
+ // Check if user owns this key or is a member of the team that owns it
48
+ let hasAccess = key.userId === userId
49
+
50
+ if (!hasAccess && key.teamId) {
51
+ const teamMember = await db
52
+ .select()
53
+ .from(teamMembers)
54
+ .where(and(eq(teamMembers.teamId, key.teamId), eq(teamMembers.userId, userId)))
55
+ .limit(1)
56
+ hasAccess = teamMember.length > 0
57
+ }
58
+
59
+ if (!hasAccess) {
60
+ return NextResponse.json({ error: 'Access denied' }, { status: 403 })
61
+ }
62
+
63
+ // Update the API key
64
+ const [updatedKey] = await db
65
+ .update(apiKeys)
66
+ .set({
67
+ ...updates,
68
+ expiresAt: updates.expiresAt ? new Date(updates.expiresAt) : null,
69
+ })
70
+ .where(eq(apiKeys.id, apiKeyId))
71
+ .returning()
72
+
73
+ // Log audit event
74
+ await createAuditLog({
75
+ userId,
76
+ action: 'update',
77
+ resourceType: 'team', // API keys belong to teams
78
+ resourceId: key.teamId || userId, // Use team ID or user ID as resource ID
79
+ oldValue: { name: key.name, permissions: key.permissions },
80
+ newValue: updates,
81
+ })
82
+
83
+ return NextResponse.json({ apiKey: updatedKey })
84
+ } catch (error) {
85
+ console.error('Error updating API key:', error)
86
+ return NextResponse.json({ error: 'Failed to update API key' }, { status: 500 })
87
+ }
88
+ }
89
+
90
+ // DELETE /api/api-keys/[id] - Delete an API key
91
+ export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
92
+ try {
93
+ const user = await getUserFromToken()
94
+ if (!user) {
95
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
96
+ }
97
+
98
+ const userId = user.id
99
+ const { id: apiKeyId } = await params
100
+
101
+ // Find the API key and verify ownership
102
+ const apiKey = await db
103
+ .select()
104
+ .from(apiKeys)
105
+ .where(eq(apiKeys.id, apiKeyId))
106
+ .limit(1)
107
+
108
+ if (apiKey.length === 0) {
109
+ return NextResponse.json({ error: 'API key not found' }, { status: 404 })
110
+ }
111
+
112
+ const key = apiKey[0]
113
+
114
+ // Check if user owns this key or is a member of the team that owns it
115
+ let hasAccess = key.userId === userId
116
+
117
+ if (!hasAccess && key.teamId) {
118
+ const teamMember = await db
119
+ .select()
120
+ .from(teamMembers)
121
+ .where(and(eq(teamMembers.teamId, key.teamId), eq(teamMembers.userId, userId)))
122
+ .limit(1)
123
+ hasAccess = teamMember.length > 0
124
+ }
125
+
126
+ if (!hasAccess) {
127
+ return NextResponse.json({ error: 'Access denied' }, { status: 403 })
128
+ }
129
+
130
+ // Delete the API key
131
+ await db.delete(apiKeys).where(eq(apiKeys.id, apiKeyId))
132
+
133
+ // Log audit event
134
+ await createAuditLog({
135
+ userId,
136
+ action: 'delete',
137
+ resourceType: 'team', // API keys belong to teams
138
+ resourceId: key.teamId || userId, // Use team ID or user ID as resource ID
139
+ oldValue: { name: key.name, permissions: key.permissions },
140
+ })
141
+
142
+ return NextResponse.json({ success: true })
143
+ } catch (error) {
144
+ console.error('Error deleting API key:', error)
145
+ return NextResponse.json({ error: 'Failed to delete API key' }, { status: 500 })
146
+ }
147
+ }
@@ -0,0 +1,151 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { eq, and, desc } from 'drizzle-orm'
3
+ import { db } from '@/lib/db'
4
+ import { apiKeys, teams, teamMembers } from '@/lib/schema'
5
+ import { createAuditLog } from '@/lib/audit'
6
+ import { z } from 'zod'
7
+ import bcrypt from 'bcryptjs'
8
+ import crypto from 'crypto'
9
+ import { getUserFromToken } from '@/lib/auth'
10
+
11
+ // Validation schemas
12
+ const createApiKeySchema = z.object({
13
+ name: z.string().min(1).max(100),
14
+ teamId: z.string().optional(),
15
+ permissions: z.array(z.string()).default(['read']),
16
+ expiresAt: z.string().optional(),
17
+ })
18
+
19
+ // Generate a secure API key
20
+ function generateApiKey(): string {
21
+ return 'nm_' + crypto.randomBytes(32).toString('hex')
22
+ }
23
+
24
+ // Hash the API key for storage
25
+ async function hashApiKey(key: string): Promise<string> {
26
+ return await bcrypt.hash(key, 12)
27
+ }
28
+
29
+ // GET /api/api-keys - Get all API keys for the user
30
+ export async function GET() {
31
+ try {
32
+ const user = await getUserFromToken()
33
+ if (!user) {
34
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
35
+ }
36
+
37
+ const userId = user.id
38
+
39
+ // Get user's API keys
40
+ const userApiKeys = await db
41
+ .select({
42
+ id: apiKeys.id,
43
+ name: apiKeys.name,
44
+ permissions: apiKeys.permissions,
45
+ lastUsed: apiKeys.lastUsed,
46
+ createdAt: apiKeys.createdAt,
47
+ expiresAt: apiKeys.expiresAt,
48
+ teamId: apiKeys.teamId,
49
+ teamName: teams.name,
50
+ })
51
+ .from(apiKeys)
52
+ .leftJoin(teams, eq(apiKeys.teamId, teams.id))
53
+ .where(eq(apiKeys.userId, userId))
54
+ .orderBy(desc(apiKeys.createdAt))
55
+
56
+ // Get API keys for teams where user is a member
57
+ const teamApiKeys = await db
58
+ .select({
59
+ id: apiKeys.id,
60
+ name: apiKeys.name,
61
+ permissions: apiKeys.permissions,
62
+ lastUsed: apiKeys.lastUsed,
63
+ createdAt: apiKeys.createdAt,
64
+ expiresAt: apiKeys.expiresAt,
65
+ teamId: apiKeys.teamId,
66
+ teamName: teams.name,
67
+ })
68
+ .from(apiKeys)
69
+ .innerJoin(teams, eq(apiKeys.teamId, teams.id))
70
+ .innerJoin(teamMembers, eq(teams.id, teamMembers.teamId))
71
+ .where(eq(teamMembers.userId, userId))
72
+ .orderBy(desc(apiKeys.createdAt))
73
+
74
+ const allApiKeys = [...userApiKeys, ...teamApiKeys]
75
+
76
+ return NextResponse.json({ apiKeys: allApiKeys })
77
+ } catch (error) {
78
+ console.error('Error fetching API keys:', error)
79
+ return NextResponse.json({ error: 'Failed to fetch API keys' }, { status: 500 })
80
+ }
81
+ }
82
+
83
+ // POST /api/api-keys - Create a new API key
84
+ export async function POST(request: NextRequest) {
85
+ try {
86
+ const user = await getUserFromToken()
87
+ if (!user) {
88
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
89
+ }
90
+
91
+ const userId = user.id
92
+ const body = await request.json()
93
+
94
+ const validation = createApiKeySchema.safeParse(body)
95
+ if (!validation.success) {
96
+ return NextResponse.json({ error: 'Invalid input', details: validation.error.issues }, { status: 400 })
97
+ }
98
+
99
+ const { name, teamId, permissions, expiresAt } = validation.data
100
+
101
+ // If teamId is provided, verify user is a member of the team
102
+ if (teamId) {
103
+ const teamMember = await db
104
+ .select()
105
+ .from(teamMembers)
106
+ .where(and(eq(teamMembers.teamId, teamId), eq(teamMembers.userId, userId)))
107
+ .limit(1)
108
+
109
+ if (teamMember.length === 0) {
110
+ return NextResponse.json({ error: 'You are not a member of this team' }, { status: 403 })
111
+ }
112
+ }
113
+
114
+ // Generate and hash the API key
115
+ const apiKey = generateApiKey()
116
+ const keyHash = await hashApiKey(apiKey)
117
+
118
+ // Create the API key record
119
+ const [newApiKey] = await db
120
+ .insert(apiKeys)
121
+ .values({
122
+ userId,
123
+ teamId: teamId || null,
124
+ name,
125
+ keyHash,
126
+ permissions,
127
+ expiresAt: expiresAt ? new Date(expiresAt) : null,
128
+ })
129
+ .returning()
130
+
131
+ // Log audit event
132
+ await createAuditLog({
133
+ userId,
134
+ action: 'create',
135
+ resourceType: 'team', // API keys belong to teams
136
+ resourceId: teamId || userId, // Use team ID or user ID as resource ID
137
+ newValue: { name, permissions, teamId },
138
+ })
139
+
140
+ // Return the API key (this is the only time it will be shown)
141
+ return NextResponse.json({
142
+ apiKey: {
143
+ ...newApiKey,
144
+ key: apiKey, // Include the plain key for the user to copy
145
+ },
146
+ })
147
+ } catch (error) {
148
+ console.error('Error creating API key:', error)
149
+ return NextResponse.json({ error: 'Failed to create API key' }, { status: 500 })
150
+ }
151
+ }
@@ -0,0 +1,84 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { eq, and, desc, or, inArray } from 'drizzle-orm'
3
+ import { db } from '@/lib/db'
4
+ import { auditLogs, users, teams, teamMembers } from '@/lib/schema'
5
+ import { getUserFromToken } from '@/lib/auth'
6
+
7
+ export async function GET(request: NextRequest) {
8
+ try {
9
+ const user = await getUserFromToken()
10
+ if (!user) {
11
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
12
+ }
13
+
14
+ const { searchParams } = new URL(request.url)
15
+ const teamId = searchParams.get('teamId')
16
+
17
+ let query = db
18
+ .select({
19
+ id: auditLogs.id,
20
+ action: auditLogs.action,
21
+ resourceType: auditLogs.resourceType,
22
+ resourceId: auditLogs.resourceId,
23
+ oldValue: auditLogs.oldValue,
24
+ newValue: auditLogs.newValue,
25
+ ipAddress: auditLogs.ipAddress,
26
+ userAgent: auditLogs.userAgent,
27
+ createdAt: auditLogs.createdAt,
28
+ userId: auditLogs.userId,
29
+ userName: users.name,
30
+ userEmail: users.email,
31
+ teamId: auditLogs.teamId,
32
+ teamName: teams.name,
33
+ })
34
+ .from(auditLogs)
35
+ .leftJoin(users, eq(auditLogs.userId, users.id))
36
+ .leftJoin(teams, eq(auditLogs.teamId, teams.id))
37
+
38
+ if (teamId) {
39
+ // Check if user is member of the team
40
+ const membership = await db
41
+ .select()
42
+ .from(teamMembers)
43
+ .where(
44
+ and(
45
+ eq(teamMembers.teamId, teamId),
46
+ eq(teamMembers.userId, user.id)
47
+ )
48
+ )
49
+ .limit(1)
50
+
51
+ if (membership.length === 0) {
52
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
53
+ }
54
+
55
+ query = query.where(eq(auditLogs.teamId, teamId)) as any
56
+ } else {
57
+ // Get teams where user is member
58
+ const userTeams = await db
59
+ .select({ id: teamMembers.teamId })
60
+ .from(teamMembers)
61
+ .where(eq(teamMembers.userId, user.id))
62
+
63
+ const teamIds = userTeams.map(t => t.id)
64
+
65
+ if (teamIds.length > 0) {
66
+ query = query.where(
67
+ or(
68
+ eq(auditLogs.userId, user.id),
69
+ inArray(auditLogs.teamId, teamIds)
70
+ )
71
+ ) as any
72
+ } else {
73
+ query = query.where(eq(auditLogs.userId, user.id)) as any
74
+ }
75
+ }
76
+
77
+ const logs = await (query as any).orderBy(desc(auditLogs.createdAt)).limit(100)
78
+
79
+ return NextResponse.json({ logs })
80
+ } catch (error) {
81
+ console.error('Error fetching audit logs:', error)
82
+ return NextResponse.json({ error: 'Failed to fetch audit logs' }, { status: 500 })
83
+ }
84
+ }