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,92 @@
1
+ 'use client';
2
+
3
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { ShieldAlert, ShieldCheck, Clock, AlertTriangle, CheckCircle2 } from "lucide-react";
6
+ import { formatDistanceToNow } from "date-fns";
7
+
8
+ interface SecurityOverviewProps {
9
+ variables: any[];
10
+ teams: any[];
11
+ }
12
+
13
+ export function SecurityOverview({ variables, teams }: SecurityOverviewProps) {
14
+ const secrets = variables.filter(v => v.isSecret);
15
+
16
+ // 1. Stale Secrets (not rotated in 90 days)
17
+ const ninetyDaysAgo = new Date();
18
+ ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
19
+
20
+ const staleSecrets = secrets.filter(v =>
21
+ v.lastRotatedAt && new Date(v.lastRotatedAt) < ninetyDaysAgo
22
+ );
23
+
24
+ // 2. Expiring soon (within 7 days)
25
+ const nextWeek = new Date();
26
+ nextWeek.setDate(nextWeek.getDate() + 7);
27
+
28
+ const expiringSoon = secrets.filter(v =>
29
+ v.expiresAt && new Date(v.expiresAt) < nextWeek && new Date(v.expiresAt) > new Date()
30
+ );
31
+
32
+ // 3. Expired
33
+ const expired = secrets.filter(v =>
34
+ v.expiresAt && new Date(v.expiresAt) < new Date()
35
+ );
36
+
37
+ return (
38
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
39
+ <Card className={expired.length > 0 ? "border-destructive/50 bg-destructive/10" : "bg-card"}>
40
+ <CardHeader className="pb-2">
41
+ <CardTitle className="text-sm font-medium flex items-center gap-2">
42
+ <ShieldAlert className={`h-4 w-4 ${expired.length > 0 ? "text-destructive" : "text-emerald-500"}`} />
43
+ Expired Secrets
44
+ </CardTitle>
45
+ </CardHeader>
46
+ <CardContent>
47
+ <div className="text-2xl font-bold">{expired.length}</div>
48
+ <p className="text-xs text-muted-foreground mt-1">
49
+ {expired.length > 0 ? "Action required immediately" : "All secrets are valid"}
50
+ </p>
51
+ </CardContent>
52
+ </Card>
53
+
54
+ <Card className="bg-card">
55
+ <CardHeader className="pb-2">
56
+ <CardTitle className="text-sm font-medium flex items-center gap-2">
57
+ <Clock className="h-4 w-4 text-amber-500" />
58
+ Expiring Soon
59
+ </CardTitle>
60
+ </CardHeader>
61
+ <CardContent>
62
+ <div className="text-2xl font-bold">{expiringSoon.length}</div>
63
+ <p className="text-xs text-muted-foreground mt-1">
64
+ Expiring within the next 7 days
65
+ </p>
66
+ </CardContent>
67
+ </Card>
68
+
69
+ <Card className="bg-card">
70
+ <CardHeader className="pb-2">
71
+ <CardTitle className="text-sm font-medium flex items-center gap-2">
72
+ <AlertTriangle className="h-4 w-4 text-orange-500" />
73
+ Stale Secrets
74
+ </CardTitle>
75
+ </CardHeader>
76
+ <CardContent>
77
+ <div className="text-2xl font-bold">{staleSecrets.length}</div>
78
+ <p className="text-xs text-muted-foreground mt-1">
79
+ Not rotated in over 90 days
80
+ </p>
81
+ </CardContent>
82
+ </Card>
83
+
84
+ {expired.length === 0 && expiringSoon.length === 0 && staleSecrets.length === 0 && (
85
+ <div className="lg:col-span-3 flex items-center justify-center p-8 bg-emerald-50 border border-emerald-200 rounded-xl text-emerald-700 font-medium gap-2">
86
+ <CheckCircle2 className="h-5 w-5" />
87
+ Security health check passed. No issues detected.
88
+ </div>
89
+ )}
90
+ </div>
91
+ );
92
+ }
@@ -0,0 +1,103 @@
1
+ 'use client';
2
+
3
+ import { useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Badge } from "@/components/ui/badge";
6
+ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
7
+ import { MoreHorizontal, Crown, Shield, User, Trash2, Edit } from "lucide-react";
8
+ import { useTeamMembers } from "@/hooks/use-team-members";
9
+ import { useAuth } from "@/hooks/use-auth";
10
+ import EditMemberDialog from "./EditMemberDialog";
11
+ import RemoveMemberDialog from "./RemoveMemberDialog";
12
+
13
+ interface TeamMembersManagerProps {
14
+ teamId: string;
15
+ }
16
+
17
+ export default function TeamMembersManager({ teamId }: TeamMembersManagerProps) {
18
+ const { user } = useAuth();
19
+ const { data: members, isLoading } = useTeamMembers(teamId);
20
+
21
+ const getRoleIcon = (role: string) => {
22
+ switch (role) {
23
+ case 'owner':
24
+ return <Crown className="h-4 w-4" />;
25
+ case 'admin':
26
+ return <Shield className="h-4 w-4" />;
27
+ default:
28
+ return <User className="h-4 w-4" />;
29
+ }
30
+ };
31
+
32
+ const getRoleBadgeVariant = (role: string) => {
33
+ switch (role) {
34
+ case 'owner':
35
+ return 'default';
36
+ case 'admin':
37
+ return 'secondary';
38
+ default:
39
+ return 'outline';
40
+ }
41
+ };
42
+
43
+ if (isLoading) {
44
+ return <div className="animate-pulse space-y-3">
45
+ {[...Array(3)].map((_, i) => (
46
+ <div key={i} className="flex items-center space-x-3 p-3 border rounded-lg">
47
+ <div className="w-10 h-10 bg-gray-200 rounded-full"></div>
48
+ <div className="flex-1 space-y-2">
49
+ <div className="h-4 bg-gray-200 rounded w-1/4"></div>
50
+ <div className="h-3 bg-gray-200 rounded w-1/2"></div>
51
+ </div>
52
+ </div>
53
+ ))}
54
+ </div>;
55
+ }
56
+
57
+ return (
58
+ <div className="space-y-3">
59
+ {members?.map((member) => (
60
+ <div key={member.id} className="flex items-center justify-between p-3 border rounded-lg">
61
+ <div className="flex items-center space-x-3">
62
+ <Avatar className="h-10 w-10">
63
+ <AvatarImage src={member.user.avatar} />
64
+ <AvatarFallback>
65
+ {member.user.name.split(' ').map(n => n[0]).join('').toUpperCase()}
66
+ </AvatarFallback>
67
+ </Avatar>
68
+ <div>
69
+ <div className="flex items-center space-x-2">
70
+ <span className="font-medium">{member.user.name}</span>
71
+ <Badge variant={getRoleBadgeVariant(member.role)} className="flex items-center space-x-1">
72
+ {getRoleIcon(member.role)}
73
+ <span className="capitalize">{member.role}</span>
74
+ </Badge>
75
+ </div>
76
+ <p className="text-sm text-muted-foreground">{member.user.email}</p>
77
+ <p className="text-xs text-muted-foreground">
78
+ Joined {new Date(member.joinedAt).toLocaleDateString()}
79
+ </p>
80
+ </div>
81
+ </div>
82
+
83
+ {/* Only show actions if user can manage this member and it's not the owner */}
84
+ {user && (user.id === member.userId || members?.some(m => m.userId === user.id && (m.role === 'owner' || m.role === 'admin'))) && member.role !== 'owner' && (
85
+ <div className="flex items-center gap-1">
86
+ <EditMemberDialog teamId={teamId} member={member}>
87
+ <Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-slate-500 hover:text-slate-900">
88
+ <Edit className="h-4 w-4" />
89
+ </Button>
90
+ </EditMemberDialog>
91
+
92
+ <RemoveMemberDialog teamId={teamId} member={member}>
93
+ <Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10">
94
+ <Trash2 className="h-4 w-4" />
95
+ </Button>
96
+ </RemoveMemberDialog>
97
+ </div>
98
+ )}
99
+ </div>
100
+ ))}
101
+ </div>
102
+ );
103
+ }
@@ -0,0 +1,265 @@
1
+ 'use client';
2
+
3
+ import { useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Badge } from "@/components/ui/badge";
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogDescription,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ DialogTrigger,
13
+ } from "@/components/ui/dialog";
14
+ import { History, Eye, EyeOff, User, Clock, ChevronRight, Hash, Database, Shield, RotateCcw, AlertTriangle } from "lucide-react";
15
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
16
+ import { Separator } from "@/components/ui/separator";
17
+ import { toast } from "sonner";
18
+
19
+ interface VariableHistoryDialogProps {
20
+ variableId: string;
21
+ variableKey: string;
22
+ children?: React.ReactNode;
23
+ }
24
+
25
+ interface HistoryEntry {
26
+ id: string;
27
+ key: string;
28
+ value: string;
29
+ description?: string;
30
+ isSecret: boolean;
31
+ changeType: 'create' | 'update' | 'delete';
32
+ createdAt: string;
33
+ changer: {
34
+ id: string;
35
+ name: string;
36
+ email: string;
37
+ };
38
+ }
39
+
40
+ export default function VariableHistoryDialog({ variableId, variableKey, children }: VariableHistoryDialogProps) {
41
+ const [open, setOpen] = useState(false);
42
+ const [visibleValues, setVisibleValues] = useState<Set<string>>(new Set());
43
+ const queryClient = useQueryClient();
44
+
45
+ const { data: history, isLoading } = useQuery({
46
+ queryKey: ['variable-history', variableId],
47
+ queryFn: async (): Promise<HistoryEntry[]> => {
48
+ const response = await fetch(`/api/variable-history?variableId=${variableId}`);
49
+ if (!response.ok) {
50
+ throw new Error('Failed to fetch variable history');
51
+ }
52
+ const data = await response.json();
53
+ return data.history || [];
54
+ },
55
+ enabled: open,
56
+ });
57
+
58
+ const rollbackMutation = useMutation({
59
+ mutationFn: async (historyId: string) => {
60
+ const response = await fetch('/api/variable-history', {
61
+ method: 'POST',
62
+ headers: { 'Content-Type': 'application/json' },
63
+ body: JSON.stringify({ historyId }),
64
+ });
65
+ if (!response.ok) {
66
+ const error = await response.json();
67
+ throw new Error(error.error || 'Failed to rollback');
68
+ }
69
+ return response.json();
70
+ },
71
+ onSuccess: () => {
72
+ toast.success('Variable rolled back successfully');
73
+ queryClient.invalidateQueries({ queryKey: ['variables'] });
74
+ queryClient.invalidateQueries({ queryKey: ['variable-history', variableId] });
75
+ },
76
+ onError: (error: any) => {
77
+ toast.error(error.message);
78
+ }
79
+ });
80
+
81
+ const toggleValueVisibility = (entryId: string) => {
82
+ const newVisible = new Set(visibleValues);
83
+ if (newVisible.has(entryId)) {
84
+ newVisible.delete(entryId);
85
+ } else {
86
+ newVisible.add(entryId);
87
+ }
88
+ setVisibleValues(newVisible);
89
+ };
90
+
91
+ const getChangeTypeColor = (changeType: string) => {
92
+ switch (changeType) {
93
+ case 'create':
94
+ return 'bg-emerald-100 text-emerald-800 border-emerald-200';
95
+ case 'update':
96
+ return 'bg-blue-100 text-blue-800 border-blue-200';
97
+ case 'delete':
98
+ return 'bg-rose-100 text-rose-800 border-rose-200';
99
+ default:
100
+ return 'bg-slate-100 text-slate-800 border-slate-200';
101
+ }
102
+ };
103
+
104
+ return (
105
+ <Dialog open={open} onOpenChange={setOpen}>
106
+ <DialogTrigger asChild>
107
+ {children || (
108
+ <Button variant="ghost" size="sm" className="h-8 w-8 p-0 hover:bg-primary/5 hover:text-primary transition-colors">
109
+ <History className="h-4 w-4 text-muted-foreground" />
110
+ </Button>
111
+ )}
112
+ </DialogTrigger>
113
+ <DialogContent className="sm:max-w-[750px] p-0 overflow-hidden shadow-2xl flex flex-col max-h-[85vh]">
114
+ <DialogHeader className="p-6 bg-slate-50 border-b border-slate-200 shrink-0">
115
+ <div className="flex items-center gap-3">
116
+ <div className="p-2 bg-primary/10 rounded-lg text-primary">
117
+ <Clock className="h-6 w-6" />
118
+ </div>
119
+ <div>
120
+ <DialogTitle className="text-xl flex items-center gap-2">
121
+ Audit Logs: <code className="text-primary bg-primary/10 px-2 py-0.5 rounded text-base">{variableKey}</code>
122
+ </DialogTitle>
123
+ <DialogDescription className="text-primary/60">
124
+ Tracking all historical changes and cryptographic rotations.
125
+ </DialogDescription>
126
+ </div>
127
+ </div>
128
+ </DialogHeader>
129
+
130
+ <div className="flex-1 overflow-hidden flex flex-col">
131
+ <div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/10 hover:scrollbar-thumb-muted-foreground/20">
132
+ <div className="p-6 space-y-6">
133
+ {isLoading ? (
134
+ <div className="space-y-4">
135
+ {[...Array(4)].map((_, i) => (
136
+ <div key={i} className="animate-pulse flex gap-4">
137
+ <div className="h-10 w-10 bg-muted rounded-full shrink-0" />
138
+ <div className="flex-1 space-y-2 py-1">
139
+ <div className="h-4 bg-muted rounded w-1/4" />
140
+ <div className="h-20 bg-muted/50 rounded w-full" />
141
+ </div>
142
+ </div>
143
+ ))}
144
+ </div>
145
+ ) : history && history.length > 0 ? (
146
+ <div className="space-y-8 relative before:absolute before:left-[19px] before:top-2 before:bottom-2 before:w-[2px] before:bg-slate-100">
147
+ {history.map((entry) => (
148
+ <div key={entry.id} className="relative pl-12">
149
+ <div className={`absolute left-0 top-1 h-10 w-10 rounded-full border-4 border-white flex items-center justify-center ${
150
+ entry.changeType === 'create' ? 'bg-emerald-500 text-white' :
151
+ entry.changeType === 'delete' ? 'bg-rose-500 text-white' :
152
+ 'bg-blue-500 text-white'
153
+ }`}>
154
+ {entry.changeType === 'create' ? <Database className="h-4 w-4" /> :
155
+ entry.changeType === 'delete' ? <Hash className="h-4 w-4" /> :
156
+ <Shield className="h-4 w-4" />}
157
+ </div>
158
+
159
+ <div className="bg-slate-50 border border-slate-100 rounded-xl overflow-hidden hover:border-slate-200 transition-all hover:shadow-sm">
160
+ <div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between bg-slate-100/30">
161
+ <div className="flex items-center gap-3">
162
+ <Badge variant="outline" className={`px-2 py-0 rounded font-mono text-[10px] uppercase tracking-tighter border-slate-200 ${getChangeTypeColor(entry.changeType)}`}>
163
+ {entry.changeType}
164
+ </Badge>
165
+ <div className="flex items-center gap-1.5 text-xs font-medium text-slate-500">
166
+ <User className="h-3 w-3" />
167
+ {entry.changer.name}
168
+ </div>
169
+ </div>
170
+ <div className="text-[11px] text-slate-400 flex items-center gap-1">
171
+ <Clock className="h-3 w-3 opacity-50" />
172
+ {new Date(entry.createdAt).toLocaleString()}
173
+ </div>
174
+ {entry.changeType !== 'delete' && (
175
+ <Button
176
+ variant="ghost"
177
+ size="sm"
178
+ className="h-7 text-[10px] gap-1 px-2 text-primary hover:bg-primary/10"
179
+ onClick={() => {
180
+ if (confirm('Are you sure you want to rollback to this version?')) {
181
+ rollbackMutation.mutate(entry.id);
182
+ }
183
+ }}
184
+ disabled={rollbackMutation.isPending}
185
+ >
186
+ <RotateCcw className="h-3 w-3" />
187
+ Restore
188
+ </Button>
189
+ )}
190
+ </div>
191
+
192
+ <div className="p-4 space-y-4">
193
+ <div className="flex items-center gap-3">
194
+ <code className="text-sm font-bold text-slate-800 bg-white px-2 py-1 rounded border border-slate-200 shadow-sm">{entry.key}</code>
195
+ <Badge variant={entry.isSecret ? "destructive" : "outline"} className={`text-[10px] ${!entry.isSecret ? 'text-primary border-primary/20' : ''}`}>
196
+ {entry.isSecret ? "SECRET" : "PUBLIC"}
197
+ </Badge>
198
+ </div>
199
+
200
+ {entry.description && (
201
+ <p className="text-xs text-slate-500 leading-relaxed italic border-l-2 border-slate-100 pl-3">
202
+ &ldquo;{entry.description}&rdquo;
203
+ </p>
204
+ )}
205
+
206
+ <div className="bg-slate-50 rounded-lg p-3 border border-slate-100">
207
+ <div className="flex items-center justify-between mb-2">
208
+ <span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest flex items-center gap-1.5">
209
+ <Hash className="h-3 w-3" />
210
+ Captured Value
211
+ </span>
212
+ {entry.isSecret && (
213
+ <Button
214
+ variant="ghost"
215
+ size="sm"
216
+ onClick={() => toggleValueVisibility(entry.id)}
217
+ className="h-6 px-2 text-[10px] hover:bg-primary/5 hover:text-primary"
218
+ >
219
+ {visibleValues.has(entry.id) ? (
220
+ <><EyeOff className="h-3 w-3 mr-1" /> Hide</>
221
+ ) : (
222
+ <><Eye className="h-3 w-3 mr-1" /> Reveal</>
223
+ )}
224
+ </Button>
225
+ )}
226
+ </div>
227
+ <div className="font-mono text-xs overflow-x-auto">
228
+ {entry.isSecret ? (
229
+ visibleValues.has(entry.id) ? (
230
+ <span className="text-foreground break-all">{entry.value}</span>
231
+ ) : (
232
+ <span className="text-muted-foreground/30 tracking-[0.3em]">••••••••••••••••</span>
233
+ )
234
+ ) : (
235
+ <span className="text-foreground break-all">{entry.value}</span>
236
+ )}
237
+ </div>
238
+ </div>
239
+ </div>
240
+ </div>
241
+ </div>
242
+ ))}
243
+ </div>
244
+ ) : (
245
+ <div className="py-20 flex flex-col items-center justify-center text-center">
246
+ <div className="p-4 bg-muted rounded-full mb-4">
247
+ <History className="h-8 w-8 text-muted-foreground opacity-20" />
248
+ </div>
249
+ <h3 className="text-lg font-semibold text-muted-foreground">No History Found</h3>
250
+ <p className="text-sm text-muted-foreground/60 max-w-[200px]">Initial creation records or manual edits will appear here.</p>
251
+ </div>
252
+ )}
253
+ </div>
254
+ </div>
255
+ </div>
256
+
257
+ <div className="p-4 bg-muted/20 border-t border-muted shrink-0 flex justify-end">
258
+ <Button variant="ghost" onClick={() => setOpen(false)} className="text-muted-foreground">
259
+ Close History
260
+ </Button>
261
+ </div>
262
+ </DialogContent>
263
+ </Dialog>
264
+ );
265
+ }
@@ -0,0 +1,169 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { Button } from '@/components/ui/button';
5
+ import { Input } from '@/components/ui/input';
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
7
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
8
+ import { Trash2, Plus, Webhook as WebhookIcon, Shield } from 'lucide-react';
9
+ import { toast } from 'sonner';
10
+
11
+ interface Webhook {
12
+ id: string;
13
+ name: string;
14
+ url: string;
15
+ events: string[];
16
+ }
17
+
18
+ export function WebhooksManager({ teamId }: { teamId: string }) {
19
+ const [webhooks, setWebhooks] = useState<Webhook[]>([]);
20
+ const [loading, setLoading] = useState(true);
21
+ const [isAdding, setIsAdding] = useState(false);
22
+ const [formData, setFormData] = useState({ name: '', url: '', secret: '' });
23
+
24
+ useEffect(() => {
25
+ fetchWebhooks();
26
+ }, [teamId]);
27
+
28
+ async function fetchWebhooks() {
29
+ try {
30
+ const res = await fetch(`/api/webhooks?teamId=${teamId}`);
31
+ if (res.ok) {
32
+ const data = await res.json();
33
+ setWebhooks(data.webhooks);
34
+ }
35
+ } finally {
36
+ setLoading(false);
37
+ }
38
+ }
39
+
40
+ async function handleAdd() {
41
+ if (!formData.name || !formData.url) return;
42
+
43
+ try {
44
+ const res = await fetch('/api/webhooks', {
45
+ method: 'POST',
46
+ headers: { 'Content-Type': 'application/json' },
47
+ body: JSON.stringify({ ...formData, teamId }),
48
+ });
49
+
50
+ if (res.ok) {
51
+ toast.success('Webhook added successfully');
52
+ setFormData({ name: '', url: '', secret: '' });
53
+ setIsAdding(false);
54
+ fetchWebhooks();
55
+ } else {
56
+ toast.error('Failed to add webhook');
57
+ }
58
+ } catch (error) {
59
+ toast.error('An error occurred');
60
+ }
61
+ }
62
+
63
+ async function handleDelete(id: string) {
64
+ if (!confirm('Are you sure you want to delete this webhook?')) return;
65
+
66
+ try {
67
+ const res = await fetch('/api/webhooks', {
68
+ method: 'DELETE',
69
+ headers: { 'Content-Type': 'application/json' },
70
+ body: JSON.stringify({ id }),
71
+ });
72
+
73
+ if (res.ok) {
74
+ toast.success('Webhook deleted');
75
+ fetchWebhooks();
76
+ }
77
+ } catch (error) {
78
+ toast.error('Failed to delete webhook');
79
+ }
80
+ }
81
+
82
+ return (
83
+ <Card className="mt-8">
84
+ <CardHeader className="flex flex-row items-center justify-between space-y-0">
85
+ <div>
86
+ <CardTitle className="text-xl font-bold flex items-center gap-2">
87
+ <WebhookIcon className="h-5 w-5" />
88
+ Webhooks
89
+ </CardTitle>
90
+ <CardDescription>
91
+ Notify external services when environment variables change.
92
+ </CardDescription>
93
+ </div>
94
+ <Button size="sm" onClick={() => setIsAdding(!isAdding)}>
95
+ <Plus className="h-4 w-4 mr-1" />
96
+ Add Webhook
97
+ </Button>
98
+ </CardHeader>
99
+ <CardContent>
100
+ {isAdding && (
101
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6 p-4 border rounded-lg bg-muted/20">
102
+ <div className="space-y-2">
103
+ <label className="text-xs font-semibold uppercase opacity-70">Name</label>
104
+ <Input
105
+ placeholder="Discord/Slack Webhook"
106
+ value={formData.name}
107
+ onChange={e => setFormData(f => ({ ...f, name: e.target.value }))}
108
+ />
109
+ </div>
110
+ <div className="space-y-2">
111
+ <label className="text-xs font-semibold uppercase opacity-70">Endpoint URL</label>
112
+ <Input
113
+ placeholder="https://hooks.example.com/..."
114
+ value={formData.url}
115
+ onChange={e => setFormData(f => ({ ...f, url: e.target.value }))}
116
+ />
117
+ </div>
118
+ <div className="space-y-2">
119
+ <label className="text-xs font-semibold uppercase opacity-70">Secret (Optional)</label>
120
+ <Input
121
+ placeholder="HMAC Signing Secret"
122
+ type="password"
123
+ value={formData.secret}
124
+ onChange={e => setFormData(f => ({ ...f, secret: e.target.value }))}
125
+ />
126
+ </div>
127
+ <div className="md:col-span-3 flex justify-end gap-2">
128
+ <Button variant="ghost" size="sm" onClick={() => setIsAdding(false)}>Cancel</Button>
129
+ <Button size="sm" onClick={handleAdd}>Save Webhook</Button>
130
+ </div>
131
+ </div>
132
+ )}
133
+
134
+ <Table>
135
+ <TableHeader>
136
+ <TableRow>
137
+ <TableHead>Name</TableHead>
138
+ <TableHead>URL</TableHead>
139
+ <TableHead>Events</TableHead>
140
+ <TableHead className="w-16"></TableHead>
141
+ </TableRow>
142
+ </TableHeader>
143
+ <TableBody>
144
+ {loading ? (
145
+ <TableRow><TableCell colSpan={4} className="text-center">Loading...</TableCell></TableRow>
146
+ ) : webhooks.length === 0 ? (
147
+ <TableRow><TableCell colSpan={4} className="text-center opacity-50">No webhooks configured</TableCell></TableRow>
148
+ ) : webhooks.map((w) => (
149
+ <TableRow key={w.id}>
150
+ <TableCell className="font-medium">{w.name}</TableCell>
151
+ <TableCell className="max-w-[200px] truncate opacity-70">{w.url}</TableCell>
152
+ <TableCell>
153
+ <div className="flex gap-1">
154
+ {w.events.map(e => <span key={e} className="text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded border border-primary/20">{e}</span>)}
155
+ </div>
156
+ </TableCell>
157
+ <TableCell>
158
+ <Button variant="ghost" size="icon" className="text-destructive h-8 w-8" onClick={() => handleDelete(w.id)}>
159
+ <Trash2 className="h-4 w-4" />
160
+ </Button>
161
+ </TableCell>
162
+ </TableRow>
163
+ ))}
164
+ </TableBody>
165
+ </Table>
166
+ </CardContent>
167
+ </Card>
168
+ );
169
+ }