#!/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 [--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 [--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");