create-nextblock 0.9.95 → 0.9.98
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/[slug]/page.tsx +9 -2
- package/templates/nextblock-template/app/actions/package-actions.ts +9 -4
- package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +3 -2
- package/templates/nextblock-template/app/api/cron/sync-currencies/route.ts +5 -1
- package/templates/nextblock-template/app/api/draft/route.ts +5 -1
- package/templates/nextblock-template/app/api/media/library/route.ts +6 -2
- package/templates/nextblock-template/app/api/process-image/route.ts +58 -43
- package/templates/nextblock-template/app/api/revalidate/route.ts +21 -18
- package/templates/nextblock-template/app/api/upload/presigned-url/route.ts +20 -8
- package/templates/nextblock-template/app/api/upload/proxy/route.ts +34 -28
- package/templates/nextblock-template/app/article/[slug]/page.tsx +4 -13
- package/templates/nextblock-template/app/checkout/success/actions.ts +3 -2
- package/templates/nextblock-template/app/cms/media/actions.ts +47 -31
- package/templates/nextblock-template/app/cms/orders/actions.ts +29 -29
- package/templates/nextblock-template/app/cms/users/[id]/edit/page.tsx +28 -28
- package/templates/nextblock-template/app/cms/users/actions.ts +119 -118
- package/templates/nextblock-template/app/cms/users/page.tsx +3 -3
- package/templates/nextblock-template/app/layout.tsx +10 -7
- package/templates/nextblock-template/app/lib/site-settings.ts +7 -4
- package/templates/nextblock-template/app/page.tsx +2 -1
- package/templates/nextblock-template/app/product/[slug]/page.tsx +9 -2
- package/templates/nextblock-template/app/robots.txt/route.ts +6 -3
- package/templates/nextblock-template/app/setup/SetupWizard.tsx +55 -8
- package/templates/nextblock-template/app/setup/page.tsx +17 -1
- package/templates/nextblock-template/app/sitemap.ts +5 -3
- package/templates/nextblock-template/context/language-rest-client.ts +3 -2
- package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +125 -32
- package/templates/nextblock-template/lib/app-secrets.ts +39 -0
- package/templates/nextblock-template/lib/auth/crypto.ts +4 -1
- package/templates/nextblock-template/lib/custom-block-r2-upload.ts +23 -3
- package/templates/nextblock-template/lib/setup/actions.ts +16 -0
- package/templates/nextblock-template/lib/setup/env-status.ts +44 -5
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +172 -0
- package/templates/nextblock-template/lib/setup/schema-apply.ts +23 -7
- package/templates/nextblock-template/lib/site-url.ts +48 -0
- package/templates/nextblock-template/lib/storage/provider.ts +66 -0
- package/templates/nextblock-template/lib/storage/supabase-storage.ts +103 -0
- package/templates/nextblock-template/lib/visual-editing/draft-content.ts +1 -1
- package/templates/nextblock-template/next.config.js +37 -2
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/proxy.ts +39 -4
|
@@ -7,6 +7,8 @@ import type { Database } from "@nextblock-cms/db";
|
|
|
7
7
|
import { encodedRedirect } from "@nextblock-cms/utils/server";
|
|
8
8
|
import { DeleteObjectCommand, DeleteObjectsCommand, CopyObjectCommand, ListObjectsV2Command, HeadObjectCommand } from "@aws-sdk/client-s3";
|
|
9
9
|
import { getS3Client } from "@nextblock-cms/utils/server";
|
|
10
|
+
import { getStorageBackend, getStorageBucket } from "../../../lib/storage/provider";
|
|
11
|
+
import { supabaseRemoveObjects } from "../../../lib/storage/supabase-storage";
|
|
10
12
|
|
|
11
13
|
type Media = Database['public']['Tables']['media']['Row'];
|
|
12
14
|
|
|
@@ -85,13 +87,14 @@ export async function deleteMediaItem(mediaId: string, objectKey: string) {
|
|
|
85
87
|
return encodedRedirect("error", "/cms/media", "Forbidden: Insufficient permissions.");
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
+
const backend = getStorageBackend();
|
|
91
|
+
const bucket = getStorageBucket();
|
|
92
|
+
const s3Client = backend === 's3' ? await getS3Client() : null;
|
|
90
93
|
|
|
91
|
-
if (!
|
|
92
|
-
return encodedRedirect("error", "/cms/media", "
|
|
94
|
+
if (!bucket) {
|
|
95
|
+
return encodedRedirect("error", "/cms/media", "Storage bucket not configured for deletion.");
|
|
93
96
|
}
|
|
94
|
-
if (!s3Client) {
|
|
97
|
+
if (backend === 's3' && !s3Client) {
|
|
95
98
|
return encodedRedirect("error", "/cms/media", "R2 client is not configured for deletion.");
|
|
96
99
|
}
|
|
97
100
|
|
|
@@ -110,15 +113,18 @@ export async function deleteMediaItem(mediaId: string, objectKey: string) {
|
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
try {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
if (backend === 'supabase') {
|
|
117
|
+
await supabaseRemoveObjects(keysToDelete);
|
|
118
|
+
} else {
|
|
119
|
+
await s3Client!.send(new DeleteObjectsCommand({
|
|
120
|
+
Bucket: bucket,
|
|
121
|
+
Delete: {
|
|
122
|
+
Objects: keysToDelete.map(key => ({ Key: key })),
|
|
123
|
+
},
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
120
126
|
} catch (r2Error: unknown) {
|
|
121
|
-
console.error("Error deleting from
|
|
127
|
+
console.error("Error deleting from storage:", r2Error);
|
|
122
128
|
// Decide if you want to proceed with DB deletion if R2 deletion fails
|
|
123
129
|
// It's often better to proceed and log, or handle more gracefully.
|
|
124
130
|
// For now, we'll let it proceed to DB deletion but the error is logged.
|
|
@@ -153,13 +159,14 @@ export async function deleteMultipleMediaItems(items: Array<{ id: string; object
|
|
|
153
159
|
return { error: "No items selected for deletion." };
|
|
154
160
|
}
|
|
155
161
|
|
|
156
|
-
const
|
|
157
|
-
const
|
|
162
|
+
const backend = getStorageBackend();
|
|
163
|
+
const bucket = getStorageBucket();
|
|
164
|
+
const s3Client = backend === 's3' ? await getS3Client() : null;
|
|
158
165
|
|
|
159
|
-
if (!
|
|
160
|
-
return { error: "
|
|
166
|
+
if (!bucket) {
|
|
167
|
+
return { error: "Storage bucket not configured for deletion." };
|
|
161
168
|
}
|
|
162
|
-
if (!s3Client) {
|
|
169
|
+
if (backend === 's3' && !s3Client) {
|
|
163
170
|
return { error: "R2 client is not configured for deletion." };
|
|
164
171
|
}
|
|
165
172
|
|
|
@@ -191,23 +198,26 @@ export async function deleteMultipleMediaItems(items: Array<{ id: string; object
|
|
|
191
198
|
let r2DeletionError = null;
|
|
192
199
|
let dbDeletionError = null;
|
|
193
200
|
|
|
194
|
-
// Batch delete from
|
|
201
|
+
// Batch delete from object storage
|
|
195
202
|
try {
|
|
196
203
|
if (r2ObjectsToDelete.length > 0) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
204
|
+
if (backend === 'supabase') {
|
|
205
|
+
await supabaseRemoveObjects(allKeysToDelete);
|
|
206
|
+
} else {
|
|
207
|
+
const output = await s3Client!.send(new DeleteObjectsCommand({
|
|
208
|
+
Bucket: bucket,
|
|
209
|
+
Delete: { Objects: r2ObjectsToDelete },
|
|
210
|
+
}));
|
|
211
|
+
if (output.Errors && output.Errors.length > 0) {
|
|
212
|
+
console.error("Errors deleting some objects from storage:", output.Errors);
|
|
213
|
+
// Collect specific errors if needed, for now a general message
|
|
214
|
+
r2DeletionError = `Some objects failed to delete from storage: ${output.Errors.map(e => e.Key).join(', ')}`;
|
|
215
|
+
}
|
|
206
216
|
}
|
|
207
217
|
}
|
|
208
218
|
} catch (error: unknown) {
|
|
209
|
-
console.error("Error batch deleting from
|
|
210
|
-
r2DeletionError = `Failed to delete objects from
|
|
219
|
+
console.error("Error batch deleting from storage:", error);
|
|
220
|
+
r2DeletionError = `Failed to delete objects from storage: ${error instanceof Error ? error.message : String(error)}`;
|
|
211
221
|
}
|
|
212
222
|
|
|
213
223
|
// Batch delete from Supabase
|
|
@@ -264,8 +274,14 @@ export async function moveMultipleMediaItems(
|
|
|
264
274
|
};
|
|
265
275
|
const folder = sanitizeFolder(destinationFolder);
|
|
266
276
|
|
|
277
|
+
// Folder reorganisation uses S3 copy/list semantics that the native Supabase backend
|
|
278
|
+
// doesn't replicate yet — surface a clear message instead of a generic config error.
|
|
279
|
+
if (getStorageBackend() === 'supabase') {
|
|
280
|
+
return { error: "Moving media between folders isn't supported on Supabase Storage yet. Delete and re-upload to relocate a file." };
|
|
281
|
+
}
|
|
282
|
+
|
|
267
283
|
const s3Client = await getS3Client();
|
|
268
|
-
const R2_BUCKET_NAME =
|
|
284
|
+
const R2_BUCKET_NAME = getStorageBucket();
|
|
269
285
|
const R2_PUBLIC_URL_BASE = process.env.NEXT_PUBLIC_R2_BASE_URL || '';
|
|
270
286
|
|
|
271
287
|
if (!R2_BUCKET_NAME) {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
'use server';
|
|
2
|
-
|
|
3
|
-
import { createClient } from '@nextblock-cms/db/server';
|
|
4
|
-
import {
|
|
5
|
-
applyOrderInventoryDeduction,
|
|
6
|
-
assignInvoiceMetadata,
|
|
7
|
-
} from '@nextblock-cms/ecommerce/server';
|
|
8
|
-
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
|
|
9
|
-
import { OrderWithDetails } from './types';
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { createClient } from '@nextblock-cms/db/server';
|
|
4
|
+
import {
|
|
5
|
+
applyOrderInventoryDeduction,
|
|
6
|
+
assignInvoiceMetadata,
|
|
7
|
+
} from '@nextblock-cms/ecommerce/server';
|
|
8
|
+
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
|
|
9
|
+
import { OrderWithDetails } from './types';
|
|
10
10
|
|
|
11
11
|
const ITEMS_PER_PAGE = 20;
|
|
12
12
|
|
|
@@ -150,7 +150,7 @@ export async function getOrderDetails(orderId: string): Promise<OrderWithDetails
|
|
|
150
150
|
} as OrderWithDetails;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
export async function markOrderAsPaid(orderId: string): Promise<{ success: boolean; error?: string }> {
|
|
153
|
+
export async function markOrderAsPaid(orderId: string): Promise<{ success: boolean; error?: string }> {
|
|
154
154
|
const supabase = createClient();
|
|
155
155
|
|
|
156
156
|
// 1. Verify Authentication (using user session)
|
|
@@ -162,8 +162,8 @@ export async function markOrderAsPaid(orderId: string): Promise<{ success: boole
|
|
|
162
162
|
|
|
163
163
|
// 2. Perform Update using Service Role (Bypass RLS)
|
|
164
164
|
|
|
165
|
-
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
166
|
-
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
165
|
+
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SECRET_KEY;
|
|
166
|
+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL;
|
|
167
167
|
|
|
168
168
|
if (!serviceRoleKey || !supabaseUrl) {
|
|
169
169
|
return { success: false, error: 'Server configuration error' };
|
|
@@ -176,26 +176,26 @@ export async function markOrderAsPaid(orderId: string): Promise<{ success: boole
|
|
|
176
176
|
}
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
-
const { data: updatedData, error } = await adminSupabase
|
|
180
|
-
.from('orders')
|
|
181
|
-
.update({ status: 'paid' })
|
|
182
|
-
.eq('id', orderId)
|
|
183
|
-
.select();
|
|
179
|
+
const { data: updatedData, error } = await adminSupabase
|
|
180
|
+
.from('orders')
|
|
181
|
+
.update({ status: 'paid' })
|
|
182
|
+
.eq('id', orderId)
|
|
183
|
+
.select();
|
|
184
184
|
|
|
185
185
|
if (error) {
|
|
186
186
|
console.error('Mark Paid DB Error:', error);
|
|
187
187
|
return { success: false, error: error.message };
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
if (!updatedData || updatedData.length === 0) {
|
|
191
|
-
return { success: false, error: 'Order not found or update failed.' };
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
await assignInvoiceMetadata({
|
|
195
|
-
orderId,
|
|
196
|
-
client: adminSupabase as any,
|
|
197
|
-
});
|
|
198
|
-
await applyOrderInventoryDeduction(adminSupabase as any, orderId);
|
|
199
|
-
|
|
200
|
-
return { success: true };
|
|
201
|
-
}
|
|
190
|
+
if (!updatedData || updatedData.length === 0) {
|
|
191
|
+
return { success: false, error: 'Order not found or update failed.' };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await assignInvoiceMetadata({
|
|
195
|
+
orderId,
|
|
196
|
+
client: adminSupabase as any,
|
|
197
|
+
});
|
|
198
|
+
await applyOrderInventoryDeduction(adminSupabase as any, orderId);
|
|
199
|
+
|
|
200
|
+
return { success: true };
|
|
201
|
+
}
|
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
// app/cms/users/[id]/edit/page.tsx
|
|
2
|
-
import UserForm from "../../components/UserForm";
|
|
3
|
-
import { updateUserProfile } from "../../actions";
|
|
4
|
-
import type { Database } from "@nextblock-cms/db";
|
|
5
|
-
import { notFound } from "next/navigation";
|
|
6
|
-
import { getDefaultUserAddresses } from "@nextblock-cms/ecommerce/server";
|
|
7
|
-
|
|
8
|
-
type Profile = Database['public']['Tables']['profiles']['Row'];
|
|
9
|
-
type AuthUser = {
|
|
10
|
-
id: string;
|
|
11
|
-
email: string | undefined;
|
|
2
|
+
import UserForm from "../../components/UserForm";
|
|
3
|
+
import { updateUserProfile } from "../../actions";
|
|
4
|
+
import type { Database } from "@nextblock-cms/db";
|
|
5
|
+
import { notFound } from "next/navigation";
|
|
6
|
+
import { getDefaultUserAddresses } from "@nextblock-cms/ecommerce/server";
|
|
7
|
+
|
|
8
|
+
type Profile = Database['public']['Tables']['profiles']['Row'];
|
|
9
|
+
type AuthUser = {
|
|
10
|
+
id: string;
|
|
11
|
+
email: string | undefined;
|
|
12
12
|
created_at: string | undefined;
|
|
13
13
|
last_sign_in_at: string | undefined;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
async function getUserAndProfileData(userId: string): Promise<{
|
|
17
|
-
authUser: AuthUser;
|
|
18
|
-
profile: Profile | null;
|
|
19
|
-
addresses: Awaited<ReturnType<typeof getDefaultUserAddresses>>;
|
|
20
|
-
} | null> {
|
|
16
|
+
async function getUserAndProfileData(userId: string): Promise<{
|
|
17
|
+
authUser: AuthUser;
|
|
18
|
+
profile: Profile | null;
|
|
19
|
+
addresses: Awaited<ReturnType<typeof getDefaultUserAddresses>>;
|
|
20
|
+
} | null> {
|
|
21
21
|
|
|
22
22
|
// Fetch user from auth.users
|
|
23
23
|
// For admin operations, you might need a service_role client to fetch any user.
|
|
@@ -27,9 +27,9 @@ async function getUserAndProfileData(userId: string): Promise<{
|
|
|
27
27
|
|
|
28
28
|
// Let's use the admin API for fetching the auth user.
|
|
29
29
|
const { createClient: createServiceRoleClient } = await import('@supabase/supabase-js');
|
|
30
|
-
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
31
|
-
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
32
|
-
|
|
30
|
+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL;
|
|
31
|
+
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SECRET_KEY;
|
|
32
|
+
|
|
33
33
|
if (!supabaseUrl || !serviceRoleKey) {
|
|
34
34
|
throw new Error('Missing required environment variables');
|
|
35
35
|
}
|
|
@@ -62,10 +62,10 @@ async function getUserAndProfileData(userId: string): Promise<{
|
|
|
62
62
|
last_sign_in_at: authUserData.last_sign_in_at,
|
|
63
63
|
};
|
|
64
64
|
|
|
65
|
-
const addresses = await getDefaultUserAddresses(userId, serviceSupabase as any);
|
|
66
|
-
|
|
67
|
-
return { authUser: simplifiedAuthUser, profile: profileData as Profile | null, addresses };
|
|
68
|
-
}
|
|
65
|
+
const addresses = await getDefaultUserAddresses(userId, serviceSupabase as any);
|
|
66
|
+
|
|
67
|
+
return { authUser: simplifiedAuthUser, profile: profileData as Profile | null, addresses };
|
|
68
|
+
}
|
|
69
69
|
|
|
70
70
|
export default async function EditUserPage(props: { params: Promise<{ id: string }> }) {
|
|
71
71
|
const params = await props.params;
|
|
@@ -85,12 +85,12 @@ export default async function EditUserPage(props: { params: Promise<{ id: string
|
|
|
85
85
|
return (
|
|
86
86
|
<div className="max-w-4xl mx-auto">
|
|
87
87
|
<h1 className="text-2xl font-bold mb-6">Edit User: {userData.authUser.email}</h1>
|
|
88
|
-
<UserForm
|
|
89
|
-
userToEditAuth={userData.authUser}
|
|
90
|
-
userToEditProfile={userData.profile}
|
|
91
|
-
userToEditAddresses={userData.addresses}
|
|
92
|
-
formAction={updateUserActionWithId}
|
|
93
|
-
/>
|
|
88
|
+
<UserForm
|
|
89
|
+
userToEditAuth={userData.authUser}
|
|
90
|
+
userToEditProfile={userData.profile}
|
|
91
|
+
userToEditAddresses={userData.addresses}
|
|
92
|
+
formAction={updateUserActionWithId}
|
|
93
|
+
/>
|
|
94
94
|
</div>
|
|
95
95
|
);
|
|
96
96
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// app/cms/users/actions.ts
|
|
1
|
+
// app/cms/users/actions.ts
|
|
2
2
|
"use server";
|
|
3
3
|
|
|
4
4
|
import { createClient } from "@nextblock-cms/db/server";
|
|
@@ -8,30 +8,31 @@ import type { Database } from "@nextblock-cms/db";
|
|
|
8
8
|
import { normalizeCustomerAddress } from "@nextblock-cms/ecommerce";
|
|
9
9
|
import { upsertDefaultUserAddresses } from "@nextblock-cms/ecommerce/server";
|
|
10
10
|
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
.
|
|
23
|
-
.
|
|
24
|
-
.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
11
|
+
import { resolveSupabaseServiceKey, resolveSupabaseUrl } from '../../../lib/setup/env-status';
|
|
12
|
+
|
|
13
|
+
type UserRole = Database['public']['Enums']['user_role'];
|
|
14
|
+
|
|
15
|
+
// Helper to check admin role using the server client
|
|
16
|
+
async function verifyAdmin(supabase: ReturnType<typeof createClient>): Promise<{ isAdmin: boolean; error?: string; userId?: string }> {
|
|
17
|
+
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
|
18
|
+
if (authError || !user) {
|
|
19
|
+
return { isAdmin: false, error: "Authentication required." };
|
|
20
|
+
}
|
|
21
|
+
const { data: profile, error: profileError } = await supabase
|
|
22
|
+
.from("profiles")
|
|
23
|
+
.select("role")
|
|
24
|
+
.eq("id", user.id)
|
|
25
|
+
.single();
|
|
26
|
+
|
|
27
|
+
if (profileError || !profile) {
|
|
28
|
+
return { isAdmin: false, error: "Profile not found or error fetching profile." };
|
|
29
|
+
}
|
|
30
|
+
if (profile.role !== "ADMIN") {
|
|
31
|
+
return { isAdmin: false, error: "Admin privileges required." };
|
|
32
|
+
}
|
|
33
|
+
return { isAdmin: true, userId: user.id };
|
|
34
|
+
}
|
|
35
|
+
|
|
35
36
|
type UpdateUserProfilePayload = {
|
|
36
37
|
role: UserRole;
|
|
37
38
|
full_name?: string | null;
|
|
@@ -42,8 +43,8 @@ type UpdateUserProfilePayload = {
|
|
|
42
43
|
};
|
|
43
44
|
|
|
44
45
|
function createServiceRoleClient() {
|
|
45
|
-
const supabaseUrl =
|
|
46
|
-
const serviceRoleKey =
|
|
46
|
+
const supabaseUrl = resolveSupabaseUrl();
|
|
47
|
+
const serviceRoleKey = resolveSupabaseServiceKey();
|
|
47
48
|
|
|
48
49
|
if (!supabaseUrl || !serviceRoleKey) {
|
|
49
50
|
throw new Error('Missing required environment variables');
|
|
@@ -60,10 +61,10 @@ function createServiceRoleClient() {
|
|
|
60
61
|
export async function updateUserProfile(userIdToUpdate: string, formData: FormData) {
|
|
61
62
|
const supabase = createClient();
|
|
62
63
|
const adminCheck = await verifyAdmin(supabase);
|
|
63
|
-
if (!adminCheck.isAdmin) {
|
|
64
|
-
return { error: adminCheck.error || "Unauthorized" };
|
|
65
|
-
}
|
|
66
|
-
|
|
64
|
+
if (!adminCheck.isAdmin) {
|
|
65
|
+
return { error: adminCheck.error || "Unauthorized" };
|
|
66
|
+
}
|
|
67
|
+
|
|
67
68
|
const parseAddressField = (fieldName: string) => {
|
|
68
69
|
const rawValue = formData.get(fieldName) as string | null;
|
|
69
70
|
if (!rawValue) {
|
|
@@ -99,27 +100,27 @@ export async function updateUserProfile(userIdToUpdate: string, formData: FormDa
|
|
|
99
100
|
github_username: formData.get("github_username") as string || null,
|
|
100
101
|
phone: formData.get("phone") as string || null,
|
|
101
102
|
};
|
|
102
|
-
|
|
103
|
-
if (!rawFormData.role) {
|
|
104
|
-
return { error: "Role is a required field." };
|
|
105
|
-
}
|
|
106
|
-
if (!['ADMIN', 'WRITER', 'USER'].includes(rawFormData.role)) {
|
|
107
|
-
return { error: "Invalid role specified." };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Prevent an admin from accidentally removing their own admin role if they are the only admin
|
|
111
|
-
// This is a basic check; a more robust system might count admins.
|
|
112
|
-
if (userIdToUpdate === adminCheck.userId && rawFormData.role !== 'ADMIN') {
|
|
113
|
-
const { count } = await supabase
|
|
114
|
-
.from('profiles')
|
|
115
|
-
.select('*', { count: 'exact', head: true })
|
|
116
|
-
.eq('role', 'ADMIN');
|
|
117
|
-
if (count === 1) {
|
|
118
|
-
return { error: "Cannot remove the last admin's role." };
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
103
|
+
|
|
104
|
+
if (!rawFormData.role) {
|
|
105
|
+
return { error: "Role is a required field." };
|
|
106
|
+
}
|
|
107
|
+
if (!['ADMIN', 'WRITER', 'USER'].includes(rawFormData.role)) {
|
|
108
|
+
return { error: "Invalid role specified." };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Prevent an admin from accidentally removing their own admin role if they are the only admin
|
|
112
|
+
// This is a basic check; a more robust system might count admins.
|
|
113
|
+
if (userIdToUpdate === adminCheck.userId && rawFormData.role !== 'ADMIN') {
|
|
114
|
+
const { count } = await supabase
|
|
115
|
+
.from('profiles')
|
|
116
|
+
.select('*', { count: 'exact', head: true })
|
|
117
|
+
.eq('role', 'ADMIN');
|
|
118
|
+
if (count === 1) {
|
|
119
|
+
return { error: "Cannot remove the last admin's role." };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
123
124
|
const profileData: UpdateUserProfilePayload = {
|
|
124
125
|
role: rawFormData.role,
|
|
125
126
|
full_name: rawFormData.full_name,
|
|
@@ -135,8 +136,8 @@ export async function updateUserProfile(userIdToUpdate: string, formData: FormDa
|
|
|
135
136
|
.from("profiles")
|
|
136
137
|
.update(profileData)
|
|
137
138
|
.eq("id", userIdToUpdate);
|
|
138
|
-
|
|
139
|
-
if (error) {
|
|
139
|
+
|
|
140
|
+
if (error) {
|
|
140
141
|
console.error("Error updating user profile:", error);
|
|
141
142
|
return { error: `Failed to update profile: ${error.message}` };
|
|
142
143
|
}
|
|
@@ -154,68 +155,68 @@ export async function updateUserProfile(userIdToUpdate: string, formData: FormDa
|
|
|
154
155
|
revalidatePath("/checkout");
|
|
155
156
|
redirect(`/cms/users/${userIdToUpdate}/edit?success=User profile updated successfully`);
|
|
156
157
|
}
|
|
157
|
-
|
|
158
|
-
export async function deleteUserAndProfile(userIdToDelete: string) {
|
|
159
|
-
|
|
160
|
-
// For deleting a user, we need to use the Supabase Admin API,
|
|
161
|
-
// which requires a client initialized with the SERVICE_ROLE_KEY.
|
|
162
|
-
// This ensures the operation has the necessary privileges.
|
|
163
|
-
// IMPORTANT: Ensure SUPABASE_SERVICE_ROLE_KEY is set in your .env.local and Vercel env vars.
|
|
164
|
-
const supabaseAdmin = createClient(
|
|
165
|
-
// Re-create client with service role. This is a common pattern.
|
|
166
|
-
// Ensure your createClient function can be called without args to use env vars,
|
|
167
|
-
// or pass them explicitly if needed. The one from the template should work.
|
|
168
|
-
// If your createClient is specific to user context (cookies), you might need a separate
|
|
169
|
-
// admin client factory. For now, assuming `createClient()` can make a service client
|
|
170
|
-
// if called in a server action without user cookie context, or if it defaults to service key.
|
|
171
|
-
// A safer way:
|
|
172
|
-
// const supabaseAdmin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
|
|
173
|
-
// However, the template's createClient for server components/actions should handle this by not having cookie access.
|
|
174
|
-
// Let's assume for now createClient() is sufficient if it can use service role.
|
|
175
|
-
// A more explicit way for admin actions:
|
|
176
|
-
// import { createClient as createAdminClient } from '@supabase/supabase-js';
|
|
177
|
-
// const supabaseAdmin = createAdminClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const adminCheck = await verifyAdmin(supabaseAdmin); // Verify current user is admin
|
|
182
|
-
if (!adminCheck.isAdmin) {
|
|
183
|
-
return { error: adminCheck.error || "Unauthorized" };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (userIdToDelete === adminCheck.userId) {
|
|
187
|
-
return { error: "Admins cannot delete their own account through this panel." };
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Use the Supabase Auth Admin API to delete the user
|
|
191
|
-
// This requires the `SERVICE_ROLE_KEY` to be configured for the Supabase client.
|
|
192
|
-
// The standard `createClient` from `utils/supabase/server` might not use the service role by default.
|
|
193
|
-
// You might need a dedicated admin client instance.
|
|
194
|
-
// For this example, we'll assume `supabase.auth.admin.deleteUser` is available and configured.
|
|
195
|
-
// If not, this part needs adjustment to use a service_role client.
|
|
196
|
-
|
|
197
|
-
// The `createClient()` from `@supabase/ssr` for server context doesn't directly expose `auth.admin`.
|
|
198
|
-
// We need to create a standard Supabase client with the service role key.
|
|
199
|
-
const { createClient: createServiceRoleClient } = await import('@supabase/supabase-js');
|
|
200
|
-
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
201
|
-
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
202
|
-
|
|
203
|
-
if (!supabaseUrl || !serviceRoleKey) {
|
|
204
|
-
throw new Error('Missing required environment variables');
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const serviceSupabase = createServiceRoleClient(supabaseUrl, serviceRoleKey);
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const { error: deletionError } = await serviceSupabase.auth.admin.deleteUser(userIdToDelete);
|
|
211
|
-
|
|
212
|
-
if (deletionError) {
|
|
213
|
-
console.error("Error deleting user:", deletionError);
|
|
214
|
-
// If the profile was deleted by cascade but auth user deletion failed, this is an inconsistent state.
|
|
215
|
-
return { error: `Failed to delete user: ${deletionError.message}` };
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// The `profiles` table has ON DELETE CASCADE for the user ID, so it should be deleted automatically.
|
|
219
|
-
revalidatePath("/cms/users");
|
|
220
|
-
redirect("/cms/users?success=User deleted successfully");
|
|
158
|
+
|
|
159
|
+
export async function deleteUserAndProfile(userIdToDelete: string) {
|
|
160
|
+
|
|
161
|
+
// For deleting a user, we need to use the Supabase Admin API,
|
|
162
|
+
// which requires a client initialized with the SERVICE_ROLE_KEY.
|
|
163
|
+
// This ensures the operation has the necessary privileges.
|
|
164
|
+
// IMPORTANT: Ensure SUPABASE_SERVICE_ROLE_KEY is set in your .env.local and Vercel env vars.
|
|
165
|
+
const supabaseAdmin = createClient(
|
|
166
|
+
// Re-create client with service role. This is a common pattern.
|
|
167
|
+
// Ensure your createClient function can be called without args to use env vars,
|
|
168
|
+
// or pass them explicitly if needed. The one from the template should work.
|
|
169
|
+
// If your createClient is specific to user context (cookies), you might need a separate
|
|
170
|
+
// admin client factory. For now, assuming `createClient()` can make a service client
|
|
171
|
+
// if called in a server action without user cookie context, or if it defaults to service key.
|
|
172
|
+
// A safer way:
|
|
173
|
+
// const supabaseAdmin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
|
|
174
|
+
// However, the template's createClient for server components/actions should handle this by not having cookie access.
|
|
175
|
+
// Let's assume for now createClient() is sufficient if it can use service role.
|
|
176
|
+
// A more explicit way for admin actions:
|
|
177
|
+
// import { createClient as createAdminClient } from '@supabase/supabase-js';
|
|
178
|
+
// const supabaseAdmin = createAdminClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
const adminCheck = await verifyAdmin(supabaseAdmin); // Verify current user is admin
|
|
183
|
+
if (!adminCheck.isAdmin) {
|
|
184
|
+
return { error: adminCheck.error || "Unauthorized" };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (userIdToDelete === adminCheck.userId) {
|
|
188
|
+
return { error: "Admins cannot delete their own account through this panel." };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Use the Supabase Auth Admin API to delete the user
|
|
192
|
+
// This requires the `SERVICE_ROLE_KEY` to be configured for the Supabase client.
|
|
193
|
+
// The standard `createClient` from `utils/supabase/server` might not use the service role by default.
|
|
194
|
+
// You might need a dedicated admin client instance.
|
|
195
|
+
// For this example, we'll assume `supabase.auth.admin.deleteUser` is available and configured.
|
|
196
|
+
// If not, this part needs adjustment to use a service_role client.
|
|
197
|
+
|
|
198
|
+
// The `createClient()` from `@supabase/ssr` for server context doesn't directly expose `auth.admin`.
|
|
199
|
+
// We need to create a standard Supabase client with the service role key.
|
|
200
|
+
const { createClient: createServiceRoleClient } = await import('@supabase/supabase-js');
|
|
201
|
+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL;
|
|
202
|
+
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SECRET_KEY;
|
|
203
|
+
|
|
204
|
+
if (!supabaseUrl || !serviceRoleKey) {
|
|
205
|
+
throw new Error('Missing required environment variables');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const serviceSupabase = createServiceRoleClient(supabaseUrl, serviceRoleKey);
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
const { error: deletionError } = await serviceSupabase.auth.admin.deleteUser(userIdToDelete);
|
|
212
|
+
|
|
213
|
+
if (deletionError) {
|
|
214
|
+
console.error("Error deleting user:", deletionError);
|
|
215
|
+
// If the profile was deleted by cascade but auth user deletion failed, this is an inconsistent state.
|
|
216
|
+
return { error: `Failed to delete user: ${deletionError.message}` };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// The `profiles` table has ON DELETE CASCADE for the user ID, so it should be deleted automatically.
|
|
220
|
+
revalidatePath("/cms/users");
|
|
221
|
+
redirect("/cms/users?success=User deleted successfully");
|
|
221
222
|
}
|
|
@@ -37,9 +37,9 @@ import { DeleteUserButtonClient } from "./components/DeleteUserButton";
|
|
|
37
37
|
async function getUsersData(currentAdminId: string): Promise<UserWithProfile[]> {
|
|
38
38
|
// This needs to use a service role client to list all users from auth.users
|
|
39
39
|
const { createClient: createServiceRoleClient } = await import('@supabase/supabase-js');
|
|
40
|
-
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
41
|
-
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
42
|
-
|
|
40
|
+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL;
|
|
41
|
+
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SECRET_KEY;
|
|
42
|
+
|
|
43
43
|
if (!supabaseUrl || !serviceRoleKey) {
|
|
44
44
|
throw new Error('Missing required environment variables');
|
|
45
45
|
}
|
|
@@ -26,7 +26,12 @@ import { verifyPackageOnline } from '@nextblock-cms/db/server';
|
|
|
26
26
|
import { unstable_cache } from 'next/cache';
|
|
27
27
|
import { createStaticSupabaseClient, getSiteSettings } from './lib/site-settings';
|
|
28
28
|
import { DEFAULT_OG_IMAGE } from './lib/seo';
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
isSupabaseConfigured,
|
|
31
|
+
resolveSupabaseAnonKey,
|
|
32
|
+
resolveSupabaseUrl,
|
|
33
|
+
} from '../lib/setup/env-status';
|
|
34
|
+
import { resolveMediaBaseUrl } from '../lib/storage/provider';
|
|
30
35
|
|
|
31
36
|
const defaultUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:3000';
|
|
32
37
|
|
|
@@ -320,9 +325,7 @@ async function loadLayoutData() {
|
|
|
320
325
|
const globalCss = typeof globalCssResult === 'string' ? globalCssResult : '';
|
|
321
326
|
const translations = Array.isArray(translationsResult) ? translationsResult : [];
|
|
322
327
|
|
|
323
|
-
const hasSupabaseEnv =
|
|
324
|
-
process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
|
|
325
|
-
);
|
|
328
|
+
const hasSupabaseEnv = isSupabaseConfigured();
|
|
326
329
|
|
|
327
330
|
const [profile, headerNavItems, footerNavItems, logo] = await Promise.all([
|
|
328
331
|
user ? getProfileWithRoleServerSide(user.id) : Promise.resolve(null),
|
|
@@ -453,8 +456,8 @@ export default async function RootLayout({
|
|
|
453
456
|
// build-time-inlined NEXT_PUBLIC_* and these just match; it only matters in local dev,
|
|
454
457
|
// where the wizard writes those vars at runtime and the loaded bundle would otherwise
|
|
455
458
|
// hold stale empties until a dev-server restart. Read from server process.env (fresh).
|
|
456
|
-
const publicSupabaseUrl =
|
|
457
|
-
const publicSupabaseAnonKey =
|
|
459
|
+
const publicSupabaseUrl = resolveSupabaseUrl() || '';
|
|
460
|
+
const publicSupabaseAnonKey = resolveSupabaseAnonKey() || '';
|
|
458
461
|
|
|
459
462
|
return (
|
|
460
463
|
<html lang={serverDeterminedLocale} suppressHydrationWarning>
|
|
@@ -468,7 +471,7 @@ export default async function RootLayout({
|
|
|
468
471
|
<PublicEnvBootstrap
|
|
469
472
|
url={publicSupabaseUrl}
|
|
470
473
|
anonKey={publicSupabaseAnonKey}
|
|
471
|
-
r2Base={
|
|
474
|
+
r2Base={resolveMediaBaseUrl()}
|
|
472
475
|
/>
|
|
473
476
|
{/* In development this loads after hydration to avoid browser-hidden nonce comparisons. */}
|
|
474
477
|
<Script
|