Files
2026-06-25 19:54:40 +02:00

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");