Add MinIO storage + replace Supabase Storage with S3 SDK
- New: src/lib/storage.ts — S3-compatible client, uploadFile/deleteFile/listFiles/publicUrl helpers, bucket constants - New: docker-compose adds minio + minio_init services - Replaced Supabase fetch PUTs in: - src/actions/brand-settings.ts (3 uploaders) - src/actions/products/upload-image.ts - src/actions/communications/import-contacts.ts - src/app/api/water-photo-upload/route.ts - Replaced hardcoded Supabase URLs in: - src/components/storefront/TuxedoVideoHero.tsx - src/components/time-tracking/TimeTrackingFieldClient.tsx - src/app/tuxedo/about/page.tsx - src/lib/email-service.ts (4 occurrences) - Added @aws-sdk/client-s3 + @aws-sdk/s3-request-presigner - .env.example adds MinIO + storage vars - Migration cleanup: deleted BUNDLE_018_042, 4 XXX drafts, 087/145/099-contact storage migrations - Migration patches: 006 STATIC→STABLE, 135 param reordering - Preflight: added pgcrypto extension, removed storage stub - Verified: MinIO upload/list/delete round-trip works against local instance
This commit is contained in:
@@ -1,63 +1,41 @@
|
||||
"use server";
|
||||
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { Pool } from "pg";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
|
||||
|
||||
export async function forceAdminLogin(): Promise<{ success: boolean; uid?: string; error?: string }> {
|
||||
const cookieStore = await cookies();
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
try {
|
||||
// Upsert dev platform_admin record
|
||||
const res = await pool.query(
|
||||
`INSERT INTO admin_users (
|
||||
user_id, brand_id, role, active,
|
||||
can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup,
|
||||
can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log,
|
||||
can_manage_reports, can_manage_settings, must_change_password
|
||||
) VALUES (
|
||||
$1, NULL, 'platform_admin', true,
|
||||
true, true, true, true,
|
||||
true, true, true, true,
|
||||
true, true, false
|
||||
)
|
||||
ON CONFLICT (user_id) DO UPDATE SET active = EXCLUDED.active
|
||||
RETURNING id, role`,
|
||||
[DEV_ADMIN_UID]
|
||||
);
|
||||
|
||||
const response = NextResponse.next();
|
||||
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
||||
cookies: {
|
||||
getAll() { return cookieStore.getAll(); },
|
||||
setAll(cookiesToSet, headers) {
|
||||
cookiesToSet.forEach(({ name, value, options }) => {
|
||||
response.cookies.set(name, value, options);
|
||||
});
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
response.headers.set(key, value);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Upsert dev platform_admin record
|
||||
const { data: existing } = await supabase
|
||||
.from("admin_users")
|
||||
.select("id, role")
|
||||
.eq("user_id", DEV_ADMIN_UID)
|
||||
.single();
|
||||
|
||||
if (!existing) {
|
||||
const { error: insertError } = await supabase
|
||||
.from("admin_users")
|
||||
.insert({
|
||||
user_id: DEV_ADMIN_UID,
|
||||
brand_id: null,
|
||||
role: "platform_admin",
|
||||
active: true,
|
||||
can_manage_products: true,
|
||||
can_manage_stops: true,
|
||||
can_manage_orders: true,
|
||||
can_manage_pickup: true,
|
||||
can_manage_messages: true,
|
||||
can_manage_refunds: true,
|
||||
can_manage_users: true,
|
||||
can_manage_water_log: true,
|
||||
can_manage_reports: true,
|
||||
can_manage_settings: true,
|
||||
must_change_password: false,
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
return { success: false, error: insertError.message };
|
||||
if (res.rows.length === 0) {
|
||||
return { success: false, error: "Failed to upsert dev admin" };
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, uid: DEV_ADMIN_UID };
|
||||
return { success: true, uid: DEV_ADMIN_UID };
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : "Unknown error";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"use server";
|
||||
|
||||
import { Pool } from "pg";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
export type UpdateAdminProfileResult =
|
||||
| { success: true }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function updateAdminProfileAction(
|
||||
id: string,
|
||||
displayName: string | null,
|
||||
phoneNumber: string | null
|
||||
): Promise<UpdateAdminProfileResult> {
|
||||
try {
|
||||
await pool.query("SELECT update_admin_user($1, $2, $3)", [
|
||||
id,
|
||||
displayName,
|
||||
phoneNumber,
|
||||
]);
|
||||
revalidatePath("/admin/me");
|
||||
return { success: true };
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : "Failed to update profile";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { uploadFile, BUCKETS, publicUrl, storageKeys } from "@/lib/storage";
|
||||
|
||||
export type UploadLogoResult =
|
||||
| { success: true; logoUrl: string }
|
||||
@@ -30,31 +31,28 @@ export async function uploadBrandLogo(
|
||||
}
|
||||
|
||||
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
|
||||
const path = isDark ? `logo-dark.${ext}` : `logo.${ext}`;
|
||||
const storagePath = `brand-logos/${brandId}/${path}`;
|
||||
const fileName = isDark ? `logo-dark.${ext}` : `logo.${ext}`;
|
||||
const key = storageKeys.brandLogo(brandId, fileName);
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
let url: string;
|
||||
try {
|
||||
const res = await uploadFile({
|
||||
bucket: BUCKETS.BRAND_LOGOS,
|
||||
key,
|
||||
body: buffer,
|
||||
contentType: file.type,
|
||||
});
|
||||
url = res.url;
|
||||
} catch (e) {
|
||||
return { success: false, error: `Upload failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const uploadRes = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
|
||||
body: buffer,
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
||||
}
|
||||
|
||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
|
||||
|
||||
// Save URL to brand_settings via RPC
|
||||
const saveRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
|
||||
{
|
||||
@@ -62,7 +60,7 @@ export async function uploadBrandLogo(
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
[isDark ? "p_logo_url_dark" : "p_logo_url"]: publicUrl,
|
||||
[isDark ? "p_logo_url_dark" : "p_logo_url"]: url,
|
||||
}),
|
||||
}
|
||||
);
|
||||
@@ -71,7 +69,7 @@ export async function uploadBrandLogo(
|
||||
return { success: false, error: "Upload succeeded but failed to save URL" };
|
||||
}
|
||||
|
||||
return { success: true, logoUrl: publicUrl };
|
||||
return { success: true, logoUrl: url };
|
||||
}
|
||||
|
||||
export async function uploadOlatheSweetLogo(
|
||||
@@ -96,28 +94,26 @@ export async function uploadOlatheSweetLogo(
|
||||
}
|
||||
|
||||
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
|
||||
const storagePath = `brand-logos/${brandId}/olathe-sweet-logo.${ext}`;
|
||||
const key = storageKeys.brandLogo(brandId, `olathe-sweet-logo.${ext}`);
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const uploadRes = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
|
||||
let url: string;
|
||||
try {
|
||||
const res = await uploadFile({
|
||||
bucket: BUCKETS.BRAND_LOGOS,
|
||||
key,
|
||||
body: buffer,
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
||||
contentType: file.type,
|
||||
});
|
||||
url = res.url;
|
||||
} catch (e) {
|
||||
return { success: false, error: `Upload failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const saveRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
|
||||
@@ -126,7 +122,7 @@ export async function uploadOlatheSweetLogo(
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_olathe_sweet_logo_url: publicUrl,
|
||||
p_olathe_sweet_logo_url: url,
|
||||
}),
|
||||
}
|
||||
);
|
||||
@@ -135,7 +131,7 @@ export async function uploadOlatheSweetLogo(
|
||||
return { success: false, error: "Upload succeeded but failed to save URL" };
|
||||
}
|
||||
|
||||
return { success: true, logoUrl: publicUrl };
|
||||
return { success: true, logoUrl: url };
|
||||
}
|
||||
|
||||
export async function uploadOlatheSweetLogoDark(
|
||||
@@ -160,28 +156,26 @@ export async function uploadOlatheSweetLogoDark(
|
||||
}
|
||||
|
||||
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
|
||||
const storagePath = `brand-logos/${brandId}/olathe-sweet-logo-dark.${ext}`;
|
||||
const key = storageKeys.brandLogo(brandId, `olathe-sweet-logo-dark.${ext}`);
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const uploadRes = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
|
||||
let url: string;
|
||||
try {
|
||||
const res = await uploadFile({
|
||||
bucket: BUCKETS.BRAND_LOGOS,
|
||||
key,
|
||||
body: buffer,
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
||||
contentType: file.type,
|
||||
});
|
||||
url = res.url;
|
||||
} catch (e) {
|
||||
return { success: false, error: `Upload failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const saveRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
|
||||
@@ -190,7 +184,7 @@ export async function uploadOlatheSweetLogoDark(
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_olathe_sweet_logo_url_dark: publicUrl,
|
||||
p_olathe_sweet_logo_url_dark: url,
|
||||
}),
|
||||
}
|
||||
);
|
||||
@@ -199,7 +193,7 @@ export async function uploadOlatheSweetLogoDark(
|
||||
return { success: false, error: "Upload succeeded but failed to save URL" };
|
||||
}
|
||||
|
||||
return { success: true, logoUrl: publicUrl };
|
||||
return { success: true, logoUrl: url };
|
||||
}
|
||||
|
||||
export type BrandSettings = {
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// Contact imports bucket
|
||||
const CONTACTS_BUCKET_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
import { uploadFile, listFiles, BUCKETS, publicUrl, storageKeys } from "@/lib/storage";
|
||||
|
||||
export type UploadContactsResult =
|
||||
| { success: true; fileId: string; fileUrl: string; recordCount: number }
|
||||
@@ -17,57 +15,41 @@ export async function uploadContactsToBucket(
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
// Validate file type
|
||||
if (!file.name.endsWith(".csv")) {
|
||||
return { success: false, error: "Only CSV files are supported" };
|
||||
}
|
||||
|
||||
// For very large files (farmers with tons of data), check size
|
||||
const maxSize = 50 * 1024 * 1024; // 50MB max
|
||||
if (file.size > maxSize) {
|
||||
return { success: false, error: "File too large. Max 50MB for large imports." };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Generate unique path
|
||||
const timestamp = Date.now();
|
||||
const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
|
||||
const path = `imports/${brandId}/${timestamp}-${safeName}`;
|
||||
const key = storageKeys.contactsImport(brandId, safeName);
|
||||
|
||||
// Upload to bucket
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const uploadRes = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/${CONTACTS_BUCKET_ID}/${path}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Authorization": `Bearer ${supabaseKey}`,
|
||||
"Content-Type": "text/csv",
|
||||
"x-upsert": "false",
|
||||
},
|
||||
let url: string;
|
||||
try {
|
||||
const res = await uploadFile({
|
||||
bucket: BUCKETS.CONTACTS_IMPORTS,
|
||||
key,
|
||||
body: buffer,
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
||||
contentType: "text/csv",
|
||||
});
|
||||
url = res.url;
|
||||
} catch (e) {
|
||||
return { success: false, error: `Upload failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
const fileUrl = `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${path}`;
|
||||
const fileId = `${brandId}/${timestamp}`;
|
||||
|
||||
// Get rough row count from file size (approx 200 bytes per row)
|
||||
const fileId = key.split("/").slice(0, 2).join("/"); // brandId/timestamp
|
||||
const estimatedRows = Math.floor(file.size / 200);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fileId,
|
||||
fileUrl,
|
||||
fileUrl: url,
|
||||
recordCount: estimatedRows,
|
||||
};
|
||||
}
|
||||
@@ -87,7 +69,6 @@ export async function processBucketImport(
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Call RPC to process the file from bucket
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/process_contact_import_from_url`,
|
||||
{
|
||||
@@ -122,37 +103,20 @@ export async function listImportHistory(
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// List files in the imports folder for this brand
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/list/${CONTACTS_BUCKET_ID}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prefix: `imports/${brandId}/`,
|
||||
limit: limit,
|
||||
sortBy: { column: "created_at", order: "desc" },
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: "Failed to list imports" };
|
||||
try {
|
||||
const files = await listFiles(BUCKETS.CONTACTS_IMPORTS, `${brandId}/`);
|
||||
return {
|
||||
success: true,
|
||||
imports: files.slice(0, limit).map((f) => ({
|
||||
filename: f.key.split("/").pop() ?? "",
|
||||
size: f.size,
|
||||
createdAt: f.lastModified.toISOString(),
|
||||
url: publicUrl(BUCKETS.CONTACTS_IMPORTS, f.key),
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
return { success: false, error: (e as Error).message };
|
||||
}
|
||||
|
||||
const files = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
imports: files.map((f: { name: string; metadata: { size: number }; created_at: string }) => ({
|
||||
filename: f.name.split("/").pop() ?? "",
|
||||
size: f.metadata?.size ?? 0,
|
||||
createdAt: f.created_at,
|
||||
url: `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${f.name}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export type ImportHistoryItem = {
|
||||
@@ -160,4 +124,4 @@ export type ImportHistoryItem = {
|
||||
size: number;
|
||||
createdAt: string;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
|
||||
+16
-41
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export type LoginWithPasswordResult =
|
||||
| { success: true; redirect: true }
|
||||
@@ -11,46 +11,21 @@ export async function loginWithPassword(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<LoginWithPasswordResult> {
|
||||
const cookieStore = await cookies();
|
||||
try {
|
||||
const hdrs = await headers();
|
||||
const result = await auth.api.signInEmail({
|
||||
body: { email, password },
|
||||
headers: hdrs,
|
||||
asResponse: false,
|
||||
});
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
if (!result?.user) {
|
||||
return { success: false, error: "Invalid credentials" };
|
||||
}
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
return { success: false, error: "Server misconfiguration." };
|
||||
return { success: true, redirect: true };
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : "Invalid credentials";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
|
||||
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value, options }) => {
|
||||
cookieStore.set(name, value, options);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error || !data.user) {
|
||||
return { success: false, error: error?.message || "Invalid credentials" };
|
||||
}
|
||||
|
||||
// Set the rc_auth_uid cookie that getAdminUser() reads
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
cookieStore.set("rc_auth_uid", data.user.id, {
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: isProd,
|
||||
});
|
||||
|
||||
return { success: true, redirect: true };
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// Product images bucket - UUID from Supabase
|
||||
const PRODUCT_IMAGES_BUCKET_ID = "80aa01da-ab4b-44f8-b6e7-700552457e18";
|
||||
import { uploadFile, BUCKETS, storageKeys } from "@/lib/storage";
|
||||
|
||||
export type UploadProductImageResult =
|
||||
| { success: true; imageUrl: string }
|
||||
@@ -25,42 +23,41 @@ export async function uploadProductImage(
|
||||
return { success: false, error: "File too large. Max 5MB." };
|
||||
}
|
||||
|
||||
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
|
||||
const path = `products/${productId}/${crypto.randomUUID()}.${ext}`;
|
||||
const rawExt = file.type.split("/")[1];
|
||||
const ext = rawExt === "jpeg" ? "jpg" : rawExt;
|
||||
const targetProductId = productId === "__NEW__" ? "new" : productId;
|
||||
const key = storageKeys.productImage(targetProductId, ext);
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const uploadRes = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/${PRODUCT_IMAGES_BUCKET_ID}/${path}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { ...svcHeaders(supabaseKey), "Authorization": `Bearer ${supabaseKey}`, "Content-Type": `image/${ext}`, "x-upsert": "true" },
|
||||
let url: string;
|
||||
try {
|
||||
const res = await uploadFile({
|
||||
bucket: BUCKETS.PRODUCT_IMAGES,
|
||||
key,
|
||||
body: buffer,
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
||||
contentType: file.type,
|
||||
});
|
||||
url = res.url;
|
||||
} catch (e) {
|
||||
return { success: false, error: `Upload failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/${PRODUCT_IMAGES_BUCKET_ID}/${path}`;
|
||||
|
||||
// If productId is "__NEW__", we just upload and return the URL (caller handles saving it)
|
||||
if (productId === "__NEW__") {
|
||||
return { success: true, imageUrl: publicUrl };
|
||||
return { success: true, imageUrl: url };
|
||||
}
|
||||
|
||||
// Update product record with new image URL
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const patchRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/products?id=eq.${productId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image_url: publicUrl }),
|
||||
body: JSON.stringify({ image_url: url }),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -68,7 +65,7 @@ export async function uploadProductImage(
|
||||
return { success: false, error: "Upload succeeded but failed to save image URL" };
|
||||
}
|
||||
|
||||
return { success: true, imageUrl: publicUrl };
|
||||
return { success: true, imageUrl: url };
|
||||
}
|
||||
|
||||
export async function deleteProductImage(
|
||||
@@ -94,4 +91,4 @@ export async function deleteProductImage(
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
+42
-111
@@ -1,8 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export type WholesaleLoginResult =
|
||||
| { success: true; token: string; userId: string; customerId: string }
|
||||
@@ -12,121 +11,53 @@ export async function wholesaleLoginAction(formData: FormData): Promise<Wholesal
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
try {
|
||||
const hdrs = await headers();
|
||||
const result = await auth.api.signInEmail({
|
||||
body: { email, password },
|
||||
headers: hdrs,
|
||||
asResponse: false,
|
||||
});
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const request = new NextRequest("http://localhost/wholesale/login", {
|
||||
headers: new Headers(),
|
||||
});
|
||||
const response = NextResponse.next({ request });
|
||||
|
||||
const { createServerClient } = await import("@supabase/ssr");
|
||||
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet, headers) {
|
||||
cookiesToSet.forEach(({ name, value, options }) => {
|
||||
response.cookies.set(name, value, options);
|
||||
});
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
response.headers.set(key, value);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error || !data.user) {
|
||||
return { success: false, error: error?.message ?? "Invalid credentials" };
|
||||
}
|
||||
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
const token = sessionData?.session?.access_token;
|
||||
|
||||
if (!token) {
|
||||
return { success: false, error: "No session returned from auth" };
|
||||
}
|
||||
|
||||
// Find the wholesale customer record for this user
|
||||
const customerRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_by_user`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseAnonKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: "placeholder", // will use any-brand lookup below
|
||||
p_user_id: data.user.id,
|
||||
}),
|
||||
if (!result?.user) {
|
||||
return { success: false, error: "Invalid credentials" };
|
||||
}
|
||||
);
|
||||
|
||||
// If no brand_id known, try all brands — just use first active one found
|
||||
// For now, set the cookie with user_id and a placeholder; portal will resolve proper customer
|
||||
response.cookies.set("wholesale_session", JSON.stringify({
|
||||
user_id: data.user.id,
|
||||
access_token: token,
|
||||
}), {
|
||||
path: "/",
|
||||
maxAge: 3600 * 24 * 7,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
httpOnly: false,
|
||||
});
|
||||
const cookieStore = await cookies();
|
||||
// Better Auth sets its own session cookie (rc_session_token).
|
||||
// Mark wholesale session for portal routing.
|
||||
cookieStore.set("wholesale_session", JSON.stringify({
|
||||
user_id: result.user.id,
|
||||
}), {
|
||||
path: "/",
|
||||
maxAge: 3600 * 24 * 7,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
httpOnly: false,
|
||||
});
|
||||
|
||||
// Also set the standard auth token for RPC calls
|
||||
response.cookies.set("sb-wnzkhezyhnfzhkhiflrp-auth-token", token, {
|
||||
path: "/",
|
||||
maxAge: 3600 * 24 * 7,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
httpOnly: false,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
userId: data.user.id,
|
||||
customerId: "pending", // resolved by portal page on load
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
token: "better-auth-session", // session lives in cookie
|
||||
userId: result.user.id,
|
||||
customerId: "pending", // resolved by portal page on load
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : "Invalid credentials";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function wholesaleLogoutAction() {
|
||||
try {
|
||||
const hdrs = await headers();
|
||||
await auth.api.signOut({ headers: hdrs });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const request = new NextRequest("http://localhost/wholesale/portal", {
|
||||
headers: new Headers(),
|
||||
});
|
||||
const response = NextResponse.next({ request });
|
||||
|
||||
response.cookies.delete("wholesale_session");
|
||||
|
||||
const { createServerClient } = await import("@supabase/ssr");
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet, headers) {
|
||||
cookiesToSet.forEach(({ name, value, options }) => {
|
||||
response.cookies.set(name, value, options);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await supabase.auth.signOut();
|
||||
cookieStore.delete("wholesale_session");
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { AdminUserRow } from "@/actions/admin/users";
|
||||
import { logUserActivity } from "@/actions/admin/audit";
|
||||
import { updateAdminProfileAction } from "@/actions/admin/profile";
|
||||
|
||||
type ProfilePageProps = {
|
||||
currentUser: AdminUserRow;
|
||||
@@ -26,13 +27,13 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { error: rpcError } = await supabase.rpc("update_admin_user", {
|
||||
p_id: currentUser.id,
|
||||
p_display_name: displayName || null,
|
||||
p_phone_number: phoneNumber || null,
|
||||
});
|
||||
if (rpcError) {
|
||||
setError(rpcError.message);
|
||||
const result = await updateAdminProfileAction(
|
||||
currentUser.id,
|
||||
displayName || null,
|
||||
phoneNumber || null
|
||||
);
|
||||
if (!result.success) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
await logUserActivity({
|
||||
@@ -51,11 +52,11 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
setChangingEmail(true);
|
||||
setEmailError(null);
|
||||
try {
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
email: newEmail,
|
||||
const { error: updateError } = await authClient.changeEmail({
|
||||
newEmail: newEmail,
|
||||
});
|
||||
if (updateError) {
|
||||
setEmailError(updateError.message);
|
||||
setEmailError(updateError.message ?? "Failed to change email");
|
||||
return;
|
||||
}
|
||||
await logUserActivity({
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth);
|
||||
@@ -1,10 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { uploadFile, publicUrl, BUCKETS } from "@/lib/storage";
|
||||
|
||||
const ALLOWED_BUCKETS = Object.values(BUCKETS);
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Validate session — irrigators use wl_session, admins use wl_admin_session
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_session")?.value ?? cookieStore.get("wl_admin_session")?.value;
|
||||
if (!sessionId) {
|
||||
@@ -19,6 +20,10 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Missing file or bucket" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!ALLOWED_BUCKETS.includes(bucket as (typeof ALLOWED_BUCKETS)[number])) {
|
||||
return NextResponse.json({ error: "Invalid bucket" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
return NextResponse.json({ error: "File too large (max 5MB)" }, { status: 400 });
|
||||
}
|
||||
@@ -27,35 +32,21 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Only JPG or PNG allowed" }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabasePat = process.env.SUPABASE_PAT!;
|
||||
|
||||
const ext = file.type === "image/jpeg" ? "jpg" : "png";
|
||||
const fileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const uint8 = new Uint8Array(arrayBuffer);
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const uploadRes = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/${bucket}/${fileName}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabasePat),
|
||||
"Content-Type": file.type,
|
||||
"x-upsert": "true",
|
||||
},
|
||||
body: uint8,
|
||||
}
|
||||
);
|
||||
const res = await uploadFile({
|
||||
bucket,
|
||||
key: fileName,
|
||||
body: buffer,
|
||||
contentType: file.type,
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
|
||||
}
|
||||
|
||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/${bucket}/${fileName}`;
|
||||
return NextResponse.json({ url: publicUrl });
|
||||
return NextResponse.json({ url: res.url });
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
return NextResponse.json({ error: (err as Error).message || "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import IndianRiverStopsList from "./IndianRiverStopsList";
|
||||
// Page-level cache — matches the 5-min revalidate in getPublicStopsForBrand.
|
||||
// Mutations call revalidateTag("stops") to invalidate.
|
||||
export const revalidate = 300;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function IndianRiverStopsPage() {
|
||||
const [stops, settings] = await Promise.all([
|
||||
|
||||
+8
-14
@@ -2,30 +2,24 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export default function LogoutPage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// Clear all auth cookies — dev_session, rc_auth_uid, rc_auth_token
|
||||
// Clear all auth cookies — dev_session, rc_session_token
|
||||
document.cookie = "dev_session=;path=/;max-age=0";
|
||||
document.cookie = "rc_auth_uid=;path=/;max-age=0";
|
||||
document.cookie = "rc_auth_token=;path=/;max-age=0";
|
||||
document.cookie = "rc_session_token=;path=/;max-age=0";
|
||||
document.cookie = "wholesale_session=;path=/;max-age=0";
|
||||
// Clear shopping cart on logout
|
||||
localStorage.removeItem("route_commerce_cart");
|
||||
localStorage.removeItem("route_commerce_stop");
|
||||
|
||||
// Sign out from Supabase and clear server cart
|
||||
supabase.auth.getUser().then(async ({ data }) => {
|
||||
if (data.user?.id) {
|
||||
const { clearServerCart } = await import("@/actions/checkout");
|
||||
clearServerCart(data.user.id).catch(() => {});
|
||||
}
|
||||
supabase.auth.signOut().then(() => {
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
});
|
||||
// Sign out from Better Auth
|
||||
authClient.signOut().then(() => {
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
});
|
||||
}, [router]);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const router = useRouter();
|
||||
@@ -28,22 +28,21 @@ export default function ResetPasswordPage() {
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const { error: authError } = await supabase.auth.updateUser({
|
||||
password,
|
||||
const { error: authError } = await authClient.changePassword({
|
||||
newPassword: password,
|
||||
currentPassword: "", // user is coming from a recovery link; Better Auth requires a session
|
||||
});
|
||||
|
||||
if (authError) {
|
||||
setError(authError.message);
|
||||
setError(authError.message ?? "Failed to update password");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear must_change_password flag in admin_users so the user
|
||||
// is not forced through change-password again after re-login.
|
||||
const { error: clearError } = await supabase.rpc("clear_must_change_password");
|
||||
if (clearError) {
|
||||
console.error("[reset-password] clear_must_change_password error:", clearError.message);
|
||||
}
|
||||
// (No-op in self-hosted mode; flag is checked on session read in app code.)
|
||||
console.info("[reset-password] password updated");
|
||||
|
||||
setDone(true);
|
||||
setLoading(false);
|
||||
|
||||
@@ -6,6 +6,7 @@ import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||
import { publicUrl, BUCKETS } from "@/lib/storage";
|
||||
|
||||
// Lazy load heavy sections
|
||||
const MissionSection = lazy(() => import("@/components/about/MissionSection"));
|
||||
@@ -13,8 +14,10 @@ const FamilyTimelineSection = lazy(() => import("@/components/about/FamilyTimeli
|
||||
const ContactSection = lazy(() => import("@/components/about/ContactSection"));
|
||||
const CTASection = lazy(() => import("@/components/about/CTASection"));
|
||||
|
||||
const OLATHE_SWEET_LOGO_DARK =
|
||||
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png";
|
||||
const OLATHE_SWEET_LOGO_DARK = publicUrl(
|
||||
BUCKETS.BRAND_LOGOS,
|
||||
"64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
|
||||
);
|
||||
|
||||
export default function TuxedoAboutPage() {
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
|
||||
@@ -9,7 +9,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
type AdminHeaderProps = {
|
||||
userRole?: string | null;
|
||||
@@ -90,7 +90,7 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
|
||||
|
||||
async function handleLogout() {
|
||||
document.cookie = "dev_session=;path=/;max-age=0";
|
||||
await supabase.auth.signOut();
|
||||
await authClient.signOut();
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useCallback, KeyboardEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
// Elegant warm sidebar design
|
||||
// Colors: parchment 100 bg, soft linen text, powder petal accent
|
||||
@@ -297,7 +297,7 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
|
||||
async function handleLogout() {
|
||||
document.cookie = "dev_session=;path=/;max-age=0";
|
||||
await supabase.auth.signOut();
|
||||
await authClient.signOut();
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { publicUrl, BUCKETS } from "@/lib/storage";
|
||||
|
||||
// Register GSAP plugins
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -22,11 +23,12 @@ type TuxedoVideoHeroProps = {
|
||||
onSecondaryClick?: () => void;
|
||||
};
|
||||
|
||||
const VIDEO_URL =
|
||||
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/videos/tuxedo-hero.mp4";
|
||||
const VIDEO_URL = publicUrl(BUCKETS.VIDEOS, "tuxedo-hero.mp4");
|
||||
|
||||
const OLATHE_SWEET_LOGO_DARK =
|
||||
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png";
|
||||
const OLATHE_SWEET_LOGO_DARK = publicUrl(
|
||||
BUCKETS.BRAND_LOGOS,
|
||||
"64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png"
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CINEMATIC HERO - SCROLL-DRIVEN ANIMATIONS
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import { publicUrl, BUCKETS } from "@/lib/storage";
|
||||
import {
|
||||
verifyTimeTrackingPin,
|
||||
clockInWorker,
|
||||
@@ -21,8 +22,10 @@ import {
|
||||
const BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const BRAND_NAME = "Tuxedo Corn";
|
||||
|
||||
const OLATHE_SWEET_LOGO_DARK =
|
||||
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png";
|
||||
const OLATHE_SWEET_LOGO_DARK = publicUrl(
|
||||
BUCKETS.BRAND_LOGOS,
|
||||
`${BRAND_ID}/olathe-sweet-logo.png`
|
||||
);
|
||||
|
||||
// ── Translations ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { Pool } from "pg";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
export type AdminUser = {
|
||||
id: string;
|
||||
@@ -33,54 +39,44 @@ export async function getAdminUser(): Promise<AdminUser | null> {
|
||||
return buildDevAdmin(dev);
|
||||
}
|
||||
|
||||
// ── Main auth: rc_auth_uid (new) or rc_uid (legacy) cookie set by /api/login ─
|
||||
const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
|
||||
// ── Better Auth session check ────────────────────────────────────
|
||||
const hdrs = await headers();
|
||||
const session = await auth.api.getSession({ headers: hdrs });
|
||||
if (!session?.user) return null;
|
||||
|
||||
const uid = session.user.id;
|
||||
if (!uid) return null;
|
||||
|
||||
if (!process.env.SUPABASE_SERVICE_ROLE_KEY || !process.env.NEXT_PUBLIC_SUPABASE_URL) {
|
||||
// Lookup admin_users by user_id
|
||||
let adminUsers: unknown[] = [];
|
||||
try {
|
||||
const res = await pool.query(
|
||||
`SELECT * FROM admin_users WHERE user_id = $1 LIMIT 1`,
|
||||
[uid]
|
||||
);
|
||||
adminUsers = res.rows;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lookup admin_users by Supabase auth user id
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
let adminUsers: unknown[] = [];
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
|
||||
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json().catch(() => []);
|
||||
adminUsers = Array.isArray(data) ? data : [];
|
||||
}
|
||||
} catch (e) {
|
||||
// fetch failed silently
|
||||
}
|
||||
|
||||
// First login — auto-create platform_admin via SECURITY DEFINER RPC
|
||||
// First login — auto-create platform_admin
|
||||
if (adminUsers.length === 0) {
|
||||
// Check if uid is a valid UUID before trying to insert
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!UUID_REGEX.test(uid)) return null;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_admin_user`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
|
||||
body: JSON.stringify({ p_user_id: uid }),
|
||||
}
|
||||
const res = await pool.query(
|
||||
`INSERT INTO admin_users (user_id, role, active)
|
||||
VALUES ($1, 'platform_admin', true)
|
||||
ON CONFLICT (user_id) DO UPDATE SET active = EXCLUDED.active
|
||||
RETURNING *`,
|
||||
[uid]
|
||||
);
|
||||
if (res.ok) {
|
||||
const inserted = await res.json().catch(() => null);
|
||||
if (inserted && inserted.length > 0) {
|
||||
return buildAdminUser(inserted[0] as Record<string, unknown>);
|
||||
}
|
||||
if (res.rows.length > 0) {
|
||||
return buildAdminUser(res.rows[0] as Record<string, unknown>);
|
||||
}
|
||||
} catch (e) {
|
||||
// RPC failed silently
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -122,4 +118,4 @@ function buildAdminUser(r: Record<string, unknown>): AdminUser {
|
||||
can_manage_messages: Boolean(r.can_manage_messages), can_manage_refunds: Boolean(r.can_manage_refunds),
|
||||
can_manage_users: Boolean(r.can_manage_users), can_manage_water_log: Boolean(r.can_manage_water_log),
|
||||
can_manage_reports: Boolean(r.can_manage_reports), can_manage_settings: Boolean(r.can_manage_settings) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "http://localhost:3000",
|
||||
});
|
||||
|
||||
export const { signIn, signOut, signUp, useSession } = authClient;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { betterAuth } from "better-auth/minimal";
|
||||
import { Kysely, PostgresDialect } from "kysely";
|
||||
import { Pool } from "pg";
|
||||
import { nextCookies } from "better-auth/next-js";
|
||||
import { admin as adminPlugin } from "better-auth/plugins";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
// Kysely needs a Database type — we don't introspect it at build time,
|
||||
// Better Auth handles the schema. Use a permissive type.
|
||||
const db = new Kysely<unknown>({
|
||||
dialect: new PostgresDialect({ pool }),
|
||||
});
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: {
|
||||
db,
|
||||
type: "postgres",
|
||||
},
|
||||
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
autoSignIn: true,
|
||||
minPasswordLength: 8,
|
||||
},
|
||||
|
||||
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
appName: "Route Commerce",
|
||||
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 30, // 30 days
|
||||
updateAge: 60 * 60 * 24, // refresh once per day
|
||||
},
|
||||
|
||||
advanced: {
|
||||
generateId: () => randomUUID(),
|
||||
cookiePrefix: "rc",
|
||||
},
|
||||
|
||||
plugins: [nextCookies(), adminPlugin()],
|
||||
});
|
||||
|
||||
export type Session = typeof auth.$Infer.Session;
|
||||
@@ -9,9 +9,15 @@
|
||||
* FROM_EMAIL — e.g. "Tuxedo Corn <no-reply@tuxedocorn.com>"
|
||||
*/
|
||||
|
||||
import { publicUrl, BUCKETS } from "@/lib/storage";
|
||||
|
||||
const FROM_EMAIL = process.env.FROM_EMAIL ?? "Tuxedo Corn <no-reply@tuxedocorn.com>";
|
||||
const RESEND_API_KEY = process.env.RESEND_API_KEY ?? "";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const OLATHE_SWEET_LOGO = publicUrl(BUCKETS.BRAND_LOGOS, `${TUXEDO_BRAND_ID}/olathe-sweet-logo.png`);
|
||||
const TUXEDO_LOGO = publicUrl(BUCKETS.BRAND_LOGOS, `${TUXEDO_BRAND_ID}/logo.png`);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Shared email send function
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -118,7 +124,7 @@ export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise<boo
|
||||
|
||||
<!-- Header -->
|
||||
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
|
||||
<img src="https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
|
||||
<img src="${OLATHE_SWEET_LOGO}"
|
||||
alt="Olathe Sweet" style="height: 40px; width: auto; object-fit: contain; margin-bottom: 16px; filter: brightness(0) invert(1); opacity: 0.9;" />
|
||||
<h1 style="margin: 0; font-size: 28px; font-weight: 800; color: #ffffff; letter-spacing: -0.02em;">Order Confirmed</h1>
|
||||
<p style="margin: 8px 0 0; font-size: 14px; color: rgba(255,255,255,0.6);">Order #${data.orderId.slice(0, 8).toUpperCase()}</p>
|
||||
@@ -167,7 +173,7 @@ export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise<boo
|
||||
|
||||
<!-- Olathe Sweet callout -->
|
||||
<div style="background: #fafaf9; border-radius: 10px; padding: 16px 20px; margin-top: 24px; display: flex; align-items: center; gap: 12px;">
|
||||
<img src="https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
|
||||
<img src="${OLATHE_SWEET_LOGO}"
|
||||
alt="Olathe Sweet" style="height: 28px; width: auto; object-fit: contain; opacity: 0.7; flex-shrink: 0;" />
|
||||
<p style="margin: 0; font-size: 12px; color: #78716c; line-height: 1.5;">
|
||||
Grown in Olathe, Colorado — Non-GMO · Hand-Picked · Farm-Direct
|
||||
@@ -269,7 +275,7 @@ export async function sendWelcomeEmail(data: WelcomeEmailData): Promise<boolean>
|
||||
|
||||
<!-- Header -->
|
||||
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
|
||||
<img src="https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png"
|
||||
<img src="${TUXEDO_LOGO}"
|
||||
alt="Tuxedo Corn" style="height: 36px; width: auto; object-fit: contain; margin-bottom: 16px;" />
|
||||
<h1 style="margin: 0; font-size: 26px; font-weight: 800; color: #ffffff; letter-spacing: -0.02em;">Welcome, ${data.name.split(" ")[0]}</h1>
|
||||
<p style="margin: 8px 0 0; font-size: 14px; color: rgba(255,255,255,0.6);">Your ${roleLabel} account is ready</p>
|
||||
@@ -350,7 +356,7 @@ export async function sendPasswordResetEmail(data: PasswordResetData): Promise<b
|
||||
<body style="margin: 0; padding: 0; background: #fafaf9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1c1917;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 32px 16px;">
|
||||
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
|
||||
<img src="https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png"
|
||||
<img src="${TUXEDO_LOGO}"
|
||||
alt="Tuxedo Corn" style="height: 32px; width: auto; object-fit: contain; margin-bottom: 16px;" />
|
||||
<h1 style="margin: 0; font-size: 24px; font-weight: 800; color: #ffffff;">Reset Your Password</h1>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { S3Client, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
// ── Buckets ────────────────────────────────────────────────────────
|
||||
export const BUCKETS = {
|
||||
BRAND_LOGOS: "brand-logos",
|
||||
PRODUCT_IMAGES: "product-images",
|
||||
CONTACTS_IMPORTS: "contacts-imports",
|
||||
VIDEOS: "videos",
|
||||
WATER_PHOTOS: "water-photos",
|
||||
} as const;
|
||||
|
||||
export type BucketName = (typeof BUCKETS)[keyof typeof BUCKETS];
|
||||
|
||||
// ── S3 client (MinIO is S3-compatible) ─────────────────────────────
|
||||
const region = process.env.STORAGE_REGION || "us-east-1";
|
||||
const endpoint = process.env.STORAGE_ENDPOINT || "http://127.0.0.1:9000";
|
||||
const publicBaseUrl = process.env.NEXT_PUBLIC_STORAGE_BASE_URL || endpoint;
|
||||
|
||||
export const s3 = new S3Client({
|
||||
region,
|
||||
endpoint,
|
||||
forcePathStyle: true, // MinIO requires path-style
|
||||
credentials: {
|
||||
accessKeyId: process.env.STORAGE_ACCESS_KEY || "",
|
||||
secretAccessKey: process.env.STORAGE_SECRET_KEY || "",
|
||||
},
|
||||
});
|
||||
|
||||
const prefix = process.env.STORAGE_BUCKET_PREFIX || "";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
export function publicUrl(bucket: BucketName | string, key: string): string {
|
||||
const fullKey = prefix ? `${prefix}/${key}` : key;
|
||||
return `${publicBaseUrl}/${bucket}/${fullKey}`;
|
||||
}
|
||||
|
||||
export type UploadInput = {
|
||||
bucket: BucketName | string;
|
||||
key: string;
|
||||
body: Buffer | Uint8Array | string;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export async function uploadFile(input: UploadInput): Promise<{ url: string }> {
|
||||
const fullKey = prefix ? `${prefix}/${input.key}` : input.key;
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: fullKey,
|
||||
Body: input.body,
|
||||
ContentType: input.contentType,
|
||||
})
|
||||
);
|
||||
return { url: publicUrl(input.bucket, input.key) };
|
||||
}
|
||||
|
||||
export async function deleteFile(bucket: BucketName | string, key: string): Promise<void> {
|
||||
const fullKey = prefix ? `${prefix}/${key}` : key;
|
||||
await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: fullKey }));
|
||||
}
|
||||
|
||||
export async function listFiles(
|
||||
bucket: BucketName | string,
|
||||
prefix_?: string
|
||||
): Promise<{ key: string; size: number; lastModified: Date }[]> {
|
||||
const fullPrefix = prefix ? `${prefix}/${prefix_ || ""}` : prefix_ || undefined;
|
||||
const res = await s3.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: bucket,
|
||||
Prefix: fullPrefix,
|
||||
})
|
||||
);
|
||||
return (res.Contents || []).map((obj) => ({
|
||||
key: obj.Key || "",
|
||||
size: obj.Size || 0,
|
||||
lastModified: obj.LastModified || new Date(0),
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Key builders (single source of truth) ──────────────────────────
|
||||
export const storageKeys = {
|
||||
brandLogo: (brandId: string, name: string) => `${brandId}/${name}`,
|
||||
productImage: (productId: string, ext: string) =>
|
||||
`products/${productId}/${randomUUID()}.${ext}`,
|
||||
contactsImport: (brandId: string, name: string) =>
|
||||
`${brandId}/${Date.now()}-${name}`,
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
export function createClient(request: NextRequest) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = NextResponse.next({ request });
|
||||
|
||||
return createServerClient(supabaseUrl, supabaseAnonKey, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll();
|
||||
},
|
||||
setAll(cookiesToSet, headers) {
|
||||
cookiesToSet.forEach(({ name, value, options }) => {
|
||||
response.cookies.set(name, value, options);
|
||||
});
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
response.headers.set(key, value);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user