TeamVis Self-Host-Bundle v0.31.0
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
#!/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");
|
||||
Reference in New Issue
Block a user