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,347 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef } 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 { Upload, FileText, Database, Globe, ChevronRight, FileJson, Hash, AlertCircle } from "lucide-react";
26
+ import { useEnvironments } from "@/hooks/use-environments";
27
+ import { useVariables } from "@/hooks/use-variables";
28
+ import { Badge } from "@/components/ui/badge";
29
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
30
+
31
+ interface ImportVariablesDialogProps {
32
+ teamId: string;
33
+ children?: React.ReactNode;
34
+ }
35
+
36
+ interface ImportData {
37
+ variables: Array<{
38
+ key: string;
39
+ value: string;
40
+ description?: string;
41
+ isSecret?: boolean;
42
+ }>;
43
+ }
44
+
45
+ export default function ImportVariablesDialog({ teamId, children }: ImportVariablesDialogProps) {
46
+ const [open, setOpen] = useState(false);
47
+ const [importMethod, setImportMethod] = useState<'json' | 'env'>('json');
48
+ const [jsonData, setJsonData] = useState('');
49
+ const [envData, setEnvData] = useState('');
50
+ const [selectedEnvironmentId, setSelectedEnvironmentId] = useState('');
51
+ const [isLoading, setIsLoading] = useState(false);
52
+ const [error, setError] = useState('');
53
+ const [preview, setPreview] = useState<ImportData['variables']>([]);
54
+ const fileInputRef = useRef<HTMLInputElement>(null);
55
+ const { environments } = useEnvironments();
56
+ const queryClient = useQueryClient();
57
+
58
+ const teamEnvironments = environments?.filter(e => e.teamId === teamId) || [];
59
+
60
+ const parseEnvFile = (content: string): ImportData['variables'] => {
61
+ const variables: ImportData['variables'] = [];
62
+ const lines = content.split('\n');
63
+
64
+ for (const line of lines) {
65
+ const trimmed = line.trim();
66
+ if (!trimmed || trimmed.startsWith('#')) continue;
67
+
68
+ const [key, ...valueParts] = trimmed.split('=');
69
+ if (key && valueParts.length > 0) {
70
+ const value = valueParts.join('=').replace(/^["']|["']$/g, ''); // Remove quotes
71
+ variables.push({
72
+ key: key.trim(),
73
+ value,
74
+ isSecret: true, // Default to secret for .env imports
75
+ });
76
+ }
77
+ }
78
+
79
+ return variables;
80
+ };
81
+
82
+ const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
83
+ const file = event.target.files?.[0];
84
+ if (!file) return;
85
+
86
+ const reader = new FileReader();
87
+ reader.onload = (e) => {
88
+ const content = e.target?.result as string;
89
+ if (importMethod === 'env') {
90
+ setEnvData(content);
91
+ setPreview(parseEnvFile(content));
92
+ } else {
93
+ try {
94
+ const data = JSON.parse(content);
95
+ setJsonData(content);
96
+ setPreview(data.variables || []);
97
+ } catch (err) {
98
+ setError('Invalid JSON file');
99
+ }
100
+ }
101
+ };
102
+ reader.readAsText(file);
103
+ };
104
+
105
+ const handlePreview = () => {
106
+ setError('');
107
+ try {
108
+ if (importMethod === 'json') {
109
+ const data = JSON.parse(jsonData);
110
+ setPreview(data.variables || []);
111
+ } else {
112
+ setPreview(parseEnvFile(envData));
113
+ }
114
+ } catch (err) {
115
+ setError('Invalid data format');
116
+ }
117
+ };
118
+
119
+ const handleImport = async () => {
120
+ if (!selectedEnvironmentId || preview.length === 0) return;
121
+
122
+ setIsLoading(true);
123
+ setError('');
124
+
125
+ try {
126
+ const importPromises = preview.map(variable =>
127
+ fetch('/api/variables', {
128
+ method: 'POST',
129
+ headers: {
130
+ 'Content-Type': 'application/json',
131
+ },
132
+ body: JSON.stringify({
133
+ key: variable.key,
134
+ value: variable.value,
135
+ description: variable.description,
136
+ isSecret: variable.isSecret,
137
+ environmentId: selectedEnvironmentId,
138
+ teamId,
139
+ }),
140
+ })
141
+ );
142
+
143
+ const results = await Promise.all(importPromises);
144
+ const failures = results.filter(r => !r.ok);
145
+
146
+ if (failures.length > 0) {
147
+ setError(`${failures.length} variables failed to import`);
148
+ } else {
149
+ setOpen(false);
150
+ setJsonData('');
151
+ setEnvData('');
152
+ setPreview([]);
153
+ setSelectedEnvironmentId('');
154
+ queryClient.invalidateQueries({ queryKey: ['variables'] });
155
+ }
156
+ } catch (err) {
157
+ setError('Import failed');
158
+ } finally {
159
+ setIsLoading(false);
160
+ }
161
+ };
162
+
163
+ return (
164
+ <Dialog open={open} onOpenChange={setOpen}>
165
+ <DialogTrigger asChild>
166
+ {children || (
167
+ <Button variant="outline" className="gap-2 border-primary/20 hover:border-primary/50 transition-all shadow-sm">
168
+ <Upload className="h-4 w-4 text-primary" />
169
+ Import Variables
170
+ </Button>
171
+ )}
172
+ </DialogTrigger>
173
+ <DialogContent className="sm:max-w-[650px] p-0 overflow-hidden shadow-2xl flex flex-col max-h-[90vh]">
174
+ <DialogHeader className="p-6 bg-slate-50 border-b border-slate-200 shrink-0">
175
+ <div className="flex items-center gap-3">
176
+ <div className="p-2 bg-primary/10 rounded-lg text-primary">
177
+ <Database className="h-6 w-6" />
178
+ </div>
179
+ <div>
180
+ <DialogTitle className="text-xl">Import Variables</DialogTitle>
181
+ <DialogDescription className="text-primary/60">
182
+ Bulk import secrets and configuration from external sources.
183
+ </DialogDescription>
184
+ </div>
185
+ </div>
186
+ </DialogHeader>
187
+
188
+ <div className="p-6 space-y-6 overflow-y-auto flex-1 scrollbar-thin">
189
+ {error && (
190
+ <div className="text-sm font-medium text-destructive bg-destructive/10 p-3 rounded-lg border border-destructive/20 flex items-center gap-2">
191
+ <AlertCircle className="h-4 w-4" />
192
+ {error}
193
+ </div>
194
+ )}
195
+
196
+ <div className="grid grid-cols-2 gap-6">
197
+ <div className="space-y-3">
198
+ <Label className="text-sm font-semibold flex items-center gap-2 text-muted-foreground font-mono uppercase tracking-wider">
199
+ <FileJson className="h-3.5 w-3.5" />
200
+ Import Format
201
+ </Label>
202
+ <Select value={importMethod} onValueChange={(value: 'json' | 'env') => setImportMethod(value)}>
203
+ <SelectTrigger className="h-11 bg-muted/30 focus-visible:ring-primary/20">
204
+ <SelectValue />
205
+ </SelectTrigger>
206
+ <SelectContent>
207
+ <SelectItem value="json" className="py-2">
208
+ <span className="font-medium">JSON Format</span>
209
+ <span className="block text-[10px] text-muted-foreground">Nobalmako export format</span>
210
+ </SelectItem>
211
+ <SelectItem value="env" className="py-2">
212
+ <span className="font-medium">.env File</span>
213
+ <span className="block text-[10px] text-muted-foreground">Standard key=value pairs</span>
214
+ </SelectItem>
215
+ </SelectContent>
216
+ </Select>
217
+ </div>
218
+
219
+ <div className="space-y-3">
220
+ <Label className="text-sm font-semibold flex items-center gap-2 text-muted-foreground font-mono uppercase tracking-wider">
221
+ <Globe className="h-3.5 w-3.5" />
222
+ Target Env
223
+ </Label>
224
+ <Select value={selectedEnvironmentId} onValueChange={setSelectedEnvironmentId}>
225
+ <SelectTrigger className="h-11 bg-muted/30 focus-visible:ring-primary/20">
226
+ <SelectValue placeholder="Select destination..." />
227
+ </SelectTrigger>
228
+ <SelectContent>
229
+ {teamEnvironments.map((env) => (
230
+ <SelectItem key={env.id} value={env.id} className="py-2">
231
+ <div className="flex items-center gap-2">
232
+ <div className="h-2 w-2 rounded-full" style={{ backgroundColor: env.color || '#3b82f6' }} />
233
+ <span className="font-medium">{env.name}</span>
234
+ </div>
235
+ </SelectItem>
236
+ ))}
237
+ </SelectContent>
238
+ </Select>
239
+ </div>
240
+ </div>
241
+
242
+ <div className="space-y-4">
243
+ <div className="flex items-center justify-between">
244
+ <Label className="text-sm font-semibold flex items-center gap-2 text-muted-foreground font-mono uppercase tracking-wider">
245
+ <Hash className="h-3.5 w-3.5" />
246
+ Raw Data / File Upload
247
+ </Label>
248
+ <Button
249
+ type="button"
250
+ variant="ghost"
251
+ size="sm"
252
+ className="text-xs text-primary hover:text-primary hover:bg-primary/5 h-7"
253
+ onClick={() => fileInputRef.current?.click()}
254
+ >
255
+ <Upload className="h-3 w-3 mr-1.5" />
256
+ Upload File
257
+ </Button>
258
+ <input
259
+ type="file"
260
+ ref={fileInputRef}
261
+ className="hidden"
262
+ onChange={handleFileUpload}
263
+ accept={importMethod === 'json' ? '.json' : '.env'}
264
+ />
265
+ </div>
266
+
267
+ <div className="relative group">
268
+ <Textarea
269
+ placeholder={importMethod === 'json'
270
+ ? '{\n "variables": [\n { "key": "API_KEY", "value": "xyz", "isSecret": true }\n ]\n}'
271
+ : 'API_KEY=your-secret\nDB_URL=postgres://...'
272
+ }
273
+ value={importMethod === 'json' ? jsonData : envData}
274
+ onChange={(e) => importMethod === 'json' ? setJsonData(e.target.value) : setEnvData(e.target.value)}
275
+ rows={8}
276
+ className="font-mono text-xs bg-slate-50 border-slate-200 group-focus:border-primary/30 min-h-[150px] resize-none text-slate-800"
277
+ />
278
+ <div className="absolute top-3 right-3 opacity-30 group-hover:opacity-100 transition-opacity">
279
+ <FileText className="h-4 w-4 text-slate-400" />
280
+ </div>
281
+ </div>
282
+ </div>
283
+
284
+ <Button
285
+ type="button"
286
+ variant="secondary"
287
+ onClick={handlePreview}
288
+ className="w-full bg-slate-100 text-slate-900 hover:bg-slate-200 border-0"
289
+ >
290
+ Process & Preview Changes
291
+ </Button>
292
+
293
+ {preview.length > 0 && (
294
+ <div className="space-y-3 pt-2">
295
+ <div className="flex items-center justify-between px-1">
296
+ <Label className="text-xs font-bold text-slate-400 uppercase tracking-widest">Preview ({preview.length} items)</Label>
297
+ <Badge variant="outline" className="text-[10px] bg-slate-50 text-slate-600 border-slate-200">Ready to import</Badge>
298
+ </div>
299
+ <div className="max-h-[180px] overflow-y-auto rounded-lg border border-slate-200 divide-y divide-slate-100 bg-slate-50/30">
300
+ {preview.map((variable, index) => (
301
+ <div key={index} className="px-4 py-3 flex items-center justify-between hover:bg-white transition-colors">
302
+ <div className="flex flex-col gap-0.5">
303
+ <code className="text-sm font-semibold text-slate-900">{variable.key}</code>
304
+ {variable.description && <span className="text-[10px] text-slate-500 truncate max-w-[200px]">{variable.description}</span>}
305
+ </div>
306
+ <Badge variant={variable.isSecret ? "destructive" : "outline"} className={`text-[10px] px-1.5 py-0 rounded-sm font-mono border-slate-200 ${!variable.isSecret ? 'text-primary' : ''}`}>
307
+ {variable.isSecret ? 'SECRET' : 'PUBLIC'}
308
+ </Badge>
309
+ </div>
310
+ ))}
311
+ </div>
312
+ </div>
313
+ )}
314
+ </div>
315
+
316
+ <div className="p-6 bg-slate-50 border-t border-slate-200 shrink-0 flex items-center justify-between gap-3">
317
+ <Button
318
+ type="button"
319
+ variant="ghost"
320
+ onClick={() => setOpen(false)}
321
+ disabled={isLoading}
322
+ className="text-slate-500 hover:text-slate-900"
323
+ >
324
+ Cancel
325
+ </Button>
326
+ <Button
327
+ onClick={handleImport}
328
+ disabled={!selectedEnvironmentId || preview.length === 0 || isLoading}
329
+ className="px-8 shadow-lg shadow-primary/20 gap-2 h-11"
330
+ >
331
+ {isLoading ? (
332
+ <>
333
+ <div className="h-4 w-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
334
+ Importing...
335
+ </>
336
+ ) : (
337
+ <>
338
+ Confirm Import ({preview.length})
339
+ <ChevronRight className="h-4 w-4 opacity-50" />
340
+ </>
341
+ )}
342
+ </Button>
343
+ </div>
344
+ </DialogContent>
345
+ </Dialog>
346
+ );
347
+ }
@@ -0,0 +1,191 @@
1
+ 'use client';
2
+
3
+ import { useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Input } from "@/components/ui/input";
6
+ import { Label } from "@/components/ui/label";
7
+ import {
8
+ Dialog,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogFooter,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ DialogTrigger,
15
+ } from "@/components/ui/dialog";
16
+ import {
17
+ Select,
18
+ SelectContent,
19
+ SelectItem,
20
+ SelectTrigger,
21
+ SelectValue,
22
+ } from "@/components/ui/select";
23
+ import { UserPlus, Mail, Shield, ChevronRight } from "lucide-react";
24
+ import { useTeamMembers } from "@/hooks/use-team-members";
25
+ import { useQueryClient } from "@tanstack/react-query";
26
+ import { toast } from "sonner";
27
+
28
+ interface InviteMemberDialogProps {
29
+ teamId: string;
30
+ children?: React.ReactNode;
31
+ }
32
+
33
+ export default function InviteMemberDialog({ teamId, children }: InviteMemberDialogProps) {
34
+ const [open, setOpen] = useState(false);
35
+ const [email, setEmail] = useState("");
36
+ const [role, setRole] = useState("developer");
37
+ const [isLoading, setIsLoading] = useState(false);
38
+ const [error, setError] = useState("");
39
+ const queryClient = useQueryClient();
40
+
41
+ const handleSubmit = async (e: React.FormEvent) => {
42
+ e.preventDefault();
43
+ setError("");
44
+ setIsLoading(true);
45
+
46
+ try {
47
+ const response = await fetch('/api/team-members', {
48
+ method: 'POST',
49
+ headers: {
50
+ 'Content-Type': 'application/json',
51
+ },
52
+ body: JSON.stringify({ teamId, email: email.toLowerCase(), role }),
53
+ });
54
+
55
+ const data = await response.json();
56
+
57
+ if (response.ok) {
58
+ setOpen(false);
59
+ toast.success(data.message || "Member added", {
60
+ description: data.message ? `An invitation email has been sent to ${email}.` : `${email} has been added to the team.`,
61
+ });
62
+ setEmail("");
63
+ setRole("developer");
64
+ queryClient.invalidateQueries({ queryKey: ["team-members", teamId] });
65
+ } else {
66
+ setError(data.error || "Failed to invite member");
67
+ }
68
+ } catch (err) {
69
+ setError("An unexpected error occurred");
70
+ } finally {
71
+ setIsLoading(false);
72
+ }
73
+ };
74
+
75
+ return (
76
+ <Dialog open={open} onOpenChange={setOpen}>
77
+ <DialogTrigger asChild>
78
+ {children || (
79
+ <Button variant="outline" className="gap-2 border-primary/20 hover:border-primary/50 transition-all shadow-sm">
80
+ <UserPlus className="h-4 w-4 text-primary" />
81
+ Invite Member
82
+ </Button>
83
+ )}
84
+ </DialogTrigger>
85
+ <DialogContent className="sm:max-w-[425px] p-0 overflow-hidden shadow-2xl">
86
+ <DialogHeader className="p-6 bg-primary/5 border-b border-primary/10">
87
+ <div className="flex items-center gap-3">
88
+ <div className="p-2 bg-primary/10 rounded-lg text-primary">
89
+ <UserPlus className="h-6 w-6" />
90
+ </div>
91
+ <div>
92
+ <DialogTitle className="text-xl">Invite Team Member</DialogTitle>
93
+ <DialogDescription className="text-primary/60">
94
+ Grant project access to a new member.
95
+ </DialogDescription>
96
+ </div>
97
+ </div>
98
+ </DialogHeader>
99
+ <form onSubmit={handleSubmit} className="p-6">
100
+ <div className="space-y-6">
101
+ {error && (
102
+ <div className="text-sm font-medium text-destructive bg-destructive/10 p-3 rounded-lg border border-destructive/20 flex items-center gap-2">
103
+ <div className="h-1.5 w-1.5 rounded-full bg-destructive animate-pulse" />
104
+ {error}
105
+ </div>
106
+ )}
107
+
108
+ <div className="space-y-3">
109
+ <Label htmlFor="email" className="text-sm font-semibold flex items-center gap-2 text-muted-foreground">
110
+ <Mail className="h-4 w-4" />
111
+ Email Address
112
+ </Label>
113
+ <Input
114
+ id="email"
115
+ type="email"
116
+ value={email}
117
+ onChange={(e) => setEmail(e.target.value)}
118
+ placeholder="colleague@company.com"
119
+ className="h-11 bg-muted/30 focus-visible:ring-primary/20"
120
+ required
121
+ disabled={isLoading}
122
+ />
123
+ <p className="text-[11px] text-muted-foreground ml-1">An invitation email will be sent to this address.</p>
124
+ </div>
125
+
126
+ <div className="space-y-3">
127
+ <Label htmlFor="role" className="text-sm font-semibold flex items-center gap-2 text-muted-foreground">
128
+ <Shield className="h-4 w-4" />
129
+ Access Level
130
+ </Label>
131
+ <Select value={role} onValueChange={setRole} disabled={isLoading}>
132
+ <SelectTrigger className="h-11 bg-muted/30 focus-visible:ring-primary/20">
133
+ <SelectValue />
134
+ </SelectTrigger>
135
+ <SelectContent>
136
+ <SelectItem value="admin" className="py-3">
137
+ <div className="flex flex-col gap-0.5">
138
+ <span className="font-medium">Admin</span>
139
+ <span className="text-[10px] text-muted-foreground leading-tight">Full access to settings and members</span>
140
+ </div>
141
+ </SelectItem>
142
+ <SelectItem value="developer" className="py-3">
143
+ <div className="flex flex-col gap-0.5">
144
+ <span className="font-medium">Developer</span>
145
+ <span className="text-[10px] text-muted-foreground leading-tight">Can manage environments and secrets</span>
146
+ </div>
147
+ </SelectItem>
148
+ <SelectItem value="viewer" className="py-3">
149
+ <div className="flex flex-col gap-0.5">
150
+ <span className="font-medium">Viewer</span>
151
+ <span className="text-[10px] text-muted-foreground leading-tight">Read-only access to variables</span>
152
+ </div>
153
+ </SelectItem>
154
+ </SelectContent>
155
+ </Select>
156
+ </div>
157
+ </div>
158
+
159
+ <div className="mt-8 flex items-center justify-end gap-3 pt-6 border-t border-muted">
160
+ <Button
161
+ type="button"
162
+ variant="ghost"
163
+ onClick={() => setOpen(false)}
164
+ disabled={isLoading}
165
+ className="text-muted-foreground hover:text-foreground"
166
+ >
167
+ Cancel
168
+ </Button>
169
+ <Button
170
+ type="submit"
171
+ disabled={isLoading}
172
+ className="px-6 h-11 bg-primary shadow-lg shadow-primary/20"
173
+ >
174
+ {isLoading ? (
175
+ <>
176
+ <div className="h-4 w-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
177
+ Sending...
178
+ </>
179
+ ) : (
180
+ <>
181
+ Send Invitation
182
+ <ChevronRight className="h-4 w-4 ml-1 opacity-50" />
183
+ </>
184
+ )}
185
+ </Button>
186
+ </div>
187
+ </form>
188
+ </DialogContent>
189
+ </Dialog>
190
+ );
191
+ }
@@ -0,0 +1,111 @@
1
+
2
+ 'use client';
3
+
4
+ import { useState } from "react";
5
+ import { Button } from "@/components/ui/button";
6
+ import {
7
+ AlertDialog,
8
+ AlertDialogAction,
9
+ AlertDialogCancel,
10
+ AlertDialogContent,
11
+ AlertDialogDescription,
12
+ AlertDialogFooter,
13
+ AlertDialogHeader,
14
+ AlertDialogTitle,
15
+ AlertDialogTrigger,
16
+ } from "@/components/ui/alert-dialog";
17
+ import { LogOut, AlertTriangle } from "lucide-react";
18
+ import { useRouter } from "next/navigation";
19
+ import { useQueryClient } from "@tanstack/react-query";
20
+
21
+ interface LeaveProjectDialogProps {
22
+ teamId: string;
23
+ teamName: string;
24
+ memberId: string;
25
+ children?: React.ReactNode;
26
+ }
27
+
28
+ export default function LeaveProjectDialog({ teamId, teamName, memberId, children }: LeaveProjectDialogProps) {
29
+ const [isLoading, setIsLoading] = useState(false);
30
+ const [error, setError] = useState("");
31
+ const [open, setOpen] = useState(false);
32
+ const router = useRouter();
33
+ const queryClient = useQueryClient();
34
+
35
+ const handleLeave = async (e: React.MouseEvent) => {
36
+ e.preventDefault();
37
+ setError("");
38
+ setIsLoading(true);
39
+
40
+ try {
41
+ const response = await fetch('/api/team-members', {
42
+ method: 'DELETE',
43
+ headers: {
44
+ 'Content-Type': 'application/json',
45
+ },
46
+ body: JSON.stringify({ teamId, memberId }),
47
+ });
48
+
49
+ const data = await response.json();
50
+
51
+ if (response.ok) {
52
+ setOpen(false);
53
+ queryClient.invalidateQueries({ queryKey: ["teams"] });
54
+ router.push("/dashboard");
55
+ } else {
56
+ setError(data.error || "Failed to leave project");
57
+ }
58
+ } catch (err) {
59
+ setError("An unexpected error occurred");
60
+ } finally {
61
+ setIsLoading(false);
62
+ }
63
+ };
64
+
65
+ return (
66
+ <AlertDialog open={open} onOpenChange={setOpen}>
67
+ <AlertDialogTrigger asChild>
68
+ {children || (
69
+ <Button variant="outline" size="sm" className="gap-2 border-slate-200">
70
+ <LogOut className="h-4 w-4" />
71
+ Leave Project
72
+ </Button>
73
+ )}
74
+ </AlertDialogTrigger>
75
+ <AlertDialogContent className="bg-white border-slate-200 sm:max-w-[450px]">
76
+ <AlertDialogHeader>
77
+ <div className="flex items-center gap-3 text-amber-500 mb-2">
78
+ <div className="p-2 bg-amber-100 rounded-full">
79
+ <AlertTriangle className="h-6 w-6" />
80
+ </div>
81
+ <AlertDialogTitle className="text-xl">Leave Project</AlertDialogTitle>
82
+ </div>
83
+ <AlertDialogDescription className="text-slate-600 text-sm leading-relaxed">
84
+ Are you sure you want to leave <span className="font-semibold text-slate-900">{teamName}</span>?
85
+ You will lose all access to secrets and environments in this project. You'll need an invite to join again.
86
+ </AlertDialogDescription>
87
+ </AlertDialogHeader>
88
+
89
+ {error && (
90
+ <div className="mt-4 p-3 text-sm font-medium text-destructive bg-destructive/10 rounded-lg border border-destructive/20 text-center">
91
+ {error}
92
+ </div>
93
+ )}
94
+
95
+ <AlertDialogFooter className="mt-6">
96
+ <AlertDialogCancel className="border-slate-200 hover:bg-slate-50 text-slate-700">
97
+ Cancel
98
+ </AlertDialogCancel>
99
+ <Button
100
+ onClick={handleLeave}
101
+ disabled={isLoading}
102
+ variant="default"
103
+ className="bg-slate-950 hover:bg-slate-800 text-white min-w-[120px]"
104
+ >
105
+ {isLoading ? "Leaving..." : "Leave Project"}
106
+ </Button>
107
+ </AlertDialogFooter>
108
+ </AlertDialogContent>
109
+ </AlertDialog>
110
+ );
111
+ }