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,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
+ }