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.
@@ -57,6 +57,75 @@ function createServiceRoleClient() {
57
57
  });
58
58
  }
59
59
 
60
+ export async function createUser(formData: FormData) {
61
+ const supabase = createClient();
62
+ const adminCheck = await verifyAdmin(supabase);
63
+ if (!adminCheck.isAdmin) {
64
+ return { error: adminCheck.error || "Unauthorized" };
65
+ }
66
+
67
+ const email = (formData.get("email") as string | null)?.trim().toLowerCase() || "";
68
+ const password = (formData.get("password") as string | null) || "";
69
+ const fullName = (formData.get("full_name") as string | null)?.trim() || "";
70
+ const role = formData.get("role") as UserRole;
71
+ // Admin-created accounts are confirmed by default so the user can sign in
72
+ // immediately without an SMTP round-trip (mirrors completeSetup / auto-accept).
73
+ const emailConfirm = formData.get("email_confirm") !== "false";
74
+
75
+ if (!email) {
76
+ return { error: "Email is required." };
77
+ }
78
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
79
+ return { error: "Enter a valid email address." };
80
+ }
81
+ if (!password || password.length < 8) {
82
+ return { error: "Password must be at least 8 characters." };
83
+ }
84
+ if (!role || !['ADMIN', 'WRITER', 'USER'].includes(role)) {
85
+ return { error: "Invalid role specified." };
86
+ }
87
+
88
+ const adminSupabase = createServiceRoleClient();
89
+
90
+ const { data: created, error: createError } = await adminSupabase.auth.admin.createUser({
91
+ email,
92
+ password,
93
+ email_confirm: emailConfirm,
94
+ user_metadata: fullName ? { full_name: fullName } : {},
95
+ });
96
+
97
+ if (createError || !created?.user) {
98
+ if (createError && /already|registered|exists/i.test(createError.message)) {
99
+ return { error: "An account with this email already exists." };
100
+ }
101
+ return { error: `Failed to create user: ${createError?.message ?? 'unknown error'}` };
102
+ }
103
+
104
+ // The handle_new_user trigger inserts the profile row during createUser and assigns
105
+ // role USER (an admin already exists, so this account is never the first user). Apply
106
+ // the admin's chosen role and name explicitly afterward.
107
+ const { error: profileError } = await adminSupabase
108
+ .from("profiles")
109
+ .update({ role, full_name: fullName || null })
110
+ .eq("id", created.user.id);
111
+
112
+ revalidatePath("/cms/users");
113
+
114
+ if (profileError) {
115
+ // The account was created (trigger seeded role USER), but applying the chosen role
116
+ // failed. Land the admin on the edit screen — the recovery path — rather than
117
+ // stranding them on the create form, where a retry would hit "email already exists".
118
+ console.error("Error setting new user profile:", profileError);
119
+ redirect(
120
+ `/cms/users/${created.user.id}/edit?success=${encodeURIComponent(
121
+ "User created, but their role wasn't applied automatically — set it below and save.",
122
+ )}`,
123
+ );
124
+ }
125
+
126
+ redirect(`/cms/users/${created.user.id}/edit?success=User created successfully`);
127
+ }
128
+
60
129
  export async function updateUserProfile(userIdToUpdate: string, formData: FormData) {
61
130
  const supabase = createClient();
62
131
  const adminCheck = await verifyAdmin(supabase);
@@ -0,0 +1,217 @@
1
+ // app/cms/users/components/CreateUserForm.tsx
2
+ "use client";
3
+
4
+ import React, { useState, useTransition } from "react";
5
+ import { useRouter } from "next/navigation";
6
+ import { Button } from "@nextblock-cms/ui";
7
+ import { Input } from "@nextblock-cms/ui";
8
+ import { Label } from "@nextblock-cms/ui";
9
+ import { Checkbox } from "@nextblock-cms/ui";
10
+ import {
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ } from "@nextblock-cms/ui";
17
+ import { Alert, AlertTitle, AlertDescription, Spinner } from "@nextblock-cms/ui";
18
+ import { Eye, EyeOff, RefreshCw } from "lucide-react";
19
+ import type { Database } from "@nextblock-cms/db";
20
+ import { createUser } from "../actions";
21
+
22
+ type UserRole = Database["public"]["Enums"]["user_role"];
23
+
24
+ function generatePassword(length = 16): string {
25
+ const charset =
26
+ "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*";
27
+ const values = new Uint32Array(length);
28
+ crypto.getRandomValues(values);
29
+ let result = "";
30
+ for (let i = 0; i < length; i++) {
31
+ result += charset[values[i] % charset.length];
32
+ }
33
+ return result;
34
+ }
35
+
36
+ export default function CreateUserForm() {
37
+ const router = useRouter();
38
+ const [isPending, startTransition] = useTransition();
39
+
40
+ const [email, setEmail] = useState("");
41
+ const [password, setPassword] = useState("");
42
+ const [fullName, setFullName] = useState("");
43
+ const [role, setRole] = useState<UserRole>("USER");
44
+ const [emailConfirm, setEmailConfirm] = useState(true);
45
+ const [showPassword, setShowPassword] = useState(false);
46
+
47
+ // Only errors surface here — the action redirects on success.
48
+ const [formError, setFormError] = useState<string | null>(null);
49
+
50
+ const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
51
+ event.preventDefault();
52
+ setFormError(null);
53
+
54
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
55
+ setFormError("Enter a valid email address.");
56
+ return;
57
+ }
58
+ if (password.length < 8) {
59
+ setFormError("Password must be at least 8 characters.");
60
+ return;
61
+ }
62
+
63
+ const formData = new FormData();
64
+ formData.set("email", email);
65
+ formData.set("password", password);
66
+ formData.set("full_name", fullName);
67
+ formData.set("role", role);
68
+ formData.set("email_confirm", emailConfirm ? "true" : "false");
69
+
70
+ startTransition(async () => {
71
+ try {
72
+ const result = await createUser(formData);
73
+ // On success the action redirects; only errors return here.
74
+ if (result?.error) {
75
+ setFormError(result.error);
76
+ }
77
+ } catch {
78
+ setFormError("Something went wrong creating the user. Please try again.");
79
+ }
80
+ });
81
+ };
82
+
83
+ return (
84
+ <form onSubmit={handleSubmit} className="space-y-6">
85
+ {formError && (
86
+ <Alert variant="destructive">
87
+ <AlertTitle>Error</AlertTitle>
88
+ <AlertDescription>{formError}</AlertDescription>
89
+ </Alert>
90
+ )}
91
+
92
+ <div>
93
+ <Label htmlFor="email">Email</Label>
94
+ <Input
95
+ id="email"
96
+ name="email"
97
+ type="email"
98
+ value={email}
99
+ onChange={(e) => setEmail(e.target.value)}
100
+ required
101
+ autoComplete="off"
102
+ className="mt-1"
103
+ placeholder="user@example.com"
104
+ />
105
+ </div>
106
+
107
+ <div>
108
+ <Label htmlFor="full_name">Full Name</Label>
109
+ <Input
110
+ id="full_name"
111
+ name="full_name"
112
+ value={fullName}
113
+ onChange={(e) => setFullName(e.target.value)}
114
+ className="mt-1"
115
+ placeholder="Jane Doe"
116
+ />
117
+ <p className="text-xs text-muted-foreground mt-1">Optional. Can be edited later.</p>
118
+ </div>
119
+
120
+ <div>
121
+ <Label htmlFor="password">Password</Label>
122
+ <div className="flex gap-2 mt-1">
123
+ <div className="relative flex-1">
124
+ <Input
125
+ id="password"
126
+ name="password"
127
+ type={showPassword ? "text" : "password"}
128
+ value={password}
129
+ onChange={(e) => setPassword(e.target.value)}
130
+ required
131
+ minLength={8}
132
+ autoComplete="new-password"
133
+ className="pr-10"
134
+ placeholder="At least 8 characters"
135
+ />
136
+ <button
137
+ type="button"
138
+ onClick={() => setShowPassword((v) => !v)}
139
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
140
+ aria-label={showPassword ? "Hide password" : "Show password"}
141
+ >
142
+ {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
143
+ </button>
144
+ </div>
145
+ <Button
146
+ type="button"
147
+ variant="outline"
148
+ onClick={() => {
149
+ setPassword(generatePassword());
150
+ setShowPassword(true);
151
+ }}
152
+ >
153
+ <RefreshCw className="mr-2 h-4 w-4" /> Generate
154
+ </Button>
155
+ </div>
156
+ <p className="text-xs text-muted-foreground mt-1">
157
+ Share this password with the user securely. They can change it later from their profile.
158
+ </p>
159
+ </div>
160
+
161
+ <div>
162
+ <Label htmlFor="role">Role</Label>
163
+ <Select value={role} onValueChange={(val: UserRole) => setRole(val)}>
164
+ <SelectTrigger id="role" className="mt-1">
165
+ <SelectValue placeholder="Select role" />
166
+ </SelectTrigger>
167
+ <SelectContent>
168
+ <SelectItem value="USER">User</SelectItem>
169
+ <SelectItem value="WRITER">Writer</SelectItem>
170
+ <SelectItem value="ADMIN">Admin</SelectItem>
171
+ </SelectContent>
172
+ </Select>
173
+ <p className="text-xs text-muted-foreground mt-1">
174
+ Writers and Admins can access the CMS. Users have public-site access only.
175
+ </p>
176
+ </div>
177
+
178
+ <div className="flex items-start space-x-2 pt-2">
179
+ <Checkbox
180
+ id="email_confirm"
181
+ checked={emailConfirm}
182
+ onCheckedChange={(checked) => setEmailConfirm(checked as boolean)}
183
+ className="mt-0.5"
184
+ />
185
+ <div>
186
+ <Label htmlFor="email_confirm" className="font-normal leading-none">
187
+ Mark email as confirmed
188
+ </Label>
189
+ <p className="text-xs text-muted-foreground mt-1">
190
+ The user can sign in immediately without a verification email. Uncheck only if your
191
+ project sends confirmation emails and you want the user to verify first.
192
+ </p>
193
+ </div>
194
+ </div>
195
+
196
+ <div className="flex justify-end space-x-3 pt-4">
197
+ <Button
198
+ type="button"
199
+ variant="outline"
200
+ onClick={() => router.push("/cms/users")}
201
+ disabled={isPending}
202
+ >
203
+ Cancel
204
+ </Button>
205
+ <Button type="submit" disabled={isPending}>
206
+ {isPending ? (
207
+ <>
208
+ <Spinner className="mr-2 h-4 w-4" /> Creating...
209
+ </>
210
+ ) : (
211
+ "Create User"
212
+ )}
213
+ </Button>
214
+ </div>
215
+ </form>
216
+ );
217
+ }
@@ -16,9 +16,11 @@ interface UserFormProps {
16
16
  shippingAddress: CustomerAddressInput | null;
17
17
  };
18
18
  formAction: (formData: FormData) => Promise<{ error?: string } | void>;
19
+ /** Lock the role selector — true when this user is the only remaining Admin. */
20
+ lockRole?: boolean;
19
21
  }
20
22
 
21
- export default function UserForm({ userToEditAuth, userToEditProfile, userToEditAddresses, formAction }: UserFormProps) {
23
+ export default function UserForm({ userToEditAuth, userToEditProfile, userToEditAddresses, formAction, lockRole }: UserFormProps) {
22
24
  const searchParams = useSearchParams();
23
25
  const successMsg = searchParams.get('success');
24
26
 
@@ -63,6 +65,7 @@ export default function UserForm({ userToEditAuth, userToEditProfile, userToEdit
63
65
  email={userToEditAuth.email}
64
66
  onAction={handleAdminSave}
65
67
  initialSuccessMessage={successMsg}
68
+ lockRole={lockRole}
66
69
  />
67
70
  </div>
68
71
  );
@@ -0,0 +1,44 @@
1
+ // app/cms/users/new/page.tsx
2
+ import Link from "next/link";
3
+ import { ArrowLeft } from "lucide-react";
4
+ import { Button } from "@nextblock-cms/ui";
5
+ import { createClient } from "@nextblock-cms/db/server";
6
+ import CreateUserForm from "../components/CreateUserForm";
7
+
8
+ export default async function NewUserPage() {
9
+ const supabase = createClient();
10
+ const { data: { user: currentAdmin } } = await supabase.auth.getUser();
11
+
12
+ if (!currentAdmin) {
13
+ return <p>Access Denied. Not authenticated.</p>;
14
+ }
15
+
16
+ // User management is admin-only (mirrors the users list page). The CMS layout already
17
+ // gates ADMIN/WRITER; this blocks writers and direct-access attempts.
18
+ const { data: adminProfile } = await supabase
19
+ .from("profiles")
20
+ .select("role")
21
+ .eq("id", currentAdmin.id)
22
+ .single();
23
+ if (adminProfile?.role !== "ADMIN") {
24
+ return <p>Access Denied. Admin privileges required.</p>;
25
+ }
26
+
27
+ return (
28
+ <div className="max-w-xl mx-auto">
29
+ <div className="flex items-center gap-3 mb-6">
30
+ <Button variant="outline" size="icon" aria-label="Back to users" asChild>
31
+ <Link href="/cms/users">
32
+ <ArrowLeft className="h-4 w-4" />
33
+ </Link>
34
+ </Button>
35
+ <h1 className="text-2xl font-bold">Create User</h1>
36
+ </div>
37
+ <p className="text-sm text-muted-foreground mb-6">
38
+ Create an account directly. After creating, you can fill in the profile, addresses,
39
+ and avatar on the next screen.
40
+ </p>
41
+ <CreateUserForm />
42
+ </div>
43
+ );
44
+ }
@@ -11,7 +11,7 @@ import {
11
11
  TableRow,
12
12
  } from "@nextblock-cms/ui";
13
13
  import { Badge } from "@nextblock-cms/ui";
14
- import { MoreHorizontal, Edit3, Users } from "lucide-react";
14
+ import { MoreHorizontal, Edit3, Users, PlusCircle } from "lucide-react";
15
15
  import {
16
16
  DropdownMenu,
17
17
  DropdownMenuContent,
@@ -110,7 +110,11 @@ export default async function CmsUsersListPage() {
110
110
  <div className="w-full">
111
111
  <div className="flex justify-between items-center mb-6">
112
112
  <h1 className="text-2xl font-semibold">Manage Users</h1>
113
- {/* No "Create New User" button as users are created via sign-up flow. Admins manage roles. */}
113
+ <Button asChild>
114
+ <Link href="/cms/users/new" className="flex items-center">
115
+ <PlusCircle className="mr-2 h-4 w-4" /> Create User
116
+ </Link>
117
+ </Button>
114
118
  </div>
115
119
 
116
120
  {users.length === 0 ? (
@@ -118,8 +122,13 @@ export default async function CmsUsersListPage() {
118
122
  <Users className="mx-auto h-12 w-12 text-muted-foreground" />
119
123
  <h3 className="mt-2 text-sm font-medium text-foreground">No other users found</h3>
120
124
  <p className="mt-1 text-sm text-muted-foreground">
121
- New users will appear here after they sign up.
125
+ Create a user, or new users will appear here after they sign up.
122
126
  </p>
127
+ <Button asChild className="mt-4">
128
+ <Link href="/cms/users/new" className="flex items-center">
129
+ <PlusCircle className="mr-2 h-4 w-4" /> Create User
130
+ </Link>
131
+ </Button>
123
132
  </div>
124
133
  ) : (
125
134
  <div className="rounded-lg border overflow-hidden">
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextblock-cms/template",
3
- "version": "0.12.15",
3
+ "version": "0.13.1",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "dev": "next dev",