feat(storage): MinIO object storage, Neon Auth, Supabase removal
Deploy to route.crispygoat.com / deploy (push) Failing after 7s
Deploy to route.crispygoat.com / deploy (push) Failing after 7s
- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject, deleteObject, presigned URL helpers, and BUCKETS constant - Wire product images, brand logos, and water log photos to MinIO via the new storage client - Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call) - Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge Function proxy) - Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass brand_settings.logo_url from all call sites - Update email templates to use dynamic logoUrl instead of hardcoded Supabase bucket URLs - Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage, TimeTrackingFieldClient; use brand_settings props + local public/ fallback - Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to public/ for local development - Add MinIO env vars to .env.example (endpoint, access key, secret, buckets) - Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props - Fix admin/users.ts logoUrl type (null → undefined for optional string) - Remove stale sb- cookie from wholesale-auth - Migrate tuxedo/about page to remove supabase import and use pool query for wholesale_settings lookup
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Create an admin user in Neon Auth via direct HTTP call.
|
||||
* Run: npx tsx scripts/create-admin-user.ts [email] [password] [name]
|
||||
*
|
||||
* Makes a direct HTTP request to the Neon Auth API so we don't need
|
||||
* a Next.js request context, then updates email_verified in the DB.
|
||||
*/
|
||||
import { config } from "dotenv";
|
||||
config({ path: ".env.local" });
|
||||
import pg from "pg";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
async function main() {
|
||||
const email = process.argv[2] ?? "admin@example.com";
|
||||
const password = process.argv[3] ?? "Admin1234!";
|
||||
const name = process.argv[4] ?? "Admin";
|
||||
|
||||
const baseUrl = process.env.NEON_AUTH_BASE_URL!;
|
||||
|
||||
console.log(`Creating user: ${email}`);
|
||||
|
||||
const res = await fetch(`${baseUrl}/sign-up/email`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Origin": "http://localhost:4000",
|
||||
"x-neon-auth-proxy": "node",
|
||||
},
|
||||
body: JSON.stringify({ email, password, name, callbackURL: "/admin" }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
console.log(`Sign-up status: ${res.status}`);
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("Failed to create user:", JSON.stringify(data));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const userId = data.user?.id;
|
||||
console.log("User created:", data.user?.email, "| ID:", userId);
|
||||
|
||||
// Mark email verified in DB so they can log in immediately
|
||||
if (userId) {
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
try {
|
||||
await pool.query(
|
||||
"UPDATE neon_auth.user SET email_verified = true WHERE id = $1",
|
||||
[userId]
|
||||
);
|
||||
console.log("Email verified in DB.");
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nLog in at http://localhost:4000/login with ${email} / ${password}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Provision an admin user by email.
|
||||
* Run: npx tsx scripts/provision-admin.ts [email] [role]
|
||||
*
|
||||
* Role options: platform_admin, brand_admin, store_employee
|
||||
*/
|
||||
import pg from "pg";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
// Load .env.local manually
|
||||
const envPath = path.join(process.cwd(), ".env.local");
|
||||
if (fs.existsSync(envPath)) {
|
||||
const envContent = fs.readFileSync(envPath, "utf-8");
|
||||
for (const line of envContent.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith("#")) {
|
||||
const [key, ...valueParts] = trimmed.split("=");
|
||||
process.env[key.trim()] = valueParts.join("=").trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
async function main() {
|
||||
const email = process.argv[2] ?? "admin@example.com";
|
||||
const role = process.argv[3] ?? "platform_admin";
|
||||
|
||||
console.log("DATABASE_URL:", process.env.DATABASE_URL ? "set" : "NOT SET");
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
try {
|
||||
// Find the Neon Auth user by email
|
||||
const userResult = await pool.query(
|
||||
"SELECT id FROM neon_auth.user WHERE email = $1",
|
||||
[email]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
console.error(`User ${email} not found in neon_auth.user`);
|
||||
console.error("Please sign up first at /login");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const userId = userResult.rows[0].id;
|
||||
console.log(`Found Neon Auth user: ${userId} (${email})`);
|
||||
|
||||
// Check if already in admin_users
|
||||
const existingAdmin = await pool.query(
|
||||
"SELECT id FROM admin_users WHERE user_id = $1",
|
||||
[userId]
|
||||
);
|
||||
|
||||
let adminUserId: string;
|
||||
|
||||
if (existingAdmin.rows.length > 0) {
|
||||
adminUserId = existingAdmin.rows[0].id;
|
||||
console.log(`User already in admin_users: ${adminUserId}`);
|
||||
} else {
|
||||
// Insert into admin_users
|
||||
const insertResult = await pool.query(
|
||||
`INSERT INTO admin_users (user_id, email, name, role,
|
||||
can_manage_orders, can_manage_products, can_manage_stops,
|
||||
can_manage_customers, can_manage_wholesale, can_manage_billing,
|
||||
can_manage_settings, can_manage_water_log, can_manage_time_tracking,
|
||||
can_manage_route_trace, can_manage_reports, can_manage_communications)
|
||||
VALUES ($1, $2, $3, $4, true, true, true, true, true, true, true, true, true, true, true, true)
|
||||
RETURNING id`,
|
||||
[userId, email, email.split("@")[0], role]
|
||||
);
|
||||
adminUserId = insertResult.rows[0].id;
|
||||
console.log(`Created admin_users record: ${adminUserId}`);
|
||||
}
|
||||
|
||||
// Get brands
|
||||
const brandsResult = await pool.query("SELECT id, name, slug FROM brands LIMIT 10");
|
||||
|
||||
if (brandsResult.rows.length === 0) {
|
||||
console.error("No brands found. Please create a brand first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("\nAvailable brands:");
|
||||
brandsResult.rows.forEach((b, i) => {
|
||||
console.log(` ${i + 1}. ${b.name} (${b.slug}) - ${b.id}`);
|
||||
});
|
||||
|
||||
// Link to first brand (or create a default one)
|
||||
const brand = brandsResult.rows[0];
|
||||
|
||||
// Check if already linked
|
||||
const existingLink = await pool.query(
|
||||
"SELECT 1 FROM admin_user_brands WHERE admin_user_id = $1 AND brand_id = $2",
|
||||
[adminUserId, brand.id]
|
||||
);
|
||||
|
||||
if (existingLink.rows.length > 0) {
|
||||
console.log(`Already linked to brand: ${brand.name}`);
|
||||
} else {
|
||||
await pool.query(
|
||||
"INSERT INTO admin_user_brands (admin_user_id, brand_id) VALUES ($1, $2)",
|
||||
[adminUserId, brand.id]
|
||||
);
|
||||
console.log(`Linked to brand: ${brand.name} (${brand.slug})`);
|
||||
}
|
||||
|
||||
console.log(`\n✅ ${email} is now provisioned as ${role}!`);
|
||||
console.log(` They can access the admin at http://localhost:4000/admin`);
|
||||
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Seed an admin user for development.
|
||||
* Run: npx tsx scripts/seed-admin.ts [email] [password] [name]
|
||||
*/
|
||||
import { config } from "dotenv";
|
||||
config({ path: ".env.local" });
|
||||
import pg from "pg";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
async function main() {
|
||||
const email = process.argv[2] ?? "admin@test.com";
|
||||
const password = process.argv[3] ?? "Admin1234!";
|
||||
const name = process.argv[4] ?? "Admin";
|
||||
|
||||
const baseUrl = process.env.NEON_AUTH_BASE_URL!;
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
try {
|
||||
// 1. Create or get brand
|
||||
const brandResult = await pool.query(`
|
||||
INSERT INTO brands (name, slug, plan_tier)
|
||||
VALUES ('Demo Brand', 'demo', 'enterprise')
|
||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id, name, slug
|
||||
`);
|
||||
const brand = brandResult.rows[0];
|
||||
console.log("✓ Brand:", brand.name, `(${brand.id})`);
|
||||
|
||||
// 2. Create user in Neon Auth via direct API
|
||||
const res = await fetch(`${baseUrl}/sign-up/email`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Origin": "http://localhost:4000",
|
||||
"x-neon-auth-proxy": "node",
|
||||
},
|
||||
body: JSON.stringify({ email, password, name, callbackURL: "/admin" }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok && data.code !== "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
|
||||
console.error("✗ Failed to create user:", data);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const userId = data.user?.id;
|
||||
console.log("✓ Neon Auth user:", email, userId ? `(${userId})` : "(already existed)");
|
||||
|
||||
// 3. Verify email in Neon Auth DB
|
||||
if (userId) {
|
||||
await pool.query(
|
||||
`UPDATE neon_auth.user SET "emailVerified" = true WHERE id = $1`,
|
||||
[userId]
|
||||
);
|
||||
console.log("✓ Email verified in DB");
|
||||
}
|
||||
|
||||
// 4. Get user ID from neon_auth.user by email
|
||||
const neonUser = await pool.query(
|
||||
`SELECT id FROM neon_auth.user WHERE email = $1`,
|
||||
[email]
|
||||
);
|
||||
const neonUserId = neonUser.rows[0]?.id;
|
||||
if (!neonUserId) {
|
||||
console.error("✗ Could not find user in neon_auth.user");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 5. Insert into admin_users (upsert)
|
||||
const adminResult = await pool.query(`
|
||||
INSERT INTO admin_users (user_id, email, name, role)
|
||||
VALUES ($1, $2, $3, 'brand_admin')
|
||||
ON CONFLICT (email) DO UPDATE SET user_id = EXCLUDED.user_id, name = EXCLUDED.name, role = EXCLUDED.role
|
||||
RETURNING id
|
||||
`, [neonUserId, email, name]);
|
||||
const adminUser = adminResult.rows[0];
|
||||
console.log("✓ Admin user:", email, `(${adminUser.id})`);
|
||||
|
||||
// 6. Link to brand
|
||||
await pool.query(`
|
||||
INSERT INTO admin_user_brands (admin_user_id, brand_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, [adminUser.id, brand.id]);
|
||||
console.log("✓ Linked to brand:", brand.name);
|
||||
|
||||
console.log(`\n🎉 All set! Log in at http://localhost:4000/login`);
|
||||
console.log(` Email: ${email}`);
|
||||
console.log(` Password: ${password}`);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,11 @@
|
||||
import { config } from "dotenv";
|
||||
config({ path: ".env.local" });
|
||||
import pg from "pg";
|
||||
const { Pool } = pg;
|
||||
async function main() {
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const r = await pool.query(`SELECT id, email, "emailVerified" FROM neon_auth.user WHERE email = 'admin@test.com'`);
|
||||
console.log(JSON.stringify(r.rows, null, 2));
|
||||
await pool.end();
|
||||
}
|
||||
main();
|
||||
Reference in New Issue
Block a user