@wopr-network/platform-ui-core 1.8.1 → 1.10.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.
- package/package.json +1 -1
- package/src/__tests__/instance-list.test.tsx +21 -0
- package/src/app/admin/email-templates/email-templates-client.tsx +618 -0
- package/src/app/admin/email-templates/error.tsx +72 -0
- package/src/app/admin/email-templates/loading.tsx +38 -0
- package/src/app/admin/email-templates/page.tsx +5 -0
- package/src/app/fleet/settings/page.tsx +19 -0
- package/src/components/fleet/update-settings-card.tsx +173 -0
- package/src/lib/trpc-types.ts +20 -0
package/package.json
CHANGED
|
@@ -3,8 +3,29 @@ import userEvent from "@testing-library/user-event";
|
|
|
3
3
|
import { describe, expect, it, vi } from "vitest";
|
|
4
4
|
import { InstanceListClient } from "../app/instances/instance-list-client";
|
|
5
5
|
|
|
6
|
+
vi.mock("@/lib/tenant-context", () => ({
|
|
7
|
+
useTenant: vi.fn().mockReturnValue({
|
|
8
|
+
activeTenantId: "tenant-001",
|
|
9
|
+
tenants: [],
|
|
10
|
+
isLoading: false,
|
|
11
|
+
switchTenant: vi.fn(),
|
|
12
|
+
}),
|
|
13
|
+
getActiveTenantId: vi.fn().mockReturnValue("tenant-001"),
|
|
14
|
+
}));
|
|
15
|
+
|
|
6
16
|
vi.mock("@/lib/trpc", () => ({
|
|
7
17
|
trpc: {
|
|
18
|
+
fleetUpdateConfig: {
|
|
19
|
+
getUpdateConfig: {
|
|
20
|
+
useQuery: vi.fn().mockReturnValue({
|
|
21
|
+
data: null,
|
|
22
|
+
isLoading: false,
|
|
23
|
+
isError: false,
|
|
24
|
+
error: null,
|
|
25
|
+
refetch: vi.fn(),
|
|
26
|
+
}),
|
|
27
|
+
},
|
|
28
|
+
},
|
|
8
29
|
fleet: {
|
|
9
30
|
getChangelog: {
|
|
10
31
|
useQuery: vi.fn().mockReturnValue({ data: null, isLoading: false, error: null }),
|
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Eye, Mail, MailCheck, Save, Search, Sprout, X } from "lucide-react";
|
|
4
|
+
import { useCallback, useMemo, useState } from "react";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { Badge } from "@/components/ui/badge";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
9
|
+
import { Input } from "@/components/ui/input";
|
|
10
|
+
import { Label } from "@/components/ui/label";
|
|
11
|
+
import { Separator } from "@/components/ui/separator";
|
|
12
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
13
|
+
import { Switch } from "@/components/ui/switch";
|
|
14
|
+
import {
|
|
15
|
+
Table,
|
|
16
|
+
TableBody,
|
|
17
|
+
TableCell,
|
|
18
|
+
TableHead,
|
|
19
|
+
TableHeader,
|
|
20
|
+
TableRow,
|
|
21
|
+
} from "@/components/ui/table";
|
|
22
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
23
|
+
import { trpc } from "@/lib/trpc";
|
|
24
|
+
import { cn } from "@/lib/utils";
|
|
25
|
+
|
|
26
|
+
/* ------------------------------------------------------------------ */
|
|
27
|
+
/* Types */
|
|
28
|
+
/* ------------------------------------------------------------------ */
|
|
29
|
+
|
|
30
|
+
interface EmailTemplate {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
description: string | null;
|
|
34
|
+
subject: string;
|
|
35
|
+
htmlBody: string;
|
|
36
|
+
textBody: string;
|
|
37
|
+
active: boolean;
|
|
38
|
+
updatedAt: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* ------------------------------------------------------------------ */
|
|
42
|
+
/* Variable map — derived from template name */
|
|
43
|
+
/* ------------------------------------------------------------------ */
|
|
44
|
+
|
|
45
|
+
const TEMPLATE_VARIABLES: Record<string, string[]> = {
|
|
46
|
+
welcome: ["userName", "loginUrl", "platformName"],
|
|
47
|
+
"password-reset": ["userName", "resetUrl", "expiresIn"],
|
|
48
|
+
"email-verification": ["userName", "verifyUrl", "expiresIn"],
|
|
49
|
+
"invite-member": ["inviterName", "orgName", "role", "inviteUrl", "expiresIn"],
|
|
50
|
+
"payment-receipt": ["userName", "amount", "currency", "date", "invoiceUrl"],
|
|
51
|
+
"payment-failed": ["userName", "amount", "currency", "retryUrl"],
|
|
52
|
+
"credits-low": ["userName", "balance", "topupUrl"],
|
|
53
|
+
"usage-alert": ["userName", "usagePercent", "limit", "settingsUrl"],
|
|
54
|
+
"instance-down": ["userName", "instanceName", "downSince", "dashboardUrl"],
|
|
55
|
+
"instance-recovered": ["userName", "instanceName", "recoveredAt", "dashboardUrl"],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function getVariablesForTemplate(name: string): string[] {
|
|
59
|
+
const key = name.toLowerCase().replace(/_/g, "-");
|
|
60
|
+
for (const [pattern, vars] of Object.entries(TEMPLATE_VARIABLES)) {
|
|
61
|
+
if (key.includes(pattern)) return vars;
|
|
62
|
+
}
|
|
63
|
+
return ["userName", "platformName"];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildSampleData(variables: string[]): Record<string, string> {
|
|
67
|
+
const samples: Record<string, string> = {
|
|
68
|
+
userName: "Jane Doe",
|
|
69
|
+
loginUrl: "https://app.example.com/login",
|
|
70
|
+
platformName: "Platform",
|
|
71
|
+
resetUrl: "https://app.example.com/reset?token=abc",
|
|
72
|
+
verifyUrl: "https://app.example.com/verify?token=abc",
|
|
73
|
+
expiresIn: "24 hours",
|
|
74
|
+
inviterName: "John Admin",
|
|
75
|
+
orgName: "Acme Corp",
|
|
76
|
+
role: "member",
|
|
77
|
+
inviteUrl: "https://app.example.com/invite?token=abc",
|
|
78
|
+
amount: "$25.00",
|
|
79
|
+
currency: "USD",
|
|
80
|
+
date: new Date().toLocaleDateString(),
|
|
81
|
+
invoiceUrl: "https://app.example.com/invoices/123",
|
|
82
|
+
retryUrl: "https://app.example.com/billing",
|
|
83
|
+
balance: "$2.50",
|
|
84
|
+
topupUrl: "https://app.example.com/billing/topup",
|
|
85
|
+
usagePercent: "90%",
|
|
86
|
+
limit: "$100.00",
|
|
87
|
+
settingsUrl: "https://app.example.com/settings",
|
|
88
|
+
instanceName: "prod-bot-1",
|
|
89
|
+
downSince: "10 minutes ago",
|
|
90
|
+
recoveredAt: new Date().toLocaleTimeString(),
|
|
91
|
+
dashboardUrl: "https://app.example.com/fleet",
|
|
92
|
+
};
|
|
93
|
+
const result: Record<string, string> = {};
|
|
94
|
+
for (const v of variables) {
|
|
95
|
+
result[v] = samples[v] ?? `{{${v}}}`;
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* ------------------------------------------------------------------ */
|
|
101
|
+
/* Main component */
|
|
102
|
+
/* ------------------------------------------------------------------ */
|
|
103
|
+
|
|
104
|
+
export function EmailTemplatesClient() {
|
|
105
|
+
const [search, setSearch] = useState("");
|
|
106
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
107
|
+
|
|
108
|
+
// --- Editor state ---
|
|
109
|
+
const [editDescription, setEditDescription] = useState("");
|
|
110
|
+
const [editSubject, setEditSubject] = useState("");
|
|
111
|
+
const [editHtmlBody, setEditHtmlBody] = useState("");
|
|
112
|
+
const [editTextBody, setEditTextBody] = useState("");
|
|
113
|
+
const [editActive, setEditActive] = useState(true);
|
|
114
|
+
|
|
115
|
+
// --- Preview state ---
|
|
116
|
+
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
|
|
117
|
+
const [previewSubject, setPreviewSubject] = useState<string | null>(null);
|
|
118
|
+
const [previewText, setPreviewText] = useState<string | null>(null);
|
|
119
|
+
|
|
120
|
+
const utils = trpc.useUtils();
|
|
121
|
+
|
|
122
|
+
// --- Queries ---
|
|
123
|
+
const listQuery = trpc.notificationTemplates.listTemplates.useQuery(undefined, {
|
|
124
|
+
refetchOnWindowFocus: false,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// --- Mutations ---
|
|
128
|
+
const updateMutation = trpc.notificationTemplates.updateTemplate.useMutation({
|
|
129
|
+
onSuccess: () => {
|
|
130
|
+
toast.success("Template saved successfully.");
|
|
131
|
+
utils.notificationTemplates.listTemplates.invalidate();
|
|
132
|
+
},
|
|
133
|
+
onError: (err) => {
|
|
134
|
+
toast.error(`Failed to save template: ${err.message}`);
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const previewMutation = trpc.notificationTemplates.previewTemplate.useMutation({
|
|
139
|
+
onError: (err) => {
|
|
140
|
+
toast.error(`Preview failed: ${err.message}`);
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const seedMutation = trpc.notificationTemplates.seedDefaults.useMutation({
|
|
145
|
+
// NOTE: Cast needed because trpc-types.ts uses AnyTRPCMutationProcedure stubs
|
|
146
|
+
// that erase return types. Remove when @wopr-network/sdk is published.
|
|
147
|
+
onSuccess: (data: unknown) => {
|
|
148
|
+
const result = data as { seeded: number } | undefined;
|
|
149
|
+
const count = result?.seeded ?? 0;
|
|
150
|
+
toast.success(`Seeded ${count} default template${count !== 1 ? "s" : ""}.`);
|
|
151
|
+
utils.notificationTemplates.listTemplates.invalidate();
|
|
152
|
+
},
|
|
153
|
+
onError: (err) => {
|
|
154
|
+
toast.error(`Seed failed: ${err.message}`);
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// --- Derived data ---
|
|
159
|
+
// NOTE: Cast needed because trpc-types.ts uses AnyTRPCMutationProcedure stubs
|
|
160
|
+
// that erase return types. Remove when @wopr-network/sdk is published.
|
|
161
|
+
const templates = (listQuery.data ?? []) as EmailTemplate[];
|
|
162
|
+
|
|
163
|
+
const filtered = useMemo(() => {
|
|
164
|
+
if (!search.trim()) return templates;
|
|
165
|
+
const q = search.toLowerCase();
|
|
166
|
+
return templates.filter(
|
|
167
|
+
(t) =>
|
|
168
|
+
t.name.toLowerCase().includes(q) ||
|
|
169
|
+
(t.description ?? "").toLowerCase().includes(q) ||
|
|
170
|
+
t.subject.toLowerCase().includes(q),
|
|
171
|
+
);
|
|
172
|
+
}, [templates, search]);
|
|
173
|
+
|
|
174
|
+
const selectedTemplate = useMemo(
|
|
175
|
+
() => templates.find((t) => t.id === selectedId) ?? null,
|
|
176
|
+
[templates, selectedId],
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// --- Handlers ---
|
|
180
|
+
|
|
181
|
+
const openEditor = useCallback((template: EmailTemplate) => {
|
|
182
|
+
setSelectedId(template.id);
|
|
183
|
+
setEditDescription(template.description ?? "");
|
|
184
|
+
setEditSubject(template.subject);
|
|
185
|
+
setEditHtmlBody(template.htmlBody);
|
|
186
|
+
setEditTextBody(template.textBody);
|
|
187
|
+
setEditActive(template.active);
|
|
188
|
+
setPreviewHtml(null);
|
|
189
|
+
setPreviewSubject(null);
|
|
190
|
+
setPreviewText(null);
|
|
191
|
+
}, []);
|
|
192
|
+
|
|
193
|
+
const closeEditor = useCallback(() => {
|
|
194
|
+
// Dirty check — warn if editor state differs from loaded template values
|
|
195
|
+
if (selectedTemplate) {
|
|
196
|
+
const isDirty =
|
|
197
|
+
editDescription !== (selectedTemplate.description ?? "") ||
|
|
198
|
+
editSubject !== selectedTemplate.subject ||
|
|
199
|
+
editHtmlBody !== selectedTemplate.htmlBody ||
|
|
200
|
+
editTextBody !== selectedTemplate.textBody ||
|
|
201
|
+
editActive !== selectedTemplate.active;
|
|
202
|
+
|
|
203
|
+
if (isDirty) {
|
|
204
|
+
const confirmed = window.confirm(
|
|
205
|
+
"You have unsaved changes. Are you sure you want to close without saving?",
|
|
206
|
+
);
|
|
207
|
+
if (!confirmed) return;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
setSelectedId(null);
|
|
212
|
+
setPreviewHtml(null);
|
|
213
|
+
setPreviewSubject(null);
|
|
214
|
+
setPreviewText(null);
|
|
215
|
+
}, [selectedTemplate, editDescription, editSubject, editHtmlBody, editTextBody, editActive]);
|
|
216
|
+
|
|
217
|
+
const handleSave = useCallback(() => {
|
|
218
|
+
if (!selectedId) return;
|
|
219
|
+
updateMutation.mutate({
|
|
220
|
+
id: selectedId,
|
|
221
|
+
description: editDescription,
|
|
222
|
+
subject: editSubject,
|
|
223
|
+
htmlBody: editHtmlBody,
|
|
224
|
+
textBody: editTextBody,
|
|
225
|
+
active: editActive,
|
|
226
|
+
});
|
|
227
|
+
}, [
|
|
228
|
+
selectedId,
|
|
229
|
+
editDescription,
|
|
230
|
+
editSubject,
|
|
231
|
+
editHtmlBody,
|
|
232
|
+
editTextBody,
|
|
233
|
+
editActive,
|
|
234
|
+
updateMutation,
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
const handlePreview = useCallback(() => {
|
|
238
|
+
if (!selectedTemplate) return;
|
|
239
|
+
const variables = getVariablesForTemplate(selectedTemplate.name);
|
|
240
|
+
const sampleData = buildSampleData(variables);
|
|
241
|
+
|
|
242
|
+
previewMutation.mutate(
|
|
243
|
+
{
|
|
244
|
+
id: selectedTemplate.id,
|
|
245
|
+
subject: editSubject,
|
|
246
|
+
htmlBody: editHtmlBody,
|
|
247
|
+
textBody: editTextBody,
|
|
248
|
+
sampleData,
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
// NOTE: Cast needed because trpc-types.ts uses AnyTRPCMutationProcedure stubs
|
|
252
|
+
// that erase return types. Remove when @wopr-network/sdk is published.
|
|
253
|
+
onSuccess: (data: unknown) => {
|
|
254
|
+
const result = data as {
|
|
255
|
+
html: string;
|
|
256
|
+
subject: string;
|
|
257
|
+
text: string;
|
|
258
|
+
} | null;
|
|
259
|
+
if (result) {
|
|
260
|
+
setPreviewHtml(result.html);
|
|
261
|
+
setPreviewSubject(result.subject);
|
|
262
|
+
setPreviewText(result.text);
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
);
|
|
267
|
+
}, [selectedTemplate, editSubject, editHtmlBody, editTextBody, previewMutation]);
|
|
268
|
+
|
|
269
|
+
const handleSeed = useCallback(() => {
|
|
270
|
+
seedMutation.mutate(undefined);
|
|
271
|
+
}, [seedMutation]);
|
|
272
|
+
|
|
273
|
+
// --- Loading state ---
|
|
274
|
+
|
|
275
|
+
if (listQuery.isLoading) {
|
|
276
|
+
return (
|
|
277
|
+
<div className="p-6 space-y-6">
|
|
278
|
+
<Skeleton className="h-8 w-72" />
|
|
279
|
+
<Skeleton className="h-10 w-full max-w-sm" />
|
|
280
|
+
<div className="space-y-2">
|
|
281
|
+
{Array.from({ length: 5 }, (_, i) => (
|
|
282
|
+
<Skeleton key={`skel-${i.toString()}`} className="h-12 w-full" />
|
|
283
|
+
))}
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (listQuery.isError) {
|
|
290
|
+
return (
|
|
291
|
+
<div className="flex h-40 flex-col items-center justify-center gap-3">
|
|
292
|
+
<p className="text-sm text-destructive font-mono">
|
|
293
|
+
Failed to load email templates. Please try again.
|
|
294
|
+
</p>
|
|
295
|
+
<Button variant="outline" size="sm" onClick={() => listQuery.refetch()}>
|
|
296
|
+
Retry
|
|
297
|
+
</Button>
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// --- Editor panel ---
|
|
303
|
+
|
|
304
|
+
if (selectedTemplate) {
|
|
305
|
+
const variables = getVariablesForTemplate(selectedTemplate.name);
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<div className="flex flex-col h-full">
|
|
309
|
+
{/* Header bar */}
|
|
310
|
+
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
|
311
|
+
<div className="flex items-center gap-3">
|
|
312
|
+
<Button
|
|
313
|
+
variant="ghost"
|
|
314
|
+
size="sm"
|
|
315
|
+
onClick={closeEditor}
|
|
316
|
+
className="text-muted-foreground hover:text-foreground"
|
|
317
|
+
>
|
|
318
|
+
<X className="size-4 mr-1" />
|
|
319
|
+
Back
|
|
320
|
+
</Button>
|
|
321
|
+
<Separator orientation="vertical" className="h-6" />
|
|
322
|
+
<div className="flex items-center gap-2">
|
|
323
|
+
<MailCheck className="size-4 text-amber-400" />
|
|
324
|
+
<h2 className="text-lg font-bold uppercase tracking-wider">
|
|
325
|
+
{selectedTemplate.name}
|
|
326
|
+
</h2>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
<div className="flex items-center gap-2">
|
|
330
|
+
<Button
|
|
331
|
+
variant="ghost"
|
|
332
|
+
className="border border-border hover:bg-secondary"
|
|
333
|
+
onClick={handlePreview}
|
|
334
|
+
disabled={previewMutation.isPending}
|
|
335
|
+
>
|
|
336
|
+
<Eye className="size-4 mr-1.5" />
|
|
337
|
+
{previewMutation.isPending ? "Rendering..." : "Preview"}
|
|
338
|
+
</Button>
|
|
339
|
+
<Button
|
|
340
|
+
variant="ghost"
|
|
341
|
+
className="bg-amber-500/10 text-amber-400 border border-amber-500/30 hover:bg-amber-500/20"
|
|
342
|
+
onClick={handleSave}
|
|
343
|
+
disabled={updateMutation.isPending}
|
|
344
|
+
>
|
|
345
|
+
<Save className="size-4 mr-1.5" />
|
|
346
|
+
{updateMutation.isPending ? "Saving..." : "Save"}
|
|
347
|
+
</Button>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
<div className="flex flex-1 min-h-0">
|
|
352
|
+
{/* Left: editor form */}
|
|
353
|
+
<div className="flex-1 overflow-auto p-6 space-y-6">
|
|
354
|
+
<Card className="border-border">
|
|
355
|
+
<CardHeader>
|
|
356
|
+
<CardTitle className="text-base">Template Settings</CardTitle>
|
|
357
|
+
<CardDescription>
|
|
358
|
+
Edit the template content. Use Handlebars syntax (
|
|
359
|
+
<code className="text-xs font-mono text-amber-400">{"{{variableName}}"}</code>)
|
|
360
|
+
for dynamic values.
|
|
361
|
+
</CardDescription>
|
|
362
|
+
</CardHeader>
|
|
363
|
+
<CardContent className="space-y-5">
|
|
364
|
+
{/* Description */}
|
|
365
|
+
<div className="space-y-2">
|
|
366
|
+
<Label htmlFor="tpl-description">Description</Label>
|
|
367
|
+
<Input
|
|
368
|
+
id="tpl-description"
|
|
369
|
+
value={editDescription}
|
|
370
|
+
onChange={(e) => setEditDescription(e.target.value)}
|
|
371
|
+
placeholder="Brief description of when this template is sent"
|
|
372
|
+
className="bg-black/20 border-border focus:border-amber-500/50"
|
|
373
|
+
/>
|
|
374
|
+
</div>
|
|
375
|
+
|
|
376
|
+
{/* Subject */}
|
|
377
|
+
<div className="space-y-2">
|
|
378
|
+
<Label htmlFor="tpl-subject">Subject Line</Label>
|
|
379
|
+
<Input
|
|
380
|
+
id="tpl-subject"
|
|
381
|
+
value={editSubject}
|
|
382
|
+
onChange={(e) => setEditSubject(e.target.value)}
|
|
383
|
+
placeholder="Email subject (supports Handlebars)"
|
|
384
|
+
className="font-mono text-sm bg-black/20 border-border focus:border-amber-500/50"
|
|
385
|
+
/>
|
|
386
|
+
</div>
|
|
387
|
+
|
|
388
|
+
{/* HTML Body */}
|
|
389
|
+
<div className="space-y-2">
|
|
390
|
+
<Label htmlFor="tpl-html">HTML Body</Label>
|
|
391
|
+
<Textarea
|
|
392
|
+
id="tpl-html"
|
|
393
|
+
value={editHtmlBody}
|
|
394
|
+
onChange={(e) => setEditHtmlBody(e.target.value)}
|
|
395
|
+
placeholder="HTML email body (supports Handlebars)"
|
|
396
|
+
className="min-h-[240px] font-mono text-sm leading-relaxed bg-black/20 border-border focus:border-amber-500/50"
|
|
397
|
+
/>
|
|
398
|
+
</div>
|
|
399
|
+
|
|
400
|
+
{/* Text Body */}
|
|
401
|
+
<div className="space-y-2">
|
|
402
|
+
<Label htmlFor="tpl-text">Text Body</Label>
|
|
403
|
+
<Textarea
|
|
404
|
+
id="tpl-text"
|
|
405
|
+
value={editTextBody}
|
|
406
|
+
onChange={(e) => setEditTextBody(e.target.value)}
|
|
407
|
+
placeholder="Plain text email body (supports Handlebars)"
|
|
408
|
+
className="min-h-[160px] font-mono text-sm leading-relaxed bg-black/20 border-border focus:border-amber-500/50"
|
|
409
|
+
/>
|
|
410
|
+
</div>
|
|
411
|
+
|
|
412
|
+
{/* Active toggle */}
|
|
413
|
+
<div className="flex items-center justify-between rounded-md border border-border p-3">
|
|
414
|
+
<div className="space-y-0.5">
|
|
415
|
+
<Label>Active</Label>
|
|
416
|
+
<p className="text-xs text-muted-foreground">
|
|
417
|
+
Inactive templates will not be sent.
|
|
418
|
+
</p>
|
|
419
|
+
</div>
|
|
420
|
+
<Switch
|
|
421
|
+
checked={editActive}
|
|
422
|
+
onCheckedChange={setEditActive}
|
|
423
|
+
className="data-[state=checked]:bg-amber-500"
|
|
424
|
+
/>
|
|
425
|
+
</div>
|
|
426
|
+
</CardContent>
|
|
427
|
+
</Card>
|
|
428
|
+
|
|
429
|
+
{/* Available variables */}
|
|
430
|
+
<Card className="border-border">
|
|
431
|
+
<CardHeader>
|
|
432
|
+
<CardTitle className="text-base">Available Variables</CardTitle>
|
|
433
|
+
<CardDescription>
|
|
434
|
+
Use these in your template with Handlebars syntax.
|
|
435
|
+
</CardDescription>
|
|
436
|
+
</CardHeader>
|
|
437
|
+
<CardContent>
|
|
438
|
+
<div className="flex flex-wrap gap-2">
|
|
439
|
+
{variables.map((v) => (
|
|
440
|
+
<Badge
|
|
441
|
+
key={v}
|
|
442
|
+
variant="outline"
|
|
443
|
+
className="border-amber-500/30 text-amber-400 font-mono text-xs"
|
|
444
|
+
>
|
|
445
|
+
{`{{${v}}}`}
|
|
446
|
+
</Badge>
|
|
447
|
+
))}
|
|
448
|
+
</div>
|
|
449
|
+
</CardContent>
|
|
450
|
+
</Card>
|
|
451
|
+
</div>
|
|
452
|
+
|
|
453
|
+
{/* Right: preview panel */}
|
|
454
|
+
<div className="w-[480px] shrink-0 border-l border-border overflow-auto p-6 space-y-4">
|
|
455
|
+
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
|
456
|
+
Preview
|
|
457
|
+
</h3>
|
|
458
|
+
|
|
459
|
+
{previewHtml === null && previewSubject === null ? (
|
|
460
|
+
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
|
461
|
+
<Eye className="size-8 mb-3 opacity-40" />
|
|
462
|
+
<p className="text-sm font-mono">
|
|
463
|
+
Click "Preview" to render the template
|
|
464
|
+
</p>
|
|
465
|
+
</div>
|
|
466
|
+
) : (
|
|
467
|
+
<div className="space-y-4">
|
|
468
|
+
{/* Rendered subject */}
|
|
469
|
+
{previewSubject !== null && (
|
|
470
|
+
<div className="space-y-1">
|
|
471
|
+
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
472
|
+
Subject
|
|
473
|
+
</span>
|
|
474
|
+
<p className="text-sm font-medium rounded-md border border-border bg-black/20 px-3 py-2">
|
|
475
|
+
{previewSubject}
|
|
476
|
+
</p>
|
|
477
|
+
</div>
|
|
478
|
+
)}
|
|
479
|
+
|
|
480
|
+
{/* Rendered HTML */}
|
|
481
|
+
{previewHtml !== null && (
|
|
482
|
+
<div className="space-y-1">
|
|
483
|
+
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
484
|
+
HTML Preview
|
|
485
|
+
</span>
|
|
486
|
+
<div className="rounded-md border border-border overflow-auto max-h-[400px]">
|
|
487
|
+
<div
|
|
488
|
+
className="bg-white p-4 text-black text-sm [color-scheme:light]"
|
|
489
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: Admin preview of controlled email template
|
|
490
|
+
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
|
491
|
+
/>
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
)}
|
|
495
|
+
|
|
496
|
+
{/* Rendered text */}
|
|
497
|
+
{previewText !== null && (
|
|
498
|
+
<div className="space-y-1">
|
|
499
|
+
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
500
|
+
Text Preview
|
|
501
|
+
</span>
|
|
502
|
+
<pre className="whitespace-pre-wrap rounded-md border border-border bg-black/20 p-3 text-xs font-mono leading-relaxed">
|
|
503
|
+
{previewText}
|
|
504
|
+
</pre>
|
|
505
|
+
</div>
|
|
506
|
+
)}
|
|
507
|
+
</div>
|
|
508
|
+
)}
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
</div>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// --- List view ---
|
|
516
|
+
|
|
517
|
+
return (
|
|
518
|
+
<div className="p-6 space-y-6">
|
|
519
|
+
{/* Header */}
|
|
520
|
+
<div className="flex items-center justify-between">
|
|
521
|
+
<div className="flex items-center gap-3">
|
|
522
|
+
<Mail className="size-5 text-amber-400" />
|
|
523
|
+
<h1 className="text-xl font-bold uppercase tracking-wider [text-shadow:0_0_10px_rgba(251,191,36,0.25)]">
|
|
524
|
+
Email Templates
|
|
525
|
+
</h1>
|
|
526
|
+
</div>
|
|
527
|
+
<Button
|
|
528
|
+
variant="ghost"
|
|
529
|
+
className="bg-amber-500/10 text-amber-400 border border-amber-500/30 hover:bg-amber-500/20"
|
|
530
|
+
onClick={handleSeed}
|
|
531
|
+
disabled={seedMutation.isPending}
|
|
532
|
+
>
|
|
533
|
+
<Sprout className="size-4 mr-1.5" />
|
|
534
|
+
{seedMutation.isPending ? "Seeding..." : "Seed Defaults"}
|
|
535
|
+
</Button>
|
|
536
|
+
</div>
|
|
537
|
+
|
|
538
|
+
{/* Search */}
|
|
539
|
+
<div className="relative max-w-sm">
|
|
540
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
541
|
+
<Input
|
|
542
|
+
placeholder="Search templates..."
|
|
543
|
+
value={search}
|
|
544
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
545
|
+
className="pl-9 bg-black/20 border-border focus:border-amber-500/50"
|
|
546
|
+
/>
|
|
547
|
+
</div>
|
|
548
|
+
|
|
549
|
+
{/* Table */}
|
|
550
|
+
<div className="border border-amber-500/10 rounded-sm">
|
|
551
|
+
<Table>
|
|
552
|
+
<TableHeader>
|
|
553
|
+
<TableRow className="bg-secondary hover:bg-secondary">
|
|
554
|
+
<TableHead className="text-xs uppercase tracking-wider">Name</TableHead>
|
|
555
|
+
<TableHead className="text-xs uppercase tracking-wider">Description</TableHead>
|
|
556
|
+
<TableHead className="text-xs uppercase tracking-wider">Subject</TableHead>
|
|
557
|
+
<TableHead className="text-xs uppercase tracking-wider">Status</TableHead>
|
|
558
|
+
<TableHead className="text-xs uppercase tracking-wider">Updated</TableHead>
|
|
559
|
+
</TableRow>
|
|
560
|
+
</TableHeader>
|
|
561
|
+
<TableBody>
|
|
562
|
+
{filtered.length === 0 ? (
|
|
563
|
+
<TableRow>
|
|
564
|
+
<TableCell colSpan={5} className="text-center py-12">
|
|
565
|
+
<span className="text-sm text-muted-foreground font-mono">
|
|
566
|
+
{templates.length === 0
|
|
567
|
+
? '> No templates found. Click "Seed Defaults" to create initial templates.'
|
|
568
|
+
: "> No templates match your search."}
|
|
569
|
+
</span>
|
|
570
|
+
</TableCell>
|
|
571
|
+
</TableRow>
|
|
572
|
+
) : (
|
|
573
|
+
filtered.map((template) => (
|
|
574
|
+
<TableRow
|
|
575
|
+
key={template.id}
|
|
576
|
+
className="cursor-pointer hover:bg-secondary/50"
|
|
577
|
+
onClick={() => openEditor(template)}
|
|
578
|
+
>
|
|
579
|
+
<TableCell>
|
|
580
|
+
<span className="text-sm font-medium font-mono">{template.name}</span>
|
|
581
|
+
</TableCell>
|
|
582
|
+
<TableCell>
|
|
583
|
+
<span className="text-sm text-muted-foreground line-clamp-1">
|
|
584
|
+
{template.description ?? "--"}
|
|
585
|
+
</span>
|
|
586
|
+
</TableCell>
|
|
587
|
+
<TableCell>
|
|
588
|
+
<span className="text-sm text-muted-foreground font-mono line-clamp-1 max-w-[200px]">
|
|
589
|
+
{template.subject}
|
|
590
|
+
</span>
|
|
591
|
+
</TableCell>
|
|
592
|
+
<TableCell>
|
|
593
|
+
<Badge
|
|
594
|
+
variant="outline"
|
|
595
|
+
className={cn(
|
|
596
|
+
"text-xs",
|
|
597
|
+
template.active
|
|
598
|
+
? "border-amber-500/30 text-amber-400"
|
|
599
|
+
: "border-border text-muted-foreground",
|
|
600
|
+
)}
|
|
601
|
+
>
|
|
602
|
+
{template.active ? "Active" : "Inactive"}
|
|
603
|
+
</Badge>
|
|
604
|
+
</TableCell>
|
|
605
|
+
<TableCell>
|
|
606
|
+
<span className="text-xs text-muted-foreground">
|
|
607
|
+
{new Date(template.updatedAt).toLocaleDateString()}
|
|
608
|
+
</span>
|
|
609
|
+
</TableCell>
|
|
610
|
+
</TableRow>
|
|
611
|
+
))
|
|
612
|
+
)}
|
|
613
|
+
</TableBody>
|
|
614
|
+
</Table>
|
|
615
|
+
</div>
|
|
616
|
+
</div>
|
|
617
|
+
);
|
|
618
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AlertTriangleIcon, HomeIcon, RefreshCwIcon } from "lucide-react";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { logger } from "@/lib/logger";
|
|
8
|
+
|
|
9
|
+
const log = logger("error-boundary:email-templates");
|
|
10
|
+
|
|
11
|
+
export default function EmailTemplatesError({
|
|
12
|
+
error,
|
|
13
|
+
reset,
|
|
14
|
+
}: {
|
|
15
|
+
error: Error & { digest?: string };
|
|
16
|
+
reset: () => void;
|
|
17
|
+
}) {
|
|
18
|
+
const [showDetails, setShowDetails] = useState(false);
|
|
19
|
+
const isDev = process.env.NODE_ENV === "development";
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
log.error("Email templates error", error);
|
|
23
|
+
}, [error]);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="flex h-full items-center justify-center p-6">
|
|
27
|
+
<Card className="w-full max-w-lg">
|
|
28
|
+
<CardHeader>
|
|
29
|
+
<div className="flex items-center gap-3">
|
|
30
|
+
<AlertTriangleIcon className="size-6 text-destructive" />
|
|
31
|
+
<CardTitle className="text-xl">Email Templates Error</CardTitle>
|
|
32
|
+
</div>
|
|
33
|
+
</CardHeader>
|
|
34
|
+
<CardContent className="space-y-4">
|
|
35
|
+
<p className="text-muted-foreground">
|
|
36
|
+
Something went wrong loading the email template editor. This may be a temporary issue.
|
|
37
|
+
</p>
|
|
38
|
+
{isDev && (
|
|
39
|
+
<Button
|
|
40
|
+
type="button"
|
|
41
|
+
variant="link"
|
|
42
|
+
size="sm"
|
|
43
|
+
onClick={() => setShowDetails((v) => !v)}
|
|
44
|
+
className="h-auto p-0 text-muted-foreground"
|
|
45
|
+
>
|
|
46
|
+
{showDetails ? "Hide" : "Show"} error details
|
|
47
|
+
</Button>
|
|
48
|
+
)}
|
|
49
|
+
{isDev && showDetails && (
|
|
50
|
+
<pre className="max-h-48 overflow-auto rounded-md border bg-muted p-3 text-xs">
|
|
51
|
+
{error.message}
|
|
52
|
+
{error.stack && `\n\n${error.stack}`}
|
|
53
|
+
{error.digest && `\n\nDigest: ${error.digest}`}
|
|
54
|
+
</pre>
|
|
55
|
+
)}
|
|
56
|
+
</CardContent>
|
|
57
|
+
<CardFooter className="gap-3">
|
|
58
|
+
<Button onClick={reset}>
|
|
59
|
+
<RefreshCwIcon />
|
|
60
|
+
Try Again
|
|
61
|
+
</Button>
|
|
62
|
+
<Button variant="outline" asChild>
|
|
63
|
+
<a href="/admin">
|
|
64
|
+
<HomeIcon />
|
|
65
|
+
Admin Home
|
|
66
|
+
</a>
|
|
67
|
+
</Button>
|
|
68
|
+
</CardFooter>
|
|
69
|
+
</Card>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
2
|
+
|
|
3
|
+
export default function AdminEmailTemplatesLoading() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="space-y-6 p-6">
|
|
6
|
+
<div className="flex items-center justify-between">
|
|
7
|
+
<div className="flex items-center gap-3">
|
|
8
|
+
<Skeleton className="h-5 w-5" />
|
|
9
|
+
<Skeleton className="h-8 w-40" />
|
|
10
|
+
</div>
|
|
11
|
+
<Skeleton className="h-9 w-32" />
|
|
12
|
+
</div>
|
|
13
|
+
<Skeleton className="h-10 w-full max-w-sm" />
|
|
14
|
+
<div className="rounded-md border">
|
|
15
|
+
<div className="border-b px-4 py-3">
|
|
16
|
+
<div className="flex items-center gap-4">
|
|
17
|
+
<Skeleton className="h-4 w-24" />
|
|
18
|
+
<Skeleton className="h-4 w-40" />
|
|
19
|
+
<Skeleton className="h-4 w-32" />
|
|
20
|
+
<Skeleton className="h-4 w-16" />
|
|
21
|
+
<Skeleton className="h-4 w-20" />
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
<div className="space-y-0">
|
|
25
|
+
{Array.from({ length: 6 }, (_, n) => `sk-${n}`).map((skId) => (
|
|
26
|
+
<div key={skId} className="flex items-center gap-4 border-b px-4 py-3 last:border-b-0">
|
|
27
|
+
<Skeleton className="h-4 w-32" />
|
|
28
|
+
<Skeleton className="h-4 w-48" />
|
|
29
|
+
<Skeleton className="h-4 w-40" />
|
|
30
|
+
<Skeleton className="h-5 w-16" />
|
|
31
|
+
<Skeleton className="h-4 w-20" />
|
|
32
|
+
</div>
|
|
33
|
+
))}
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Settings2 } from "lucide-react";
|
|
2
|
+
import { UpdateSettingsCard } from "@/components/fleet/update-settings-card";
|
|
3
|
+
|
|
4
|
+
export default function FleetSettingsPage() {
|
|
5
|
+
return (
|
|
6
|
+
<div className="p-6 space-y-6">
|
|
7
|
+
<div>
|
|
8
|
+
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
|
9
|
+
<Settings2 className="h-6 w-6 text-muted-foreground" />
|
|
10
|
+
Fleet Settings
|
|
11
|
+
</h1>
|
|
12
|
+
<p className="text-muted-foreground mt-1">
|
|
13
|
+
Configure update behavior and maintenance windows for your fleet.
|
|
14
|
+
</p>
|
|
15
|
+
</div>
|
|
16
|
+
<UpdateSettingsCard />
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Clock, Loader2, RefreshCw } from "lucide-react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { Label } from "@/components/ui/label";
|
|
8
|
+
import {
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
SelectValue,
|
|
14
|
+
} from "@/components/ui/select";
|
|
15
|
+
import { Switch } from "@/components/ui/switch";
|
|
16
|
+
import { useTenant } from "@/lib/tenant-context";
|
|
17
|
+
import { trpc } from "@/lib/trpc";
|
|
18
|
+
|
|
19
|
+
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) => {
|
|
20
|
+
const label =
|
|
21
|
+
i === 0 ? "12:00 AM" : i < 12 ? `${i}:00 AM` : i === 12 ? "12:00 PM" : `${i - 12}:00 PM`;
|
|
22
|
+
return { value: String(i), label };
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export function UpdateSettingsCard() {
|
|
26
|
+
const { activeTenantId: tenantId } = useTenant();
|
|
27
|
+
const [saving, setSaving] = useState(false);
|
|
28
|
+
|
|
29
|
+
const configQuery = trpc.fleetUpdateConfig.getUpdateConfig.useQuery(
|
|
30
|
+
{ tenantId },
|
|
31
|
+
{ enabled: !!tenantId },
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const setConfigMutation = trpc.fleetUpdateConfig.setUpdateConfig.useMutation();
|
|
35
|
+
|
|
36
|
+
const mode = configQuery.data?.mode ?? "auto";
|
|
37
|
+
const preferredHourUtc = configQuery.data?.preferredHourUtc ?? 3;
|
|
38
|
+
|
|
39
|
+
async function handleModeToggle(checked: boolean) {
|
|
40
|
+
const newMode = checked ? "auto" : "manual";
|
|
41
|
+
setSaving(true);
|
|
42
|
+
try {
|
|
43
|
+
await setConfigMutation.mutateAsync({
|
|
44
|
+
tenantId,
|
|
45
|
+
mode: newMode,
|
|
46
|
+
preferredHourUtc,
|
|
47
|
+
});
|
|
48
|
+
await configQuery.refetch();
|
|
49
|
+
toast.success(`Updates set to ${newMode}`);
|
|
50
|
+
} catch {
|
|
51
|
+
toast.error("Failed to update settings");
|
|
52
|
+
} finally {
|
|
53
|
+
setSaving(false);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function handleHourChange(value: string) {
|
|
58
|
+
const hour = Number.parseInt(value, 10);
|
|
59
|
+
setSaving(true);
|
|
60
|
+
try {
|
|
61
|
+
await setConfigMutation.mutateAsync({
|
|
62
|
+
tenantId,
|
|
63
|
+
mode,
|
|
64
|
+
preferredHourUtc: hour,
|
|
65
|
+
});
|
|
66
|
+
await configQuery.refetch();
|
|
67
|
+
toast.success("Preferred update window saved");
|
|
68
|
+
} catch {
|
|
69
|
+
toast.error("Failed to update settings");
|
|
70
|
+
} finally {
|
|
71
|
+
setSaving(false);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!tenantId) return null;
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<Card>
|
|
79
|
+
<CardHeader>
|
|
80
|
+
<CardTitle className="flex items-center gap-2">
|
|
81
|
+
<RefreshCw className="h-5 w-5 text-muted-foreground" />
|
|
82
|
+
Auto-Update Settings
|
|
83
|
+
</CardTitle>
|
|
84
|
+
<CardDescription>
|
|
85
|
+
Control how your fleet receives updates. Auto mode applies updates during your preferred
|
|
86
|
+
maintenance window. Manual mode shows an update badge — you choose when to apply.
|
|
87
|
+
</CardDescription>
|
|
88
|
+
</CardHeader>
|
|
89
|
+
<CardContent className="space-y-6">
|
|
90
|
+
{configQuery.isLoading ? (
|
|
91
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
92
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
93
|
+
Loading settings...
|
|
94
|
+
</div>
|
|
95
|
+
) : configQuery.isError ? (
|
|
96
|
+
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
|
97
|
+
Failed to load update settings. Please try again later.
|
|
98
|
+
</div>
|
|
99
|
+
) : (
|
|
100
|
+
<>
|
|
101
|
+
{/* Auto/Manual toggle */}
|
|
102
|
+
<div className="flex items-center justify-between rounded-lg border border-border p-4">
|
|
103
|
+
<div className="space-y-0.5">
|
|
104
|
+
<Label htmlFor="auto-update-toggle" className="text-base font-medium">
|
|
105
|
+
Automatic Updates
|
|
106
|
+
</Label>
|
|
107
|
+
<p className="text-sm text-muted-foreground">
|
|
108
|
+
{mode === "auto"
|
|
109
|
+
? "Instances update automatically during the maintenance window"
|
|
110
|
+
: "You'll be notified when updates are available"}
|
|
111
|
+
</p>
|
|
112
|
+
</div>
|
|
113
|
+
<div className="flex items-center gap-2">
|
|
114
|
+
{saving && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
|
|
115
|
+
<Switch
|
|
116
|
+
id="auto-update-toggle"
|
|
117
|
+
checked={mode === "auto"}
|
|
118
|
+
onCheckedChange={handleModeToggle}
|
|
119
|
+
disabled={saving}
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{/* Preferred hour picker — only shown in auto mode */}
|
|
125
|
+
{mode === "auto" && (
|
|
126
|
+
<div className="flex items-center justify-between rounded-lg border border-border p-4">
|
|
127
|
+
<div className="space-y-0.5">
|
|
128
|
+
<Label className="flex items-center gap-2 text-base font-medium">
|
|
129
|
+
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
130
|
+
Maintenance Window
|
|
131
|
+
</Label>
|
|
132
|
+
<p className="text-sm text-muted-foreground">
|
|
133
|
+
Preferred hour for applying updates (UTC)
|
|
134
|
+
</p>
|
|
135
|
+
</div>
|
|
136
|
+
<Select
|
|
137
|
+
value={String(preferredHourUtc)}
|
|
138
|
+
onValueChange={handleHourChange}
|
|
139
|
+
disabled={saving}
|
|
140
|
+
>
|
|
141
|
+
<SelectTrigger className="w-[140px]">
|
|
142
|
+
<SelectValue />
|
|
143
|
+
</SelectTrigger>
|
|
144
|
+
<SelectContent>
|
|
145
|
+
{HOUR_OPTIONS.map((opt) => (
|
|
146
|
+
<SelectItem key={opt.value} value={opt.value}>
|
|
147
|
+
{opt.label}
|
|
148
|
+
</SelectItem>
|
|
149
|
+
))}
|
|
150
|
+
</SelectContent>
|
|
151
|
+
</Select>
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
{/* Current status */}
|
|
156
|
+
{configQuery.data?.updatedAt && (
|
|
157
|
+
<p className="text-xs text-muted-foreground">
|
|
158
|
+
Last changed:{" "}
|
|
159
|
+
{new Date(configQuery.data.updatedAt).toLocaleString(undefined, {
|
|
160
|
+
year: "numeric",
|
|
161
|
+
month: "short",
|
|
162
|
+
day: "numeric",
|
|
163
|
+
hour: "2-digit",
|
|
164
|
+
minute: "2-digit",
|
|
165
|
+
})}
|
|
166
|
+
</p>
|
|
167
|
+
)}
|
|
168
|
+
</>
|
|
169
|
+
)}
|
|
170
|
+
</CardContent>
|
|
171
|
+
</Card>
|
|
172
|
+
);
|
|
173
|
+
}
|
package/src/lib/trpc-types.ts
CHANGED
|
@@ -170,6 +170,26 @@ type AppRouterRecord = {
|
|
|
170
170
|
orgTopupCheckout: AnyTRPCMutationProcedure;
|
|
171
171
|
orgSetupIntent: AnyTRPCMutationProcedure;
|
|
172
172
|
};
|
|
173
|
+
/** Separate from `fleet` — backed by createFleetUpdateConfigRouter in platform-core. */
|
|
174
|
+
fleetUpdateConfig: {
|
|
175
|
+
getUpdateConfig: AnyTRPCQueryProcedure;
|
|
176
|
+
setUpdateConfig: AnyTRPCMutationProcedure;
|
|
177
|
+
};
|
|
178
|
+
/** Admin-only: DB-driven email template management. */
|
|
179
|
+
notificationTemplates: {
|
|
180
|
+
listTemplates: AnyTRPCQueryProcedure;
|
|
181
|
+
getTemplate: AnyTRPCQueryProcedure;
|
|
182
|
+
updateTemplate: AnyTRPCMutationProcedure;
|
|
183
|
+
previewTemplate: AnyTRPCMutationProcedure;
|
|
184
|
+
seedDefaults: AnyTRPCMutationProcedure;
|
|
185
|
+
};
|
|
186
|
+
/** Admin-only: fleet rollout management. */
|
|
187
|
+
adminFleetUpdate: {
|
|
188
|
+
rolloutStatus: AnyTRPCQueryProcedure;
|
|
189
|
+
forceRollout: AnyTRPCMutationProcedure;
|
|
190
|
+
listTenantConfigs: AnyTRPCQueryProcedure;
|
|
191
|
+
setTenantConfig: AnyTRPCMutationProcedure;
|
|
192
|
+
};
|
|
173
193
|
adminMarketplace: {
|
|
174
194
|
listPlugins: AnyTRPCQueryProcedure;
|
|
175
195
|
updatePlugin: AnyTRPCMutationProcedure;
|