@codaijs/keel 0.1.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 (116) hide show
  1. package/dist/__tests__/cli.test.d.ts +2 -0
  2. package/dist/__tests__/cli.test.d.ts.map +1 -0
  3. package/dist/__tests__/cli.test.js +173 -0
  4. package/dist/__tests__/cli.test.js.map +1 -0
  5. package/dist/__tests__/registry.test.d.ts +2 -0
  6. package/dist/__tests__/registry.test.d.ts.map +1 -0
  7. package/dist/__tests__/registry.test.js +86 -0
  8. package/dist/__tests__/registry.test.js.map +1 -0
  9. package/dist/__tests__/sail-installer.test.d.ts +2 -0
  10. package/dist/__tests__/sail-installer.test.d.ts.map +1 -0
  11. package/dist/__tests__/sail-installer.test.js +158 -0
  12. package/dist/__tests__/sail-installer.test.js.map +1 -0
  13. package/dist/create-runner.d.ts +11 -0
  14. package/dist/create-runner.d.ts.map +1 -0
  15. package/dist/create-runner.js +63 -0
  16. package/dist/create-runner.js.map +1 -0
  17. package/dist/create.d.ts +10 -0
  18. package/dist/create.d.ts.map +1 -0
  19. package/dist/create.js +15 -0
  20. package/dist/create.js.map +1 -0
  21. package/dist/manage.d.ts +24 -0
  22. package/dist/manage.d.ts.map +1 -0
  23. package/dist/manage.js +1461 -0
  24. package/dist/manage.js.map +1 -0
  25. package/dist/prompts.d.ts +36 -0
  26. package/dist/prompts.d.ts.map +1 -0
  27. package/dist/prompts.js +208 -0
  28. package/dist/prompts.js.map +1 -0
  29. package/dist/sail-installer.d.ts +37 -0
  30. package/dist/sail-installer.d.ts.map +1 -0
  31. package/dist/sail-installer.js +935 -0
  32. package/dist/sail-installer.js.map +1 -0
  33. package/dist/scaffold.d.ts +10 -0
  34. package/dist/scaffold.d.ts.map +1 -0
  35. package/dist/scaffold.js +297 -0
  36. package/dist/scaffold.js.map +1 -0
  37. package/package.json +57 -0
  38. package/sails/_template/addon.json +20 -0
  39. package/sails/_template/install.ts +402 -0
  40. package/sails/admin-dashboard/README.md +117 -0
  41. package/sails/admin-dashboard/addon.json +28 -0
  42. package/sails/admin-dashboard/files/backend/middleware/admin.ts +34 -0
  43. package/sails/admin-dashboard/files/backend/routes/admin.ts +243 -0
  44. package/sails/admin-dashboard/files/frontend/components/admin/StatsCard.tsx +40 -0
  45. package/sails/admin-dashboard/files/frontend/components/admin/UsersTable.tsx +240 -0
  46. package/sails/admin-dashboard/files/frontend/hooks/useAdmin.ts +149 -0
  47. package/sails/admin-dashboard/files/frontend/pages/admin/Dashboard.tsx +173 -0
  48. package/sails/admin-dashboard/files/frontend/pages/admin/UserDetail.tsx +203 -0
  49. package/sails/admin-dashboard/install.ts +305 -0
  50. package/sails/analytics/README.md +178 -0
  51. package/sails/analytics/addon.json +27 -0
  52. package/sails/analytics/files/frontend/components/AnalyticsProvider.tsx +58 -0
  53. package/sails/analytics/files/frontend/hooks/useAnalytics.ts +64 -0
  54. package/sails/analytics/files/frontend/lib/analytics.ts +103 -0
  55. package/sails/analytics/install.ts +297 -0
  56. package/sails/file-uploads/README.md +191 -0
  57. package/sails/file-uploads/addon.json +30 -0
  58. package/sails/file-uploads/files/backend/routes/files.ts +198 -0
  59. package/sails/file-uploads/files/backend/schema/files.ts +36 -0
  60. package/sails/file-uploads/files/backend/services/file-storage.ts +128 -0
  61. package/sails/file-uploads/files/frontend/components/FileList.tsx +248 -0
  62. package/sails/file-uploads/files/frontend/components/FileUploadButton.tsx +147 -0
  63. package/sails/file-uploads/files/frontend/hooks/useFileUpload.ts +106 -0
  64. package/sails/file-uploads/files/frontend/hooks/useFiles.ts +118 -0
  65. package/sails/file-uploads/files/frontend/pages/Files.tsx +37 -0
  66. package/sails/file-uploads/install.ts +466 -0
  67. package/sails/gdpr/README.md +174 -0
  68. package/sails/gdpr/addon.json +27 -0
  69. package/sails/gdpr/files/backend/routes/gdpr.ts +140 -0
  70. package/sails/gdpr/files/backend/services/gdpr.ts +293 -0
  71. package/sails/gdpr/files/frontend/components/auth/ConsentCheckboxes.tsx +97 -0
  72. package/sails/gdpr/files/frontend/components/gdpr/AccountDeletionRequest.tsx +192 -0
  73. package/sails/gdpr/files/frontend/components/gdpr/DataExportButton.tsx +75 -0
  74. package/sails/gdpr/files/frontend/pages/PrivacyPolicy.tsx +186 -0
  75. package/sails/gdpr/install.ts +756 -0
  76. package/sails/google-oauth/README.md +121 -0
  77. package/sails/google-oauth/addon.json +22 -0
  78. package/sails/google-oauth/files/GoogleButton.tsx +50 -0
  79. package/sails/google-oauth/install.ts +252 -0
  80. package/sails/i18n/README.md +193 -0
  81. package/sails/i18n/addon.json +30 -0
  82. package/sails/i18n/files/frontend/components/LanguageSwitcher.tsx +108 -0
  83. package/sails/i18n/files/frontend/hooks/useLanguage.ts +31 -0
  84. package/sails/i18n/files/frontend/lib/i18n.ts +32 -0
  85. package/sails/i18n/files/frontend/locales/de/common.json +44 -0
  86. package/sails/i18n/files/frontend/locales/en/common.json +44 -0
  87. package/sails/i18n/install.ts +407 -0
  88. package/sails/push-notifications/README.md +163 -0
  89. package/sails/push-notifications/addon.json +31 -0
  90. package/sails/push-notifications/files/backend/routes/notifications.ts +153 -0
  91. package/sails/push-notifications/files/backend/schema/notifications.ts +31 -0
  92. package/sails/push-notifications/files/backend/services/notifications.ts +117 -0
  93. package/sails/push-notifications/files/frontend/components/PushNotificationInit.tsx +12 -0
  94. package/sails/push-notifications/files/frontend/hooks/usePushNotifications.ts +154 -0
  95. package/sails/push-notifications/install.ts +384 -0
  96. package/sails/r2-storage/README.md +101 -0
  97. package/sails/r2-storage/addon.json +29 -0
  98. package/sails/r2-storage/files/backend/services/storage.ts +71 -0
  99. package/sails/r2-storage/files/frontend/components/ProfilePictureUpload.tsx +167 -0
  100. package/sails/r2-storage/install.ts +412 -0
  101. package/sails/rate-limiting/README.md +145 -0
  102. package/sails/rate-limiting/addon.json +20 -0
  103. package/sails/rate-limiting/files/backend/middleware/rate-limit-store.ts +104 -0
  104. package/sails/rate-limiting/files/backend/middleware/rate-limit.ts +137 -0
  105. package/sails/rate-limiting/install.ts +300 -0
  106. package/sails/registry.json +107 -0
  107. package/sails/stripe/README.md +214 -0
  108. package/sails/stripe/addon.json +24 -0
  109. package/sails/stripe/files/backend/routes/stripe.ts +154 -0
  110. package/sails/stripe/files/backend/schema/stripe.ts +74 -0
  111. package/sails/stripe/files/backend/services/stripe.ts +224 -0
  112. package/sails/stripe/files/frontend/components/SubscriptionStatus.tsx +135 -0
  113. package/sails/stripe/files/frontend/hooks/useSubscription.ts +86 -0
  114. package/sails/stripe/files/frontend/pages/Checkout.tsx +116 -0
  115. package/sails/stripe/files/frontend/pages/Pricing.tsx +226 -0
  116. package/sails/stripe/install.ts +378 -0
@@ -0,0 +1,173 @@
1
+ import { useMemo } from "react";
2
+ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
3
+ import StatsCard from "@/components/admin/StatsCard.js";
4
+ import UsersTable from "@/components/admin/UsersTable.js";
5
+ import { useAdminStats, useAdminUsers } from "@/hooks/useAdmin.js";
6
+
7
+ export default function AdminDashboard() {
8
+ const { stats, isLoading: statsLoading } = useAdminStats();
9
+ const {
10
+ users,
11
+ pagination,
12
+ isLoading: usersLoading,
13
+ search,
14
+ setSearch,
15
+ setPage,
16
+ } = useAdminUsers();
17
+
18
+ // Fill in missing days for the chart (last 30 days)
19
+ const chartData = useMemo(() => {
20
+ if (!stats?.signupsByDay) return [];
21
+
22
+ const map = new Map<string, number>();
23
+ for (const entry of stats.signupsByDay) {
24
+ map.set(entry.date, entry.count);
25
+ }
26
+
27
+ const days: Array<{ date: string; signups: number }> = [];
28
+ const now = new Date();
29
+ for (let i = 29; i >= 0; i--) {
30
+ const d = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
31
+ const key = d.toISOString().slice(0, 10);
32
+ days.push({ date: key, signups: map.get(key) ?? 0 });
33
+ }
34
+
35
+ return days;
36
+ }, [stats?.signupsByDay]);
37
+
38
+ return (
39
+ <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
40
+ {/* Header */}
41
+ <div className="mb-8">
42
+ <h1 className="text-2xl font-bold text-white">Admin Dashboard</h1>
43
+ <p className="mt-1 text-sm text-keel-gray-400">
44
+ User management and application metrics
45
+ </p>
46
+ </div>
47
+
48
+ {/* Stats Cards */}
49
+ <div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
50
+ <StatsCard
51
+ icon={
52
+ <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
53
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
54
+ </svg>
55
+ }
56
+ label="Total Users"
57
+ value={statsLoading ? "..." : (stats?.totalUsers ?? 0)}
58
+ />
59
+ <StatsCard
60
+ icon={
61
+ <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
62
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
63
+ </svg>
64
+ }
65
+ label="New This Week"
66
+ value={statsLoading ? "..." : (stats?.newUsersWeek ?? 0)}
67
+ trend={
68
+ stats
69
+ ? { value: stats.newUsersWeek, label: "last 7 days" }
70
+ : undefined
71
+ }
72
+ />
73
+ <StatsCard
74
+ icon={
75
+ <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
76
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
77
+ </svg>
78
+ }
79
+ label="New This Month"
80
+ value={statsLoading ? "..." : (stats?.newUsersMonth ?? 0)}
81
+ trend={
82
+ stats
83
+ ? { value: stats.newUsersMonth, label: "last 30 days" }
84
+ : undefined
85
+ }
86
+ />
87
+ <StatsCard
88
+ icon={
89
+ <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
90
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728M9.172 15.828a4 4 0 010-5.656m5.656 0a4 4 0 010 5.656M12 12h.01" />
91
+ </svg>
92
+ }
93
+ label="Active Sessions"
94
+ value={statsLoading ? "..." : (stats?.activeSessions ?? 0)}
95
+ />
96
+ </div>
97
+
98
+ {/* Signups Chart */}
99
+ <div className="mb-8 rounded-xl border border-keel-gray-800 bg-keel-gray-900 p-6">
100
+ <h2 className="mb-4 text-lg font-semibold text-white">
101
+ User Signups (Last 30 Days)
102
+ </h2>
103
+ {statsLoading ? (
104
+ <div className="flex h-64 items-center justify-center">
105
+ <div className="h-6 w-6 animate-spin rounded-full border-2 border-keel-gray-800 border-t-keel-blue" />
106
+ </div>
107
+ ) : (
108
+ <div className="h-64">
109
+ <ResponsiveContainer width="100%" height="100%">
110
+ <LineChart data={chartData}>
111
+ <CartesianGrid strokeDasharray="3 3" stroke="#2a2a3e" />
112
+ <XAxis
113
+ dataKey="date"
114
+ stroke="#6b7280"
115
+ fontSize={12}
116
+ tickFormatter={(val: string) => {
117
+ const d = new Date(val);
118
+ return `${d.getMonth() + 1}/${d.getDate()}`;
119
+ }}
120
+ />
121
+ <YAxis
122
+ stroke="#6b7280"
123
+ fontSize={12}
124
+ allowDecimals={false}
125
+ />
126
+ <Tooltip
127
+ contentStyle={{
128
+ backgroundColor: "#1a1a2e",
129
+ border: "1px solid #2a2a3e",
130
+ borderRadius: "8px",
131
+ color: "#fff",
132
+ fontSize: "13px",
133
+ }}
134
+ labelFormatter={(label: string) => {
135
+ return new Date(label).toLocaleDateString(undefined, {
136
+ weekday: "short",
137
+ month: "short",
138
+ day: "numeric",
139
+ });
140
+ }}
141
+ />
142
+ <Line
143
+ type="monotone"
144
+ dataKey="signups"
145
+ stroke="#3b82f6"
146
+ strokeWidth={2}
147
+ dot={{ r: 3, fill: "#3b82f6" }}
148
+ activeDot={{ r: 5, fill: "#3b82f6" }}
149
+ />
150
+ </LineChart>
151
+ </ResponsiveContainer>
152
+ </div>
153
+ )}
154
+ </div>
155
+
156
+ {/* Users Table */}
157
+ <div>
158
+ <h2 className="mb-4 text-lg font-semibold text-white">Users</h2>
159
+ <UsersTable
160
+ users={users}
161
+ pagination={pagination}
162
+ isLoading={usersLoading}
163
+ search={search}
164
+ onSearchChange={(val) => {
165
+ setSearch(val);
166
+ setPage(1);
167
+ }}
168
+ onPageChange={setPage}
169
+ />
170
+ </div>
171
+ </div>
172
+ );
173
+ }
@@ -0,0 +1,203 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { useParams, useNavigate } from "react-router";
3
+ import { fetchUser, updateUser, deleteUser, type AdminUser } from "@/hooks/useAdmin.js";
4
+
5
+ export default function UserDetail() {
6
+ const { id } = useParams<{ id: string }>();
7
+ const navigate = useNavigate();
8
+
9
+ const [user, setUser] = useState<AdminUser | null>(null);
10
+ const [activeSessions, setActiveSessions] = useState(0);
11
+ const [isLoading, setIsLoading] = useState(true);
12
+ const [error, setError] = useState<string | null>(null);
13
+ const [actionLoading, setActionLoading] = useState<string | null>(null);
14
+
15
+ const load = useCallback(async () => {
16
+ if (!id) return;
17
+ setIsLoading(true);
18
+ setError(null);
19
+ try {
20
+ const data = await fetchUser(id);
21
+ setUser(data.user);
22
+ setActiveSessions(data.activeSessions);
23
+ } catch (err) {
24
+ setError(err instanceof Error ? err.message : "Failed to load user");
25
+ } finally {
26
+ setIsLoading(false);
27
+ }
28
+ }, [id]);
29
+
30
+ useEffect(() => {
31
+ load();
32
+ }, [load]);
33
+
34
+ const handleVerifyEmail = async () => {
35
+ if (!user) return;
36
+ setActionLoading("verify");
37
+ try {
38
+ const result = await updateUser(user.id, { emailVerified: true });
39
+ setUser(result.user);
40
+ } catch (err) {
41
+ setError(err instanceof Error ? err.message : "Failed to verify email");
42
+ } finally {
43
+ setActionLoading(null);
44
+ }
45
+ };
46
+
47
+ const handleDelete = async () => {
48
+ if (!user) return;
49
+ const confirmed = window.confirm(
50
+ `Are you sure you want to delete ${user.name} (${user.email})? This action cannot be undone.`,
51
+ );
52
+ if (!confirmed) return;
53
+
54
+ setActionLoading("delete");
55
+ try {
56
+ await deleteUser(user.id);
57
+ navigate("/admin");
58
+ } catch (err) {
59
+ setError(err instanceof Error ? err.message : "Failed to delete user");
60
+ setActionLoading(null);
61
+ }
62
+ };
63
+
64
+ if (isLoading) {
65
+ return (
66
+ <div className="flex min-h-[50vh] items-center justify-center">
67
+ <div className="h-8 w-8 animate-spin rounded-full border-2 border-keel-gray-800 border-t-keel-blue" />
68
+ </div>
69
+ );
70
+ }
71
+
72
+ if (error || !user) {
73
+ return (
74
+ <div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
75
+ <div className="rounded-xl border border-red-500/20 bg-red-500/5 p-6 text-center">
76
+ <p className="text-sm text-red-400">{error ?? "User not found"}</p>
77
+ <button
78
+ onClick={() => navigate("/admin")}
79
+ className="mt-4 text-sm font-medium text-keel-blue hover:underline"
80
+ >
81
+ Back to dashboard
82
+ </button>
83
+ </div>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ return (
89
+ <div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
90
+ {/* Back button */}
91
+ <button
92
+ onClick={() => navigate("/admin")}
93
+ className="mb-6 flex items-center gap-1.5 text-sm font-medium text-keel-gray-400 transition-colors hover:text-white"
94
+ >
95
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
96
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
97
+ </svg>
98
+ Back to dashboard
99
+ </button>
100
+
101
+ {/* User info card */}
102
+ <div className="rounded-xl border border-keel-gray-800 bg-keel-gray-900 p-6">
103
+ <div className="flex items-start gap-4">
104
+ {user.image ? (
105
+ <img
106
+ src={user.image}
107
+ alt={user.name}
108
+ className="h-16 w-16 rounded-full object-cover"
109
+ />
110
+ ) : (
111
+ <div className="flex h-16 w-16 items-center justify-center rounded-full bg-keel-blue/20 text-xl font-bold text-keel-blue">
112
+ {user.name.charAt(0).toUpperCase()}
113
+ </div>
114
+ )}
115
+
116
+ <div className="min-w-0 flex-1">
117
+ <h1 className="text-xl font-bold text-white">{user.name}</h1>
118
+ <p className="text-sm text-keel-gray-400">{user.email}</p>
119
+
120
+ <div className="mt-3 flex flex-wrap gap-2">
121
+ {user.emailVerified ? (
122
+ <span className="inline-flex items-center rounded-full bg-green-500/10 px-2.5 py-0.5 text-xs font-medium text-green-400">
123
+ Email Verified
124
+ </span>
125
+ ) : (
126
+ <span className="inline-flex items-center rounded-full bg-yellow-500/10 px-2.5 py-0.5 text-xs font-medium text-yellow-400">
127
+ Email Not Verified
128
+ </span>
129
+ )}
130
+ <span className="inline-flex items-center rounded-full bg-keel-blue/10 px-2.5 py-0.5 text-xs font-medium text-keel-blue">
131
+ {activeSessions} active session{activeSessions !== 1 ? "s" : ""}
132
+ </span>
133
+ </div>
134
+ </div>
135
+ </div>
136
+
137
+ {/* Details */}
138
+ <div className="mt-6 grid grid-cols-1 gap-4 border-t border-keel-gray-800 pt-6 sm:grid-cols-2">
139
+ <div>
140
+ <p className="text-xs font-medium uppercase tracking-wider text-keel-gray-400">
141
+ User ID
142
+ </p>
143
+ <p className="mt-1 font-mono text-sm text-keel-gray-200">{user.id}</p>
144
+ </div>
145
+ <div>
146
+ <p className="text-xs font-medium uppercase tracking-wider text-keel-gray-400">
147
+ Member Since
148
+ </p>
149
+ <p className="mt-1 text-sm text-keel-gray-200">
150
+ {new Date(user.createdAt).toLocaleDateString(undefined, {
151
+ year: "numeric",
152
+ month: "long",
153
+ day: "numeric",
154
+ })}
155
+ </p>
156
+ </div>
157
+ <div>
158
+ <p className="text-xs font-medium uppercase tracking-wider text-keel-gray-400">
159
+ Last Updated
160
+ </p>
161
+ <p className="mt-1 text-sm text-keel-gray-200">
162
+ {new Date(user.updatedAt).toLocaleDateString(undefined, {
163
+ year: "numeric",
164
+ month: "long",
165
+ day: "numeric",
166
+ hour: "2-digit",
167
+ minute: "2-digit",
168
+ })}
169
+ </p>
170
+ </div>
171
+ <div>
172
+ <p className="text-xs font-medium uppercase tracking-wider text-keel-gray-400">
173
+ Profile Image
174
+ </p>
175
+ <p className="mt-1 text-sm text-keel-gray-200">
176
+ {user.image ? "Custom image set" : "No image"}
177
+ </p>
178
+ </div>
179
+ </div>
180
+
181
+ {/* Actions */}
182
+ <div className="mt-6 flex flex-wrap gap-3 border-t border-keel-gray-800 pt-6">
183
+ {!user.emailVerified && (
184
+ <button
185
+ onClick={handleVerifyEmail}
186
+ disabled={actionLoading === "verify"}
187
+ className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
188
+ >
189
+ {actionLoading === "verify" ? "Verifying..." : "Verify Email"}
190
+ </button>
191
+ )}
192
+ <button
193
+ onClick={handleDelete}
194
+ disabled={!!actionLoading}
195
+ className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
196
+ >
197
+ {actionLoading === "delete" ? "Deleting..." : "Delete User"}
198
+ </button>
199
+ </div>
200
+ </div>
201
+ </div>
202
+ );
203
+ }
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Admin Dashboard Sail Installer
3
+ *
4
+ * Adds an admin dashboard for user management and basic metrics.
5
+ * Includes user listing, user detail, stats cards, and a signup chart.
6
+ *
7
+ * Usage:
8
+ * npx tsx sails/admin-dashboard/install.ts
9
+ */
10
+
11
+ import {
12
+ readFileSync,
13
+ writeFileSync,
14
+ copyFileSync,
15
+ existsSync,
16
+ mkdirSync,
17
+ } from "node:fs";
18
+ import { resolve, dirname, join } from "node:path";
19
+ import { execSync } from "node:child_process";
20
+ import { input, confirm } from "@inquirer/prompts";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Paths
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const SAIL_DIR = dirname(new URL(import.meta.url).pathname);
27
+ const PROJECT_ROOT = resolve(SAIL_DIR, "../..");
28
+ const BACKEND_ROOT = join(PROJECT_ROOT, "packages/backend");
29
+ const FRONTEND_ROOT = join(PROJECT_ROOT, "packages/frontend");
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Helpers
33
+ // ---------------------------------------------------------------------------
34
+
35
+ interface SailManifest {
36
+ name: string;
37
+ displayName: string;
38
+ version: string;
39
+ requiredEnvVars: { key: string; description: string }[];
40
+ dependencies: { backend: Record<string, string>; frontend: Record<string, string> };
41
+ }
42
+
43
+ function loadManifest(): SailManifest {
44
+ return JSON.parse(readFileSync(join(SAIL_DIR, "addon.json"), "utf-8"));
45
+ }
46
+
47
+ function insertAtMarker(filePath: string, marker: string, code: string): void {
48
+ if (!existsSync(filePath)) {
49
+ console.warn(` Warning: File not found: ${filePath}`);
50
+ return;
51
+ }
52
+ let content = readFileSync(filePath, "utf-8");
53
+ if (!content.includes(marker)) {
54
+ console.warn(` Warning: Marker "${marker}" not found in ${filePath}`);
55
+ return;
56
+ }
57
+ if (content.includes(code.trim())) {
58
+ console.log(` Skipped (already present) -> ${filePath}`);
59
+ return;
60
+ }
61
+ content = content.replace(marker, `${marker}\n${code}`);
62
+ writeFileSync(filePath, content, "utf-8");
63
+ console.log(` Modified -> ${filePath}`);
64
+ }
65
+
66
+ function copyFile(src: string, dest: string, label: string): void {
67
+ mkdirSync(dirname(dest), { recursive: true });
68
+ copyFileSync(src, dest);
69
+ console.log(` Copied -> ${label}`);
70
+ }
71
+
72
+ function installDeps(deps: Record<string, string>, workspace: string): void {
73
+ const entries = Object.entries(deps);
74
+ if (entries.length === 0) return;
75
+ const packages = entries.map(([n, v]) => `${n}@${v}`).join(" ");
76
+ const cmd = `npm install ${packages} --workspace=${workspace}`;
77
+ console.log(` Running: ${cmd}`);
78
+ execSync(cmd, { cwd: PROJECT_ROOT, stdio: "inherit" });
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Email validation helper
83
+ // ---------------------------------------------------------------------------
84
+
85
+ function isValidEmail(email: string): boolean {
86
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
87
+ }
88
+
89
+ function validateAdminEmails(value: string): true | string {
90
+ if (!value || value.trim().length === 0) {
91
+ return "At least one admin email is required.";
92
+ }
93
+ const emails = value.split(",").map((e) => e.trim()).filter(Boolean);
94
+ if (emails.length === 0) {
95
+ return "At least one admin email is required.";
96
+ }
97
+ for (const email of emails) {
98
+ if (!isValidEmail(email)) {
99
+ return `Invalid email format: "${email}". Please provide valid email addresses separated by commas.`;
100
+ }
101
+ }
102
+ return true;
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Main
107
+ // ---------------------------------------------------------------------------
108
+
109
+ async function main(): Promise<void> {
110
+ const manifest = loadManifest();
111
+
112
+ // -- Step 1: Welcome --------------------------------------------------------
113
+ console.log("\n------------------------------------------------------------");
114
+ console.log(` Admin Dashboard Sail Installer (v${manifest.version})`);
115
+ console.log("------------------------------------------------------------");
116
+ console.log();
117
+ console.log(" This sail adds an admin dashboard to your project:");
118
+ console.log(" - Dashboard with stats cards and signup chart");
119
+ console.log(" - User management (list, search, view details)");
120
+ console.log(" - Admin actions (verify email, delete user)");
121
+ console.log(" - Access controlled via ADMIN_EMAILS env var");
122
+ console.log(" - Charts powered by recharts");
123
+ console.log();
124
+
125
+ const pkgPath = join(PROJECT_ROOT, "package.json");
126
+ if (existsSync(pkgPath)) {
127
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
128
+ console.log(` Template version: ${pkg.version ?? "unknown"}`);
129
+ console.log();
130
+ }
131
+
132
+ // -- Step 2: Collect admin emails -------------------------------------------
133
+ console.log(" The admin dashboard restricts access based on email addresses.");
134
+ console.log(" Only users whose email is in the ADMIN_EMAILS list can access");
135
+ console.log(" the /admin routes.");
136
+ console.log();
137
+
138
+ const adminEmails = await input({
139
+ message: "Admin email addresses (comma-separated):",
140
+ validate: validateAdminEmails,
141
+ });
142
+
143
+ const emails = adminEmails.split(",").map((e) => e.trim()).filter(Boolean);
144
+
145
+ console.log();
146
+ console.log(` Admin emails: ${emails.join(", ")}`);
147
+ console.log();
148
+
149
+ // -- Step 3: Summary --------------------------------------------------------
150
+ console.log(" Summary of changes:");
151
+ console.log(" -------------------");
152
+ console.log(" Files to create (backend):");
153
+ console.log(" + packages/backend/src/middleware/admin.ts");
154
+ console.log(" + packages/backend/src/routes/admin.ts");
155
+ console.log();
156
+ console.log(" Files to create (frontend):");
157
+ console.log(" + packages/frontend/src/pages/admin/Dashboard.tsx");
158
+ console.log(" + packages/frontend/src/pages/admin/UserDetail.tsx");
159
+ console.log(" + packages/frontend/src/components/admin/StatsCard.tsx");
160
+ console.log(" + packages/frontend/src/components/admin/UsersTable.tsx");
161
+ console.log(" + packages/frontend/src/hooks/useAdmin.ts");
162
+ console.log();
163
+ console.log(" Files to modify:");
164
+ console.log(" ~ packages/backend/src/index.ts (add admin routes)");
165
+ console.log(" ~ packages/backend/src/env.ts (add ADMIN_EMAILS)");
166
+ console.log(" ~ packages/frontend/src/router.tsx (add admin pages)");
167
+ console.log(" ~ .env.example / .env");
168
+ console.log();
169
+ console.log(" Environment variables:");
170
+ console.log(` ADMIN_EMAILS=${emails.join(",")}`);
171
+ console.log();
172
+
173
+ // -- Step 4: Confirm --------------------------------------------------------
174
+ const proceed = await confirm({ message: "Proceed with installation?", default: true });
175
+ if (!proceed) {
176
+ console.log("\n Installation cancelled.\n");
177
+ process.exit(0);
178
+ }
179
+
180
+ console.log();
181
+ console.log(" Installing...");
182
+ console.log();
183
+
184
+ // -- Step 5: Copy files -----------------------------------------------------
185
+ console.log(" Copying backend files...");
186
+ const backendFiles = [
187
+ { src: "backend/middleware/admin.ts", dest: "src/middleware/admin.ts" },
188
+ { src: "backend/routes/admin.ts", dest: "src/routes/admin.ts" },
189
+ ];
190
+ for (const f of backendFiles) {
191
+ copyFile(join(SAIL_DIR, "files", f.src), join(BACKEND_ROOT, f.dest), f.dest);
192
+ }
193
+
194
+ console.log();
195
+ console.log(" Copying frontend files...");
196
+ const frontendFiles = [
197
+ { src: "frontend/pages/admin/Dashboard.tsx", dest: "src/pages/admin/Dashboard.tsx" },
198
+ { src: "frontend/pages/admin/UserDetail.tsx", dest: "src/pages/admin/UserDetail.tsx" },
199
+ { src: "frontend/components/admin/StatsCard.tsx", dest: "src/components/admin/StatsCard.tsx" },
200
+ { src: "frontend/components/admin/UsersTable.tsx", dest: "src/components/admin/UsersTable.tsx" },
201
+ { src: "frontend/hooks/useAdmin.ts", dest: "src/hooks/useAdmin.ts" },
202
+ ];
203
+ for (const f of frontendFiles) {
204
+ copyFile(join(SAIL_DIR, "files", f.src), join(FRONTEND_ROOT, f.dest), f.dest);
205
+ }
206
+
207
+ // -- Step 6: Modify existing files ------------------------------------------
208
+ console.log();
209
+ console.log(" Modifying backend files...");
210
+
211
+ insertAtMarker(
212
+ join(BACKEND_ROOT, "src/index.ts"),
213
+ "// [SAIL_IMPORTS]",
214
+ 'import adminRoutes from "./routes/admin.js";',
215
+ );
216
+ insertAtMarker(
217
+ join(BACKEND_ROOT, "src/index.ts"),
218
+ "// [SAIL_ROUTES]",
219
+ 'app.use("/api/admin", adminRoutes);',
220
+ );
221
+ insertAtMarker(
222
+ join(BACKEND_ROOT, "src/env.ts"),
223
+ "// [SAIL_ENV_VARS]",
224
+ ' ADMIN_EMAILS: z.string().min(1, "ADMIN_EMAILS is required (comma-separated admin emails)"),',
225
+ );
226
+
227
+ console.log();
228
+ console.log(" Modifying frontend files...");
229
+
230
+ const routerPath = join(FRONTEND_ROOT, "src/router.tsx");
231
+ if (existsSync(routerPath)) {
232
+ let routerContent = readFileSync(routerPath, "utf-8");
233
+
234
+ // Add imports if not present
235
+ if (!routerContent.includes("AdminDashboard")) {
236
+ routerContent = routerContent.replace(
237
+ "export function AppRouter() {",
238
+ 'import AdminDashboard from "./pages/admin/Dashboard";\nimport AdminUserDetail from "./pages/admin/UserDetail";\n\nexport function AppRouter() {',
239
+ );
240
+ }
241
+
242
+ // Add admin routes before the SAIL_ROUTES marker
243
+ if (!routerContent.includes('path="/admin"')) {
244
+ routerContent = routerContent.replace(
245
+ "{/* [SAIL_ROUTES] */}",
246
+ `<Route element={<ProtectedRoute />}>
247
+ <Route path="/admin" element={<AdminDashboard />} />
248
+ <Route path="/admin/users/:id" element={<AdminUserDetail />} />
249
+ </Route>
250
+ {/* [SAIL_ROUTES] */}`,
251
+ );
252
+ }
253
+
254
+ writeFileSync(routerPath, routerContent, "utf-8");
255
+ console.log(" Modified -> src/router.tsx");
256
+ }
257
+
258
+ // -- Step 7: Update env files -----------------------------------------------
259
+ console.log();
260
+ console.log(" Updating environment files...");
261
+
262
+ for (const envFile of [".env.example", ".env"]) {
263
+ const envPath = join(PROJECT_ROOT, envFile);
264
+ if (!existsSync(envPath)) continue;
265
+
266
+ let content = readFileSync(envPath, "utf-8");
267
+ if (!content.includes("ADMIN_EMAILS")) {
268
+ content += `\n# Admin Dashboard\nADMIN_EMAILS=${emails.join(",")}\n`;
269
+ writeFileSync(envPath, content, "utf-8");
270
+ console.log(` Updated ${envFile}`);
271
+ }
272
+ }
273
+
274
+ // -- Step 8: Install dependencies -------------------------------------------
275
+ console.log();
276
+ console.log(" Installing dependencies...");
277
+ installDeps(manifest.dependencies.backend, "packages/backend");
278
+ installDeps(manifest.dependencies.frontend, "packages/frontend");
279
+
280
+ // -- Step 9: Next steps -----------------------------------------------------
281
+ console.log();
282
+ console.log("------------------------------------------------------------");
283
+ console.log(" Admin Dashboard installed successfully!");
284
+ console.log("------------------------------------------------------------");
285
+ console.log();
286
+ console.log(" Next steps:");
287
+ console.log();
288
+ console.log(" 1. Start your dev server:");
289
+ console.log(" npm run dev");
290
+ console.log();
291
+ console.log(" 2. Navigate to /admin in your browser");
292
+ console.log(" (you must be logged in with an admin email)");
293
+ console.log();
294
+ console.log(" 3. To add/remove admin users, update ADMIN_EMAILS in .env:");
295
+ console.log(` ADMIN_EMAILS=${emails.join(",")}`);
296
+ console.log();
297
+ console.log(" 4. Optionally add an admin link to your Header component");
298
+ console.log(" for users with admin privileges.");
299
+ console.log();
300
+ }
301
+
302
+ main().catch((err) => {
303
+ console.error("Installation failed:", err);
304
+ process.exit(1);
305
+ });