137 lines
5.0 KiB
JavaScript
137 lines
5.0 KiB
JavaScript
#!/usr/bin/env node
|
|
// create-admin.mjs — Bootstrap für den ERSTEN Admin einer TeamVis-Instanz.
|
|
//
|
|
// Die normale Admin-Anlage läuft über Einladungen (admin_invites), die aber
|
|
// einen bestehenden Admin voraussetzen. Bei einer frischen Kunden-Instanz
|
|
// gibt es noch keinen — dieses Script legt den ersten direkt an
|
|
// (admin_users: email + bcrypt-Hash + name + role), identisch zur App-Logik
|
|
// (activateInvite, bcrypt cost 12).
|
|
//
|
|
// Aufruf (Service-Role-Key + URL aus der Umgebung oder .env.local):
|
|
// node scripts/create-admin.mjs <email> [--name "Vorname Nachname"] [--role admin|viewer]
|
|
// # Passwort wird sicher abgefragt (oder via ENV ADMIN_PASSWORD)
|
|
//
|
|
// Beispiel mit dotenv-cli (liest .env.local):
|
|
// npx dotenv -e .env.local -- node scripts/create-admin.mjs chef@kunde.de --name "Max Chef"
|
|
|
|
import { readFileSync } from "node:fs";
|
|
import { createInterface } from "node:readline";
|
|
import { createClient } from "@supabase/supabase-js";
|
|
import bcrypt from "bcryptjs";
|
|
|
|
// ── ENV laden (process.env, sonst .env.local manuell parsen) ──────────
|
|
function env(key) {
|
|
if (process.env[key]) return process.env[key];
|
|
try {
|
|
const file = readFileSync(new URL("../.env.local", import.meta.url), "utf8");
|
|
const line = file.split("\n").find((l) => l.startsWith(key + "="));
|
|
if (line) return line.slice(key.length + 1).trim().replace(/^["']|["']$/g, "");
|
|
} catch {
|
|
/* keine .env.local — dann muss die Variable in der Umgebung stehen */
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// Achtung: NICHT `URL` als Variablennamen verwenden — das überschattet den
|
|
// globalen URL-Konstruktor, den env() oben via `new URL()` braucht.
|
|
const supabaseUrl = env("NEXT_PUBLIC_SUPABASE_URL");
|
|
const serviceKey = env("SUPABASE_SERVICE_ROLE_KEY");
|
|
if (!supabaseUrl || !serviceKey) {
|
|
console.error(
|
|
"FEHLER: NEXT_PUBLIC_SUPABASE_URL und SUPABASE_SERVICE_ROLE_KEY müssen gesetzt sein\n" +
|
|
"(per Umgebung oder .env.local). Tipp: npx dotenv -e .env.local -- node scripts/create-admin.mjs …",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// ── Argumente parsen ──────────────────────────────────────────────────
|
|
const args = process.argv.slice(2);
|
|
const email = args.find((a) => !a.startsWith("--"));
|
|
const nameIdx = args.indexOf("--name");
|
|
const name = nameIdx >= 0 ? args[nameIdx + 1] : null;
|
|
const roleIdx = args.indexOf("--role");
|
|
const role = roleIdx >= 0 ? args[roleIdx + 1] : "admin";
|
|
|
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
console.error("FEHLER: gültige E-Mail als erstes Argument angeben.");
|
|
console.error('Aufruf: node scripts/create-admin.mjs <email> [--name "…"] [--role admin|viewer]');
|
|
process.exit(1);
|
|
}
|
|
if (role !== "admin" && role !== "viewer") {
|
|
console.error("FEHLER: --role muss 'admin' oder 'viewer' sein.");
|
|
process.exit(1);
|
|
}
|
|
|
|
// ── Passwort sicher einlesen (verdeckt) oder aus ENV ──────────────────
|
|
async function readPassword() {
|
|
if (process.env.ADMIN_PASSWORD) return process.env.ADMIN_PASSWORD;
|
|
return new Promise((resolve) => {
|
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
const stdout = process.stdout;
|
|
rl.question("Passwort (min. 10 Zeichen): ", (answer) => {
|
|
rl.close();
|
|
stdout.write("\n");
|
|
resolve(answer);
|
|
});
|
|
// Eingabe verdecken
|
|
rl._writeToOutput = () => rl.output.write("*");
|
|
});
|
|
}
|
|
|
|
const supabase = createClient(supabaseUrl, serviceKey, {
|
|
auth: { persistSession: false },
|
|
});
|
|
|
|
// Duplikat-Check
|
|
const { data: existing } = await supabase
|
|
.from("admin_users")
|
|
.select("id")
|
|
.eq("email", email)
|
|
.maybeSingle();
|
|
if (existing) {
|
|
console.error(`FEHLER: Es existiert bereits ein Admin mit ${email}.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const password = await readPassword();
|
|
if (!password || password.length < 10) {
|
|
console.error("FEHLER: Passwort muss mindestens 10 Zeichen haben.");
|
|
process.exit(1);
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(password, 12);
|
|
const baseRow = {
|
|
email,
|
|
password_hash: passwordHash,
|
|
name: name?.trim() || null,
|
|
role,
|
|
};
|
|
|
|
// Temp-Passwort → beim ersten Login erzwungene Änderung (Migration 0047).
|
|
let { error } = await supabase.from("admin_users").insert({
|
|
...baseRow,
|
|
must_change_password: true,
|
|
});
|
|
|
|
// Instanz ohne Migration 0047: PostgREST kennt die Spalte nicht (PGRST204).
|
|
// Das darf den Admin-Bootstrap nicht killen — ohne Flag erneut anlegen,
|
|
// die erzwungene Passwortänderung entfällt dort schlicht.
|
|
if (
|
|
error &&
|
|
(error.code === "PGRST204" || /must_change_password/.test(error.message))
|
|
) {
|
|
console.warn(
|
|
"⚠ Migration 0047 fehlt (must_change_password) — Admin wird ohne " +
|
|
"erzwungene Passwortänderung angelegt.",
|
|
);
|
|
({ error } = await supabase.from("admin_users").insert(baseRow));
|
|
}
|
|
|
|
if (error) {
|
|
console.error("FEHLER beim Anlegen:", error.message);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`✓ Admin angelegt: ${email} (Rolle: ${role})`);
|
|
console.log(" Login unter /admin/login");
|