mirror of
https://github.com/devkitshq/notifkit.git
synced 2026-10-19 18:24:52 +02:00
feat(db): add admin_users schema and repository with create-admin script [skip ci]
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS "admin_users" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"email" varchar NOT NULL,
|
||||
"username" varchar,
|
||||
"password_hash" varchar NOT NULL,
|
||||
"role" varchar DEFAULT 'admin' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "admin_users_email_unique" UNIQUE("email"),
|
||||
CONSTRAINT "admin_users_username_unique" UNIQUE("username")
|
||||
);
|
||||
@@ -36,6 +36,13 @@
|
||||
"when": 1788780502388,
|
||||
"tag": "0004_pretty_bruce_banner",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1788781000000,
|
||||
"tag": "0005_admin_users",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Create or update an admin user for the Notifkit Dashboard.
|
||||
*
|
||||
* Usage:
|
||||
* ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=secret node scripts/create-admin.mjs
|
||||
* node scripts/create-admin.mjs admin@example.com secret [username] [role]
|
||||
*/
|
||||
|
||||
import { config } from "dotenv";
|
||||
import { resolve } from "node:path";
|
||||
import postgres from "postgres";
|
||||
import { scrypt, randomBytes } from "node:crypto";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
config({ path: resolve(process.cwd(), ".env") });
|
||||
|
||||
const scryptAsync = promisify(scrypt);
|
||||
|
||||
async function hashPassword(password) {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const derivedKey = await scryptAsync(password, salt, 64);
|
||||
return `${salt}:${derivedKey.toString("hex")}`;
|
||||
}
|
||||
|
||||
const email = process.argv[2] || process.env.ADMIN_EMAIL;
|
||||
const password = process.argv[3] || process.env.ADMIN_PASSWORD;
|
||||
const username = process.argv[4] || process.env.ADMIN_USERNAME || null;
|
||||
const role = process.argv[5] || "admin";
|
||||
|
||||
if (!email || !password) {
|
||||
console.error("Usage: node scripts/create-admin.mjs <email> <password> [username] [role]");
|
||||
console.error("Or set ADMIN_EMAIL and ADMIN_PASSWORD in environment.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const databaseUrl =
|
||||
process.env.DATABASE_URL || "postgres://platform:platform@localhost:5432/notifkit";
|
||||
const sql = postgres(databaseUrl);
|
||||
|
||||
try {
|
||||
const passwordHash = await hashPassword(password);
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const normalizedUsername = username ? username.trim() : null;
|
||||
|
||||
const rows = await sql`
|
||||
INSERT INTO admin_users (email, username, password_hash, role)
|
||||
VALUES (${normalizedEmail}, ${normalizedUsername}, ${passwordHash}, ${role})
|
||||
ON CONFLICT (email) DO UPDATE
|
||||
SET password_hash = EXCLUDED.password_hash,
|
||||
username = COALESCE(EXCLUDED.username, admin_users.username),
|
||||
role = EXCLUDED.role,
|
||||
updated_at = NOW()
|
||||
RETURNING id, email, username, role, created_at, updated_at
|
||||
`;
|
||||
|
||||
console.log("\nAdmin user successfully saved:\n");
|
||||
console.log(` ID: ${rows[0].id}`);
|
||||
console.log(` Email: ${rows[0].email}`);
|
||||
console.log(` Username: ${rows[0].username || "(none)"}`);
|
||||
console.log(` Role: ${rows[0].role}\n`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to create admin: ${err.message}`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
@@ -43,6 +43,9 @@ export const baseConfigSchema = z.object({
|
||||
// Windows puts a trailing space in the variable, which otherwise turns every
|
||||
// admin request into a 401 that reads like a wrong key.
|
||||
ADMIN_API_KEY: z.string().trim().optional(),
|
||||
ADMIN_EMAIL: z.string().trim().email().optional(),
|
||||
ADMIN_PASSWORD: z.string().trim().min(6).optional(),
|
||||
ADMIN_USERNAME: z.string().trim().optional(),
|
||||
WORKER_CONCURRENCY: z.coerce.number().int().min(1).default(10),
|
||||
QUEUE_MAX_LEN: z.coerce.number().int().min(1).default(10000000),
|
||||
DB_MAX_CONNECTIONS: z.coerce.number().int().min(1).default(2),
|
||||
|
||||
@@ -367,8 +367,21 @@ export const scheduledPayloads = pgTable("scheduled_payloads", {
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const adminUsers = pgTable("admin_users", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
email: varchar("email").notNull().unique(),
|
||||
username: varchar("username").unique(),
|
||||
passwordHash: varchar("password_hash").notNull(),
|
||||
role: varchar("role").notNull().default("admin"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// ─── Generated Zod Schemas ──────────────────────────────────────────────────
|
||||
|
||||
export const insertAdminUserSchema = createInsertSchema(adminUsers);
|
||||
export const selectAdminUserSchema = createSelectSchema(adminUsers);
|
||||
|
||||
export const insertProjectSchema = createInsertSchema(projects);
|
||||
export const selectProjectSchema = createSelectSchema(projects);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq, and, sql as drizzleSql, inArray, desc } from "drizzle-orm";
|
||||
import { eq, and, or, sql as drizzleSql, inArray, desc } from "drizzle-orm";
|
||||
import type { Db } from "@/index.js";
|
||||
import type { Preferences, ContactChannel } from "@/contracts/index.js";
|
||||
import {
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
projectApiKeys,
|
||||
suppressions,
|
||||
messageLogs,
|
||||
adminUsers,
|
||||
} from "@/db/schema.js";
|
||||
|
||||
// ─── Domain types ───────────────────────────────────────────────────────────
|
||||
@@ -1244,3 +1245,83 @@ export class SegmentRepository {
|
||||
return (rows as any[]).map((r) => r.segment);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AdminUserRepository ───────────────────────────────────────────────────
|
||||
|
||||
export interface AdminUserRecord {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string | null;
|
||||
passwordHash: string;
|
||||
role: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class AdminUserRepository {
|
||||
constructor(private readonly db: Db) {}
|
||||
|
||||
async findByEmailOrUsername(identifier: string): Promise<AdminUserRecord | null> {
|
||||
const normalized = identifier.trim().toLowerCase();
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(adminUsers)
|
||||
.where(or(eq(adminUsers.email, normalized), eq(adminUsers.username, identifier.trim())))
|
||||
.limit(1);
|
||||
return (rows[0] as AdminUserRecord) ?? null;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<AdminUserRecord | null> {
|
||||
const rows = await this.db.select().from(adminUsers).where(eq(adminUsers.id, id)).limit(1);
|
||||
return (rows[0] as AdminUserRecord) ?? null;
|
||||
}
|
||||
|
||||
async create(data: {
|
||||
email: string;
|
||||
username?: string | null;
|
||||
passwordHash: string;
|
||||
role?: string;
|
||||
}): Promise<AdminUserRecord> {
|
||||
const rows = await this.db
|
||||
.insert(adminUsers)
|
||||
.values({
|
||||
email: data.email.trim().toLowerCase(),
|
||||
username: data.username ? data.username.trim() : null,
|
||||
passwordHash: data.passwordHash,
|
||||
role: data.role || "admin",
|
||||
})
|
||||
.returning();
|
||||
return rows[0] as AdminUserRecord;
|
||||
}
|
||||
|
||||
async updatePassword(id: string, passwordHash: string): Promise<boolean> {
|
||||
const rows = await this.db
|
||||
.update(adminUsers)
|
||||
.set({ passwordHash, updatedAt: new Date() })
|
||||
.where(eq(adminUsers.id, id))
|
||||
.returning();
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async list(): Promise<Omit<AdminUserRecord, "passwordHash">[]> {
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: adminUsers.id,
|
||||
email: adminUsers.email,
|
||||
username: adminUsers.username,
|
||||
role: adminUsers.role,
|
||||
createdAt: adminUsers.createdAt,
|
||||
updatedAt: adminUsers.updatedAt,
|
||||
})
|
||||
.from(adminUsers)
|
||||
.orderBy(adminUsers.createdAt);
|
||||
return rows as Omit<AdminUserRecord, "passwordHash">[];
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
const rows = await this.db
|
||||
.select({ count: drizzleSql<number>`count(*)::int` })
|
||||
.from(adminUsers);
|
||||
return rows[0]?.count ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user