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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user