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.
- package/README.md +112 -0
- package/components.json +22 -0
- package/dist/nobalmako.js +272 -0
- package/drizzle/0000_pink_spiral.sql +126 -0
- package/drizzle/meta/0000_snapshot.json +1027 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +10 -0
- package/eslint.config.mjs +18 -0
- package/next.config.ts +7 -0
- package/package.json +80 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/server/index.ts +118 -0
- package/src/app/api/api-keys/[id]/route.ts +147 -0
- package/src/app/api/api-keys/route.ts +151 -0
- package/src/app/api/audit-logs/route.ts +84 -0
- package/src/app/api/auth/forgot-password/route.ts +47 -0
- package/src/app/api/auth/login/route.ts +99 -0
- package/src/app/api/auth/logout/route.ts +15 -0
- package/src/app/api/auth/me/route.ts +23 -0
- package/src/app/api/auth/mfa/setup/route.ts +33 -0
- package/src/app/api/auth/mfa/verify/route.ts +45 -0
- package/src/app/api/auth/register/route.ts +140 -0
- package/src/app/api/auth/reset-password/route.ts +52 -0
- package/src/app/api/auth/update/route.ts +71 -0
- package/src/app/api/auth/verify/route.ts +39 -0
- package/src/app/api/environments/route.ts +227 -0
- package/src/app/api/team-members/route.ts +385 -0
- package/src/app/api/teams/route.ts +217 -0
- package/src/app/api/variable-history/route.ts +218 -0
- package/src/app/api/variables/route.ts +476 -0
- package/src/app/api/webhooks/route.ts +77 -0
- package/src/app/api-keys/APIKeysClient.tsx +316 -0
- package/src/app/api-keys/page.tsx +10 -0
- package/src/app/api-reference/page.tsx +324 -0
- package/src/app/audit-log/AuditLogClient.tsx +229 -0
- package/src/app/audit-log/page.tsx +10 -0
- package/src/app/auth/forgot-password/page.tsx +121 -0
- package/src/app/auth/login/LoginForm.tsx +145 -0
- package/src/app/auth/login/page.tsx +11 -0
- package/src/app/auth/register/RegisterForm.tsx +156 -0
- package/src/app/auth/register/page.tsx +16 -0
- package/src/app/auth/reset-password/page.tsx +160 -0
- package/src/app/dashboard/DashboardClient.tsx +219 -0
- package/src/app/dashboard/page.tsx +11 -0
- package/src/app/docs/page.tsx +251 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +123 -0
- package/src/app/layout.tsx +35 -0
- package/src/app/page.tsx +231 -0
- package/src/app/profile/ProfileClient.tsx +230 -0
- package/src/app/profile/page.tsx +10 -0
- package/src/app/project/[id]/ProjectDetailsClient.tsx +512 -0
- package/src/app/project/[id]/page.tsx +17 -0
- package/src/bin/nobalmako.ts +341 -0
- package/src/components/ApiKeysManager.tsx +529 -0
- package/src/components/AppLayout.tsx +193 -0
- package/src/components/BulkActions.tsx +138 -0
- package/src/components/CreateEnvironmentDialog.tsx +207 -0
- package/src/components/CreateTeamDialog.tsx +174 -0
- package/src/components/CreateVariableDialog.tsx +311 -0
- package/src/components/DeleteEnvironmentDialog.tsx +104 -0
- package/src/components/DeleteTeamDialog.tsx +112 -0
- package/src/components/DeleteVariableDialog.tsx +103 -0
- package/src/components/EditEnvironmentDialog.tsx +202 -0
- package/src/components/EditMemberDialog.tsx +143 -0
- package/src/components/EditTeamDialog.tsx +178 -0
- package/src/components/EditVariableDialog.tsx +231 -0
- package/src/components/ImportVariablesDialog.tsx +347 -0
- package/src/components/InviteMemberDialog.tsx +191 -0
- package/src/components/LeaveProjectDialog.tsx +111 -0
- package/src/components/MFASettings.tsx +136 -0
- package/src/components/ProjectDiff.tsx +123 -0
- package/src/components/Providers.tsx +24 -0
- package/src/components/RemoveMemberDialog.tsx +112 -0
- package/src/components/SearchDialog.tsx +276 -0
- package/src/components/SecurityOverview.tsx +92 -0
- package/src/components/TeamMembersManager.tsx +103 -0
- package/src/components/VariableHistoryDialog.tsx +265 -0
- package/src/components/WebhooksManager.tsx +169 -0
- package/src/components/ui/alert-dialog.tsx +160 -0
- package/src/components/ui/alert.tsx +59 -0
- package/src/components/ui/avatar.tsx +53 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/checkbox.tsx +32 -0
- package/src/components/ui/dialog.tsx +143 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +24 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sonner.tsx +37 -0
- package/src/components/ui/switch.tsx +31 -0
- package/src/components/ui/table.tsx +117 -0
- package/src/components/ui/tabs.tsx +66 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/hooks/use-api-keys.ts +95 -0
- package/src/hooks/use-audit-logs.ts +58 -0
- package/src/hooks/use-auth.tsx +121 -0
- package/src/hooks/use-environments.ts +33 -0
- package/src/hooks/use-project-permissions.ts +49 -0
- package/src/hooks/use-team-members.ts +30 -0
- package/src/hooks/use-teams.ts +33 -0
- package/src/hooks/use-variables.ts +38 -0
- package/src/lib/audit.ts +36 -0
- package/src/lib/auth.ts +108 -0
- package/src/lib/crypto.ts +39 -0
- package/src/lib/db.ts +15 -0
- package/src/lib/dynamic-providers.ts +19 -0
- package/src/lib/email.ts +110 -0
- package/src/lib/mail.ts +51 -0
- package/src/lib/permissions.ts +51 -0
- package/src/lib/schema.ts +240 -0
- package/src/lib/seed.ts +107 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/webhooks.ts +42 -0
- package/tsconfig.json +34 -0
package/src/lib/auth.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
import { cookies, headers } from 'next/headers';
|
|
3
|
+
import { db } from '@/lib/db';
|
|
4
|
+
import { users, apiKeys } from '@/lib/schema';
|
|
5
|
+
import { eq } from 'drizzle-orm';
|
|
6
|
+
import bcrypt from 'bcryptjs';
|
|
7
|
+
|
|
8
|
+
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
|
|
9
|
+
|
|
10
|
+
export interface AuthUser {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
email: string;
|
|
14
|
+
avatar?: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function getUserFromToken(): Promise<AuthUser | null> {
|
|
18
|
+
try {
|
|
19
|
+
const headerList = await headers();
|
|
20
|
+
const authHeader = headerList.get('Authorization');
|
|
21
|
+
|
|
22
|
+
// Check for API Key first (Bearer nm_...)
|
|
23
|
+
if (authHeader && authHeader.startsWith('Bearer nm_')) {
|
|
24
|
+
const apiKeyString = authHeader.replace('Bearer ', '');
|
|
25
|
+
|
|
26
|
+
// In a real production app with many keys, we'd use a faster lookup (like a prefix or hint)
|
|
27
|
+
// but for this implementation we'll fetch all keys and verify (or use a better strategy if possible)
|
|
28
|
+
// Since we hash with bcrypt, we can't search by index.
|
|
29
|
+
// Optimization: we could store a non-hashed hint/prefix to narrow it down.
|
|
30
|
+
// But for now, we'll fetch all and check.
|
|
31
|
+
const allKeys = await db.select().from(apiKeys);
|
|
32
|
+
|
|
33
|
+
for (const keyRecord of allKeys) {
|
|
34
|
+
const isValid = await bcrypt.compare(apiKeyString, keyRecord.keyHash);
|
|
35
|
+
if (isValid) {
|
|
36
|
+
// Verify expiry
|
|
37
|
+
if (keyRecord.expiresAt && keyRecord.expiresAt < new Date()) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// IP Whitelisting
|
|
42
|
+
const userIp = headerList.get('x-forwarded-for')?.split(',')[0] || headerList.get('x-real-ip');
|
|
43
|
+
if (keyRecord.allowedIps && keyRecord.allowedIps.length > 0) {
|
|
44
|
+
if (!userIp || !keyRecord.allowedIps.includes(userIp)) {
|
|
45
|
+
console.warn(`Blocked request from ${userIp} for API key ${keyRecord.id}`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Fetch the user
|
|
51
|
+
const [user] = await db
|
|
52
|
+
.select({
|
|
53
|
+
id: users.id,
|
|
54
|
+
name: users.name,
|
|
55
|
+
email: users.email,
|
|
56
|
+
avatar: users.avatar,
|
|
57
|
+
})
|
|
58
|
+
.from(users)
|
|
59
|
+
.where(eq(users.id, keyRecord.userId))
|
|
60
|
+
.limit(1);
|
|
61
|
+
|
|
62
|
+
if (user) {
|
|
63
|
+
// Update last used
|
|
64
|
+
await db.update(apiKeys)
|
|
65
|
+
.set({ lastUsed: new Date() })
|
|
66
|
+
.where(eq(apiKeys.id, keyRecord.id));
|
|
67
|
+
|
|
68
|
+
return user;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Fallback to cookie-based JWT
|
|
75
|
+
const cookieStore = await cookies();
|
|
76
|
+
const token = cookieStore.get('auth-token')?.value;
|
|
77
|
+
|
|
78
|
+
if (!token) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const decoded = jwt.verify(token, JWT_SECRET) as { userId: string; email: string };
|
|
83
|
+
|
|
84
|
+
const [user] = await db
|
|
85
|
+
.select({
|
|
86
|
+
id: users.id,
|
|
87
|
+
name: users.name,
|
|
88
|
+
email: users.email,
|
|
89
|
+
avatar: users.avatar,
|
|
90
|
+
})
|
|
91
|
+
.from(users)
|
|
92
|
+
.where(eq(users.id, decoded.userId))
|
|
93
|
+
.limit(1);
|
|
94
|
+
|
|
95
|
+
return user || null;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error('Token verification error:', error);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function verifyToken(token: string): { userId: number; email: string } | null {
|
|
103
|
+
try {
|
|
104
|
+
return jwt.verify(token, JWT_SECRET) as { userId: number; email: string };
|
|
105
|
+
} catch (_error) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
const ALGORITHM = 'aes-256-cbc';
|
|
4
|
+
const IV_LENGTH = 16; // For AES, this is always 16
|
|
5
|
+
|
|
6
|
+
function getEncryptionKey() {
|
|
7
|
+
const key = process.env.ENCRYPTION_KEY;
|
|
8
|
+
if (!key) {
|
|
9
|
+
throw new Error('ENCRYPTION_KEY is not set in environment variables');
|
|
10
|
+
}
|
|
11
|
+
return key;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function encrypt(text: string): string {
|
|
15
|
+
const key = getEncryptionKey();
|
|
16
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
17
|
+
const cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(key, 'hex'), iv);
|
|
18
|
+
let encrypted = cipher.update(text);
|
|
19
|
+
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
|
20
|
+
return iv.toString('hex') + ':' + encrypted.toString('hex');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function decrypt(text: string): string {
|
|
24
|
+
const key = getEncryptionKey();
|
|
25
|
+
try {
|
|
26
|
+
const textParts = text.split(':');
|
|
27
|
+
const iv = Buffer.from(textParts.shift()!, 'hex');
|
|
28
|
+
const encryptedText = Buffer.from(textParts.join(':'), 'hex');
|
|
29
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(key, 'hex'), iv);
|
|
30
|
+
let decrypted = decipher.update(encryptedText);
|
|
31
|
+
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
32
|
+
return decrypted.toString();
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error('Decryption failed:', error);
|
|
35
|
+
// If decryption fails, return the original text as fallback (for migration)
|
|
36
|
+
// In a real app, you might want to handle this differently
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/lib/db.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { drizzle } from 'drizzle-orm/neon-serverless'
|
|
2
|
+
import { Pool } from '@neondatabase/serverless'
|
|
3
|
+
import * as schema from './schema'
|
|
4
|
+
import 'dotenv/config'
|
|
5
|
+
|
|
6
|
+
const connectionString = process.env.DATABASE_URL
|
|
7
|
+
if (!connectionString) {
|
|
8
|
+
throw new Error('DATABASE_URL is not set')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Neon Serverless Pool supports transactions over WebSockets
|
|
12
|
+
const pool = new Pool({ connectionString })
|
|
13
|
+
export const db = drizzle(pool, { schema })
|
|
14
|
+
|
|
15
|
+
export type DB = typeof db
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export async function resolveDynamicSecret(v: any) {
|
|
2
|
+
if (!v.isDynamic || !v.provider) return v.value;
|
|
3
|
+
|
|
4
|
+
try {
|
|
5
|
+
switch (v.provider) {
|
|
6
|
+
case 'aws':
|
|
7
|
+
// Mocking AWS SM call
|
|
8
|
+
return `dynamic-aws-resolved-${v.key}-value`;
|
|
9
|
+
case 'vault':
|
|
10
|
+
// Mocking Hashicorp Vault call
|
|
11
|
+
return `dynamic-vault-resolved-${v.key}-value`;
|
|
12
|
+
default:
|
|
13
|
+
return v.value;
|
|
14
|
+
}
|
|
15
|
+
} catch (error) {
|
|
16
|
+
console.error(`Failed to resolve dynamic secret ${v.id}:`, error);
|
|
17
|
+
return 'REF_ERROR: Failed to resolve dynamic secret';
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/lib/email.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer';
|
|
2
|
+
|
|
3
|
+
// SMTP Configuration from environment variables
|
|
4
|
+
const SMTP_HOST = process.env.SMTP_HOST || 'smtp.mailtrap.io';
|
|
5
|
+
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '2525');
|
|
6
|
+
const SMTP_USER = process.env.SMTP_USER || '';
|
|
7
|
+
const SMTP_PASS = process.env.SMTP_PASS || '';
|
|
8
|
+
const FROM_EMAIL = process.env.FROM_EMAIL || 'noreply@nobalmako.com';
|
|
9
|
+
|
|
10
|
+
const transporter = nodemailer.createTransport({
|
|
11
|
+
host: SMTP_HOST,
|
|
12
|
+
port: SMTP_PORT,
|
|
13
|
+
secure: SMTP_PORT === 465, // true for 465, false for other ports
|
|
14
|
+
auth: {
|
|
15
|
+
user: SMTP_USER,
|
|
16
|
+
pass: SMTP_PASS,
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export async function sendEmail({ to, subject, html, text }: { to: string; subject: string; html: string; text?: string }) {
|
|
21
|
+
try {
|
|
22
|
+
const info = await transporter.sendMail({
|
|
23
|
+
from: `"Nobalmako" <${FROM_EMAIL}>`,
|
|
24
|
+
to,
|
|
25
|
+
subject,
|
|
26
|
+
text: text || subject, // Fallback if html not supported
|
|
27
|
+
html,
|
|
28
|
+
});
|
|
29
|
+
console.log('Email sent: %s', info.messageId);
|
|
30
|
+
return { success: true, messageId: info.messageId };
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error('Error sending email:', error);
|
|
33
|
+
return { success: false, error };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Pre-defined templates
|
|
38
|
+
export const emailTemplates = {
|
|
39
|
+
welcome: (name: string) => ({
|
|
40
|
+
subject: 'Welcome to Nobalmako! 🚀',
|
|
41
|
+
html: `
|
|
42
|
+
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;">
|
|
43
|
+
<h1 style="color: #2563eb;">Welcome, ${name}!</h1>
|
|
44
|
+
<p>We're thrilled to have you join Nobalmako. You're now ready to start securing and managing your environment variables with ease.</p>
|
|
45
|
+
<p>With Nobalmako, you can:</p>
|
|
46
|
+
<ul>
|
|
47
|
+
<li>Collaborate with your team on projects</li>
|
|
48
|
+
<li>Inherit variables between environments</li>
|
|
49
|
+
<li>Set up dynamic secrets with AWS & Vault</li>
|
|
50
|
+
<li>Lock down access with IP whitelisting and MFA</li>
|
|
51
|
+
</ul>
|
|
52
|
+
<a href="https://nobalmako.com/dashboard" style="display: inline-block; background-color: #2563eb; color: white; padding: 12px 24px; border-radius: 5px; text-decoration: none; margin-top: 20px;">Go to Dashboard</a>
|
|
53
|
+
<p style="margin-top: 30px; font-size: 0.8em; color: #666;">If you have any questions, just reply to this email.</p>
|
|
54
|
+
</div>
|
|
55
|
+
`,
|
|
56
|
+
}),
|
|
57
|
+
|
|
58
|
+
verification: (name: string, token: string) => ({
|
|
59
|
+
subject: 'Verify your Nobalmako account',
|
|
60
|
+
html: `
|
|
61
|
+
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;">
|
|
62
|
+
<h1 style="color: #2563eb;">One last step, ${name}!</h1>
|
|
63
|
+
<p>Please click the button below to verify your email address and activate your account.</p>
|
|
64
|
+
<a href="https://nobalmako.com/api/auth/verify?token=${token}" style="display: inline-block; background-color: #2563eb; color: white; padding: 12px 24px; border-radius: 5px; text-decoration: none; margin-top: 20px;">Verify Email</a>
|
|
65
|
+
<p style="margin-top: 20px;">Or copy and paste this link:</p>
|
|
66
|
+
<p style="font-size: 0.8em; color: #666; word-break: break-all;">https://nobalmako.com/api/auth/verify?token=${token}</p>
|
|
67
|
+
<p style="margin-top: 30px; font-size: 0.8em; color: #666;">This link will expire in 24 hours.</p>
|
|
68
|
+
</div>
|
|
69
|
+
`,
|
|
70
|
+
}),
|
|
71
|
+
|
|
72
|
+
mfaEnabled: (name: string) => ({
|
|
73
|
+
subject: 'MFA has been enabled on your account',
|
|
74
|
+
html: `
|
|
75
|
+
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;">
|
|
76
|
+
<h1 style="color: #10b981;">Security Update</h1>
|
|
77
|
+
<p>Hello ${name},</p>
|
|
78
|
+
<p>Multi-Factor Authentication (MFA) has been successfully enabled on your Nobalmako account. Your account is now even more secure.</p>
|
|
79
|
+
<p>If you did not perform this action, please contact our security team immediately.</p>
|
|
80
|
+
<a href="https://nobalmako.com/profile" style="display: inline-block; background-color: #10b981; color: white; padding: 12px 24px; border-radius: 5px; text-decoration: none; margin-top: 20px;">Manage Security Settings</a>
|
|
81
|
+
</div>
|
|
82
|
+
`,
|
|
83
|
+
}),
|
|
84
|
+
|
|
85
|
+
passwordReset: (name: string, token: string) => ({
|
|
86
|
+
subject: 'Reset your Nobalmako password',
|
|
87
|
+
html: `
|
|
88
|
+
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;">
|
|
89
|
+
<h1 style="color: #2563eb;">Password Reset Request</h1>
|
|
90
|
+
<p>Hello ${name},</p>
|
|
91
|
+
<p>We received a request to reset your password. Click the button below to choose a new one.</p>
|
|
92
|
+
<a href="https://nobalmako.com/auth/reset-password?token=${token}" style="display: inline-block; background-color: #2563eb; color: white; padding: 12px 24px; border-radius: 5px; text-decoration: none; margin-top: 20px;">Reset Password</a>
|
|
93
|
+
<p style="margin-top: 20px; font-size: 0.8em; color: #666;">This link will expire in 1 hour. If you didn't request this, you can safely ignore this email.</p>
|
|
94
|
+
</div>
|
|
95
|
+
`,
|
|
96
|
+
}),
|
|
97
|
+
|
|
98
|
+
teamInvitation: (inviterName: string, teamName: string, teamId: string, token: string) => ({
|
|
99
|
+
subject: `Invitation to join ${teamName} on Nobalmako`,
|
|
100
|
+
html: `
|
|
101
|
+
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;">
|
|
102
|
+
<h1 style="color: #2563eb;">You've been invited!</h1>
|
|
103
|
+
<p>${inviterName} has invited you to join the team <strong>${teamName}</strong> on Nobalmako.</p>
|
|
104
|
+
<p>Nobalmako is a secure environment variable management platform that helps teams collaborate safely.</p>
|
|
105
|
+
<a href="https://nobalmako.com/auth/register?invite=${token}" style="display: inline-block; background-color: #2563eb; color: white; padding: 12px 24px; border-radius: 5px; text-decoration: none; margin-top: 20px;">Accept Invitation</a>
|
|
106
|
+
<p style="margin-top: 20px; font-size: 0.8em; color: #666;">If you already have an account, make sure to log in first or use the same email address.</p>
|
|
107
|
+
</div>
|
|
108
|
+
`,
|
|
109
|
+
})
|
|
110
|
+
};
|
package/src/lib/mail.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer';
|
|
2
|
+
|
|
3
|
+
const transporter = nodemailer.createTransport({
|
|
4
|
+
host: process.env.SMTP_HOST,
|
|
5
|
+
port: parseInt(process.env.SMTP_PORT || '587'),
|
|
6
|
+
secure: process.env.SMTP_SECURE === 'true',
|
|
7
|
+
auth: {
|
|
8
|
+
user: process.env.SMTP_USER,
|
|
9
|
+
pass: process.env.SMTP_PASS,
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export async function sendInviteEmail({
|
|
14
|
+
to,
|
|
15
|
+
teamName,
|
|
16
|
+
inviterName,
|
|
17
|
+
inviteLink
|
|
18
|
+
}: {
|
|
19
|
+
to: string,
|
|
20
|
+
teamName: string,
|
|
21
|
+
inviterName: string,
|
|
22
|
+
inviteLink: string
|
|
23
|
+
}) {
|
|
24
|
+
const mailOptions = {
|
|
25
|
+
from: `"Nobalmako" <${process.env.SMTP_FROM || process.env.SMTP_USER}>`,
|
|
26
|
+
to,
|
|
27
|
+
subject: `You've been invited to join ${teamName} on Nobalmako`,
|
|
28
|
+
html: `
|
|
29
|
+
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; rounded: 8px;">
|
|
30
|
+
<h2 style="color: #0f172a; margin-bottom: 24px;">Join your team on Nobalmako</h2>
|
|
31
|
+
<p style="color: #475569; font-size: 16px; line-height: 24px;">
|
|
32
|
+
<strong>${inviterName}</strong> has invited you to join the project <strong>${teamName}</strong> on Nobalmako.
|
|
33
|
+
</p>
|
|
34
|
+
<div style="margin: 32px 0;">
|
|
35
|
+
<a href="${inviteLink}" style="background-color: #3b82f6; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 600; display: inline-block;">
|
|
36
|
+
Accept Invitation
|
|
37
|
+
</a>
|
|
38
|
+
</div>
|
|
39
|
+
<p style="color: #64748b; font-size: 14px; margin-top: 32px;">
|
|
40
|
+
If you don't have an account yet, you'll be asked to create one after clicking the button.
|
|
41
|
+
</p>
|
|
42
|
+
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 32px 0;" />
|
|
43
|
+
<p style="color: #94a3b8; font-size: 12px;">
|
|
44
|
+
Nobalmako - Secure Environment Variable Manager
|
|
45
|
+
</p>
|
|
46
|
+
</div>
|
|
47
|
+
`,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return transporter.sendMail(mailOptions);
|
|
51
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { db } from './db';
|
|
2
|
+
import { teamMembers, teams } from './schema';
|
|
3
|
+
import { and, eq } from 'drizzle-orm';
|
|
4
|
+
|
|
5
|
+
export type Role = 'owner' | 'admin' | 'developer' | 'viewer';
|
|
6
|
+
|
|
7
|
+
export const RoleHierarchy: Record<Role, number> = {
|
|
8
|
+
owner: 4,
|
|
9
|
+
admin: 3,
|
|
10
|
+
developer: 2,
|
|
11
|
+
viewer: 1,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export async function getUserRoleInTeam(userId: string, teamId: string): Promise<Role | null> {
|
|
15
|
+
// Check if user is the owner of the team
|
|
16
|
+
const team = await db
|
|
17
|
+
.select()
|
|
18
|
+
.from(teams)
|
|
19
|
+
.where(and(eq(teams.id, teamId), eq(teams.ownerId, userId)))
|
|
20
|
+
.limit(1);
|
|
21
|
+
|
|
22
|
+
if (team.length > 0) return 'owner';
|
|
23
|
+
|
|
24
|
+
// Check team_members table
|
|
25
|
+
const membership = await db
|
|
26
|
+
.select()
|
|
27
|
+
.from(teamMembers)
|
|
28
|
+
.where(and(eq(teamMembers.teamId, teamId), eq(teamMembers.userId, userId)))
|
|
29
|
+
.limit(1);
|
|
30
|
+
|
|
31
|
+
if (membership.length > 0) return membership[0].role as Role;
|
|
32
|
+
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function hasPermission(
|
|
37
|
+
userId: string,
|
|
38
|
+
teamId: string,
|
|
39
|
+
requiredRole: Role
|
|
40
|
+
): Promise<boolean> {
|
|
41
|
+
const userRole = await getUserRoleInTeam(userId, teamId);
|
|
42
|
+
if (!userRole) return false;
|
|
43
|
+
|
|
44
|
+
return RoleHierarchy[userRole] >= RoleHierarchy[requiredRole];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const Permissions = {
|
|
48
|
+
MANAGE_TEAM: 'admin' as Role,
|
|
49
|
+
MANAGE_SECRETS: 'developer' as Role,
|
|
50
|
+
VIEW_SECRETS: 'viewer' as Role,
|
|
51
|
+
};
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { pgTable, text, timestamp, boolean, jsonb, index, uuid } from 'drizzle-orm/pg-core'
|
|
2
|
+
import { relations } from 'drizzle-orm'
|
|
3
|
+
|
|
4
|
+
// Users table
|
|
5
|
+
export const users = pgTable('users', {
|
|
6
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
7
|
+
email: text('email').unique().notNull(),
|
|
8
|
+
name: text('full_name').notNull(),
|
|
9
|
+
password: text('password').notNull(),
|
|
10
|
+
avatar: text('avatar'),
|
|
11
|
+
role: text('role').default('user').notNull(), // 'user', 'admin'
|
|
12
|
+
emailVerified: timestamp('email_verified'),
|
|
13
|
+
verificationToken: text('verification_token'),
|
|
14
|
+
resetToken: text('reset_token'),
|
|
15
|
+
resetTokenExpiry: timestamp('reset_token_expiry'),
|
|
16
|
+
mfaEnabled: boolean('mfa_enabled').default(false).notNull(),
|
|
17
|
+
mfaSecret: text('mfa_secret'),
|
|
18
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
19
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
// Teams/Projects table
|
|
23
|
+
export const teams = pgTable('teams', {
|
|
24
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
25
|
+
name: text('name').notNull(),
|
|
26
|
+
description: text('description'),
|
|
27
|
+
color: text('color').default('#3b82f6'),
|
|
28
|
+
ownerId: uuid('owner_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
|
29
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
30
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
31
|
+
}, (table) => ({
|
|
32
|
+
ownerIdx: index('teams_owner_idx').on(table.ownerId),
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
// Team members
|
|
36
|
+
export const teamMembers = pgTable('team_members', {
|
|
37
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
38
|
+
teamId: uuid('team_id').references(() => teams.id, { onDelete: 'cascade' }).notNull(),
|
|
39
|
+
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
|
40
|
+
role: text('role').default('developer').notNull(), // 'owner', 'admin', 'developer', 'viewer'
|
|
41
|
+
joinedAt: timestamp('joined_at').defaultNow().notNull(),
|
|
42
|
+
}, (table) => ({
|
|
43
|
+
teamUserIdx: index('team_members_team_user_idx').on(table.teamId, table.userId),
|
|
44
|
+
}))
|
|
45
|
+
|
|
46
|
+
// Environments (dev, staging, prod, etc.)
|
|
47
|
+
export const environments = pgTable('environments', {
|
|
48
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
49
|
+
teamId: uuid('team_id').references(() => teams.id, { onDelete: 'cascade' }).notNull(),
|
|
50
|
+
parentId: uuid('parent_id').references((): any => environments.id), // For inheritance
|
|
51
|
+
name: text('name').notNull(),
|
|
52
|
+
description: text('description'),
|
|
53
|
+
color: text('color').default('#3b82f6'), // hex color
|
|
54
|
+
isDefault: boolean('is_default').default(false),
|
|
55
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
56
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
57
|
+
}, (table) => ({
|
|
58
|
+
teamIdx: index('environments_team_idx').on(table.teamId),
|
|
59
|
+
}))
|
|
60
|
+
|
|
61
|
+
// Environment Variables
|
|
62
|
+
export const environmentVariables = pgTable('environment_variables', {
|
|
63
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
64
|
+
teamId: uuid('team_id').references(() => teams.id, { onDelete: 'cascade' }).notNull(),
|
|
65
|
+
environmentId: uuid('environment_id').references(() => environments.id, { onDelete: 'cascade' }).notNull(),
|
|
66
|
+
key: text('key').notNull(),
|
|
67
|
+
value: text('value').notNull(),
|
|
68
|
+
description: text('description'),
|
|
69
|
+
isSecret: boolean('is_secret').default(false),
|
|
70
|
+
expiresAt: timestamp('expires_at'),
|
|
71
|
+
lastRotatedAt: timestamp('last_rotated_at'),
|
|
72
|
+
isDynamic: boolean('is_dynamic').default(false),
|
|
73
|
+
provider: text('provider'), // 'aws', 'vault', 'gcp'
|
|
74
|
+
providerConfig: jsonb('provider_config'),
|
|
75
|
+
tags: jsonb('tags').$type<string[]>(),
|
|
76
|
+
createdBy: uuid('created_by').references(() => users.id).notNull(),
|
|
77
|
+
updatedBy: uuid('updated_by').references(() => users.id),
|
|
78
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
79
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
80
|
+
}, (table) => ({
|
|
81
|
+
teamEnvIdx: index('env_vars_team_env_idx').on(table.teamId, table.environmentId),
|
|
82
|
+
keyIdx: index('env_vars_key_idx').on(table.key),
|
|
83
|
+
createdByIdx: index('env_vars_created_by_idx').on(table.createdBy),
|
|
84
|
+
}))
|
|
85
|
+
|
|
86
|
+
// API keys for external access
|
|
87
|
+
export const apiKeys = pgTable('api_keys', {
|
|
88
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
89
|
+
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
|
90
|
+
teamId: uuid('team_id').references(() => teams.id, { onDelete: 'cascade' }),
|
|
91
|
+
name: text('name').notNull(),
|
|
92
|
+
keyHash: text('key_hash').notNull(), // Hashed API key
|
|
93
|
+
permissions: jsonb('permissions').$type<string[]>().default(['read']), // ['read', 'write', 'admin']
|
|
94
|
+
allowedIps: jsonb('allowed_ips').$type<string[]>().default([]), // CIDR or IPs
|
|
95
|
+
lastUsed: timestamp('last_used'),
|
|
96
|
+
expiresAt: timestamp('expires_at'),
|
|
97
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
98
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
99
|
+
}, (table) => ({
|
|
100
|
+
userIdx: index('api_keys_user_idx').on(table.userId),
|
|
101
|
+
teamIdx: index('api_keys_team_idx').on(table.teamId),
|
|
102
|
+
}))
|
|
103
|
+
export const variableHistory = pgTable('variable_history', {
|
|
104
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
105
|
+
variableId: uuid('variable_id').notNull(),
|
|
106
|
+
teamId: uuid('team_id').references(() => teams.id, { onDelete: 'cascade' }).notNull(),
|
|
107
|
+
environmentId: uuid('environment_id').references(() => environments.id, { onDelete: 'cascade' }).notNull(),
|
|
108
|
+
key: text('key').notNull(),
|
|
109
|
+
value: text('value').notNull(),
|
|
110
|
+
description: text('description'),
|
|
111
|
+
isSecret: boolean('is_secret').default(false),
|
|
112
|
+
changedBy: uuid('changed_by').references(() => users.id).notNull(),
|
|
113
|
+
changeType: text('change_type').notNull(), // 'create', 'update', 'delete'
|
|
114
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
115
|
+
}, (table) => ({
|
|
116
|
+
variableIdx: index('variable_history_variable_idx').on(table.variableId),
|
|
117
|
+
teamIdx: index('variable_history_team_idx').on(table.teamId),
|
|
118
|
+
}))
|
|
119
|
+
export const auditLogs = pgTable('audit_logs', {
|
|
120
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
121
|
+
userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }),
|
|
122
|
+
teamId: uuid('team_id').references(() => teams.id, { onDelete: 'cascade' }),
|
|
123
|
+
action: text('action').notNull(), // 'create', 'update', 'delete', 'view'
|
|
124
|
+
resourceType: text('resource_type').notNull(), // 'variable', 'environment', 'team'
|
|
125
|
+
resourceId: uuid('resource_id').notNull(),
|
|
126
|
+
oldValue: jsonb('old_value'),
|
|
127
|
+
newValue: jsonb('new_value'),
|
|
128
|
+
ipAddress: text('ip_address'),
|
|
129
|
+
userAgent: text('user_agent'),
|
|
130
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
131
|
+
}, (table) => ({
|
|
132
|
+
userIdx: index('audit_logs_user_idx').on(table.userId),
|
|
133
|
+
teamIdx: index('audit_logs_team_idx').on(table.teamId),
|
|
134
|
+
resourceIdx: index('audit_logs_resource_idx').on(table.resourceType, table.resourceId),
|
|
135
|
+
}))
|
|
136
|
+
|
|
137
|
+
// Invitations table
|
|
138
|
+
export const invitations = pgTable('invitations', {
|
|
139
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
140
|
+
teamId: uuid('team_id').references(() => teams.id, { onDelete: 'cascade' }).notNull(),
|
|
141
|
+
email: text('email').notNull(),
|
|
142
|
+
role: text('role').default('developer').notNull(), // 'admin', 'developer', 'viewer'
|
|
143
|
+
invitedBy: uuid('invited_by').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
|
144
|
+
token: text('token').unique().notNull(),
|
|
145
|
+
status: text('status').default('pending').notNull(), // 'pending', 'accepted', 'expired'
|
|
146
|
+
expiresAt: timestamp('expires_at').notNull(),
|
|
147
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
148
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
149
|
+
}, (table) => ({
|
|
150
|
+
teamIdx: index('invitations_team_idx').on(table.teamId),
|
|
151
|
+
emailIdx: index('invitations_email_idx').on(table.email),
|
|
152
|
+
tokenIdx: index('invitations_token_idx').on(table.token),
|
|
153
|
+
}))
|
|
154
|
+
|
|
155
|
+
// Webhooks for notifications
|
|
156
|
+
export const webhooks = pgTable('webhooks', {
|
|
157
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
158
|
+
teamId: uuid('team_id').references(() => teams.id, { onDelete: 'cascade' }).notNull(),
|
|
159
|
+
url: text('url').notNull(),
|
|
160
|
+
name: text('name').notNull(),
|
|
161
|
+
events: jsonb('events').$type<string[]>().default(['variable.update', 'variable.delete']), // ['variable.create', 'variable.update', 'variable.delete']
|
|
162
|
+
secret: text('secret'),
|
|
163
|
+
isActive: boolean('is_active').default(true).notNull(),
|
|
164
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
165
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
166
|
+
}, (table) => ({
|
|
167
|
+
teamIdx: index('webhooks_team_idx').on(table.teamId),
|
|
168
|
+
}))
|
|
169
|
+
|
|
170
|
+
// Relations
|
|
171
|
+
export const usersRelations = relations(users, ({ many }) => ({
|
|
172
|
+
ownedTeams: many(teams, { relationName: 'teamOwner' }),
|
|
173
|
+
teamMemberships: many(teamMembers),
|
|
174
|
+
createdVariables: many(environmentVariables, { relationName: 'variableCreator' }),
|
|
175
|
+
updatedVariables: many(environmentVariables, { relationName: 'variableUpdater' }),
|
|
176
|
+
auditLogs: many(auditLogs),
|
|
177
|
+
sentInvitations: many(invitations),
|
|
178
|
+
}))
|
|
179
|
+
|
|
180
|
+
export const teamsRelations = relations(teams, ({ one, many }) => ({
|
|
181
|
+
owner: one(users, { fields: [teams.ownerId], references: [users.id], relationName: 'teamOwner' }),
|
|
182
|
+
members: many(teamMembers),
|
|
183
|
+
environments: many(environments),
|
|
184
|
+
variables: many(environmentVariables),
|
|
185
|
+
auditLogs: many(auditLogs),
|
|
186
|
+
invitations: many(invitations),
|
|
187
|
+
webhooks: many(webhooks),
|
|
188
|
+
}))
|
|
189
|
+
|
|
190
|
+
export const invitationsRelations = relations(invitations, ({ one }) => ({
|
|
191
|
+
team: one(teams, { fields: [invitations.teamId], references: [teams.id] }),
|
|
192
|
+
inviter: one(users, { fields: [invitations.invitedBy], references: [users.id] }),
|
|
193
|
+
}))
|
|
194
|
+
|
|
195
|
+
export const teamMembersRelations = relations(teamMembers, ({ one }) => ({
|
|
196
|
+
team: one(teams, { fields: [teamMembers.teamId], references: [teams.id] }),
|
|
197
|
+
user: one(users, { fields: [teamMembers.userId], references: [users.id] }),
|
|
198
|
+
}))
|
|
199
|
+
|
|
200
|
+
export const environmentsRelations = relations(environments, ({ one, many }) => ({
|
|
201
|
+
team: one(teams, { fields: [environments.teamId], references: [teams.id] }),
|
|
202
|
+
variables: many(environmentVariables),
|
|
203
|
+
}))
|
|
204
|
+
|
|
205
|
+
export const environmentVariablesRelations = relations(environmentVariables, ({ one }) => ({
|
|
206
|
+
team: one(teams, { fields: [environmentVariables.teamId], references: [teams.id] }),
|
|
207
|
+
environment: one(environments, { fields: [environmentVariables.environmentId], references: [environments.id] }),
|
|
208
|
+
creator: one(users, { fields: [environmentVariables.createdBy], references: [users.id], relationName: 'variableCreator' }),
|
|
209
|
+
updater: one(users, { fields: [environmentVariables.updatedBy], references: [users.id], relationName: 'variableUpdater' }),
|
|
210
|
+
}))
|
|
211
|
+
|
|
212
|
+
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
|
|
213
|
+
user: one(users, { fields: [apiKeys.userId], references: [users.id] }),
|
|
214
|
+
team: one(teams, { fields: [apiKeys.teamId], references: [teams.id] }),
|
|
215
|
+
}))
|
|
216
|
+
|
|
217
|
+
// Types
|
|
218
|
+
export type User = typeof users.$inferSelect
|
|
219
|
+
export type NewUser = typeof users.$inferInsert
|
|
220
|
+
|
|
221
|
+
export type Team = typeof teams.$inferSelect
|
|
222
|
+
export type NewTeam = typeof teams.$inferInsert
|
|
223
|
+
|
|
224
|
+
export type TeamMember = typeof teamMembers.$inferSelect
|
|
225
|
+
export type NewTeamMember = typeof teamMembers.$inferInsert
|
|
226
|
+
|
|
227
|
+
export type Environment = typeof environments.$inferSelect
|
|
228
|
+
export type NewEnvironment = typeof environments.$inferInsert
|
|
229
|
+
|
|
230
|
+
export type EnvironmentVariable = typeof environmentVariables.$inferSelect
|
|
231
|
+
export type NewEnvironmentVariable = typeof environmentVariables.$inferInsert
|
|
232
|
+
|
|
233
|
+
export type VariableHistory = typeof variableHistory.$inferSelect
|
|
234
|
+
export type NewVariableHistory = typeof variableHistory.$inferInsert
|
|
235
|
+
|
|
236
|
+
export type ApiKey = typeof apiKeys.$inferSelect
|
|
237
|
+
export type NewApiKey = typeof apiKeys.$inferInsert
|
|
238
|
+
|
|
239
|
+
export type AuditLog = typeof auditLogs.$inferSelect
|
|
240
|
+
export type NewAuditLog = typeof auditLogs.$inferInsert
|