create-nextblock 0.12.16 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nextblock",
3
- "version": "0.12.16",
3
+ "version": "0.13.1",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -348,12 +348,35 @@ export async function saveNotificationEmails(emails: string) {
348
348
  return { error: "Unauthorized. Admin role required." };
349
349
  }
350
350
 
351
- // Basic validation of emails
352
- const cleaned = emails
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
- .join(", ");
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
- className="h-8 font-mono text-xs"
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
- const [emailsInput, setEmailsInput] = useState("");
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
- setEmailsInput(res.emails);
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
- const res = await saveNotificationEmails(emailsInput);
59
- setSavingEmails(false);
60
-
61
- if (res.error) {
62
- setSettingsError(res.error);
63
- } else {
64
- setSettingsSuccess("Notification settings saved successfully.");
65
- if (res.emails) {
66
- setEmailsInput(res.emails);
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
- setTimeout(() => {
69
- setSettingsSuccess(null);
70
- setIsSettingsOpen(false);
71
- }, 1500);
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={() => setIsSettingsOpen(true)}
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
- {isSettingsOpen && (
352
- <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/45 backdrop-blur-sm animate-in fade-in duration-200">
353
- <div className="bg-background border border-border rounded-2xl max-w-md w-full p-6 shadow-xl space-y-4 animate-in zoom-in-95 duration-200">
354
- <div className="flex items-center justify-between">
355
- <h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
356
- <Mail className="h-5 w-5 text-primary" />
357
- Notification Settings
358
- </h3>
359
- <button
360
- onClick={() => setIsSettingsOpen(false)}
361
- className="text-muted-foreground hover:text-foreground transition-colors"
362
- >
363
- <X className="h-5 w-5" />
364
- </button>
365
- </div>
366
- <p className="text-sm text-muted-foreground text-left">
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
- </p>
369
- <div className="space-y-1.5 text-left">
370
- <label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block">
371
- Email Recipients
372
- </label>
373
- <textarea
374
- value={emailsInput}
375
- onChange={(e) => setEmailsInput(e.target.value)}
376
- placeholder="admin@example.com, moderator@example.com"
377
- className="w-full min-h-[80px] bg-background border border-border rounded-xl px-3 py-2 text-sm text-foreground focus:ring-1 focus:ring-primary focus:outline-none"
378
- />
379
- <span className="text-[10px] text-muted-foreground">
380
- Enter a comma-separated list of email addresses.
381
- </span>
382
- </div>
383
-
384
- {settingsError && <div className="text-xs font-medium text-destructive text-left">{settingsError}</div>}
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
- </div>
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
- return { authUser: simplifiedAuthUser, profile: profileData as Profile | null, addresses };
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
  );