144 lines
4.2 KiB
JavaScript
144 lines
4.2 KiB
JavaScript
|
|
/**
|
||
|
|
* One-time script: migrates email/password hashes from a Clerk CSV export into
|
||
|
|
* BetterAuth's accounts table.
|
||
|
|
*
|
||
|
|
* Usage:
|
||
|
|
* DATABASE_URL=... CLERK_SECRET_KEY=... node scripts/migrate-clerk-passwords.mjs
|
||
|
|
*
|
||
|
|
* Before running:
|
||
|
|
* 1. Go to Clerk dashboard → Users → Export → Download CSV
|
||
|
|
* 2. Save the file as exported_users.csv in the project root
|
||
|
|
*
|
||
|
|
* The script is idempotent — safe to run multiple times.
|
||
|
|
* Delete exported_users.csv after confirming the migration succeeded.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { readFileSync, existsSync } from "fs";
|
||
|
|
import { resolve } from "path";
|
||
|
|
import postgres from "postgres";
|
||
|
|
|
||
|
|
const DATABASE_URL = process.env.DATABASE_DIRECT_URL || process.env.DATABASE_URL;
|
||
|
|
if (!DATABASE_URL) {
|
||
|
|
console.error("ERROR: DATABASE_URL (or DATABASE_DIRECT_URL) is required");
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
const CSV_PATH = resolve(process.cwd(), "exported_users.csv");
|
||
|
|
if (!existsSync(CSV_PATH)) {
|
||
|
|
console.error(`ERROR: exported_users.csv not found at ${CSV_PATH}`);
|
||
|
|
console.error("Export users from the Clerk dashboard (Users → Export) and save as exported_users.csv");
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
const sql = postgres(DATABASE_URL, { max: 1 });
|
||
|
|
|
||
|
|
try {
|
||
|
|
await migratePasswords();
|
||
|
|
} catch (err) {
|
||
|
|
console.error("Migration failed:", err);
|
||
|
|
await sql.end().catch(() => {});
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
await sql.end().catch(() => {});
|
||
|
|
process.exit(0);
|
||
|
|
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function parseCSV(content) {
|
||
|
|
const lines = content.split("\n").filter((line) => line.trim());
|
||
|
|
if (lines.length < 2) return [];
|
||
|
|
|
||
|
|
const headers = lines[0].split(",").map((h) => h.trim().replace(/^"|"$/g, ""));
|
||
|
|
|
||
|
|
return lines.slice(1).map((line) => {
|
||
|
|
// Handle quoted fields that may contain commas
|
||
|
|
const values = [];
|
||
|
|
let current = "";
|
||
|
|
let inQuotes = false;
|
||
|
|
for (const char of line) {
|
||
|
|
if (char === '"') {
|
||
|
|
inQuotes = !inQuotes;
|
||
|
|
} else if (char === "," && !inQuotes) {
|
||
|
|
values.push(current.trim());
|
||
|
|
current = "";
|
||
|
|
} else {
|
||
|
|
current += char;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
values.push(current.trim());
|
||
|
|
|
||
|
|
return headers.reduce((obj, header, i) => {
|
||
|
|
obj[header] = (values[i] ?? "").replace(/^"|"$/g, "");
|
||
|
|
return obj;
|
||
|
|
}, {});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function migratePasswords() {
|
||
|
|
const csvContent = readFileSync(CSV_PATH, "utf-8");
|
||
|
|
const rows = parseCSV(csvContent);
|
||
|
|
console.log(`Parsed ${rows.length} users from CSV`);
|
||
|
|
|
||
|
|
// Only process rows with a password hash
|
||
|
|
const passwordRows = rows.filter((r) => r.password_digest && r.password_hasher === "bcrypt");
|
||
|
|
console.log(`Found ${passwordRows.length} users with bcrypt password hashes`);
|
||
|
|
|
||
|
|
if (passwordRows.length === 0) {
|
||
|
|
console.log("Nothing to migrate.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let created = 0;
|
||
|
|
let skipped = 0;
|
||
|
|
let notFound = 0;
|
||
|
|
|
||
|
|
for (const row of passwordRows) {
|
||
|
|
const clerkId = row.id;
|
||
|
|
const passwordHash = row.password_digest;
|
||
|
|
|
||
|
|
// Find the user in our DB by their Clerk ID
|
||
|
|
const [dbUser] = await sql`
|
||
|
|
SELECT id FROM users WHERE clerk_id = ${clerkId} LIMIT 1
|
||
|
|
`;
|
||
|
|
|
||
|
|
if (!dbUser) {
|
||
|
|
notFound++;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if a credential account already exists for this user
|
||
|
|
const [existing] = await sql`
|
||
|
|
SELECT 1 FROM accounts
|
||
|
|
WHERE user_id = ${dbUser.id}::uuid AND provider_id = 'credential'
|
||
|
|
LIMIT 1
|
||
|
|
`;
|
||
|
|
|
||
|
|
if (existing) {
|
||
|
|
skipped++;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Insert credential account with the migrated bcrypt hash
|
||
|
|
await sql`
|
||
|
|
INSERT INTO accounts (id, account_id, provider_id, user_id, password, created_at, updated_at)
|
||
|
|
VALUES (
|
||
|
|
gen_random_uuid()::text,
|
||
|
|
${dbUser.id},
|
||
|
|
'credential',
|
||
|
|
${dbUser.id}::uuid,
|
||
|
|
${passwordHash},
|
||
|
|
now(),
|
||
|
|
now()
|
||
|
|
)
|
||
|
|
`;
|
||
|
|
created++;
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(`Done: ${created} credential accounts created, ${skipped} already existed, ${notFound} Clerk users not found in DB`);
|
||
|
|
if (notFound > 0) {
|
||
|
|
console.log(" (notFound users may have been deleted or never synced via webhook)");
|
||
|
|
}
|
||
|
|
console.log("\nIMPORTANT: Delete exported_users.csv now — it contains password hashes.");
|
||
|
|
}
|