create-nextblock 0.12.15 → 0.13.1
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/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +10 -3
- package/templates/nextblock-template/app/actions/interactions.ts +27 -4
- package/templates/nextblock-template/app/cms/custom-blocks/components/BlockComposer.tsx +40 -2
- package/templates/nextblock-template/app/cms/interactions/EmailRecipientsInput.tsx +189 -0
- package/templates/nextblock-template/app/cms/interactions/InteractionsModerationClient.tsx +138 -71
- package/templates/nextblock-template/app/cms/users/[id]/edit/page.tsx +20 -1
- package/templates/nextblock-template/app/cms/users/actions.ts +69 -0
- package/templates/nextblock-template/app/cms/users/components/CreateUserForm.tsx +217 -0
- package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +4 -1
- package/templates/nextblock-template/app/cms/users/new/page.tsx +44 -0
- package/templates/nextblock-template/app/cms/users/page.tsx +12 -3
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx
CHANGED
|
@@ -25,10 +25,11 @@ export default function TwoFactorForm({
|
|
|
25
25
|
type === 'email' && pendingEmailCode ? `Enter the code we sent to ${email}.` : null,
|
|
26
26
|
);
|
|
27
27
|
|
|
28
|
-
const submit = () => {
|
|
28
|
+
const submit = (codeToSubmit: string = code) => {
|
|
29
|
+
if (codeToSubmit.length !== 6) return;
|
|
29
30
|
setError(null);
|
|
30
31
|
const formData = new FormData();
|
|
31
|
-
formData.append('code',
|
|
32
|
+
formData.append('code', codeToSubmit);
|
|
32
33
|
formData.append('redirect_to', redirectTo);
|
|
33
34
|
startTransition(async () => {
|
|
34
35
|
try {
|
|
@@ -85,7 +86,13 @@ export default function TwoFactorForm({
|
|
|
85
86
|
autoFocus
|
|
86
87
|
maxLength={6}
|
|
87
88
|
value={code}
|
|
88
|
-
onChange={(e) =>
|
|
89
|
+
onChange={(e) => {
|
|
90
|
+
const next = e.target.value.replace(/\D/g, '').slice(0, 6);
|
|
91
|
+
setCode(next);
|
|
92
|
+
// Auto-submit the moment a full 6-digit code is entered (typed, pasted,
|
|
93
|
+
// or filled by the OS one-time-code autofill) — no button press needed.
|
|
94
|
+
if (next.length === 6 && !isPending) submit(next);
|
|
95
|
+
}}
|
|
89
96
|
placeholder="000000"
|
|
90
97
|
className="tracking-[0.5em] text-center text-lg"
|
|
91
98
|
/>
|
|
@@ -348,12 +348,35 @@ export async function saveNotificationEmails(emails: string) {
|
|
|
348
348
|
return { error: "Unauthorized. Admin role required." };
|
|
349
349
|
}
|
|
350
350
|
|
|
351
|
-
//
|
|
352
|
-
|
|
351
|
+
// Validate every address, dedupe (case-insensitive), and normalize to lowercase.
|
|
352
|
+
// Mirrors the client-side check so a crafted/legacy payload can't persist junk.
|
|
353
|
+
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
354
|
+
const tokens = emails
|
|
353
355
|
.split(",")
|
|
354
356
|
.map((e) => e.trim())
|
|
355
|
-
.filter(Boolean)
|
|
356
|
-
|
|
357
|
+
.filter(Boolean);
|
|
358
|
+
|
|
359
|
+
const seen = new Set<string>();
|
|
360
|
+
const valid: string[] = [];
|
|
361
|
+
const invalid: string[] = [];
|
|
362
|
+
for (const token of tokens) {
|
|
363
|
+
const lower = token.toLowerCase();
|
|
364
|
+
if (!emailRe.test(lower)) {
|
|
365
|
+
invalid.push(token);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (seen.has(lower)) continue;
|
|
369
|
+
seen.add(lower);
|
|
370
|
+
valid.push(lower);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (invalid.length > 0) {
|
|
374
|
+
return {
|
|
375
|
+
error: `Invalid email address${invalid.length > 1 ? "es" : ""}: ${invalid.join(", ")}`,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const cleaned = valid.join(", ");
|
|
357
380
|
|
|
358
381
|
try {
|
|
359
382
|
const { error } = await supabase
|
|
@@ -55,7 +55,7 @@ import {
|
|
|
55
55
|
createCustomBlockDefinition,
|
|
56
56
|
updateCustomBlockDefinition,
|
|
57
57
|
} from "../actions";
|
|
58
|
-
import { orderCustomBlockFieldsByLayout } from "@nextblock-cms/utils";
|
|
58
|
+
import { orderCustomBlockFieldsByLayout, cn } from "@nextblock-cms/utils";
|
|
59
59
|
import type { CustomBlockDefinition, CustomBlockField } from "@nextblock-cms/utils";
|
|
60
60
|
|
|
61
61
|
// Allowed container and field tags
|
|
@@ -544,6 +544,14 @@ export function BlockComposer({ initialData, mode }: BlockComposerProps) {
|
|
|
544
544
|
toast.error("Block must contain at least one field.");
|
|
545
545
|
return;
|
|
546
546
|
}
|
|
547
|
+
if (fields.some((field) => !field.key.trim())) {
|
|
548
|
+
toast.error("Every property needs a key.");
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (duplicateFieldKeys.size > 0) {
|
|
552
|
+
toast.error("Property keys must be unique — fix the highlighted duplicate keys.");
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
547
555
|
|
|
548
556
|
const payload = {
|
|
549
557
|
name,
|
|
@@ -823,10 +831,30 @@ export function BlockComposer({ initialData, mode }: BlockComposerProps) {
|
|
|
823
831
|
return used;
|
|
824
832
|
}, [layoutFieldRefs, selectedNodePath]);
|
|
825
833
|
|
|
834
|
+
// Property keys that appear on more than one field. Duplicate keys are invalid (a key
|
|
835
|
+
// must be unique), so they're flagged in the editor rather than silently dropping a field.
|
|
836
|
+
const duplicateFieldKeys = useMemo(() => {
|
|
837
|
+
const counts = new Map<string, number>();
|
|
838
|
+
for (const field of fields) {
|
|
839
|
+
counts.set(field.key, (counts.get(field.key) ?? 0) + 1);
|
|
840
|
+
}
|
|
841
|
+
const dups = new Set<string>();
|
|
842
|
+
for (const [key, count] of counts) {
|
|
843
|
+
if (count > 1) dups.add(key);
|
|
844
|
+
}
|
|
845
|
+
return dups;
|
|
846
|
+
}, [fields]);
|
|
847
|
+
|
|
826
848
|
// Keep the Properties Schema list itself ordered to match the layout blueprint,
|
|
827
849
|
// so the fields editor mirrors the visual tree order everywhere.
|
|
828
850
|
useEffect(() => {
|
|
829
851
|
setFields((prev) => {
|
|
852
|
+
// While any keys collide, skip layout-driven reordering: orderCustomBlockFieldsByLayout
|
|
853
|
+
// dedupes by key and would silently drop the colliding field. Keep the list intact so
|
|
854
|
+
// the duplicate can be flagged (red border) and fixed by the user instead of vanishing.
|
|
855
|
+
const keys = prev.map((field) => field.key);
|
|
856
|
+
if (new Set(keys).size !== keys.length) return prev;
|
|
857
|
+
|
|
830
858
|
const ordered = orderCustomBlockFieldsByLayout(prev, layoutSchema);
|
|
831
859
|
const unchanged =
|
|
832
860
|
ordered.length === prev.length && ordered.every((field, index) => field === prev[index]);
|
|
@@ -1063,8 +1091,18 @@ export function BlockComposer({ initialData, mode }: BlockComposerProps) {
|
|
|
1063
1091
|
value={field.key}
|
|
1064
1092
|
placeholder="e.g. quote"
|
|
1065
1093
|
onChange={(e) => updateField(idx, { key: e.target.value })}
|
|
1066
|
-
|
|
1094
|
+
aria-invalid={duplicateFieldKeys.has(field.key)}
|
|
1095
|
+
className={cn(
|
|
1096
|
+
"h-8 font-mono text-xs",
|
|
1097
|
+
duplicateFieldKeys.has(field.key) &&
|
|
1098
|
+
"border-destructive focus-visible:ring-destructive"
|
|
1099
|
+
)}
|
|
1067
1100
|
/>
|
|
1101
|
+
{duplicateFieldKeys.has(field.key) && (
|
|
1102
|
+
<p className="text-[10px] font-medium text-destructive">
|
|
1103
|
+
Duplicate key — property keys must be unique.
|
|
1104
|
+
</p>
|
|
1105
|
+
)}
|
|
1068
1106
|
</div>
|
|
1069
1107
|
<div className="md:col-span-4 space-y-1">
|
|
1070
1108
|
<Label className="text-[10px] uppercase tracking-wide font-semibold text-muted-foreground">Label</Label>
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
|
4
|
+
import { X } from "lucide-react";
|
|
5
|
+
import { cn } from "@nextblock-cms/utils";
|
|
6
|
+
|
|
7
|
+
// Same address shape used elsewhere in the app (createUser). Intentionally pragmatic
|
|
8
|
+
// rather than RFC-exhaustive: something@something.tld with no whitespace.
|
|
9
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
10
|
+
|
|
11
|
+
export function isValidEmail(value: string): boolean {
|
|
12
|
+
return EMAIL_RE.test(value.trim().toLowerCase());
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface EmailRecipientsInputHandle {
|
|
16
|
+
/**
|
|
17
|
+
* Commit any text still sitting in the input, then return the full recipient list.
|
|
18
|
+
* Returns `null` (and shows an inline error) when that pending text is not a valid
|
|
19
|
+
* email — the caller should abort the save in that case. Reading the returned array
|
|
20
|
+
* directly (rather than parent state) avoids a stale-closure race on save.
|
|
21
|
+
*/
|
|
22
|
+
flush: () => string[] | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface EmailRecipientsInputProps {
|
|
26
|
+
value: string[];
|
|
27
|
+
onChange: (emails: string[]) => void;
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
inputId?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const EmailRecipientsInput = forwardRef<EmailRecipientsInputHandle, EmailRecipientsInputProps>(
|
|
33
|
+
function EmailRecipientsInput({ value, onChange, disabled, inputId }, ref) {
|
|
34
|
+
const [inputValue, setInputValue] = useState("");
|
|
35
|
+
const [error, setError] = useState<string | null>(null);
|
|
36
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
37
|
+
|
|
38
|
+
const hasEmail = (list: string[], email: string) =>
|
|
39
|
+
list.some((e) => e.toLowerCase() === email);
|
|
40
|
+
|
|
41
|
+
// Commit a single typed token. Returns the resulting list, or null when invalid.
|
|
42
|
+
const commit = (raw: string): string[] | null => {
|
|
43
|
+
const email = raw.trim().toLowerCase();
|
|
44
|
+
if (!email) return value;
|
|
45
|
+
if (!EMAIL_RE.test(email)) {
|
|
46
|
+
setError(`"${raw.trim()}" is not a valid email address.`);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (hasEmail(value, email)) {
|
|
50
|
+
// Already a recipient — harmless. Clear the input without raising an error so
|
|
51
|
+
// it can't collide with the save-success banner when committed via Save/flush.
|
|
52
|
+
setInputValue("");
|
|
53
|
+
setError(null);
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
const next = [...value, email];
|
|
57
|
+
onChange(next);
|
|
58
|
+
setInputValue("");
|
|
59
|
+
setError(null);
|
|
60
|
+
return next;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Add several tokens at once (paste of a comma/space/newline separated list).
|
|
64
|
+
const addMany = (raw: string) => {
|
|
65
|
+
const tokens = raw
|
|
66
|
+
.split(/[,\s;]+/)
|
|
67
|
+
.map((t) => t.trim())
|
|
68
|
+
.filter(Boolean);
|
|
69
|
+
if (tokens.length === 0) return;
|
|
70
|
+
|
|
71
|
+
const next = [...value];
|
|
72
|
+
const invalid: string[] = [];
|
|
73
|
+
for (const token of tokens) {
|
|
74
|
+
const email = token.toLowerCase();
|
|
75
|
+
if (!EMAIL_RE.test(email)) {
|
|
76
|
+
invalid.push(token);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!hasEmail(next, email)) next.push(email);
|
|
80
|
+
}
|
|
81
|
+
onChange(next);
|
|
82
|
+
setInputValue("");
|
|
83
|
+
setError(
|
|
84
|
+
invalid.length
|
|
85
|
+
? `Skipped ${invalid.length} invalid address${invalid.length > 1 ? "es" : ""}: ${invalid.join(", ")}`
|
|
86
|
+
: null,
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
91
|
+
if (e.key === "Enter" || e.key === ",") {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
commit(inputValue);
|
|
94
|
+
} else if (e.key === "Backspace" && inputValue === "" && value.length > 0) {
|
|
95
|
+
onChange(value.slice(0, -1));
|
|
96
|
+
setError(null);
|
|
97
|
+
} else if (error) {
|
|
98
|
+
setError(null);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
|
103
|
+
const text = e.clipboardData.getData("text");
|
|
104
|
+
if (/[,\s;]/.test(text)) {
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
// Prepend whatever is already typed so a pasted "@corp.com, ..." completes the
|
|
107
|
+
// in-progress "support" rather than discarding it.
|
|
108
|
+
addMany(inputValue + text);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const removeAt = (idx: number) => {
|
|
113
|
+
onChange(value.filter((_, i) => i !== idx));
|
|
114
|
+
setError(null);
|
|
115
|
+
inputRef.current?.focus();
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// `commit` closes over the latest inputValue/value, so re-create the handle when
|
|
119
|
+
// either changes to keep flush() reading current state.
|
|
120
|
+
useImperativeHandle(ref, () => ({ flush: () => commit(inputValue) }), [inputValue, value]);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className="space-y-1.5">
|
|
124
|
+
<div
|
|
125
|
+
onClick={() => inputRef.current?.focus()}
|
|
126
|
+
className={cn(
|
|
127
|
+
"flex flex-wrap gap-1.5 min-h-[84px] w-full items-start content-start bg-background border rounded-xl px-2.5 py-2 text-sm cursor-text transition-colors",
|
|
128
|
+
"focus-within:ring-1 focus-within:ring-primary focus-within:border-primary",
|
|
129
|
+
error ? "border-destructive" : "border-border",
|
|
130
|
+
disabled && "opacity-60 pointer-events-none",
|
|
131
|
+
)}
|
|
132
|
+
>
|
|
133
|
+
{value.map((email, idx) => (
|
|
134
|
+
<span
|
|
135
|
+
key={email}
|
|
136
|
+
className="inline-flex items-center gap-1 h-7 pl-2.5 pr-1 rounded-full bg-secondary text-secondary-foreground text-xs font-medium"
|
|
137
|
+
>
|
|
138
|
+
{email}
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
onClick={(e) => {
|
|
142
|
+
e.stopPropagation();
|
|
143
|
+
removeAt(idx);
|
|
144
|
+
}}
|
|
145
|
+
className="inline-flex items-center justify-center h-4 w-4 rounded-full text-muted-foreground hover:text-foreground hover:bg-black/10 dark:hover:bg-white/15 focus:outline-none focus-visible:ring-1 focus-visible:ring-primary transition-colors"
|
|
146
|
+
aria-label={`Remove ${email}`}
|
|
147
|
+
>
|
|
148
|
+
<X className="h-3 w-3" />
|
|
149
|
+
</button>
|
|
150
|
+
</span>
|
|
151
|
+
))}
|
|
152
|
+
<input
|
|
153
|
+
id={inputId}
|
|
154
|
+
ref={inputRef}
|
|
155
|
+
type="email"
|
|
156
|
+
value={inputValue}
|
|
157
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
158
|
+
onKeyDown={handleKeyDown}
|
|
159
|
+
onPaste={handlePaste}
|
|
160
|
+
onBlur={() => commit(inputValue)}
|
|
161
|
+
disabled={disabled}
|
|
162
|
+
aria-invalid={!!error}
|
|
163
|
+
placeholder={value.length === 0 ? "admin@example.com" : "Add another…"}
|
|
164
|
+
className="flex-1 min-w-[160px] h-7 bg-transparent outline-none placeholder:text-muted-foreground"
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div className="flex items-center justify-between gap-3">
|
|
169
|
+
<span className="text-[10px] text-muted-foreground">
|
|
170
|
+
Type an address and press Enter or comma to add it.
|
|
171
|
+
</span>
|
|
172
|
+
{value.length > 0 && (
|
|
173
|
+
<span className="text-[10px] font-medium text-muted-foreground shrink-0">
|
|
174
|
+
{value.length} recipient{value.length > 1 ? "s" : ""}
|
|
175
|
+
</span>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{error && (
|
|
180
|
+
<p role="alert" className="text-xs font-medium text-destructive">
|
|
181
|
+
{error}
|
|
182
|
+
</p>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
export default EmailRecipientsInput;
|
|
@@ -4,8 +4,17 @@ import React, { useState, useTransition } from "react";
|
|
|
4
4
|
import { Button } from "@nextblock-cms/ui";
|
|
5
5
|
import { Avatar, AvatarFallback, AvatarImage } from "@nextblock-cms/ui";
|
|
6
6
|
import { Badge } from "@nextblock-cms/ui";
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogHeader,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
DialogDescription,
|
|
13
|
+
DialogFooter,
|
|
14
|
+
} from "@nextblock-cms/ui";
|
|
7
15
|
import { updateInteractionStatus, saveNotificationEmails } from "../../actions/interactions";
|
|
8
16
|
import { cn } from "@nextblock-cms/utils";
|
|
17
|
+
import EmailRecipientsInput, { type EmailRecipientsInputHandle } from "./EmailRecipientsInput";
|
|
9
18
|
import {
|
|
10
19
|
MessageSquare,
|
|
11
20
|
Check,
|
|
@@ -31,44 +40,100 @@ export default function InteractionsModerationClient({
|
|
|
31
40
|
}: InteractionsModerationClientProps) {
|
|
32
41
|
const [interactions, setInteractions] = useState<any[]>(initialInteractions);
|
|
33
42
|
|
|
34
|
-
// Notification settings states
|
|
35
|
-
|
|
43
|
+
// Notification settings states. `emails` is the working (editable) list; `savedEmails`
|
|
44
|
+
// mirrors what is persisted, so cancelling can revert unsaved edits.
|
|
45
|
+
const [emails, setEmails] = useState<string[]>([]);
|
|
46
|
+
const [savedEmails, setSavedEmails] = useState<string[]>([]);
|
|
36
47
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
|
37
48
|
const [savingEmails, setSavingEmails] = useState(false);
|
|
38
49
|
const [settingsError, setSettingsError] = useState<string | null>(null);
|
|
39
50
|
const [settingsSuccess, setSettingsSuccess] = useState<string | null>(null);
|
|
51
|
+
const recipientsRef = React.useRef<EmailRecipientsInputHandle>(null);
|
|
52
|
+
const autoCloseTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
53
|
+
|
|
54
|
+
const clearAutoClose = () => {
|
|
55
|
+
if (autoCloseTimer.current) {
|
|
56
|
+
clearTimeout(autoCloseTimer.current);
|
|
57
|
+
autoCloseTimer.current = null;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Cancel any pending success auto-close timer on unmount.
|
|
62
|
+
React.useEffect(() => clearAutoClose, []);
|
|
63
|
+
|
|
64
|
+
const parseEmails = (raw: string | undefined | null): string[] =>
|
|
65
|
+
(raw || "")
|
|
66
|
+
.split(",")
|
|
67
|
+
.map((e) => e.trim())
|
|
68
|
+
.filter(Boolean);
|
|
40
69
|
|
|
41
70
|
React.useEffect(() => {
|
|
42
71
|
if (isAdmin) {
|
|
43
72
|
import("../../actions/interactions").then(({ getNotificationEmails }) => {
|
|
44
73
|
getNotificationEmails().then((res) => {
|
|
45
74
|
if (res.success && res.emails) {
|
|
46
|
-
|
|
75
|
+
const list = parseEmails(res.emails);
|
|
76
|
+
setEmails(list);
|
|
77
|
+
setSavedEmails(list);
|
|
47
78
|
}
|
|
48
79
|
});
|
|
49
80
|
});
|
|
50
81
|
}
|
|
51
82
|
}, [isAdmin]);
|
|
52
83
|
|
|
84
|
+
const openSettings = () => {
|
|
85
|
+
// Start from the last-saved list with a clean slate — discards any edits abandoned
|
|
86
|
+
// in a previous open, and clears stale success/error banners.
|
|
87
|
+
clearAutoClose();
|
|
88
|
+
setEmails(savedEmails);
|
|
89
|
+
setSettingsError(null);
|
|
90
|
+
setSettingsSuccess(null);
|
|
91
|
+
setIsSettingsOpen(true);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const closeSettings = () => {
|
|
95
|
+
if (savingEmails) return; // don't dismiss mid-save
|
|
96
|
+
clearAutoClose();
|
|
97
|
+
setEmails(savedEmails); // revert unsaved edits
|
|
98
|
+
setSettingsError(null);
|
|
99
|
+
setSettingsSuccess(null);
|
|
100
|
+
setIsSettingsOpen(false);
|
|
101
|
+
};
|
|
102
|
+
|
|
53
103
|
const handleSaveEmails = async () => {
|
|
104
|
+
// Commit any address still typed in the input. `null` means it's invalid — the
|
|
105
|
+
// input already shows the inline error, so don't proceed to save.
|
|
106
|
+
const finalList = recipientsRef.current?.flush();
|
|
107
|
+
if (finalList === null) return;
|
|
108
|
+
const list = finalList ?? emails;
|
|
109
|
+
|
|
110
|
+
clearAutoClose(); // supersede any prior success timer
|
|
54
111
|
setSavingEmails(true);
|
|
55
112
|
setSettingsError(null);
|
|
56
113
|
setSettingsSuccess(null);
|
|
57
114
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
115
|
+
try {
|
|
116
|
+
const res = await saveNotificationEmails(list.join(", "));
|
|
117
|
+
if (res.error) {
|
|
118
|
+
setSettingsError(res.error);
|
|
119
|
+
} else {
|
|
120
|
+
const saved = parseEmails(res.emails);
|
|
121
|
+
setEmails(saved);
|
|
122
|
+
setSavedEmails(saved);
|
|
123
|
+
setSettingsSuccess("Notification settings saved successfully.");
|
|
124
|
+
// Auto-close after a beat. Tracked so a reopen/second save cancels it.
|
|
125
|
+
autoCloseTimer.current = setTimeout(() => {
|
|
126
|
+
autoCloseTimer.current = null;
|
|
127
|
+
setSettingsSuccess(null);
|
|
128
|
+
setIsSettingsOpen(false);
|
|
129
|
+
}, 1500);
|
|
67
130
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
131
|
+
} catch {
|
|
132
|
+
// The server action rejected (offline, 500, auth outage). Surface it — the
|
|
133
|
+
// `finally` re-enables the close paths so the dialog can never get stuck.
|
|
134
|
+
setSettingsError("Couldn't save settings. Check your connection and try again.");
|
|
135
|
+
} finally {
|
|
136
|
+
setSavingEmails(false);
|
|
72
137
|
}
|
|
73
138
|
};
|
|
74
139
|
const [filterType, setFilterType] = useState<"all" | "review" | "comment">("all");
|
|
@@ -141,7 +206,7 @@ export default function InteractionsModerationClient({
|
|
|
141
206
|
{isAdmin && (
|
|
142
207
|
<div className="flex items-center gap-2 self-start md:self-auto">
|
|
143
208
|
<Button
|
|
144
|
-
onClick={
|
|
209
|
+
onClick={openSettings}
|
|
145
210
|
className="flex items-center gap-2 text-xs"
|
|
146
211
|
variant="outline"
|
|
147
212
|
>
|
|
@@ -347,62 +412,64 @@ export default function InteractionsModerationClient({
|
|
|
347
412
|
)}
|
|
348
413
|
</div>
|
|
349
414
|
|
|
350
|
-
{/* Settings Modal
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
415
|
+
{/* Settings Modal — Radix Dialog handles focus trap/restore, Escape, and overlay
|
|
416
|
+
click. onOpenChange fires for every close path; closeSettings() blocks dismissal
|
|
417
|
+
mid-save and reverts unsaved edits. */}
|
|
418
|
+
<Dialog
|
|
419
|
+
open={isSettingsOpen}
|
|
420
|
+
onOpenChange={(open) => {
|
|
421
|
+
if (!open) closeSettings();
|
|
422
|
+
else setIsSettingsOpen(true);
|
|
423
|
+
}}
|
|
424
|
+
>
|
|
425
|
+
<DialogContent
|
|
426
|
+
className="max-w-md rounded-2xl space-y-4"
|
|
427
|
+
onInteractOutside={(e) => {
|
|
428
|
+
if (savingEmails) e.preventDefault();
|
|
429
|
+
}}
|
|
430
|
+
onEscapeKeyDown={(e) => {
|
|
431
|
+
if (savingEmails) e.preventDefault();
|
|
432
|
+
}}
|
|
433
|
+
>
|
|
434
|
+
<DialogHeader>
|
|
435
|
+
<DialogTitle className="flex items-center gap-2">
|
|
436
|
+
<Mail className="h-5 w-5 text-primary" />
|
|
437
|
+
Notification Settings
|
|
438
|
+
</DialogTitle>
|
|
439
|
+
<DialogDescription>
|
|
367
440
|
Configure which email addresses receive notification alerts when new pending reviews or comments are submitted.
|
|
368
|
-
</
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
{settingsSuccess && <div className="text-xs font-medium text-emerald-600 text-left">{settingsSuccess}</div>}
|
|
386
|
-
|
|
387
|
-
<div className="flex justify-end gap-3 pt-2">
|
|
388
|
-
<Button
|
|
389
|
-
variant="ghost"
|
|
390
|
-
onClick={() => setIsSettingsOpen(false)}
|
|
391
|
-
disabled={savingEmails}
|
|
392
|
-
>
|
|
393
|
-
Cancel
|
|
394
|
-
</Button>
|
|
395
|
-
<Button
|
|
396
|
-
onClick={handleSaveEmails}
|
|
397
|
-
disabled={savingEmails}
|
|
398
|
-
className="min-w-[100px]"
|
|
399
|
-
>
|
|
400
|
-
{savingEmails ? "Saving..." : "Save Settings"}
|
|
401
|
-
</Button>
|
|
402
|
-
</div>
|
|
441
|
+
</DialogDescription>
|
|
442
|
+
</DialogHeader>
|
|
443
|
+
|
|
444
|
+
<div className="space-y-1.5 text-left">
|
|
445
|
+
<label
|
|
446
|
+
htmlFor="notification-emails-input"
|
|
447
|
+
className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block"
|
|
448
|
+
>
|
|
449
|
+
Email Recipients
|
|
450
|
+
</label>
|
|
451
|
+
<EmailRecipientsInput
|
|
452
|
+
ref={recipientsRef}
|
|
453
|
+
inputId="notification-emails-input"
|
|
454
|
+
value={emails}
|
|
455
|
+
onChange={setEmails}
|
|
456
|
+
disabled={savingEmails}
|
|
457
|
+
/>
|
|
403
458
|
</div>
|
|
404
|
-
|
|
405
|
-
|
|
459
|
+
|
|
460
|
+
{settingsError && <div role="alert" className="text-xs font-medium text-destructive text-left">{settingsError}</div>}
|
|
461
|
+
{settingsSuccess && <div className="text-xs font-medium text-emerald-600 text-left">{settingsSuccess}</div>}
|
|
462
|
+
|
|
463
|
+
<DialogFooter className="gap-3 sm:gap-2">
|
|
464
|
+
<Button variant="ghost" onClick={closeSettings} disabled={savingEmails}>
|
|
465
|
+
Cancel
|
|
466
|
+
</Button>
|
|
467
|
+
<Button onClick={handleSaveEmails} disabled={savingEmails} className="min-w-[100px]">
|
|
468
|
+
{savingEmails ? "Saving..." : "Save Settings"}
|
|
469
|
+
</Button>
|
|
470
|
+
</DialogFooter>
|
|
471
|
+
</DialogContent>
|
|
472
|
+
</Dialog>
|
|
406
473
|
</div>
|
|
407
474
|
);
|
|
408
475
|
}
|
|
@@ -17,6 +17,7 @@ async function getUserAndProfileData(userId: string): Promise<{
|
|
|
17
17
|
authUser: AuthUser;
|
|
18
18
|
profile: Profile | null;
|
|
19
19
|
addresses: Awaited<ReturnType<typeof getDefaultUserAddresses>>;
|
|
20
|
+
isSoleAdmin: boolean;
|
|
20
21
|
} | null> {
|
|
21
22
|
|
|
22
23
|
// Fetch user from auth.users
|
|
@@ -64,7 +65,24 @@ async function getUserAndProfileData(userId: string): Promise<{
|
|
|
64
65
|
|
|
65
66
|
const addresses = await getDefaultUserAddresses(userId, serviceSupabase as any);
|
|
66
67
|
|
|
67
|
-
|
|
68
|
+
// Is this user the only remaining Admin? If so, the edit form locks the role selector
|
|
69
|
+
// so the last admin can't demote themselves out of CMS access (the server action also
|
|
70
|
+
// guards this — the lock just prevents hitting that error).
|
|
71
|
+
let isSoleAdmin = false;
|
|
72
|
+
if ((profileData as Profile | null)?.role === 'ADMIN') {
|
|
73
|
+
const { count } = await serviceSupabase
|
|
74
|
+
.from('profiles')
|
|
75
|
+
.select('*', { count: 'exact', head: true })
|
|
76
|
+
.eq('role', 'ADMIN');
|
|
77
|
+
isSoleAdmin = count === 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
authUser: simplifiedAuthUser,
|
|
82
|
+
profile: profileData as Profile | null,
|
|
83
|
+
addresses,
|
|
84
|
+
isSoleAdmin,
|
|
85
|
+
};
|
|
68
86
|
}
|
|
69
87
|
|
|
70
88
|
export default async function EditUserPage(props: { params: Promise<{ id: string }> }) {
|
|
@@ -90,6 +108,7 @@ export default async function EditUserPage(props: { params: Promise<{ id: string
|
|
|
90
108
|
userToEditProfile={userData.profile}
|
|
91
109
|
userToEditAddresses={userData.addresses}
|
|
92
110
|
formAction={updateUserActionWithId}
|
|
111
|
+
lockRole={userData.isSoleAdmin}
|
|
93
112
|
/>
|
|
94
113
|
</div>
|
|
95
114
|
);
|