fix user permissions + improve notification system
This commit is contained in:
@@ -52,6 +52,11 @@ const eventNotifOfflineInput = document.getElementById("event-notif-offline");
|
|||||||
const eventNotifPurgatoryInput = document.getElementById("event-notif-purgatory");
|
const eventNotifPurgatoryInput = document.getElementById("event-notif-purgatory");
|
||||||
const saveEventNotifsBtn = document.getElementById("save-event-notifs");
|
const saveEventNotifsBtn = document.getElementById("save-event-notifs");
|
||||||
|
|
||||||
|
const clientEventWebhookInput = document.getElementById("client-event-webhook");
|
||||||
|
const clientEventTelegramInput = document.getElementById("client-event-telegram");
|
||||||
|
const clientEventPushInput = document.getElementById("client-event-push");
|
||||||
|
const saveEventChannelsBtn = document.getElementById("save-event-channels");
|
||||||
|
|
||||||
// Browser permission status bar
|
// Browser permission status bar
|
||||||
const desktopNotifStatusBar = document.getElementById("desktop-notif-status-bar");
|
const desktopNotifStatusBar = document.getElementById("desktop-notif-status-bar");
|
||||||
const desktopNotifStatusIcon = document.getElementById("desktop-notif-status-icon");
|
const desktopNotifStatusIcon = document.getElementById("desktop-notif-status-icon");
|
||||||
@@ -354,6 +359,9 @@ function applyMySettings(settings) {
|
|||||||
if (telegramBotTokenInput) telegramBotTokenInput.value = settings.telegram_bot_token || "";
|
if (telegramBotTokenInput) telegramBotTokenInput.value = settings.telegram_bot_token || "";
|
||||||
if (telegramChatIdInput) telegramChatIdInput.value = settings.telegram_chat_id || "";
|
if (telegramChatIdInput) telegramChatIdInput.value = settings.telegram_chat_id || "";
|
||||||
if (telegramTemplateInput) telegramTemplateInput.value = settings.telegram_template || "";
|
if (telegramTemplateInput) telegramTemplateInput.value = settings.telegram_template || "";
|
||||||
|
if (clientEventWebhookInput) clientEventWebhookInput.checked = settings.client_event_webhook !== 0;
|
||||||
|
if (clientEventTelegramInput) clientEventTelegramInput.checked = settings.client_event_telegram !== 0;
|
||||||
|
if (clientEventPushInput) clientEventPushInput.checked = settings.client_event_push !== 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMySettings() {
|
async function loadMySettings() {
|
||||||
@@ -676,6 +684,22 @@ function wireEventNotifSave() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wireEventChannelsSave() {
|
||||||
|
if (!saveEventChannelsBtn) return;
|
||||||
|
saveEventChannelsBtn.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await saveMySettings({
|
||||||
|
client_event_webhook: clientEventWebhookInput?.checked ?? true,
|
||||||
|
client_event_telegram: clientEventTelegramInput?.checked ?? true,
|
||||||
|
client_event_push: clientEventPushInput?.checked ?? true,
|
||||||
|
});
|
||||||
|
window.showToast?.("Event delivery channel settings saved", "success", 3000);
|
||||||
|
} catch (err) {
|
||||||
|
window.showToast?.(err?.message || "Failed to save event channel settings", "error", 4000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Bootstrap ─────────────────────────────────────────────────────────────────
|
// ── Bootstrap ─────────────────────────────────────────────────────────────────
|
||||||
initWebhookTemplateEditor();
|
initWebhookTemplateEditor();
|
||||||
wireKeywordSave();
|
wireKeywordSave();
|
||||||
@@ -684,6 +708,7 @@ wireTelegramSave();
|
|||||||
loadMySettings();
|
loadMySettings();
|
||||||
loadEventNotifPrefs();
|
loadEventNotifPrefs();
|
||||||
wireEventNotifSave();
|
wireEventNotifSave();
|
||||||
|
wireEventChannelsSave();
|
||||||
updateDesktopPermissionUi();
|
updateDesktopPermissionUi();
|
||||||
wireDesktopPermissionBtn();
|
wireDesktopPermissionBtn();
|
||||||
initRoleUi();
|
initRoleUi();
|
||||||
|
|||||||
@@ -313,6 +313,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Client Event Delivery Channels -->
|
||||||
|
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-4">
|
||||||
|
<div class="flex items-center justify-between flex-wrap gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Client Event Delivery Channels</h2>
|
||||||
|
<p class="text-sm text-slate-400">
|
||||||
|
Choose which channels receive client lifecycle events (online, offline, purgatory).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="save-event-channels"
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-save"></i>
|
||||||
|
<span>Save</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
||||||
|
<label class="flex items-center gap-3 rounded-lg bg-slate-950 border border-slate-800 px-4 py-3 cursor-pointer hover:border-slate-600 transition-colors">
|
||||||
|
<input id="client-event-webhook" type="checkbox" class="h-4 w-4 accent-indigo-500 shrink-0" />
|
||||||
|
<span class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-slate-200 font-medium"><i class="fa-brands fa-discord mr-1"></i> Discord Webhook</span>
|
||||||
|
<span class="text-slate-500 text-xs">Send client events to your webhook</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-3 rounded-lg bg-slate-950 border border-slate-800 px-4 py-3 cursor-pointer hover:border-slate-600 transition-colors">
|
||||||
|
<input id="client-event-telegram" type="checkbox" class="h-4 w-4 accent-sky-500 shrink-0" />
|
||||||
|
<span class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-slate-200 font-medium"><i class="fa-brands fa-telegram mr-1"></i> Telegram Bot</span>
|
||||||
|
<span class="text-slate-500 text-xs">Send client events to your Telegram</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-3 rounded-lg bg-slate-950 border border-slate-800 px-4 py-3 cursor-pointer hover:border-slate-600 transition-colors">
|
||||||
|
<input id="client-event-push" type="checkbox" class="h-4 w-4 accent-emerald-500 shrink-0" />
|
||||||
|
<span class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-slate-200 font-medium"><i class="fa-solid fa-bell mr-1"></i> Push Notifications</span>
|
||||||
|
<span class="text-slate-500 text-xs">Send client events via browser push</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Notification feed -->
|
<!-- Notification feed -->
|
||||||
<div
|
<div
|
||||||
id="notification-panel"
|
id="notification-panel"
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ export interface AuditLogFilters {
|
|||||||
startDate?: number;
|
startDate?: number;
|
||||||
endDate?: number;
|
endDate?: number;
|
||||||
successOnly?: boolean;
|
successOnly?: boolean;
|
||||||
|
allowedClientIds?: string[];
|
||||||
|
deniedClientIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logAudit(entry: AuditLogEntry): void {
|
export function logAudit(entry: AuditLogEntry): void {
|
||||||
@@ -216,6 +218,19 @@ export function getAuditLogs(filters: AuditLogFilters = {}): {
|
|||||||
where.push("success = 1");
|
where.push("success = 1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filters.allowedClientIds) {
|
||||||
|
if (filters.allowedClientIds.length === 0) {
|
||||||
|
return { logs: [], total: 0, page, pageSize };
|
||||||
|
}
|
||||||
|
const placeholders = filters.allowedClientIds.map(() => "?").join(",");
|
||||||
|
where.push(`(target_client_id IN (${placeholders}) OR target_client_id IS NULL)`);
|
||||||
|
params.push(...filters.allowedClientIds);
|
||||||
|
} else if (filters.deniedClientIds && filters.deniedClientIds.length > 0) {
|
||||||
|
const placeholders = filters.deniedClientIds.map(() => "?").join(",");
|
||||||
|
where.push(`(target_client_id NOT IN (${placeholders}) OR target_client_id IS NULL)`);
|
||||||
|
params.push(...filters.deniedClientIds);
|
||||||
|
}
|
||||||
|
|
||||||
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
||||||
|
|
||||||
const countQuery = `SELECT COUNT(*) as total FROM audit_logs ${whereSql}`;
|
const countQuery = `SELECT COUNT(*) as total FROM audit_logs ${whereSql}`;
|
||||||
|
|||||||
+44
-12
@@ -1215,16 +1215,31 @@ export function lookupClientByPublicKey(
|
|||||||
return { id: row.id, enrollmentStatus: row.enrollment_status };
|
return { id: row.id, enrollmentStatus: row.enrollment_status };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEnrollmentStats(): {
|
export function getEnrollmentStats(opts?: {
|
||||||
|
allowedClientIds?: string[];
|
||||||
|
deniedClientIds?: string[];
|
||||||
|
}): {
|
||||||
pending: number;
|
pending: number;
|
||||||
approved: number;
|
approved: number;
|
||||||
denied: number;
|
denied: number;
|
||||||
} {
|
} {
|
||||||
const rows = db
|
let sql = `SELECT COALESCE(enrollment_status,'pending') as status, COUNT(*) as c FROM clients`;
|
||||||
.query<{ status: string; c: number }>(
|
const params: any[] = [];
|
||||||
`SELECT COALESCE(enrollment_status,'pending') as status, COUNT(*) as c FROM clients GROUP BY enrollment_status`,
|
|
||||||
)
|
if (opts?.allowedClientIds) {
|
||||||
.all();
|
if (opts.allowedClientIds.length === 0) return { pending: 0, approved: 0, denied: 0 };
|
||||||
|
const placeholders = opts.allowedClientIds.map(() => "?").join(",");
|
||||||
|
sql += ` WHERE id IN (${placeholders})`;
|
||||||
|
params.push(...opts.allowedClientIds);
|
||||||
|
} else if (opts?.deniedClientIds && opts.deniedClientIds.length > 0) {
|
||||||
|
const placeholders = opts.deniedClientIds.map(() => "?").join(",");
|
||||||
|
sql += ` WHERE id NOT IN (${placeholders})`;
|
||||||
|
params.push(...opts.deniedClientIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` GROUP BY enrollment_status`;
|
||||||
|
|
||||||
|
const rows = db.query<{ status: string; c: number }>(sql).all(...params);
|
||||||
const stats = { pending: 0, approved: 0, denied: 0 };
|
const stats = { pending: 0, approved: 0, denied: 0 };
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (r.status === "approved") stats.approved = Number(r.c);
|
if (r.status === "approved") stats.approved = Number(r.c);
|
||||||
@@ -1287,7 +1302,10 @@ export function getAllPushSubscriptions(): PushSubscriptionRecord[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPendingClients(): {
|
export function getPendingClients(opts?: {
|
||||||
|
allowedClientIds?: string[];
|
||||||
|
deniedClientIds?: string[];
|
||||||
|
}): {
|
||||||
id: string;
|
id: string;
|
||||||
host: string | null;
|
host: string | null;
|
||||||
os: string | null;
|
os: string | null;
|
||||||
@@ -1298,12 +1316,26 @@ export function getPendingClients(): {
|
|||||||
keyFingerprint: string | null;
|
keyFingerprint: string | null;
|
||||||
lastSeen: number;
|
lastSeen: number;
|
||||||
}[] {
|
}[] {
|
||||||
|
let sql = `SELECT id, host, os, user, ip, country, public_key as publicKey, key_fingerprint as keyFingerprint, last_seen as lastSeen
|
||||||
|
FROM clients WHERE (enrollment_status='pending' OR enrollment_status IS NULL)`;
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (opts?.allowedClientIds) {
|
||||||
|
if (opts.allowedClientIds.length === 0) return [];
|
||||||
|
const placeholders = opts.allowedClientIds.map(() => "?").join(",");
|
||||||
|
sql += ` AND id IN (${placeholders})`;
|
||||||
|
params.push(...opts.allowedClientIds);
|
||||||
|
} else if (opts?.deniedClientIds && opts.deniedClientIds.length > 0) {
|
||||||
|
const placeholders = opts.deniedClientIds.map(() => "?").join(",");
|
||||||
|
sql += ` AND id NOT IN (${placeholders})`;
|
||||||
|
params.push(...opts.deniedClientIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` ORDER BY last_seen DESC`;
|
||||||
|
|
||||||
return db
|
return db
|
||||||
.query<any>(
|
.query<any>(sql)
|
||||||
`SELECT id, host, os, user, ip, country, public_key as publicKey, key_fingerprint as keyFingerprint, last_seen as lastSeen
|
.all(...params)
|
||||||
FROM clients WHERE enrollment_status='pending' OR enrollment_status IS NULL ORDER BY last_seen DESC`,
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
.map((r: any) => ({
|
.map((r: any) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
host: r.host,
|
host: r.host,
|
||||||
|
|||||||
@@ -264,6 +264,9 @@ const deliverNotificationWithScreenshotForRecord = (record: NotificationRecord)
|
|||||||
telegramBotToken: u.telegram_bot_token || "",
|
telegramBotToken: u.telegram_bot_token || "",
|
||||||
telegramChatId: u.telegram_chat_id || "",
|
telegramChatId: u.telegram_chat_id || "",
|
||||||
telegramTemplate: u.telegram_template,
|
telegramTemplate: u.telegram_template,
|
||||||
|
clientEventWebhook: u.client_event_webhook === 1,
|
||||||
|
clientEventTelegram: u.client_event_telegram === 1,
|
||||||
|
clientEventPush: u.client_event_push === 1,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
return deliverNotificationWithScreenshot(record, getUserDeliveryTargets);
|
return deliverNotificationWithScreenshot(record, getUserDeliveryTargets);
|
||||||
@@ -293,6 +296,9 @@ const notificationPluginHandlers = createNotificationPluginHandlers({
|
|||||||
telegramBotToken: u.telegram_bot_token || "",
|
telegramBotToken: u.telegram_bot_token || "",
|
||||||
telegramChatId: u.telegram_chat_id || "",
|
telegramChatId: u.telegram_chat_id || "",
|
||||||
telegramTemplate: u.telegram_template,
|
telegramTemplate: u.telegram_template,
|
||||||
|
clientEventWebhook: u.client_event_webhook === 1,
|
||||||
|
clientEventTelegram: u.client_event_telegram === 1,
|
||||||
|
clientEventPush: u.client_event_push === 1,
|
||||||
});
|
});
|
||||||
if (event === "client_purgatory") {
|
if (event === "client_purgatory") {
|
||||||
return getUsersForNotificationDelivery()
|
return getUsersForNotificationDelivery()
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ describe("checkPermission", () => {
|
|||||||
expect(checkPermission(makeUser({ role: "admin" }), "audit:view")).toBe(true);
|
expect(checkPermission(makeUser({ role: "admin" }), "audit:view")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("operator does not have audit:view", () => {
|
test("operator has audit:view", () => {
|
||||||
expect(checkPermission(makeUser({ role: "operator" }), "audit:view")).toBe(false);
|
expect(checkPermission(makeUser({ role: "operator" }), "audit:view")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ export type UserDeliveryTarget = {
|
|||||||
telegramBotToken: string;
|
telegramBotToken: string;
|
||||||
telegramChatId: string;
|
telegramChatId: string;
|
||||||
telegramTemplate: string | null;
|
telegramTemplate: string | null;
|
||||||
|
clientEventWebhook: boolean;
|
||||||
|
clientEventTelegram: boolean;
|
||||||
|
clientEventPush: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_WEBHOOK_TEMPLATE =
|
export const DEFAULT_WEBHOOK_TEMPLATE =
|
||||||
@@ -324,6 +327,7 @@ export async function deliverWebPushClientEvent(
|
|||||||
info: { id: string; host?: string; user?: string; os?: string },
|
info: { id: string; host?: string; user?: string; os?: string },
|
||||||
canUserAccessClient: (userId: number, userRole: string, clientId: string) => boolean,
|
canUserAccessClient: (userId: number, userRole: string, clientId: string) => boolean,
|
||||||
getUserRole: (userId: number) => string | undefined,
|
getUserRole: (userId: number) => string | undefined,
|
||||||
|
isClientEventPushEnabled?: (userId: number) => boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const subs = getAllPushSubscriptions();
|
const subs = getAllPushSubscriptions();
|
||||||
if (subs.length === 0) return;
|
if (subs.length === 0) return;
|
||||||
@@ -356,6 +360,7 @@ export async function deliverWebPushClientEvent(
|
|||||||
subs.map(async (sub) => {
|
subs.map(async (sub) => {
|
||||||
const role = getUserRole(sub.userId);
|
const role = getUserRole(sub.userId);
|
||||||
if (!role) return;
|
if (!role) return;
|
||||||
|
if (isClientEventPushEnabled && !isClientEventPushEnabled(sub.userId)) return;
|
||||||
if (event === "client_purgatory") {
|
if (event === "client_purgatory") {
|
||||||
if (role !== "admin" && role !== "operator") return;
|
if (role !== "admin" && role !== "operator") return;
|
||||||
} else if (role !== "admin") {
|
} else if (role !== "admin") {
|
||||||
@@ -418,7 +423,7 @@ export async function deliverClientEventToExternalChannels(
|
|||||||
|
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
targets.map(async (target) => {
|
targets.map(async (target) => {
|
||||||
if (target.webhookEnabled && target.webhookUrl) {
|
if (target.webhookEnabled && target.clientEventWebhook && target.webhookUrl) {
|
||||||
const url = target.webhookUrl.trim();
|
const url = target.webhookUrl.trim();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
let parsed: URL;
|
let parsed: URL;
|
||||||
@@ -474,7 +479,7 @@ export async function deliverClientEventToExternalChannels(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target.telegramEnabled && target.telegramBotToken && target.telegramChatId) {
|
if (target.telegramEnabled && target.clientEventTelegram && target.telegramBotToken && target.telegramChatId) {
|
||||||
const token = target.telegramBotToken.trim();
|
const token = target.telegramBotToken.trim();
|
||||||
const chatId = target.telegramChatId.trim();
|
const chatId = target.telegramChatId.trim();
|
||||||
if (!token || !chatId) return;
|
if (!token || !chatId) return;
|
||||||
|
|||||||
@@ -14,6 +14,25 @@ import {
|
|||||||
import { logAudit, AuditAction } from "../../auditLog";
|
import { logAudit, AuditAction } from "../../auditLog";
|
||||||
import * as clientManager from "../../clientManager";
|
import * as clientManager from "../../clientManager";
|
||||||
import { setOnlineState } from "../../db";
|
import { setOnlineState } from "../../db";
|
||||||
|
import {
|
||||||
|
canUserAccessClient,
|
||||||
|
getUserClientAccessScope,
|
||||||
|
listUserClientRuleIdsByAccess,
|
||||||
|
} from "../../users";
|
||||||
|
|
||||||
|
function getClientScopeFilters(userId: number, role: string): { allowedClientIds?: string[]; deniedClientIds?: string[] } {
|
||||||
|
if (role === "admin") return {};
|
||||||
|
const scope = getUserClientAccessScope(userId);
|
||||||
|
if (scope === "none") return { allowedClientIds: [] };
|
||||||
|
if (scope === "all") return {};
|
||||||
|
if (scope === "allowlist") {
|
||||||
|
return { allowedClientIds: listUserClientRuleIdsByAccess(userId, "allow") };
|
||||||
|
}
|
||||||
|
if (scope === "denylist") {
|
||||||
|
return { deniedClientIds: listUserClientRuleIdsByAccess(userId, "deny") };
|
||||||
|
}
|
||||||
|
return { allowedClientIds: [] };
|
||||||
|
}
|
||||||
|
|
||||||
let _postApproveHook: ((clientId: string) => void) | undefined;
|
let _postApproveHook: ((clientId: string) => void) | undefined;
|
||||||
|
|
||||||
@@ -31,7 +50,8 @@ export async function handleEnrollmentRoutes(
|
|||||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||||
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
|
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
|
||||||
|
|
||||||
const clients = getPendingClients();
|
const scopeFilters = getClientScopeFilters(user.userId, user.role);
|
||||||
|
const clients = getPendingClients(scopeFilters);
|
||||||
return Response.json({ items: clients });
|
return Response.json({ items: clients });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +60,8 @@ export async function handleEnrollmentRoutes(
|
|||||||
const user = await authenticateRequest(req);
|
const user = await authenticateRequest(req);
|
||||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||||
|
|
||||||
const stats = getEnrollmentStats();
|
const scopeFilters = getClientScopeFilters(user.userId, user.role);
|
||||||
|
const stats = getEnrollmentStats(scopeFilters);
|
||||||
return Response.json(stats);
|
return Response.json(stats);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +87,10 @@ export async function handleEnrollmentRoutes(
|
|||||||
const current = getClientEnrollmentStatus(clientId);
|
const current = getClientEnrollmentStatus(clientId);
|
||||||
if (!current) return Response.json({ error: "Client not found" }, { status: 404 });
|
if (!current) return Response.json({ error: "Client not found" }, { status: 404 });
|
||||||
|
|
||||||
|
if (!canUserAccessClient(user.userId, user.role, clientId)) {
|
||||||
|
return new Response("Forbidden: Client access denied", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
setClientEnrollmentStatus(clientId, "approved", user.username);
|
setClientEnrollmentStatus(clientId, "approved", user.username);
|
||||||
|
|
||||||
_postApproveHook?.(clientId);
|
_postApproveHook?.(clientId);
|
||||||
@@ -94,6 +119,10 @@ export async function handleEnrollmentRoutes(
|
|||||||
const current = getClientEnrollmentStatus(clientId);
|
const current = getClientEnrollmentStatus(clientId);
|
||||||
if (!current) return Response.json({ error: "Client not found" }, { status: 404 });
|
if (!current) return Response.json({ error: "Client not found" }, { status: 404 });
|
||||||
|
|
||||||
|
if (!canUserAccessClient(user.userId, user.role, clientId)) {
|
||||||
|
return new Response("Forbidden: Client access denied", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
setClientEnrollmentStatus(clientId, "denied");
|
setClientEnrollmentStatus(clientId, "denied");
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
@@ -120,6 +149,10 @@ export async function handleEnrollmentRoutes(
|
|||||||
const current = getClientEnrollmentStatus(clientId);
|
const current = getClientEnrollmentStatus(clientId);
|
||||||
if (!current) return Response.json({ error: "Client not found" }, { status: 404 });
|
if (!current) return Response.json({ error: "Client not found" }, { status: 404 });
|
||||||
|
|
||||||
|
if (!canUserAccessClient(user.userId, user.role, clientId)) {
|
||||||
|
return new Response("Forbidden: Client access denied", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
setClientEnrollmentStatus(clientId, "pending");
|
setClientEnrollmentStatus(clientId, "pending");
|
||||||
|
|
||||||
return Response.json({ ok: true, status: "pending" });
|
return Response.json({ ok: true, status: "pending" });
|
||||||
@@ -151,6 +184,7 @@ export async function handleEnrollmentRoutes(
|
|||||||
if (action === "ban-ip") {
|
if (action === "ban-ip") {
|
||||||
let banned = 0;
|
let banned = 0;
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
|
if (!canUserAccessClient(user.userId, user.role, id)) continue;
|
||||||
const clientIp = getClientIp(id);
|
const clientIp = getClientIp(id);
|
||||||
if (!clientIp) continue;
|
if (!clientIp) continue;
|
||||||
banIp(clientIp, `Bulk banned from purgatory by ${user.username}`);
|
banIp(clientIp, `Bulk banned from purgatory by ${user.username}`);
|
||||||
@@ -178,6 +212,7 @@ export async function handleEnrollmentRoutes(
|
|||||||
const status = action === "approve" ? "approved" : action === "deny" ? "denied" : "pending";
|
const status = action === "approve" ? "approved" : action === "deny" ? "denied" : "pending";
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
|
if (!canUserAccessClient(user.userId, user.role, id)) continue;
|
||||||
const ok = setClientEnrollmentStatus(
|
const ok = setClientEnrollmentStatus(
|
||||||
id,
|
id,
|
||||||
status as "approved" | "denied" | "pending",
|
status as "approved" | "denied" | "pending",
|
||||||
@@ -215,6 +250,10 @@ export async function handleEnrollmentRoutes(
|
|||||||
const targetIp = getClientIp(clientId);
|
const targetIp = getClientIp(clientId);
|
||||||
if (!targetIp) return Response.json({ error: "Client IP not found" }, { status: 404 });
|
if (!targetIp) return Response.json({ error: "Client IP not found" }, { status: 404 });
|
||||||
|
|
||||||
|
if (!canUserAccessClient(user.userId, user.role, clientId)) {
|
||||||
|
return new Response("Forbidden: Client access denied", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
banIp(targetIp, `Banned from purgatory by ${user.username} for client ${clientId}`);
|
banIp(targetIp, `Banned from purgatory by ${user.username} for client ${clientId}`);
|
||||||
setClientEnrollmentStatus(clientId, "denied");
|
setClientEnrollmentStatus(clientId, "denied");
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfi
|
|||||||
import { getClientMetricsSummary } from "../../db";
|
import { getClientMetricsSummary } from "../../db";
|
||||||
import { metrics } from "../../metrics";
|
import { metrics } from "../../metrics";
|
||||||
import { requirePermission } from "../../rbac";
|
import { requirePermission } from "../../rbac";
|
||||||
import { getUserTelegramChatId, setUserTelegramChatId } from "../../users";
|
import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess } from "../../users";
|
||||||
import { runCertbotSetup } from "../certbot-setup";
|
import { runCertbotSetup } from "../certbot-setup";
|
||||||
import {
|
import {
|
||||||
getActiveProxies,
|
getActiveProxies,
|
||||||
@@ -311,6 +311,20 @@ export async function handleMiscRoutes(
|
|||||||
const endDate = Number(url.searchParams.get("endDate") || 0) || undefined;
|
const endDate = Number(url.searchParams.get("endDate") || 0) || undefined;
|
||||||
const successOnly = url.searchParams.get("successOnly") === "true";
|
const successOnly = url.searchParams.get("successOnly") === "true";
|
||||||
|
|
||||||
|
let allowedClientIds: string[] | undefined;
|
||||||
|
let deniedClientIds: string[] | undefined;
|
||||||
|
if (user.role !== "admin") {
|
||||||
|
const scope = getUserClientAccessScope(user.userId);
|
||||||
|
if (scope === "none") {
|
||||||
|
return Response.json({ logs: [], total: 0, page, pageSize }, { headers: deps.CORS_HEADERS });
|
||||||
|
}
|
||||||
|
if (scope === "allowlist") {
|
||||||
|
allowedClientIds = listUserClientRuleIdsByAccess(user.userId, "allow");
|
||||||
|
} else if (scope === "denylist") {
|
||||||
|
deniedClientIds = listUserClientRuleIdsByAccess(user.userId, "deny");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = getAuditLogs({
|
const result = getAuditLogs({
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -320,6 +334,8 @@ export async function handleMiscRoutes(
|
|||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
successOnly,
|
successOnly,
|
||||||
|
allowedClientIds,
|
||||||
|
deniedClientIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json(result, { headers: deps.CORS_HEADERS });
|
return Response.json(result, { headers: deps.CORS_HEADERS });
|
||||||
|
|||||||
@@ -224,6 +224,9 @@ export async function handleNotificationsConfigRoutes(
|
|||||||
telegram_bot_token: null,
|
telegram_bot_token: null,
|
||||||
telegram_chat_id: null,
|
telegram_chat_id: null,
|
||||||
telegram_template: null,
|
telegram_template: null,
|
||||||
|
client_event_webhook: 1,
|
||||||
|
client_event_telegram: 1,
|
||||||
|
client_event_push: 1,
|
||||||
};
|
};
|
||||||
return Response.json({
|
return Response.json({
|
||||||
settings,
|
settings,
|
||||||
@@ -276,6 +279,15 @@ export async function handleNotificationsConfigRoutes(
|
|||||||
if (typeof body?.telegram_template === "string") {
|
if (typeof body?.telegram_template === "string") {
|
||||||
patch.telegram_template = body.telegram_template.trim() || null;
|
patch.telegram_template = body.telegram_template.trim() || null;
|
||||||
}
|
}
|
||||||
|
if (typeof body?.client_event_webhook === "boolean" || typeof body?.client_event_webhook === "number") {
|
||||||
|
patch.client_event_webhook = body.client_event_webhook ? 1 : 0;
|
||||||
|
}
|
||||||
|
if (typeof body?.client_event_telegram === "boolean" || typeof body?.client_event_telegram === "number") {
|
||||||
|
patch.client_event_telegram = body.client_event_telegram ? 1 : 0;
|
||||||
|
}
|
||||||
|
if (typeof body?.client_event_push === "boolean" || typeof body?.client_event_push === "number") {
|
||||||
|
patch.client_event_push = body.client_event_push ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
const result = updateUserNotificationSettings(user.userId, patch);
|
const result = updateUserNotificationSettings(user.userId, patch);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -357,9 +357,14 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
|
|||||||
safeSendViewer(session.viewer, item);
|
safeSendViewer(session.viewer, item);
|
||||||
}
|
}
|
||||||
|
|
||||||
void deliverWebPushClientEvent(event, info, deps.canUserAccessClient, deps.getUserRole);
|
|
||||||
|
|
||||||
const externalTargets = deps.getDeliveryTargetsForClientEvent(event, info.id);
|
const externalTargets = deps.getDeliveryTargetsForClientEvent(event, info.id);
|
||||||
|
const pushEnabledByUser = new Map(externalTargets.map((t) => [t.userId, t.clientEventPush]));
|
||||||
|
|
||||||
|
void deliverWebPushClientEvent(event, info, deps.canUserAccessClient, deps.getUserRole, (userId) => {
|
||||||
|
const enabled = pushEnabledByUser.get(userId);
|
||||||
|
return enabled !== undefined ? enabled : true;
|
||||||
|
});
|
||||||
|
|
||||||
void deliverClientEventToExternalChannels(event, info, externalTargets);
|
void deliverClientEventToExternalChannels(event, info, externalTargets);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,22 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clientEventColumns: Array<{ sql: string; label: string }> = [
|
||||||
|
{ sql: `ALTER TABLE users ADD COLUMN client_event_webhook INTEGER DEFAULT 1`, label: "client_event_webhook" },
|
||||||
|
{ sql: `ALTER TABLE users ADD COLUMN client_event_telegram INTEGER DEFAULT 1`, label: "client_event_telegram" },
|
||||||
|
{ sql: `ALTER TABLE users ADD COLUMN client_event_push INTEGER DEFAULT 1`, label: "client_event_push" },
|
||||||
|
];
|
||||||
|
for (const col of clientEventColumns) {
|
||||||
|
try {
|
||||||
|
db.exec(col.sql);
|
||||||
|
logger.info(`[users] Added ${col.label} column to existing database`);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!err.message?.includes("duplicate column name")) {
|
||||||
|
logger.error("[users] Migration error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const userCount = db.prepare("SELECT COUNT(*) as count FROM users").get() as {
|
const userCount = db.prepare("SELECT COUNT(*) as count FROM users").get() as {
|
||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
@@ -569,7 +585,7 @@ export function canBuildClients(userId: number, role: UserRole): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function canViewAuditLogs(role: UserRole): boolean {
|
export function canViewAuditLogs(role: UserRole): boolean {
|
||||||
return role === "admin";
|
return role === "admin" || role === "operator";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasPermission(role: UserRole, permission: string, userId?: number): boolean {
|
export function hasPermission(role: UserRole, permission: string, userId?: number): boolean {
|
||||||
@@ -661,12 +677,15 @@ export interface UserNotificationSettings {
|
|||||||
telegram_bot_token: string | null;
|
telegram_bot_token: string | null;
|
||||||
telegram_chat_id: string | null;
|
telegram_chat_id: string | null;
|
||||||
telegram_template: string | null;
|
telegram_template: string | null;
|
||||||
|
client_event_webhook: number;
|
||||||
|
client_event_telegram: number;
|
||||||
|
client_event_push: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUserNotificationSettings(userId: number): UserNotificationSettings | null {
|
export function getUserNotificationSettings(userId: number): UserNotificationSettings | null {
|
||||||
const row = db
|
const row = db
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT webhook_enabled, webhook_url, webhook_template, telegram_enabled, telegram_bot_token, telegram_chat_id, telegram_template FROM users WHERE id = ?",
|
"SELECT webhook_enabled, webhook_url, webhook_template, telegram_enabled, telegram_bot_token, telegram_chat_id, telegram_template, client_event_webhook, client_event_telegram, client_event_push FROM users WHERE id = ?",
|
||||||
)
|
)
|
||||||
.get(userId) as UserNotificationSettings | undefined;
|
.get(userId) as UserNotificationSettings | undefined;
|
||||||
return row ?? null;
|
return row ?? null;
|
||||||
@@ -707,6 +726,18 @@ export function updateUserNotificationSettings(
|
|||||||
fields.push("telegram_template = ?");
|
fields.push("telegram_template = ?");
|
||||||
values.push(settings.telegram_template || null);
|
values.push(settings.telegram_template || null);
|
||||||
}
|
}
|
||||||
|
if ("client_event_webhook" in settings) {
|
||||||
|
fields.push("client_event_webhook = ?");
|
||||||
|
values.push(settings.client_event_webhook ? 1 : 0);
|
||||||
|
}
|
||||||
|
if ("client_event_telegram" in settings) {
|
||||||
|
fields.push("client_event_telegram = ?");
|
||||||
|
values.push(settings.client_event_telegram ? 1 : 0);
|
||||||
|
}
|
||||||
|
if ("client_event_push" in settings) {
|
||||||
|
fields.push("client_event_push = ?");
|
||||||
|
values.push(settings.client_event_push ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
if (fields.length === 0) return { success: true };
|
if (fields.length === 0) return { success: true };
|
||||||
|
|
||||||
@@ -733,6 +764,9 @@ export interface UserDeliveryRow {
|
|||||||
telegram_bot_token: string | null;
|
telegram_bot_token: string | null;
|
||||||
telegram_chat_id: string | null;
|
telegram_chat_id: string | null;
|
||||||
telegram_template: string | null;
|
telegram_template: string | null;
|
||||||
|
client_event_webhook: number;
|
||||||
|
client_event_telegram: number;
|
||||||
|
client_event_push: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUsersForNotificationDelivery(): UserDeliveryRow[] {
|
export function getUsersForNotificationDelivery(): UserDeliveryRow[] {
|
||||||
@@ -744,7 +778,8 @@ export function getUsersForNotificationDelivery(): UserDeliveryRow[] {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, username, role, client_scope,
|
`SELECT id, username, role, client_scope,
|
||||||
webhook_enabled, webhook_url, webhook_template,
|
webhook_enabled, webhook_url, webhook_template,
|
||||||
telegram_enabled, telegram_bot_token, telegram_chat_id, telegram_template
|
telegram_enabled, telegram_bot_token, telegram_chat_id, telegram_template,
|
||||||
|
client_event_webhook, client_event_telegram, client_event_push
|
||||||
FROM users
|
FROM users
|
||||||
WHERE (webhook_enabled = 1 AND webhook_url IS NOT NULL AND webhook_url != '')
|
WHERE (webhook_enabled = 1 AND webhook_url IS NOT NULL AND webhook_url != '')
|
||||||
OR (telegram_enabled = 1 AND telegram_bot_token IS NOT NULL AND telegram_bot_token != ''
|
OR (telegram_enabled = 1 AND telegram_bot_token IS NOT NULL AND telegram_bot_token != ''
|
||||||
|
|||||||
Reference in New Issue
Block a user