notifkit 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/README.md +29 -0
- package/dashboard/dist/assets/index-7mvXb4bS.js +310 -0
- package/dashboard/dist/assets/index-o7L-f3-c.css +1 -0
- package/dashboard/dist/favicon.svg +4 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/index.d.mts +433 -8
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{main-D6SG3Isk.mjs → main-0FHNtN5g.mjs} +2 -2
- package/dist/{main-D6SG3Isk.mjs.map → main-0FHNtN5g.mjs.map} +1 -1
- package/dist/{main-pFAfCkVX.mjs → main-7KMlWnDx.mjs} +2 -2
- package/dist/{main-pFAfCkVX.mjs.map → main-7KMlWnDx.mjs.map} +1 -1
- package/dist/{main-Bgi2wWck.mjs → main-B0BcY_JJ.mjs} +2 -2
- package/dist/{main-Bgi2wWck.mjs.map → main-B0BcY_JJ.mjs.map} +1 -1
- package/dist/{main-CFSukWm8.mjs → main-BAO4kKce.mjs} +366 -26
- package/dist/main-BAO4kKce.mjs.map +1 -0
- package/dist/{main-CRPxM-O3.mjs → main-BjXgGJBd.mjs} +2 -2
- package/dist/{main-CRPxM-O3.mjs.map → main-BjXgGJBd.mjs.map} +1 -1
- package/dist/{main-CjbVdUqa.mjs → main-BtXITy0k.mjs} +2 -2
- package/dist/{main-CjbVdUqa.mjs.map → main-BtXITy0k.mjs.map} +1 -1
- package/dist/{main-Yorxzw0Z.mjs → main-BxqAXTr6.mjs} +2 -2
- package/dist/{main-Yorxzw0Z.mjs.map → main-BxqAXTr6.mjs.map} +1 -1
- package/dist/{main-DFMHcN_d.mjs → main-zCpvA9Rx.mjs} +2 -2
- package/dist/{main-DFMHcN_d.mjs.map → main-zCpvA9Rx.mjs.map} +1 -1
- package/dist/{src-CPMwsUCJ.mjs → src-DkvHYM3y.mjs} +76 -19
- package/dist/src-DkvHYM3y.mjs.map +1 -0
- package/drizzle/0005_admin_users.sql +11 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +8 -3
- package/scripts/create-admin.mjs +67 -0
- package/src/config/index.ts +3 -0
- package/src/db/schema.ts +13 -0
- package/src/repositories/index.ts +82 -1
- package/src/services/api/admin-static.ts +224 -0
- package/src/services/api/handlers.ts +129 -12
- package/src/services/api/main.ts +72 -19
- package/src/services/auth/index.ts +103 -0
- package/dist/main-CFSukWm8.mjs.map +0 -1
- package/dist/src-CPMwsUCJ.mjs.map +0 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "admin_users" (
|
|
2
|
+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
3
|
+
"email" varchar NOT NULL,
|
|
4
|
+
"username" varchar,
|
|
5
|
+
"password_hash" varchar NOT NULL,
|
|
6
|
+
"role" varchar DEFAULT 'admin' NOT NULL,
|
|
7
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
8
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
9
|
+
CONSTRAINT "admin_users_email_unique" UNIQUE("email"),
|
|
10
|
+
CONSTRAINT "admin_users_username_unique" UNIQUE("username")
|
|
11
|
+
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "notifkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Self-hosted notification infrastructure. One call delivers to email, SMS, push, and webhook — routed by preference, quiet hours, and consent.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "devkitshq",
|
|
@@ -48,23 +48,28 @@
|
|
|
48
48
|
"dist",
|
|
49
49
|
"src",
|
|
50
50
|
"drizzle",
|
|
51
|
+
"dashboard/dist",
|
|
51
52
|
"scripts/create-project.mjs",
|
|
53
|
+
"scripts/create-admin.mjs",
|
|
52
54
|
"README.md",
|
|
53
55
|
"LICENSE"
|
|
54
56
|
],
|
|
55
57
|
"bin": {
|
|
56
|
-
"notifkit-create-project": "./scripts/create-project.mjs"
|
|
58
|
+
"notifkit-create-project": "./scripts/create-project.mjs",
|
|
59
|
+
"notifkit-create-admin": "./scripts/create-admin.mjs"
|
|
57
60
|
},
|
|
58
61
|
"scripts": {
|
|
59
62
|
"build": "tsdown",
|
|
60
63
|
"dev": "tsdown --watch",
|
|
61
64
|
"create-project": "node scripts/create-project.mjs",
|
|
65
|
+
"create-admin": "node scripts/create-admin.mjs",
|
|
66
|
+
"build:dashboard": "npm --prefix dashboard run build",
|
|
62
67
|
"test": "vitest run --coverage",
|
|
63
68
|
"test:watch": "vitest",
|
|
64
69
|
"lint": "eslint . --fix",
|
|
65
70
|
"typecheck": "tsc --noEmit",
|
|
66
71
|
"prepare": "husky",
|
|
67
|
-
"prepublishOnly": "npm run typecheck && npm run build"
|
|
72
|
+
"prepublishOnly": "npm run typecheck && npm run build && npm run build:dashboard"
|
|
68
73
|
},
|
|
69
74
|
"dependencies": {
|
|
70
75
|
"ai": "^7.0.37",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Create or update an admin user for the Notifkit Dashboard.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=secret node scripts/create-admin.mjs
|
|
7
|
+
* node scripts/create-admin.mjs admin@example.com secret [username] [role]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { config } from "dotenv";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import postgres from "postgres";
|
|
13
|
+
import { scrypt, randomBytes } from "node:crypto";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
|
|
16
|
+
config({ path: resolve(process.cwd(), ".env") });
|
|
17
|
+
|
|
18
|
+
const scryptAsync = promisify(scrypt);
|
|
19
|
+
|
|
20
|
+
async function hashPassword(password) {
|
|
21
|
+
const salt = randomBytes(16).toString("hex");
|
|
22
|
+
const derivedKey = await scryptAsync(password, salt, 64);
|
|
23
|
+
return `${salt}:${derivedKey.toString("hex")}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const email = process.argv[2] || process.env.ADMIN_EMAIL;
|
|
27
|
+
const password = process.argv[3] || process.env.ADMIN_PASSWORD;
|
|
28
|
+
const username = process.argv[4] || process.env.ADMIN_USERNAME || null;
|
|
29
|
+
const role = process.argv[5] || "admin";
|
|
30
|
+
|
|
31
|
+
if (!email || !password) {
|
|
32
|
+
console.error("Usage: node scripts/create-admin.mjs <email> <password> [username] [role]");
|
|
33
|
+
console.error("Or set ADMIN_EMAIL and ADMIN_PASSWORD in environment.");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const databaseUrl =
|
|
38
|
+
process.env.DATABASE_URL || "postgres://platform:platform@localhost:5432/notifkit";
|
|
39
|
+
const sql = postgres(databaseUrl);
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const passwordHash = await hashPassword(password);
|
|
43
|
+
const normalizedEmail = email.trim().toLowerCase();
|
|
44
|
+
const normalizedUsername = username ? username.trim() : null;
|
|
45
|
+
|
|
46
|
+
const rows = await sql`
|
|
47
|
+
INSERT INTO admin_users (email, username, password_hash, role)
|
|
48
|
+
VALUES (${normalizedEmail}, ${normalizedUsername}, ${passwordHash}, ${role})
|
|
49
|
+
ON CONFLICT (email) DO UPDATE
|
|
50
|
+
SET password_hash = EXCLUDED.password_hash,
|
|
51
|
+
username = COALESCE(EXCLUDED.username, admin_users.username),
|
|
52
|
+
role = EXCLUDED.role,
|
|
53
|
+
updated_at = NOW()
|
|
54
|
+
RETURNING id, email, username, role, created_at, updated_at
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
console.log("\nAdmin user successfully saved:\n");
|
|
58
|
+
console.log(` ID: ${rows[0].id}`);
|
|
59
|
+
console.log(` Email: ${rows[0].email}`);
|
|
60
|
+
console.log(` Username: ${rows[0].username || "(none)"}`);
|
|
61
|
+
console.log(` Role: ${rows[0].role}\n`);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error(`Failed to create admin: ${err.message}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
} finally {
|
|
66
|
+
await sql.end();
|
|
67
|
+
}
|
package/src/config/index.ts
CHANGED
|
@@ -43,6 +43,9 @@ export const baseConfigSchema = z.object({
|
|
|
43
43
|
// Windows puts a trailing space in the variable, which otherwise turns every
|
|
44
44
|
// admin request into a 401 that reads like a wrong key.
|
|
45
45
|
ADMIN_API_KEY: z.string().trim().optional(),
|
|
46
|
+
ADMIN_EMAIL: z.string().trim().email().optional(),
|
|
47
|
+
ADMIN_PASSWORD: z.string().trim().min(6).optional(),
|
|
48
|
+
ADMIN_USERNAME: z.string().trim().optional(),
|
|
46
49
|
WORKER_CONCURRENCY: z.coerce.number().int().min(1).default(10),
|
|
47
50
|
QUEUE_MAX_LEN: z.coerce.number().int().min(1).default(10000000),
|
|
48
51
|
DB_MAX_CONNECTIONS: z.coerce.number().int().min(1).default(2),
|
package/src/db/schema.ts
CHANGED
|
@@ -367,8 +367,21 @@ export const scheduledPayloads = pgTable("scheduled_payloads", {
|
|
|
367
367
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
368
368
|
});
|
|
369
369
|
|
|
370
|
+
export const adminUsers = pgTable("admin_users", {
|
|
371
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
372
|
+
email: varchar("email").notNull().unique(),
|
|
373
|
+
username: varchar("username").unique(),
|
|
374
|
+
passwordHash: varchar("password_hash").notNull(),
|
|
375
|
+
role: varchar("role").notNull().default("admin"),
|
|
376
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
377
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
378
|
+
});
|
|
379
|
+
|
|
370
380
|
// ─── Generated Zod Schemas ──────────────────────────────────────────────────
|
|
371
381
|
|
|
382
|
+
export const insertAdminUserSchema = createInsertSchema(adminUsers);
|
|
383
|
+
export const selectAdminUserSchema = createSelectSchema(adminUsers);
|
|
384
|
+
|
|
372
385
|
export const insertProjectSchema = createInsertSchema(projects);
|
|
373
386
|
export const selectProjectSchema = createSelectSchema(projects);
|
|
374
387
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { eq, and, sql as drizzleSql, inArray, desc } from "drizzle-orm";
|
|
1
|
+
import { eq, and, or, sql as drizzleSql, inArray, desc } from "drizzle-orm";
|
|
2
2
|
import type { Db } from "@/index.js";
|
|
3
3
|
import type { Preferences, ContactChannel } from "@/contracts/index.js";
|
|
4
4
|
import {
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
projectApiKeys,
|
|
19
19
|
suppressions,
|
|
20
20
|
messageLogs,
|
|
21
|
+
adminUsers,
|
|
21
22
|
} from "@/db/schema.js";
|
|
22
23
|
|
|
23
24
|
// ─── Domain types ───────────────────────────────────────────────────────────
|
|
@@ -1244,3 +1245,83 @@ export class SegmentRepository {
|
|
|
1244
1245
|
return (rows as any[]).map((r) => r.segment);
|
|
1245
1246
|
}
|
|
1246
1247
|
}
|
|
1248
|
+
|
|
1249
|
+
// ─── AdminUserRepository ───────────────────────────────────────────────────
|
|
1250
|
+
|
|
1251
|
+
export interface AdminUserRecord {
|
|
1252
|
+
id: string;
|
|
1253
|
+
email: string;
|
|
1254
|
+
username: string | null;
|
|
1255
|
+
passwordHash: string;
|
|
1256
|
+
role: string;
|
|
1257
|
+
createdAt: Date;
|
|
1258
|
+
updatedAt: Date;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
export class AdminUserRepository {
|
|
1262
|
+
constructor(private readonly db: Db) {}
|
|
1263
|
+
|
|
1264
|
+
async findByEmailOrUsername(identifier: string): Promise<AdminUserRecord | null> {
|
|
1265
|
+
const normalized = identifier.trim().toLowerCase();
|
|
1266
|
+
const rows = await this.db
|
|
1267
|
+
.select()
|
|
1268
|
+
.from(adminUsers)
|
|
1269
|
+
.where(or(eq(adminUsers.email, normalized), eq(adminUsers.username, identifier.trim())))
|
|
1270
|
+
.limit(1);
|
|
1271
|
+
return (rows[0] as AdminUserRecord) ?? null;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
async findById(id: string): Promise<AdminUserRecord | null> {
|
|
1275
|
+
const rows = await this.db.select().from(adminUsers).where(eq(adminUsers.id, id)).limit(1);
|
|
1276
|
+
return (rows[0] as AdminUserRecord) ?? null;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
async create(data: {
|
|
1280
|
+
email: string;
|
|
1281
|
+
username?: string | null;
|
|
1282
|
+
passwordHash: string;
|
|
1283
|
+
role?: string;
|
|
1284
|
+
}): Promise<AdminUserRecord> {
|
|
1285
|
+
const rows = await this.db
|
|
1286
|
+
.insert(adminUsers)
|
|
1287
|
+
.values({
|
|
1288
|
+
email: data.email.trim().toLowerCase(),
|
|
1289
|
+
username: data.username ? data.username.trim() : null,
|
|
1290
|
+
passwordHash: data.passwordHash,
|
|
1291
|
+
role: data.role || "admin",
|
|
1292
|
+
})
|
|
1293
|
+
.returning();
|
|
1294
|
+
return rows[0] as AdminUserRecord;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
async updatePassword(id: string, passwordHash: string): Promise<boolean> {
|
|
1298
|
+
const rows = await this.db
|
|
1299
|
+
.update(adminUsers)
|
|
1300
|
+
.set({ passwordHash, updatedAt: new Date() })
|
|
1301
|
+
.where(eq(adminUsers.id, id))
|
|
1302
|
+
.returning();
|
|
1303
|
+
return rows.length > 0;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
async list(): Promise<Omit<AdminUserRecord, "passwordHash">[]> {
|
|
1307
|
+
const rows = await this.db
|
|
1308
|
+
.select({
|
|
1309
|
+
id: adminUsers.id,
|
|
1310
|
+
email: adminUsers.email,
|
|
1311
|
+
username: adminUsers.username,
|
|
1312
|
+
role: adminUsers.role,
|
|
1313
|
+
createdAt: adminUsers.createdAt,
|
|
1314
|
+
updatedAt: adminUsers.updatedAt,
|
|
1315
|
+
})
|
|
1316
|
+
.from(adminUsers)
|
|
1317
|
+
.orderBy(adminUsers.createdAt);
|
|
1318
|
+
return rows as Omit<AdminUserRecord, "passwordHash">[];
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
async count(): Promise<number> {
|
|
1322
|
+
const rows = await this.db
|
|
1323
|
+
.select({ count: drizzleSql<number>`count(*)::int` })
|
|
1324
|
+
.from(adminUsers);
|
|
1325
|
+
return rows[0]?.count ?? 0;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { resolve, extname, join } from "node:path";
|
|
3
|
+
import { existsSync, statSync, createReadStream } from "node:fs";
|
|
4
|
+
import { request as httpRequest } from "node:http";
|
|
5
|
+
|
|
6
|
+
const MIME_TYPES: Record<string, string> = {
|
|
7
|
+
".html": "text/html; charset=utf-8",
|
|
8
|
+
".js": "application/javascript; charset=utf-8",
|
|
9
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
10
|
+
".css": "text/css; charset=utf-8",
|
|
11
|
+
".json": "application/json; charset=utf-8",
|
|
12
|
+
".png": "image/png",
|
|
13
|
+
".jpg": "image/jpeg",
|
|
14
|
+
".jpeg": "image/jpeg",
|
|
15
|
+
".gif": "image/gif",
|
|
16
|
+
".svg": "image/svg+xml",
|
|
17
|
+
".ico": "image/x-icon",
|
|
18
|
+
".woff": "font/woff",
|
|
19
|
+
".woff2": "font/woff2",
|
|
20
|
+
".ttf": "font/ttf",
|
|
21
|
+
".webp": "image/webp",
|
|
22
|
+
".txt": "text/plain; charset=utf-8",
|
|
23
|
+
".map": "application/json; charset=utf-8",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Possible output directories where built dashboard static files might reside
|
|
27
|
+
const POSSIBLE_DASHBOARD_DIRS = [
|
|
28
|
+
resolve(process.cwd(), "dashboard", "dist"),
|
|
29
|
+
resolve(process.cwd(), "dashboard", "out"),
|
|
30
|
+
resolve(process.cwd(), "dist", "admin"),
|
|
31
|
+
resolve(process.cwd(), "dist", "dashboard"),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function getDashboardDir(): string | null {
|
|
35
|
+
for (const dir of POSSIBLE_DASHBOARD_DIRS) {
|
|
36
|
+
if (existsSync(dir) && existsSync(join(dir, "index.html"))) {
|
|
37
|
+
return dir;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Proxies request to frontend dev server if running.
|
|
45
|
+
*/
|
|
46
|
+
function proxyToDevServer(
|
|
47
|
+
req: IncomingMessage,
|
|
48
|
+
res: ServerResponse,
|
|
49
|
+
targetHost: string,
|
|
50
|
+
targetPort: number,
|
|
51
|
+
): Promise<boolean> {
|
|
52
|
+
return new Promise((resolvePromise) => {
|
|
53
|
+
const proxyReq = httpRequest(
|
|
54
|
+
{
|
|
55
|
+
host: targetHost,
|
|
56
|
+
port: targetPort,
|
|
57
|
+
path: req.url,
|
|
58
|
+
method: req.method,
|
|
59
|
+
headers: req.headers,
|
|
60
|
+
},
|
|
61
|
+
(proxyRes) => {
|
|
62
|
+
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
|
63
|
+
proxyRes.pipe(res);
|
|
64
|
+
resolvePromise(true);
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
proxyReq.on("error", () => {
|
|
69
|
+
resolvePromise(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (req.readable) {
|
|
73
|
+
req.pipe(proxyReq);
|
|
74
|
+
} else {
|
|
75
|
+
proxyReq.end();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Handles incoming HTTP requests for `/admin` and `/admin/*`.
|
|
82
|
+
* Serves static exported assets from Vite SPA build with index.html fallback,
|
|
83
|
+
* or proxies to Vite dev server if running in development mode.
|
|
84
|
+
*/
|
|
85
|
+
export async function handleAdminRequest(
|
|
86
|
+
req: IncomingMessage,
|
|
87
|
+
res: ServerResponse,
|
|
88
|
+
url: URL,
|
|
89
|
+
): Promise<boolean> {
|
|
90
|
+
if (url.pathname !== "/admin" && !url.pathname.startsWith("/admin/")) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Redirect /admin to /admin/
|
|
95
|
+
if (url.pathname === "/admin") {
|
|
96
|
+
res.writeHead(301, { Location: "/admin/" + (url.search || "") });
|
|
97
|
+
res.end();
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// In development, attempt to proxy to local Vite dev server on port 5173 (or VITE_DEV_URL) if active
|
|
102
|
+
if (process.env.NODE_ENV !== "production") {
|
|
103
|
+
const devProxyUrl =
|
|
104
|
+
process.env.VITE_DEV_URL || process.env.ADMIN_DEV_URL || process.env.NEXT_DEV_URL;
|
|
105
|
+
const targetPort = devProxyUrl ? parseInt(new URL(devProxyUrl).port, 10) : 5173;
|
|
106
|
+
const targetHost = devProxyUrl ? new URL(devProxyUrl).hostname : "127.0.0.1";
|
|
107
|
+
|
|
108
|
+
const proxied = await proxyToDevServer(req, res, targetHost, targetPort);
|
|
109
|
+
if (proxied) {
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const dashboardDir = getDashboardDir();
|
|
115
|
+
if (!dashboardDir) {
|
|
116
|
+
// If dashboard build not found, return friendly message
|
|
117
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
118
|
+
res.end(`
|
|
119
|
+
<!DOCTYPE html>
|
|
120
|
+
<html lang="en">
|
|
121
|
+
<head>
|
|
122
|
+
<meta charset="utf-8">
|
|
123
|
+
<title>Notifkit Admin Dashboard</title>
|
|
124
|
+
<style>
|
|
125
|
+
body { font-family: system-ui, -apple-system, sans-serif; background: #09090b; color: #f4f4f5; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
|
|
126
|
+
.card { background: #18181b; border: 1px solid #27272a; padding: 2rem; border-radius: 0.75rem; max-width: 500px; text-align: center; }
|
|
127
|
+
h1 { margin-top: 0; color: #fafafa; }
|
|
128
|
+
code { background: #27272a; padding: 0.2rem 0.4rem; border-radius: 0.25rem; font-family: monospace; font-size: 0.9em; }
|
|
129
|
+
</style>
|
|
130
|
+
</head>
|
|
131
|
+
<body>
|
|
132
|
+
<div class="card">
|
|
133
|
+
<h1>Notifkit Admin Dashboard</h1>
|
|
134
|
+
<p>The dashboard static bundle has not been built yet.</p>
|
|
135
|
+
<p>Please build it by running:</p>
|
|
136
|
+
<p><code>npm run build:dashboard</code></p>
|
|
137
|
+
<p>or run the dev server with <code>npm --prefix dashboard run dev</code>.</p>
|
|
138
|
+
</div>
|
|
139
|
+
</body>
|
|
140
|
+
</html>
|
|
141
|
+
`);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Strip /admin prefix to find relative path inside dashboardDir
|
|
146
|
+
let subPath = url.pathname.slice("/admin".length);
|
|
147
|
+
if (subPath.startsWith("/")) {
|
|
148
|
+
subPath = subPath.slice(1);
|
|
149
|
+
}
|
|
150
|
+
if (!subPath) {
|
|
151
|
+
subPath = "index.html";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const normalizedSubPath = subPath.replace(/\/$/, "");
|
|
155
|
+
let filePath = resolve(dashboardDir, subPath);
|
|
156
|
+
|
|
157
|
+
// Security check: ensure filePath is inside dashboardDir
|
|
158
|
+
if (!filePath.startsWith(dashboardDir)) {
|
|
159
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
160
|
+
res.end("Forbidden");
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Check direct file
|
|
165
|
+
let stat: ReturnType<typeof statSync> | null = null;
|
|
166
|
+
if (existsSync(filePath)) {
|
|
167
|
+
stat = statSync(filePath);
|
|
168
|
+
if (stat.isDirectory()) {
|
|
169
|
+
filePath = join(filePath, "index.html");
|
|
170
|
+
stat = existsSync(filePath) ? statSync(filePath) : null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Check path.html if direct file not found
|
|
175
|
+
if (!stat) {
|
|
176
|
+
const htmlPath = resolve(dashboardDir, `${normalizedSubPath}.html`);
|
|
177
|
+
if (existsSync(htmlPath)) {
|
|
178
|
+
filePath = htmlPath;
|
|
179
|
+
stat = statSync(filePath);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Check path/index.html
|
|
184
|
+
if (!stat) {
|
|
185
|
+
const dirIndexPath = resolve(dashboardDir, normalizedSubPath, "index.html");
|
|
186
|
+
if (existsSync(dirIndexPath)) {
|
|
187
|
+
filePath = dirIndexPath;
|
|
188
|
+
stat = statSync(filePath);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Fallback to index.html for SPA client-side routing
|
|
193
|
+
if (!stat) {
|
|
194
|
+
filePath = resolve(dashboardDir, "index.html");
|
|
195
|
+
if (existsSync(filePath)) {
|
|
196
|
+
stat = statSync(filePath);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!stat) {
|
|
201
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
202
|
+
res.end("Not Found");
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const ext = extname(filePath).toLowerCase();
|
|
207
|
+
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
208
|
+
|
|
209
|
+
// Cache static assets (_next/static) aggressively (immutable, 1 year), HTML files no-cache
|
|
210
|
+
const isImmutable = filePath.includes("_next") || ext === ".js" || ext === ".css";
|
|
211
|
+
const cacheControl = isImmutable
|
|
212
|
+
? "public, max-age=31536000, immutable"
|
|
213
|
+
: "public, max-age=0, must-revalidate";
|
|
214
|
+
|
|
215
|
+
res.writeHead(200, {
|
|
216
|
+
"Content-Type": contentType,
|
|
217
|
+
"Content-Length": stat.size,
|
|
218
|
+
"Cache-Control": cacheControl,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const stream = createReadStream(filePath);
|
|
222
|
+
stream.pipe(res);
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
@@ -10,7 +10,14 @@ import type {
|
|
|
10
10
|
ProjectRepository,
|
|
11
11
|
WorkflowRepository,
|
|
12
12
|
SegmentRepository,
|
|
13
|
+
AdminUserRepository,
|
|
13
14
|
} from "@/repositories/index.js";
|
|
15
|
+
import {
|
|
16
|
+
verifyPassword,
|
|
17
|
+
createAdminSession,
|
|
18
|
+
revokeAdminSession,
|
|
19
|
+
getAdminSession,
|
|
20
|
+
} from "@/services/auth/index.js";
|
|
14
21
|
import type { Preferences } from "@/contracts/index.js";
|
|
15
22
|
import {
|
|
16
23
|
AddUserSchema,
|
|
@@ -63,6 +70,7 @@ export interface Deps {
|
|
|
63
70
|
projectRepo: ProjectRepository;
|
|
64
71
|
workflowRepo: WorkflowRepository;
|
|
65
72
|
segmentRepo: SegmentRepository;
|
|
73
|
+
adminUserRepo?: AdminUserRepository;
|
|
66
74
|
db: Db;
|
|
67
75
|
}
|
|
68
76
|
|
|
@@ -1107,19 +1115,35 @@ export function createHandlers(deps: Deps) {
|
|
|
1107
1115
|
}
|
|
1108
1116
|
}
|
|
1109
1117
|
|
|
1110
|
-
// Scoped to the caller's project
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1118
|
+
// Scoped to the caller's project if provided, otherwise aggregate across all projects
|
|
1119
|
+
let total = 0;
|
|
1120
|
+
let delivered = 0;
|
|
1121
|
+
|
|
1122
|
+
if (ctx.projectId) {
|
|
1123
|
+
const totalTasksRes = await deps.db
|
|
1124
|
+
.select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
|
|
1125
|
+
.from(messageLogs)
|
|
1126
|
+
.where(eq(messageLogs.projectId, ctx.projectId));
|
|
1127
|
+
const deliveredTasksRes = await deps.db
|
|
1128
|
+
.select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
|
|
1129
|
+
.from(messageLogs)
|
|
1130
|
+
.where(and(eq(messageLogs.projectId, ctx.projectId), eq(messageLogs.status, "delivered")));
|
|
1131
|
+
|
|
1132
|
+
total = Number(totalTasksRes[0]?.count ?? 0);
|
|
1133
|
+
delivered = Number(deliveredTasksRes[0]?.count ?? 0);
|
|
1134
|
+
} else {
|
|
1135
|
+
const totalTasksRes = await deps.db
|
|
1136
|
+
.select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
|
|
1137
|
+
.from(messageLogs);
|
|
1138
|
+
const deliveredTasksRes = await deps.db
|
|
1139
|
+
.select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
|
|
1140
|
+
.from(messageLogs)
|
|
1141
|
+
.where(eq(messageLogs.status, "delivered"));
|
|
1142
|
+
|
|
1143
|
+
total = Number(totalTasksRes[0]?.count ?? 0);
|
|
1144
|
+
delivered = Number(deliveredTasksRes[0]?.count ?? 0);
|
|
1145
|
+
}
|
|
1120
1146
|
|
|
1121
|
-
const total = Number(totalTasksRes[0]?.count ?? 0);
|
|
1122
|
-
const delivered = Number(deliveredTasksRes[0]?.count ?? 0);
|
|
1123
1147
|
const failed = streamDepths.DEAD_LETTER || 0;
|
|
1124
1148
|
const successRate = total > 0 ? Number(((delivered / total) * 100).toFixed(2)) : 100;
|
|
1125
1149
|
|
|
@@ -1747,7 +1771,100 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
|
|
|
1747
1771
|
sendNoContent(res);
|
|
1748
1772
|
}
|
|
1749
1773
|
|
|
1774
|
+
// ── POST /v1/auth/login — login ───────────────────────────────────────────
|
|
1775
|
+
async function login(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
1776
|
+
if (!deps.adminUserRepo) {
|
|
1777
|
+
sendJson(res, 500, {
|
|
1778
|
+
error: "internal_error",
|
|
1779
|
+
message: "Admin user repository not initialized",
|
|
1780
|
+
});
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
const body = await readJsonBody(req);
|
|
1785
|
+
const parsed = z
|
|
1786
|
+
.object({
|
|
1787
|
+
identifier: z.string().min(1, "Identifier (email or username) is required"),
|
|
1788
|
+
password: z.string().min(1, "Password is required"),
|
|
1789
|
+
})
|
|
1790
|
+
.safeParse(body);
|
|
1791
|
+
|
|
1792
|
+
if (!parsed.success) {
|
|
1793
|
+
sendValidationError(res, parsed.error);
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
const { identifier, password } = parsed.data;
|
|
1798
|
+
const user = await deps.adminUserRepo.findByEmailOrUsername(identifier);
|
|
1799
|
+
if (!user) {
|
|
1800
|
+
sendJson(res, 401, { error: "unauthorized", message: "Invalid credentials" });
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
const isValid = await verifyPassword(password, user.passwordHash);
|
|
1805
|
+
if (!isValid) {
|
|
1806
|
+
sendJson(res, 401, { error: "unauthorized", message: "Invalid credentials" });
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
const session = await createAdminSession(deps.redis.native, user);
|
|
1811
|
+
sendJson(res, 200, {
|
|
1812
|
+
token: session.token,
|
|
1813
|
+
user: {
|
|
1814
|
+
id: user.id,
|
|
1815
|
+
email: user.email,
|
|
1816
|
+
username: user.username,
|
|
1817
|
+
role: user.role,
|
|
1818
|
+
},
|
|
1819
|
+
expiresAt: session.expiresAt,
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
// ── POST /v1/auth/logout — logout ─────────────────────────────────────────
|
|
1824
|
+
async function logout(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
1825
|
+
const authHeader = req.headers["authorization"];
|
|
1826
|
+
let token: string | undefined;
|
|
1827
|
+
if (typeof authHeader === "string" && authHeader.toLowerCase().startsWith("bearer ")) {
|
|
1828
|
+
token = authHeader.slice(7).trim();
|
|
1829
|
+
}
|
|
1830
|
+
if (token) {
|
|
1831
|
+
await revokeAdminSession(deps.redis.native, token);
|
|
1832
|
+
}
|
|
1833
|
+
sendJson(res, 200, { message: "Logged out successfully" });
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
// ── GET /v1/auth/me — getMe ───────────────────────────────────────────────
|
|
1837
|
+
async function getMe(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
1838
|
+
const authHeader = req.headers["authorization"];
|
|
1839
|
+
let token: string | undefined;
|
|
1840
|
+
if (typeof authHeader === "string" && authHeader.toLowerCase().startsWith("bearer ")) {
|
|
1841
|
+
token = authHeader.slice(7).trim();
|
|
1842
|
+
}
|
|
1843
|
+
if (!token) {
|
|
1844
|
+
sendJson(res, 401, { error: "unauthorized", message: "Missing token" });
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
const session = await getAdminSession(deps.redis.native, token);
|
|
1849
|
+
if (!session) {
|
|
1850
|
+
sendJson(res, 401, { error: "unauthorized", message: "Invalid or expired session" });
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
sendJson(res, 200, {
|
|
1855
|
+
user: {
|
|
1856
|
+
id: session.adminId,
|
|
1857
|
+
email: session.email,
|
|
1858
|
+
username: session.username,
|
|
1859
|
+
role: session.role,
|
|
1860
|
+
},
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1750
1864
|
return {
|
|
1865
|
+
login,
|
|
1866
|
+
logout,
|
|
1867
|
+
getMe,
|
|
1751
1868
|
syncTemplates,
|
|
1752
1869
|
addUser,
|
|
1753
1870
|
updateUser,
|