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
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { usePathname, useRouter } from 'next/navigation';
|
|
6
|
+
import {
|
|
7
|
+
Shield,
|
|
8
|
+
LayoutDashboard,
|
|
9
|
+
Settings,
|
|
10
|
+
Key,
|
|
11
|
+
LogOut,
|
|
12
|
+
Menu,
|
|
13
|
+
X,
|
|
14
|
+
ChevronRight,
|
|
15
|
+
User,
|
|
16
|
+
Plus,
|
|
17
|
+
History,
|
|
18
|
+
BookOpen,
|
|
19
|
+
Globe
|
|
20
|
+
} from 'lucide-react';
|
|
21
|
+
import { Button } from '@/components/ui/button';
|
|
22
|
+
import { useAuth } from '@/hooks/use-auth';
|
|
23
|
+
import { Separator } from '@/components/ui/separator';
|
|
24
|
+
import {
|
|
25
|
+
DropdownMenu,
|
|
26
|
+
DropdownMenuContent,
|
|
27
|
+
DropdownMenuItem,
|
|
28
|
+
DropdownMenuLabel,
|
|
29
|
+
DropdownMenuSeparator,
|
|
30
|
+
DropdownMenuTrigger,
|
|
31
|
+
} from "@/components/ui/dropdown-menu";
|
|
32
|
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
33
|
+
|
|
34
|
+
interface AppLayoutProps {
|
|
35
|
+
children: React.ReactNode;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default function AppLayout({ children }: AppLayoutProps) {
|
|
39
|
+
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
40
|
+
const pathname = usePathname();
|
|
41
|
+
const { user, logout } = useAuth();
|
|
42
|
+
const router = useRouter();
|
|
43
|
+
|
|
44
|
+
const navigation = [
|
|
45
|
+
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, protected: true },
|
|
46
|
+
{ name: 'API Keys', href: '/api-keys', icon: Key, protected: true },
|
|
47
|
+
{ name: 'Audit Log', href: '/audit-log', icon: History, protected: true },
|
|
48
|
+
{ name: 'Docs', href: '/docs', icon: BookOpen, protected: false },
|
|
49
|
+
{ name: 'API Reference', href: '/api-reference', icon: Globe, protected: false },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const handleLogout = async () => {
|
|
53
|
+
await logout();
|
|
54
|
+
router.push('/');
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const getInitials = (name: string) => {
|
|
58
|
+
return name.split(' ').map(n => n[0]).join('').toUpperCase();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const filteredNavigation = navigation.filter(item => !item.protected || user);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="min-h-screen bg-background flex">
|
|
65
|
+
{/* Sidebar for desktop */}
|
|
66
|
+
<aside
|
|
67
|
+
className={`${
|
|
68
|
+
sidebarOpen ? 'w-64' : 'w-20'
|
|
69
|
+
} hidden md:flex flex-col border-r bg-card transition-all duration-300 ease-in-out shadow-sm`}
|
|
70
|
+
>
|
|
71
|
+
<div className="p-6 flex items-center justify-between">
|
|
72
|
+
<Link href={user ? "/dashboard" : "/"} className="flex items-center space-x-2">
|
|
73
|
+
<Shield className="h-8 w-8 text-primary" />
|
|
74
|
+
{sidebarOpen && <span className="text-xl font-bold tracking-tight text-foreground">Nobalmako</span>}
|
|
75
|
+
</Link>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<nav className="flex-1 px-4 space-y-2">
|
|
79
|
+
{filteredNavigation.map((item) => {
|
|
80
|
+
const isActive = pathname === item.href;
|
|
81
|
+
return (
|
|
82
|
+
<Link
|
|
83
|
+
key={item.name}
|
|
84
|
+
href={item.href}
|
|
85
|
+
className={`flex items-center space-x-3 px-3 py-2 rounded-lg transition-all duration-200 ${
|
|
86
|
+
isActive
|
|
87
|
+
? 'bg-primary shadow-md text-primary-foreground'
|
|
88
|
+
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
89
|
+
}`}
|
|
90
|
+
>
|
|
91
|
+
<item.icon className={`h-5 w-5 shrink-0 ${isActive ? 'text-primary-foreground' : 'text-muted-foreground'}`} />
|
|
92
|
+
{sidebarOpen && <span className="font-medium text-sm">{item.name}</span>}
|
|
93
|
+
</Link>
|
|
94
|
+
);
|
|
95
|
+
})}
|
|
96
|
+
</nav>
|
|
97
|
+
|
|
98
|
+
{user && (
|
|
99
|
+
<div className="p-4 mt-auto border-t border-border/50">
|
|
100
|
+
<Button
|
|
101
|
+
variant="ghost"
|
|
102
|
+
className="w-full justify-start text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
|
103
|
+
onClick={handleLogout}
|
|
104
|
+
>
|
|
105
|
+
<LogOut className="h-5 w-5 shrink-0" />
|
|
106
|
+
{sidebarOpen && <span className="ml-3 font-medium text-sm">Logout</span>}
|
|
107
|
+
</Button>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</aside>
|
|
111
|
+
|
|
112
|
+
{/* Main Content */}
|
|
113
|
+
<div className="flex-1 flex flex-col min-h-screen overflow-hidden">
|
|
114
|
+
{/* Top Navbar */}
|
|
115
|
+
<header className="h-16 border-b bg-card/80 backdrop-blur-md flex items-center justify-between px-4 md:px-8 sticky top-0 z-30 shadow-sm">
|
|
116
|
+
<div className="flex items-center">
|
|
117
|
+
<Button
|
|
118
|
+
variant="ghost"
|
|
119
|
+
size="icon"
|
|
120
|
+
className="md:hidden mr-2"
|
|
121
|
+
onClick={() => setSidebarOpen(!sidebarOpen)}
|
|
122
|
+
>
|
|
123
|
+
<Menu className="h-6 w-6" />
|
|
124
|
+
</Button>
|
|
125
|
+
|
|
126
|
+
<Breadcrumb name={navigation.find(n => n.href === pathname)?.name || 'Project Details'} />
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div className="flex items-center space-x-4">
|
|
130
|
+
{user ? (
|
|
131
|
+
<DropdownMenu>
|
|
132
|
+
<DropdownMenuTrigger asChild>
|
|
133
|
+
<Button variant="ghost" className="relative h-10 w-10 rounded-full border border-border/50 hover:border-primary/50 transition-colors bg-muted/20">
|
|
134
|
+
<Avatar className="h-9 w-9">
|
|
135
|
+
<AvatarImage src={user?.avatar || ''} alt={user?.name || ''} />
|
|
136
|
+
<AvatarFallback className="bg-primary/10 text-primary font-bold">{user?.name ? getInitials(user.name) : 'U'}</AvatarFallback>
|
|
137
|
+
</Avatar>
|
|
138
|
+
</Button>
|
|
139
|
+
</DropdownMenuTrigger>
|
|
140
|
+
<DropdownMenuContent className="w-64" align="end" sideOffset={8}>
|
|
141
|
+
<DropdownMenuLabel className="font-normal p-4">
|
|
142
|
+
<div className="flex flex-col space-y-1">
|
|
143
|
+
<p className="text-sm font-semibold text-foreground">{user?.name}</p>
|
|
144
|
+
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
|
|
145
|
+
</div>
|
|
146
|
+
</DropdownMenuLabel>
|
|
147
|
+
<DropdownMenuSeparator />
|
|
148
|
+
<DropdownMenuItem onClick={() => router.push('/profile')} className="cursor-pointer py-2.5">
|
|
149
|
+
<User className="mr-3 h-4 w-4 text-muted-foreground" />
|
|
150
|
+
<span>Profile Settings</span>
|
|
151
|
+
</DropdownMenuItem>
|
|
152
|
+
<DropdownMenuItem onClick={() => router.push('/audit-log')} className="cursor-pointer py-2.5">
|
|
153
|
+
<History className="mr-3 h-4 w-4 text-muted-foreground" />
|
|
154
|
+
<span>Audit Logs</span>
|
|
155
|
+
</DropdownMenuItem>
|
|
156
|
+
<DropdownMenuSeparator />
|
|
157
|
+
<DropdownMenuItem onClick={handleLogout} className="text-destructive cursor-pointer py-2.5 font-medium">
|
|
158
|
+
<LogOut className="mr-3 h-4 w-4" />
|
|
159
|
+
<span>Log out</span>
|
|
160
|
+
</DropdownMenuItem>
|
|
161
|
+
</DropdownMenuContent>
|
|
162
|
+
</DropdownMenu>
|
|
163
|
+
) : (
|
|
164
|
+
<div className="flex items-center gap-3">
|
|
165
|
+
<Button variant="ghost" asChild className="hidden sm:inline-flex">
|
|
166
|
+
<Link href="/auth">Sign In</Link>
|
|
167
|
+
</Button>
|
|
168
|
+
<Button asChild className="shadow-lg shadow-primary/20">
|
|
169
|
+
<Link href="/auth">Get Started</Link>
|
|
170
|
+
</Button>
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
</header>
|
|
175
|
+
|
|
176
|
+
{/* Main Content Area */}
|
|
177
|
+
<main className="flex-1 overflow-y-auto bg-[#fafafa] p-4 md:p-8">
|
|
178
|
+
{children}
|
|
179
|
+
</main>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function Breadcrumb({ name }: { name: string }) {
|
|
186
|
+
return (
|
|
187
|
+
<div className="flex items-center text-sm font-medium">
|
|
188
|
+
<span className="text-muted-foreground">App</span>
|
|
189
|
+
<ChevronRight className="h-4 w-4 mx-2 text-muted-foreground/50" />
|
|
190
|
+
<span className="text-foreground">{name}</span>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import {
|
|
6
|
+
AlertDialog,
|
|
7
|
+
AlertDialogAction,
|
|
8
|
+
AlertDialogCancel,
|
|
9
|
+
AlertDialogContent,
|
|
10
|
+
AlertDialogDescription,
|
|
11
|
+
AlertDialogFooter,
|
|
12
|
+
AlertDialogHeader,
|
|
13
|
+
AlertDialogTitle,
|
|
14
|
+
AlertDialogTrigger,
|
|
15
|
+
} from "@/components/ui/alert-dialog";
|
|
16
|
+
import { Trash2, Download } from "lucide-react";
|
|
17
|
+
import { useVariables } from "@/hooks/use-variables";
|
|
18
|
+
|
|
19
|
+
interface BulkActionsProps {
|
|
20
|
+
selectedVariables: string[];
|
|
21
|
+
onSelectionChange: (selected: string[]) => void;
|
|
22
|
+
onRefresh: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function BulkActions({ selectedVariables, onSelectionChange, onRefresh }: BulkActionsProps) {
|
|
26
|
+
const [isDeleting, setIsDeleting] = useState(false);
|
|
27
|
+
const { refetch } = useVariables();
|
|
28
|
+
|
|
29
|
+
const handleBulkDelete = async () => {
|
|
30
|
+
if (selectedVariables.length === 0) return;
|
|
31
|
+
|
|
32
|
+
setIsDeleting(true);
|
|
33
|
+
try {
|
|
34
|
+
const deletePromises = selectedVariables.map(id =>
|
|
35
|
+
fetch('/api/variables', {
|
|
36
|
+
method: 'DELETE',
|
|
37
|
+
headers: {
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
},
|
|
40
|
+
body: JSON.stringify({ id }),
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
await Promise.all(deletePromises);
|
|
45
|
+
onSelectionChange([]);
|
|
46
|
+
refetch();
|
|
47
|
+
onRefresh();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.error('Bulk delete failed:', error);
|
|
50
|
+
} finally {
|
|
51
|
+
setIsDeleting(false);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const handleExport = () => {
|
|
56
|
+
// Export selected variables as JSON
|
|
57
|
+
const exportData = {
|
|
58
|
+
exportedAt: new Date().toISOString(),
|
|
59
|
+
variables: selectedVariables,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
|
63
|
+
type: 'application/json',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const url = URL.createObjectURL(blob);
|
|
67
|
+
const a = document.createElement('a');
|
|
68
|
+
a.href = url;
|
|
69
|
+
a.download = `nobalmako-variables-${new Date().toISOString().split('T')[0]}.json`;
|
|
70
|
+
document.body.appendChild(a);
|
|
71
|
+
a.click();
|
|
72
|
+
document.body.removeChild(a);
|
|
73
|
+
URL.revokeObjectURL(url);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
if (selectedVariables.length === 0) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="flex items-center space-x-2 p-3 bg-muted rounded-lg">
|
|
82
|
+
<span className="text-sm font-medium">
|
|
83
|
+
{selectedVariables.length} variable{selectedVariables.length !== 1 ? 's' : ''} selected
|
|
84
|
+
</span>
|
|
85
|
+
|
|
86
|
+
<div className="flex items-center space-x-1">
|
|
87
|
+
<Button
|
|
88
|
+
variant="outline"
|
|
89
|
+
size="sm"
|
|
90
|
+
onClick={handleExport}
|
|
91
|
+
disabled={isDeleting}
|
|
92
|
+
>
|
|
93
|
+
<Download className="h-4 w-4 mr-1" />
|
|
94
|
+
Export
|
|
95
|
+
</Button>
|
|
96
|
+
|
|
97
|
+
<AlertDialog>
|
|
98
|
+
<AlertDialogTrigger asChild>
|
|
99
|
+
<Button
|
|
100
|
+
variant="destructive"
|
|
101
|
+
size="sm"
|
|
102
|
+
disabled={isDeleting}
|
|
103
|
+
>
|
|
104
|
+
<Trash2 className="h-4 w-4 mr-1" />
|
|
105
|
+
{isDeleting ? 'Deleting...' : 'Delete Selected'}
|
|
106
|
+
</Button>
|
|
107
|
+
</AlertDialogTrigger>
|
|
108
|
+
<AlertDialogContent>
|
|
109
|
+
<AlertDialogHeader>
|
|
110
|
+
<AlertDialogTitle>Delete Variables</AlertDialogTitle>
|
|
111
|
+
<AlertDialogDescription>
|
|
112
|
+
Are you sure you want to delete {selectedVariables.length} variable{selectedVariables.length !== 1 ? 's' : ''}?
|
|
113
|
+
This action cannot be undone.
|
|
114
|
+
</AlertDialogDescription>
|
|
115
|
+
</AlertDialogHeader>
|
|
116
|
+
<AlertDialogFooter>
|
|
117
|
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
118
|
+
<AlertDialogAction
|
|
119
|
+
onClick={handleBulkDelete}
|
|
120
|
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
121
|
+
>
|
|
122
|
+
Delete
|
|
123
|
+
</AlertDialogAction>
|
|
124
|
+
</AlertDialogFooter>
|
|
125
|
+
</AlertDialogContent>
|
|
126
|
+
</AlertDialog>
|
|
127
|
+
|
|
128
|
+
<Button
|
|
129
|
+
variant="ghost"
|
|
130
|
+
size="sm"
|
|
131
|
+
onClick={() => onSelectionChange([])}
|
|
132
|
+
>
|
|
133
|
+
Clear Selection
|
|
134
|
+
</Button>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import { Label } from "@/components/ui/label";
|
|
8
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
9
|
+
import {
|
|
10
|
+
Dialog,
|
|
11
|
+
DialogContent,
|
|
12
|
+
DialogDescription,
|
|
13
|
+
DialogFooter,
|
|
14
|
+
DialogHeader,
|
|
15
|
+
DialogTitle,
|
|
16
|
+
DialogTrigger,
|
|
17
|
+
} from "@/components/ui/dialog";
|
|
18
|
+
import {
|
|
19
|
+
Select,
|
|
20
|
+
SelectContent,
|
|
21
|
+
SelectItem,
|
|
22
|
+
SelectTrigger,
|
|
23
|
+
SelectValue,
|
|
24
|
+
} from "@/components/ui/select";
|
|
25
|
+
import { Plus, Globe, Layers, Type, AlignLeft } from "lucide-react";
|
|
26
|
+
import { useTeams } from "@/hooks/use-teams";
|
|
27
|
+
import { useEnvironments } from "@/hooks/use-environments";
|
|
28
|
+
|
|
29
|
+
interface CreateEnvironmentDialogProps {
|
|
30
|
+
teamId?: string;
|
|
31
|
+
children?: React.ReactNode;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default function CreateEnvironmentDialog({ teamId, children }: CreateEnvironmentDialogProps) {
|
|
35
|
+
const [open, setOpen] = useState(false);
|
|
36
|
+
const [name, setName] = useState("");
|
|
37
|
+
const [description, setDescription] = useState("");
|
|
38
|
+
const [parentId, setParentId] = useState("");
|
|
39
|
+
const [selectedTeamId, setSelectedTeamId] = useState(teamId || "");
|
|
40
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
41
|
+
const [error, setError] = useState("");
|
|
42
|
+
const { teams } = useTeams();
|
|
43
|
+
const { environments } = useEnvironments();
|
|
44
|
+
const queryClient = useQueryClient();
|
|
45
|
+
|
|
46
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
setError("");
|
|
49
|
+
setIsLoading(true);
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const response = await fetch('/api/environments', {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: {
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
name,
|
|
59
|
+
description,
|
|
60
|
+
teamId: selectedTeamId,
|
|
61
|
+
parentId: parentId || null
|
|
62
|
+
}),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const data = await response.json();
|
|
66
|
+
|
|
67
|
+
if (response.ok) {
|
|
68
|
+
setOpen(false);
|
|
69
|
+
setName("");
|
|
70
|
+
setDescription("");
|
|
71
|
+
setParentId("");
|
|
72
|
+
if (!teamId) setSelectedTeamId("");
|
|
73
|
+
queryClient.invalidateQueries({ queryKey: ['environments'] });
|
|
74
|
+
} else {
|
|
75
|
+
setError(data.error || "Failed to create environment");
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
setError("An unexpected error occurred");
|
|
79
|
+
} finally {
|
|
80
|
+
setIsLoading(false);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
86
|
+
<DialogTrigger asChild>
|
|
87
|
+
{children || (
|
|
88
|
+
<Button className="shadow-sm">
|
|
89
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
90
|
+
New Environment
|
|
91
|
+
</Button>
|
|
92
|
+
)}
|
|
93
|
+
</DialogTrigger>
|
|
94
|
+
<DialogContent className="sm:max-w-[425px]">
|
|
95
|
+
<DialogHeader>
|
|
96
|
+
<div className="flex items-center gap-2 mb-1">
|
|
97
|
+
<div className="p-1.5 bg-primary/10 rounded-md">
|
|
98
|
+
<Globe className="h-5 w-5 text-primary" />
|
|
99
|
+
</div>
|
|
100
|
+
<DialogTitle className="text-xl">Create New Environment</DialogTitle>
|
|
101
|
+
</div>
|
|
102
|
+
<DialogDescription>
|
|
103
|
+
Environments like "Production" or "Staging" help isolate your configuration.
|
|
104
|
+
</DialogDescription>
|
|
105
|
+
</DialogHeader>
|
|
106
|
+
<form onSubmit={handleSubmit} className="space-y-6 pt-4">
|
|
107
|
+
{error && (
|
|
108
|
+
<div className="text-sm font-medium text-destructive bg-destructive/10 p-3 rounded-md border border-destructive/20">
|
|
109
|
+
{error}
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
<div className="space-y-4">
|
|
114
|
+
{!teamId && (
|
|
115
|
+
<div className="grid gap-2">
|
|
116
|
+
<Label htmlFor="team" className="flex items-center gap-2">
|
|
117
|
+
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
|
|
118
|
+
Target Project
|
|
119
|
+
</Label>
|
|
120
|
+
<Select value={selectedTeamId} onValueChange={setSelectedTeamId} required>
|
|
121
|
+
<SelectTrigger className="bg-muted/30 border-muted">
|
|
122
|
+
<SelectValue placeholder="Select a project" />
|
|
123
|
+
</SelectTrigger>
|
|
124
|
+
<SelectContent>
|
|
125
|
+
{teams?.map((team) => (
|
|
126
|
+
<SelectItem key={team.id} value={team.id}>
|
|
127
|
+
{team.name}
|
|
128
|
+
</SelectItem>
|
|
129
|
+
))}
|
|
130
|
+
</SelectContent>
|
|
131
|
+
</Select>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
<div className="grid gap-2">
|
|
136
|
+
<Label htmlFor="name" className="flex items-center gap-2">
|
|
137
|
+
<Type className="h-3.5 w-3.5 text-muted-foreground" />
|
|
138
|
+
Environment Name
|
|
139
|
+
</Label>
|
|
140
|
+
<Input
|
|
141
|
+
id="name"
|
|
142
|
+
value={name}
|
|
143
|
+
onChange={(e) => setName(e.target.value)}
|
|
144
|
+
placeholder="e.g. production, staging, local"
|
|
145
|
+
required
|
|
146
|
+
disabled={isLoading}
|
|
147
|
+
className="bg-muted/30 border-muted"
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div className="grid gap-2">
|
|
152
|
+
<Label htmlFor="description" className="flex items-center gap-2">
|
|
153
|
+
<AlignLeft className="h-3.5 w-3.5 text-muted-foreground" />
|
|
154
|
+
Description <span className="text-xs text-muted-foreground font-normal">(Optional)</span>
|
|
155
|
+
</Label>
|
|
156
|
+
<Textarea
|
|
157
|
+
id="description"
|
|
158
|
+
value={description}
|
|
159
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
160
|
+
placeholder="What is this environment used for?"
|
|
161
|
+
className="resize-none min-h-[100px] bg-muted/30 border-muted"
|
|
162
|
+
disabled={isLoading}
|
|
163
|
+
/>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<div className="grid gap-2">
|
|
167
|
+
<Label htmlFor="parent" className="flex items-center gap-2">
|
|
168
|
+
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
|
|
169
|
+
Parent Environment <span className="text-xs text-muted-foreground font-normal">(Optional)</span>
|
|
170
|
+
</Label>
|
|
171
|
+
<Select value={parentId} onValueChange={setParentId} disabled={isLoading}>
|
|
172
|
+
<SelectTrigger className="bg-muted/30 border-muted">
|
|
173
|
+
<SelectValue placeholder="Inherit variables from..." />
|
|
174
|
+
</SelectTrigger>
|
|
175
|
+
<SelectContent>
|
|
176
|
+
<SelectItem value="none">None (Stand-alone)</SelectItem>
|
|
177
|
+
{environments?.filter(e => e.teamId === selectedTeamId).map((env) => (
|
|
178
|
+
<SelectItem key={env.id} value={env.id}>
|
|
179
|
+
{env.name}
|
|
180
|
+
</SelectItem>
|
|
181
|
+
))}
|
|
182
|
+
</SelectContent>
|
|
183
|
+
</Select>
|
|
184
|
+
<p className="text-[10px] text-muted-foreground mt-0.5">
|
|
185
|
+
Variables from the parent will be available in this environment unless overridden.
|
|
186
|
+
</p>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<DialogFooter className="pt-2">
|
|
191
|
+
<Button
|
|
192
|
+
type="button"
|
|
193
|
+
variant="ghost"
|
|
194
|
+
onClick={() => setOpen(false)}
|
|
195
|
+
disabled={isLoading}
|
|
196
|
+
>
|
|
197
|
+
Cancel
|
|
198
|
+
</Button>
|
|
199
|
+
<Button type="submit" disabled={isLoading || (!teamId && !selectedTeamId)} className="px-8">
|
|
200
|
+
{isLoading ? "Creating..." : "Create Environment"}
|
|
201
|
+
</Button>
|
|
202
|
+
</DialogFooter>
|
|
203
|
+
</form>
|
|
204
|
+
</DialogContent>
|
|
205
|
+
</Dialog>
|
|
206
|
+
);
|
|
207
|
+
}
|