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,136 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Button } from '@/components/ui/button';
5
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
6
+ import { Input } from '@/components/ui/input';
7
+ import { Label } from '@/components/ui/label';
8
+ import { ShieldCheck, ShieldAlert, Copy, Check } from 'lucide-react';
9
+ import { toast } from 'sonner';
10
+
11
+ export function MFASettings({ initialEnabled }: { initialEnabled: boolean }) {
12
+ const [enabled, setEnabled] = useState(initialEnabled);
13
+ const [setupData, setSetupData] = useState<{ qrCode: string; secret: string } | null>(null);
14
+ const [token, setToken] = useState('');
15
+ const [loading, setLoading] = useState(false);
16
+ const [copied, setCopied] = useState(false);
17
+
18
+ async function handleSetup() {
19
+ setLoading(true);
20
+ try {
21
+ const res = await fetch('/api/auth/mfa/setup', { method: 'POST' });
22
+ if (res.ok) {
23
+ const data = await res.json();
24
+ setSetupData(data);
25
+ }
26
+ } finally {
27
+ setLoading(false);
28
+ }
29
+ }
30
+
31
+ async function handleVerify() {
32
+ setLoading(true);
33
+ try {
34
+ const res = await fetch('/api/auth/mfa/verify', {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json' },
37
+ body: JSON.stringify({ token }),
38
+ });
39
+
40
+ if (res.ok) {
41
+ toast.success('MFA Enabled successfully');
42
+ setEnabled(true);
43
+ setSetupData(null);
44
+ } else {
45
+ toast.error('Invalid token');
46
+ }
47
+ } finally {
48
+ setLoading(false);
49
+ }
50
+ }
51
+
52
+ const copySecret = () => {
53
+ if (setupData) {
54
+ navigator.clipboard.writeText(setupData.secret);
55
+ setCopied(true);
56
+ setTimeout(() => setCopied(false), 2000);
57
+ toast.success('Secret copied');
58
+ }
59
+ };
60
+
61
+ return (
62
+ <Card>
63
+ <CardHeader>
64
+ <CardTitle className="flex items-center gap-2">
65
+ <ShieldCheck className={`h-5 w-5 ${enabled ? 'text-emerald-500' : 'text-muted-foreground'}`} />
66
+ Multi-Factor Authentication
67
+ </CardTitle>
68
+ <CardDescription>
69
+ Add an extra layer of security to your account using a TOTP authenticator app.
70
+ </CardDescription>
71
+ </CardHeader>
72
+ <CardContent className="space-y-4">
73
+ {enabled ? (
74
+ <div className="flex items-center justify-between p-4 bg-emerald-500/5 border border-emerald-500/20 rounded-lg">
75
+ <div className="flex items-center gap-3">
76
+ <div className="p-2 bg-emerald-500/10 rounded-full">
77
+ <ShieldCheck className="h-5 w-5 text-emerald-500" />
78
+ </div>
79
+ <div>
80
+ <p className="font-semibold text-emerald-700">MFA is active</p>
81
+ <p className="text-sm text-emerald-600/70">Your account is protected with 2FA.</p>
82
+ </div>
83
+ </div>
84
+ <Button variant="outline" size="sm" className="text-destructive border-destructive/20 hover:bg-destructive/5" disabled>Disable</Button>
85
+ </div>
86
+ ) : setupData ? (
87
+ <div className="space-y-6 animate-in fade-in duration-300">
88
+ <div className="flex flex-col md:flex-row gap-6 items-center">
89
+ <div className="bg-white p-2 rounded-lg border">
90
+ <img src={setupData.qrCode} alt="MFA QR Code" className="w-40 h-40" />
91
+ </div>
92
+ <div className="space-y-4 flex-1">
93
+ <p className="text-sm text-muted-foreground">
94
+ 1. Scan this QR code with your authenticator app (like Google Authenticator or 1Password).
95
+ </p>
96
+ <div className="space-y-2">
97
+ <Label>Or enter secret manually:</Label>
98
+ <div className="flex gap-2">
99
+ <code className="bg-muted px-3 py-2 rounded flex-1 text-xs font-mono break-all">{setupData.secret}</code>
100
+ <Button variant="ghost" size="icon" onClick={copySecret}>
101
+ {copied ? <Check className="h-4 w-4 text-emerald-500" /> : <Copy className="h-4 w-4" />}
102
+ </Button>
103
+ </div>
104
+ </div>
105
+ </div>
106
+ </div>
107
+
108
+ <div className="space-y-2 pt-4 border-t">
109
+ <Label htmlFor="mfa-token">2. Enter the 6-digit code from your app</Label>
110
+ <div className="flex gap-2">
111
+ <Input
112
+ id="mfa-token"
113
+ placeholder="000000"
114
+ value={token}
115
+ onChange={e => setToken(e.target.value)}
116
+ className="max-w-[200px]"
117
+ />
118
+ <Button onClick={handleVerify} disabled={loading || token.length !== 6}>
119
+ {loading ? 'Verifying...' : 'Enable MFA'}
120
+ </Button>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ ) : (
125
+ <div className="flex flex-col items-center justify-center py-6 text-center border-2 border-dashed rounded-lg">
126
+ <ShieldAlert className="h-10 w-10 text-muted-foreground/30 mb-3" />
127
+ <p className="text-muted-foreground mb-4">MFA is not yet configured for your account.</p>
128
+ <Button onClick={handleSetup} disabled={loading}>
129
+ Setup MFA
130
+ </Button>
131
+ </div>
132
+ )}
133
+ </CardContent>
134
+ </Card>
135
+ );
136
+ }
@@ -0,0 +1,123 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
5
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
6
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
7
+ import { Badge } from '@/components/ui/badge';
8
+ import { Columns, ArrowRight, Minus, Plus, Equal } from 'lucide-react';
9
+
10
+ interface Variable {
11
+ id: string;
12
+ key: string;
13
+ value: string;
14
+ environmentId: string;
15
+ }
16
+
17
+ interface ProjectDiffProps {
18
+ environments: any[];
19
+ variables: Variable[];
20
+ }
21
+
22
+ export function ProjectDiff({ environments, variables }: ProjectDiffProps) {
23
+ const [envA, setEnvA] = useState(environments[0]?.id || "");
24
+ const [envB, setEnvB] = useState(environments[1]?.id || "");
25
+
26
+ const varsA = variables.filter(v => v.environmentId === envA);
27
+ const varsB = variables.filter(v => v.environmentId === envB);
28
+
29
+ const allKeys = Array.from(new Set([...varsA.map(v => v.key), ...varsB.map(v => v.key)])).sort();
30
+
31
+ const getStatus = (key: string) => {
32
+ const vA = varsA.find(v => v.key === key);
33
+ const vB = varsB.find(v => v.key === key);
34
+
35
+ if (vA && !vB) return 'missing_in_b';
36
+ if (!vA && vB) return 'missing_in_a';
37
+ if (vA?.value !== vB?.value) return 'different';
38
+ return 'same';
39
+ };
40
+
41
+ return (
42
+ <Card>
43
+ <CardHeader>
44
+ <div className="flex items-center justify-between">
45
+ <div>
46
+ <CardTitle className="text-xl font-bold flex items-center gap-2">
47
+ <Columns className="h-5 w-5" />
48
+ Environment Comparison
49
+ </CardTitle>
50
+ <CardDescription>
51
+ Compare keys and values side-by-side across environments.
52
+ </CardDescription>
53
+ </div>
54
+ </div>
55
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
56
+ <div className="space-y-2">
57
+ <label className="text-xs font-semibold uppercase opacity-70">Source Environment</label>
58
+ <Select value={envA} onValueChange={setEnvA}>
59
+ <SelectTrigger>
60
+ <SelectValue placeholder="Select environment" />
61
+ </SelectTrigger>
62
+ <SelectContent>
63
+ {environments.map(e => <SelectItem key={e.id} value={e.id}>{e.name}</SelectItem>)}
64
+ </SelectContent>
65
+ </Select>
66
+ </div>
67
+ <div className="space-y-2">
68
+ <label className="text-xs font-semibold uppercase opacity-70">Target Environment</label>
69
+ <Select value={envB} onValueChange={setEnvB}>
70
+ <SelectTrigger>
71
+ <SelectValue placeholder="Select environment" />
72
+ </SelectTrigger>
73
+ <SelectContent>
74
+ {environments.map(e => <SelectItem key={e.id} value={e.id}>{e.name}</SelectItem>)}
75
+ </SelectContent>
76
+ </Select>
77
+ </div>
78
+ </div>
79
+ </CardHeader>
80
+ <CardContent>
81
+ <Table>
82
+ <TableHeader>
83
+ <TableRow>
84
+ <TableHead>Variable Key</TableHead>
85
+ <TableHead>Source Value</TableHead>
86
+ <TableHead className="w-8"></TableHead>
87
+ <TableHead>Target Value</TableHead>
88
+ <TableHead className="text-right">Status</TableHead>
89
+ </TableRow>
90
+ </TableHeader>
91
+ <TableBody>
92
+ {allKeys.map(key => {
93
+ const vA = varsA.find(v => v.key === key);
94
+ const vB = varsB.find(v => v.key === key);
95
+ const status = getStatus(key);
96
+
97
+ return (
98
+ <TableRow key={key} className={status === 'different' ? 'bg-amber-500/5' : ''}>
99
+ <TableCell className="font-mono font-medium">{key}</TableCell>
100
+ <TableCell className="opacity-70 font-mono text-xs max-w-[150px] truncate">
101
+ {vA ? (vA.value.length > 20 ? vA.value.substring(0, 20) + '...' : vA.value) : <span className="text-destructive">-</span>}
102
+ </TableCell>
103
+ <TableCell>
104
+ <ArrowRight className="h-3 w-3 opacity-30" />
105
+ </TableCell>
106
+ <TableCell className="opacity-70 font-mono text-xs max-w-[150px] truncate">
107
+ {vB ? (vB.value.length > 20 ? vB.value.substring(0, 20) + '...' : vB.value) : <span className="text-destructive">-</span>}
108
+ </TableCell>
109
+ <TableCell className="text-right">
110
+ {status === 'missing_in_b' && <Badge variant="outline" className="border-destructive/30 text-destructive bg-destructive/5"><Minus className="h-3 w-3 mr-1" /> Missing</Badge>}
111
+ {status === 'missing_in_a' && <Badge variant="outline" className="border-primary/30 text-primary bg-primary/5"><Plus className="h-3 w-3 mr-1" /> New</Badge>}
112
+ {status === 'different' && <Badge variant="outline" className="border-amber-500/30 text-amber-600 bg-amber-500/5">Modified</Badge>}
113
+ {status === 'same' && <Badge variant="outline" className="opacity-30 border-transparent"><Equal className="h-3 w-3 mr-1" /> Identical</Badge>}
114
+ </TableCell>
115
+ </TableRow>
116
+ );
117
+ })}
118
+ </TableBody>
119
+ </Table>
120
+ </CardContent>
121
+ </Card>
122
+ );
123
+ }
@@ -0,0 +1,24 @@
1
+ 'use client'
2
+
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4
+ import { useState } from 'react'
5
+
6
+ export function Providers({ children }: { children: React.ReactNode }) {
7
+ const [queryClient] = useState(
8
+ () =>
9
+ new QueryClient({
10
+ defaultOptions: {
11
+ queries: {
12
+ staleTime: 1000 * 60, // 1 minute
13
+ retry: 1,
14
+ },
15
+ },
16
+ })
17
+ )
18
+
19
+ return (
20
+ <QueryClientProvider client={queryClient}>
21
+ {children}
22
+ </QueryClientProvider>
23
+ )
24
+ }
@@ -0,0 +1,112 @@
1
+
2
+ 'use client';
3
+
4
+ import { useState } from "react";
5
+ import { useQueryClient } from "@tanstack/react-query";
6
+ import { Button } from "@/components/ui/button";
7
+ import {
8
+ AlertDialog,
9
+ AlertDialogAction,
10
+ AlertDialogCancel,
11
+ AlertDialogContent,
12
+ AlertDialogDescription,
13
+ AlertDialogFooter,
14
+ AlertDialogHeader,
15
+ AlertDialogTitle,
16
+ AlertDialogTrigger,
17
+ } from "@/components/ui/alert-dialog";
18
+ import { Trash2, UserMinus, AlertTriangle } from "lucide-react";
19
+
20
+ interface RemoveMemberDialogProps {
21
+ teamId: string;
22
+ member: {
23
+ id: string;
24
+ user: {
25
+ name: string;
26
+ email: string;
27
+ };
28
+ };
29
+ children?: React.ReactNode;
30
+ }
31
+
32
+ export default function RemoveMemberDialog({ teamId, member, children }: RemoveMemberDialogProps) {
33
+ const [isLoading, setIsLoading] = useState(false);
34
+ const [error, setError] = useState("");
35
+ const [open, setOpen] = useState(false);
36
+ const queryClient = useQueryClient();
37
+
38
+ const handleRemove = async (e: React.MouseEvent) => {
39
+ e.preventDefault();
40
+ setError("");
41
+ setIsLoading(true);
42
+
43
+ try {
44
+ const response = await fetch('/api/team-members', {
45
+ method: 'DELETE',
46
+ headers: {
47
+ 'Content-Type': 'application/json',
48
+ },
49
+ body: JSON.stringify({ teamId, memberId: member.id }),
50
+ });
51
+
52
+ const data = await response.json();
53
+
54
+ if (response.ok) {
55
+ setOpen(false);
56
+ queryClient.invalidateQueries({ queryKey: ["team-members", teamId] });
57
+ } else {
58
+ setError(data.error || "Failed to remove member");
59
+ }
60
+ } catch (err) {
61
+ setError("An unexpected error occurred");
62
+ } finally {
63
+ setIsLoading(false);
64
+ }
65
+ };
66
+
67
+ return (
68
+ <AlertDialog open={open} onOpenChange={setOpen}>
69
+ <AlertDialogTrigger asChild>
70
+ {children || (
71
+ <Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10">
72
+ <UserMinus className="h-4 w-4" />
73
+ </Button>
74
+ )}
75
+ </AlertDialogTrigger>
76
+ <AlertDialogContent className="bg-white border-slate-200 sm:max-w-[450px]">
77
+ <AlertDialogHeader>
78
+ <div className="flex items-center gap-3 text-destructive mb-2">
79
+ <div className="p-2 bg-destructive/10 rounded-full">
80
+ <AlertTriangle className="h-6 w-6" />
81
+ </div>
82
+ <AlertDialogTitle className="text-xl">Remove Member</AlertDialogTitle>
83
+ </div>
84
+ <AlertDialogDescription className="text-slate-600 text-sm leading-relaxed">
85
+ Are you sure you want to remove <span className="font-semibold text-slate-900">{member.user.name}</span> from this project?
86
+ They will lose all access to secrets and environments in this project immediately.
87
+ </AlertDialogDescription>
88
+ </AlertDialogHeader>
89
+
90
+ {error && (
91
+ <div className="mt-4 p-3 text-sm font-medium text-destructive bg-destructive/10 rounded-lg border border-destructive/20 text-center">
92
+ {error}
93
+ </div>
94
+ )}
95
+
96
+ <AlertDialogFooter className="mt-6">
97
+ <AlertDialogCancel className="border-slate-200 hover:bg-slate-50 text-slate-700">
98
+ Cancel
99
+ </AlertDialogCancel>
100
+ <Button
101
+ onClick={handleRemove}
102
+ disabled={isLoading}
103
+ variant="destructive"
104
+ className="bg-destructive hover:bg-destructive/90 text-white min-w-[120px]"
105
+ >
106
+ {isLoading ? "Removing..." : "Remove Member"}
107
+ </Button>
108
+ </AlertDialogFooter>
109
+ </AlertDialogContent>
110
+ </AlertDialog>
111
+ );
112
+ }
@@ -0,0 +1,276 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useMemo } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { Input } from "@/components/ui/input";
6
+ import { Button } from "@/components/ui/button";
7
+ import {
8
+ Dialog,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ DialogTrigger,
14
+ } from "@/components/ui/dialog";
15
+ import { Search, X, Shield, Globe, Database, Command, ChevronRight } from "lucide-react";
16
+ import { useTeams } from "@/hooks/use-teams";
17
+ import { useEnvironments } from "@/hooks/use-environments";
18
+ import { useVariables } from "@/hooks/use-variables";
19
+ import { Badge } from "@/components/ui/badge";
20
+
21
+ interface SearchDialogProps {
22
+ children?: React.ReactNode;
23
+ }
24
+
25
+ interface SearchResult {
26
+ type: 'team' | 'environment' | 'variable';
27
+ id: string;
28
+ name: string;
29
+ description?: string;
30
+ teamName?: string;
31
+ environmentName?: string;
32
+ isSecret?: boolean;
33
+ teamId?: string;
34
+ environmentId?: string;
35
+ }
36
+
37
+ export default function SearchDialog({ children }: SearchDialogProps) {
38
+ const [open, setOpen] = useState(false);
39
+ const [query, setQuery] = useState("");
40
+ const router = useRouter();
41
+ const { teams } = useTeams();
42
+ const { environments } = useEnvironments();
43
+ const { variables } = useVariables();
44
+
45
+ useEffect(() => {
46
+ const down = (e: KeyboardEvent) => {
47
+ if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
48
+ e.preventDefault();
49
+ setOpen((open) => !open);
50
+ }
51
+ };
52
+ document.addEventListener("keydown", down);
53
+ return () => document.removeEventListener("keydown", down);
54
+ }, []);
55
+
56
+ const results = useMemo(() => {
57
+ if (!query.trim()) {
58
+ return [];
59
+ }
60
+
61
+ const searchTerm = query.toLowerCase();
62
+ const searchResults: SearchResult[] = [];
63
+
64
+ // Search teams
65
+ teams?.forEach(team => {
66
+ if (team.name.toLowerCase().includes(searchTerm) ||
67
+ team.description?.toLowerCase().includes(searchTerm)) {
68
+ searchResults.push({
69
+ type: 'team',
70
+ id: team.id,
71
+ name: team.name,
72
+ description: team.description || undefined,
73
+ });
74
+ }
75
+ });
76
+
77
+ // Search environments
78
+ environments?.forEach(env => {
79
+ if (env.name.toLowerCase().includes(searchTerm) ||
80
+ env.description?.toLowerCase().includes(searchTerm)) {
81
+ searchResults.push({
82
+ type: 'environment',
83
+ id: env.id,
84
+ name: env.name,
85
+ description: env.description || undefined,
86
+ teamName: env.teamName,
87
+ teamId: env.teamId,
88
+ });
89
+ }
90
+ });
91
+
92
+ // Search variables
93
+ variables?.forEach(variable => {
94
+ if (variable.key.toLowerCase().includes(searchTerm) ||
95
+ variable.description?.toLowerCase().includes(searchTerm)) {
96
+ searchResults.push({
97
+ type: 'variable',
98
+ id: variable.id,
99
+ name: variable.key,
100
+ description: variable.description || undefined,
101
+ teamName: variable.teamName,
102
+ environmentName: variable.environmentName,
103
+ isSecret: variable.isSecret,
104
+ teamId: variable.teamId,
105
+ environmentId: variable.environmentId,
106
+ });
107
+ }
108
+ });
109
+
110
+ return searchResults.slice(0, 20); // Limit to 20 results
111
+ }, [query, teams, environments, variables]);
112
+
113
+ const handleResultClick = (result: SearchResult) => {
114
+ setOpen(false);
115
+ setQuery("");
116
+
117
+ // Navigate to the appropriate page
118
+ if (result.type === 'team') {
119
+ router.push(`/project/${result.id}`);
120
+ } else if (result.type === 'environment' || result.type === 'variable') {
121
+ router.push(`/project/${result.teamId}`);
122
+ }
123
+ };
124
+
125
+ const getResultIcon = (type: string) => {
126
+ switch (type) {
127
+ case 'team':
128
+ return <Shield className="h-4 w-4 text-primary" />;
129
+ case 'environment':
130
+ return <Globe className="h-4 w-4 text-blue-500" />;
131
+ case 'variable':
132
+ return <Database className="h-4 w-4 text-emerald-500" />;
133
+ default:
134
+ return <Command className="h-4 w-4" />;
135
+ }
136
+ };
137
+
138
+ return (
139
+ <Dialog open={open} onOpenChange={setOpen}>
140
+ <DialogTrigger asChild>
141
+ {children || (
142
+ <Button variant="outline" className="relative h-10 w-full justify-start rounded-[0.5rem] bg-background text-sm font-normal text-muted-foreground shadow-none sm:pr-12 md:w-40 lg:w-64 border-primary/10 hover:border-primary/30 transition-all font-mono">
143
+ <Search className="h-4 w-4 mr-2 text-primary/50" />
144
+ <span className="inline-flex">Search...</span>
145
+ <kbd className="pointer-events-none absolute right-[0.4rem] top-[0.4rem] hidden h-6 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
146
+ <span className="text-xs">⌘</span>K
147
+ </kbd>
148
+ </Button>
149
+ )}
150
+ </DialogTrigger>
151
+ <DialogContent className="sm:max-w-[650px] p-0 overflow-hidden shadow-2xl flex flex-col max-h-[80vh]">
152
+ <DialogHeader className="p-4 border-b border-slate-200">
153
+ <div className="flex items-center gap-2">
154
+ <div className="p-1.5 bg-primary/10 rounded-md">
155
+ <Command className="h-5 w-5 text-primary" />
156
+ </div>
157
+ <DialogTitle className="text-base font-semibold">Command Palette</DialogTitle>
158
+ </div>
159
+ </DialogHeader>
160
+
161
+ <div className="p-4 bg-slate-50 border-b border-slate-100">
162
+ <div className="relative group">
163
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-slate-400 group-focus-within:text-primary transition-colors" />
164
+ <Input
165
+ autoFocus
166
+ placeholder="Search keys, environments, or projects..."
167
+ value={query}
168
+ onChange={(e) => setQuery(e.target.value)}
169
+ className="h-12 pl-11 bg-white border-slate-200 focus-visible:ring-primary/20 text-base text-slate-900 placeholder:text-slate-400"
170
+ />
171
+ {query && (
172
+ <Button
173
+ variant="ghost"
174
+ size="sm"
175
+ onClick={() => setQuery("")}
176
+ className="absolute right-2 top-1/2 transform -translate-y-1/2 h-8 w-8 p-0"
177
+ >
178
+ <X className="h-4 w-4 opacity-40 hover:opacity-100" />
179
+ </Button>
180
+ )}
181
+ </div>
182
+ </div>
183
+
184
+ <div className="flex-1 min-h-[300px] overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/10 hover:scrollbar-thumb-muted-foreground/20">
185
+ <div className="p-2 space-y-1">
186
+ {results.length > 0 ? (
187
+ <>
188
+ <div className="px-3 py-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider">Search Results</div>
189
+ {results.map((result) => (
190
+ <button
191
+ key={`${result.type}-${result.id}`}
192
+ onClick={() => handleResultClick(result)}
193
+ className="w-full flex items-center justify-between p-3 rounded-lg hover:bg-slate-50 transition-all group border border-transparent hover:border-slate-100"
194
+ >
195
+ <div className="flex items-center gap-4 text-left overflow-hidden">
196
+ <div className="p-2 bg-slate-50 rounded-md group-hover:bg-primary/5 transition-colors">
197
+ {getResultIcon(result.type)}
198
+ </div>
199
+ <div className="flex flex-col gap-0.5 overflow-hidden">
200
+ <div className="flex items-center gap-2">
201
+ <span className="font-semibold text-sm text-slate-900 truncate">{result.name}</span>
202
+ <Badge variant="outline" className="text-[9px] uppercase tracking-tighter px-1 h-4 font-mono font-normal border-slate-200">
203
+ {result.type}
204
+ </Badge>
205
+ {result.isSecret && (
206
+ <Badge variant="destructive" className="text-[8px] h-3 px-1 leading-none">SECURE</Badge>
207
+ )}
208
+ </div>
209
+ <div className="text-xs text-slate-500 truncate max-w-[400px]">
210
+ {result.type === 'team' ? (
211
+ result.description || "Project Space"
212
+ ) : (
213
+ <div className="flex items-center gap-1">
214
+ <span className="font-medium text-slate-600">{result.teamName}</span>
215
+ {result.environmentName && (
216
+ <>
217
+ <ChevronRight className="h-3 w-3 opacity-40 text-slate-400" />
218
+ <span className="text-primary/70">{result.environmentName}</span>
219
+ </>
220
+ )}
221
+ </div>
222
+ )}
223
+ </div>
224
+ </div>
225
+ </div>
226
+ <ChevronRight className="h-4 w-4 opacity-0 group-hover:opacity-40 transition-all mr-2" />
227
+ </button>
228
+ ))}
229
+ </>
230
+ ) : query.trim() ? (
231
+ <div className="py-20 flex flex-col items-center justify-center text-center opacity-50">
232
+ <div className="p-4 bg-muted rounded-full mb-4">
233
+ <Search className="h-8 w-8" />
234
+ </div>
235
+ <h3 className="text-base font-semibold">No results found</h3>
236
+ <p className="text-sm">We couldn&apos;t find anything matching &ldquo;{query}&rdquo;</p>
237
+ </div>
238
+ ) : (
239
+ <div className="py-12 flex flex-col items-center justify-center text-center opacity-40">
240
+ <Command className="h-10 w-10 mb-4 animate-pulse text-primary" />
241
+ <h3 className="text-sm font-medium">Type to search everything</h3>
242
+ <p className="text-xs mt-1">Projects, environments, secrets, keys...</p>
243
+ <div className="mt-8 grid grid-cols-3 gap-8">
244
+ <div className="flex flex-col items-center gap-1.5 grayscale opacity-50">
245
+ <Shield className="h-5 w-5" />
246
+ <span className="text-[10px] font-bold">PROJECTS</span>
247
+ </div>
248
+ <div className="flex flex-col items-center gap-1.5 grayscale opacity-50">
249
+ <Globe className="h-5 w-5" />
250
+ <span className="text-[10px] font-bold">ENVS</span>
251
+ </div>
252
+ <div className="flex flex-col items-center gap-1.5 grayscale opacity-50">
253
+ <Database className="h-5 w-5" />
254
+ <span className="text-[10px] font-bold">KEYS</span>
255
+ </div>
256
+ </div>
257
+ </div>
258
+ )}
259
+ </div>
260
+ </div>
261
+
262
+ <div className="p-3 bg-muted/30 border-t border-muted text-[10px] text-muted-foreground flex items-center justify-between">
263
+ <div className="flex items-center gap-4 ml-2">
264
+ <span className="flex items-center gap-1">
265
+ <kbd className="px-1 border rounded bg-background shadow-xs">↵</kbd> Select
266
+ </span>
267
+ <span className="flex items-center gap-1">
268
+ <kbd className="px-1 border rounded bg-background shadow-xs">↑↓</kbd> Navigate
269
+ </span>
270
+ </div>
271
+ <span className="mr-2">ESC to close</span>
272
+ </div>
273
+ </DialogContent>
274
+ </Dialog>
275
+ );
276
+ }