mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: unify alerts into one plugin, channels, rules and inbox
This commit is contained in:
1 parent
fbe736bc20
commit
7c02daccf4
129 files changed
+20957
-2687
No files matched your search
@@ -237,9 +237,12 @@ export async function runLogin(
|
||||
await unlockUser(user, identity, deviceType);
|
||||
if ("password" in identity && identity.password) {
|
||||
// A 2.8 key still wrapped by the password only opens here, so this is the
|
||||
// first chance to move that user's TOTP secret into the plugin.
|
||||
// first chance to move that user's TOTP secret and channel configs.
|
||||
const { runTotpMigration } = await import("../upgrade/totp-migration.js");
|
||||
await runTotpMigration(user.id);
|
||||
const { runNotificationChannelMigration } =
|
||||
await import("../upgrade/notification-channel-migration.js");
|
||||
await runNotificationChannelMigration(user.id);
|
||||
}
|
||||
await syncSharedCredentialsForUserRoles(
|
||||
user.id,
|
||||
|
||||
@@ -59,10 +59,6 @@ export async function runCoreBootMigrations(): Promise<void> {
|
||||
await import("./utils/crypto-migration/private-shared-ssh-auth-migration.js");
|
||||
await runPrivateSharedSshAuthMigration();
|
||||
|
||||
const { runChannelConfigEncryptionMigration } =
|
||||
await import("./utils/crypto-migration/channel-config-encryption.js");
|
||||
await runChannelConfigEncryptionMigration();
|
||||
|
||||
const { runExternalIdentityMigration } =
|
||||
await import("./upgrade/external-identity-migration.js");
|
||||
await runExternalIdentityMigration();
|
||||
|
||||
@@ -7,7 +7,6 @@ import multer from "multer";
|
||||
import cookieParser from "cookie-parser";
|
||||
import userRoutes from "./routes/users.js";
|
||||
import hostRoutes from "./routes/host.js";
|
||||
import alertRoutes from "./routes/alerts.js";
|
||||
import credentialsRoutes from "./routes/credentials.js";
|
||||
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
@@ -17,7 +16,6 @@ import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
|
||||
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
|
||||
import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
||||
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||
import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
|
||||
import syncRoutes from "./routes/sync.js";
|
||||
import dashboardRoutes from "./routes/dashboard-routes.js";
|
||||
import {
|
||||
@@ -48,7 +46,6 @@ import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js";
|
||||
import { acmeChallengeHandler } from "../tls/acme-challenges.js";
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentDismissedAlertRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
@@ -866,13 +863,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE dismissed_alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
alert_id TEXT NOT NULL,
|
||||
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_credential_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
credential_id INTEGER NOT NULL,
|
||||
@@ -1008,21 +998,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
// Rows the user owns in plugin tables travel with the export.
|
||||
await writeUserPluginTables(exportDb, userId);
|
||||
|
||||
const dismissedAlertRepository = createCurrentDismissedAlertRepository();
|
||||
const alerts = await dismissedAlertRepository.listByUserId(userId);
|
||||
const insertAlert = exportDb.prepare(`
|
||||
INSERT INTO dismissed_alerts (id, user_id, alert_id, dismissed_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
for (const alert of alerts) {
|
||||
insertAlert.run(
|
||||
alert.id,
|
||||
alert.userId,
|
||||
alert.alertId,
|
||||
alert.dismissedAt,
|
||||
);
|
||||
}
|
||||
|
||||
const sshCredentialUsageRepository =
|
||||
createCurrentSshCredentialUsageRepository();
|
||||
const usage = await sshCredentialUsageRepository.listByUserId(userId);
|
||||
@@ -1217,7 +1192,6 @@ app.post(
|
||||
sshHostsImported: 0,
|
||||
sshCredentialsImported: 0,
|
||||
pluginItemsImported: 0,
|
||||
dismissedAlertsImported: 0,
|
||||
credentialUsageImported: 0,
|
||||
settingsImported: 0,
|
||||
skippedItems: 0,
|
||||
@@ -1366,37 +1340,6 @@ app.post(
|
||||
result.summary.skippedItems += pluginRows.skipped;
|
||||
result.summary.errors.push(...pluginRows.errors);
|
||||
|
||||
const dismissedAlertRepository =
|
||||
createCurrentDismissedAlertRepository();
|
||||
|
||||
try {
|
||||
const importedAlerts = importDb
|
||||
.prepare("SELECT * FROM dismissed_alerts")
|
||||
.all();
|
||||
for (const alert of importedAlerts) {
|
||||
try {
|
||||
const created = await dismissedAlertRepository.createForImport(
|
||||
userId,
|
||||
alert.alert_id,
|
||||
alert.dismissed_at,
|
||||
);
|
||||
if (created) {
|
||||
result.summary.dismissedAlertsImported++;
|
||||
} else {
|
||||
result.summary.skippedItems++;
|
||||
}
|
||||
} catch (alertError) {
|
||||
result.summary.errors.push(
|
||||
`Dismissed alert import error: ${alertError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
"dismissed_alerts table not found in import file, skipping",
|
||||
);
|
||||
}
|
||||
|
||||
const targetUser = await userRepository.findById(userId);
|
||||
if (targetUser?.isAdmin) {
|
||||
try {
|
||||
@@ -1614,7 +1557,6 @@ app.post("/database/restore", requireAdmin, async (req, res) => {
|
||||
|
||||
app.use("/users", userRoutes);
|
||||
app.use("/host", hostRoutes);
|
||||
app.use("/alerts", alertRoutes);
|
||||
app.use("/credentials", credentialsRoutes);
|
||||
app.use("/ssh-auth", sshAuthRoutes);
|
||||
app.use("/rbac", rbacRoutes);
|
||||
@@ -1624,7 +1566,6 @@ app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
|
||||
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
||||
app.use("/ui-preferences", uiPreferencesRoutes);
|
||||
registerAuditLogRoutes(app, authenticateJWT);
|
||||
app.use("/", notificationChannelsRoutes);
|
||||
app.use("/sync", syncRoutes);
|
||||
app.use("/dashboard", dashboardRoutes);
|
||||
app.use("/plugins", pluginRoutes);
|
||||
|
||||
@@ -322,14 +322,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dismissed_alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
alert_id TEXT NOT NULL,
|
||||
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -1549,29 +1541,6 @@ const migrateSchema = () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM notification_channels LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notification_channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create notification_channels table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
// --- alerts end ---
|
||||
|
||||
|
||||
|
||||
@@ -109,11 +109,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
|
||||
table: "user_open_tabs",
|
||||
columns: "user_id",
|
||||
},
|
||||
{
|
||||
name: "idx_dismissed_alerts_user_id",
|
||||
table: "dismissed_alerts",
|
||||
columns: "user_id",
|
||||
},
|
||||
];
|
||||
|
||||
interface IndexableDatabase {
|
||||
|
||||
@@ -271,20 +271,6 @@ export const hosts = mysqlTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const dismissedAlerts = mysqlTable(
|
||||
"dismissed_alerts",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
alertId: text("alert_id").notNull(),
|
||||
dismissedAt: text("dismissed_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [index("idx_dismissed_alerts_user_id").on(table.userId)],
|
||||
);
|
||||
|
||||
export const sshCredentials = mysqlTable(
|
||||
"ssh_credentials",
|
||||
@@ -720,22 +706,6 @@ export const uiPreferences = mysqlTable("ui_preferences", {
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
|
||||
// --- alerts begin ---
|
||||
export const notificationChannels = mysqlTable("notification_channels", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
type: text("type").notNull(),
|
||||
config: text("config").notNull(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
// --- alerts end ---
|
||||
|
||||
// --- sync begin ---
|
||||
// Records a delete for a synced entity type so the other side of a sync
|
||||
// pair (embedded desktop backend <-> connected remote server) learns about
|
||||
|
||||
@@ -272,20 +272,6 @@ export const hosts = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const dismissedAlerts = pgTable(
|
||||
"dismissed_alerts",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
alertId: text("alert_id").notNull(),
|
||||
dismissedAt: text("dismissed_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [index("idx_dismissed_alerts_user_id").on(table.userId)],
|
||||
);
|
||||
|
||||
export const sshCredentials = pgTable(
|
||||
"ssh_credentials",
|
||||
@@ -721,22 +707,6 @@ export const uiPreferences = pgTable("ui_preferences", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// --- alerts begin ---
|
||||
export const notificationChannels = pgTable("notification_channels", {
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
type: text("type").notNull(),
|
||||
config: text("config").notNull(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
// --- alerts end ---
|
||||
|
||||
// --- sync begin ---
|
||||
// Records a delete for a synced entity type so the other side of a sync
|
||||
// pair (embedded desktop backend <-> connected remote server) learns about
|
||||
|
||||
@@ -264,20 +264,6 @@ export const hosts = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const dismissedAlerts = sqliteTable(
|
||||
"dismissed_alerts",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
alertId: text("alert_id").notNull(),
|
||||
dismissedAt: text("dismissed_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [index("idx_dismissed_alerts_user_id").on(table.userId)],
|
||||
);
|
||||
|
||||
export const sshCredentials = sqliteTable(
|
||||
"ssh_credentials",
|
||||
@@ -717,22 +703,6 @@ export const uiPreferences = sqliteTable("ui_preferences", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// --- alerts begin ---
|
||||
export const notificationChannels = sqliteTable("notification_channels", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
type: text("type").notNull(),
|
||||
config: text("config").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
// --- alerts end ---
|
||||
|
||||
// --- sync begin ---
|
||||
// Records a delete for a synced entity type so the other side of a sync
|
||||
// pair (embedded desktop backend <-> connected remote server) learns about
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { dismissedAlerts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
|
||||
export type DismissedAlertRecord = typeof dismissedAlerts.$inferSelect;
|
||||
|
||||
export class DismissedAlertRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserId(userId: string): Promise<DismissedAlertRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
}
|
||||
|
||||
async listAlertIdsByUserId(userId: string): Promise<string[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ alertId: dismissedAlerts.alertId })
|
||||
.from(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
return rows.map((row) => row.alertId);
|
||||
}
|
||||
|
||||
async findForUser(
|
||||
userId: string,
|
||||
alertId: string,
|
||||
): Promise<DismissedAlertRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, userId),
|
||||
eq(dismissedAlerts.alertId, alertId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async create(userId: string, alertId: string): Promise<void> {
|
||||
await this.context.drizzle.insert(dismissedAlerts).values({
|
||||
userId,
|
||||
alertId,
|
||||
});
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async createForImport(
|
||||
userId: string,
|
||||
alertId: string,
|
||||
dismissedAt = new Date().toISOString(),
|
||||
): Promise<boolean> {
|
||||
const existing = await this.findForUser(userId, alertId);
|
||||
if (existing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(dismissedAlerts).values({
|
||||
userId,
|
||||
alertId,
|
||||
dismissedAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, alertId: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, userId),
|
||||
eq(dismissedAlerts.alertId, alertId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,9 @@ import { getDb, getSqlite } from "../db/index.js";
|
||||
import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
|
||||
import { primeSettingsCache, readCachedSetting } from "./settings-cache.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { NotificationChannelRepository } from "./notification-channel-repository.js";
|
||||
import { ApiKeyRepository } from "./api-key-repository.js";
|
||||
import { AuditLogRepository } from "./audit-log-repository.js";
|
||||
import { CredentialRepository } from "./credential-repository.js";
|
||||
import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
|
||||
import { HostFolderRepository } from "./host-folder-repository.js";
|
||||
import { HostRepository } from "./host-repository.js";
|
||||
import { HostResolutionRepository } from "./host-resolution-repository.js";
|
||||
@@ -119,13 +117,6 @@ export function getCurrentSettingValue(key: string): string | null {
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export function createCurrentNotificationChannelRepository(): NotificationChannelRepository {
|
||||
return new NotificationChannelRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("notification_channel_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentApiKeyRepository(): ApiKeyRepository {
|
||||
return new ApiKeyRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -154,13 +145,6 @@ export function createCurrentSyncTombstoneRepository(): SyncTombstoneRepository
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentDismissedAlertRepository(): DismissedAlertRepository {
|
||||
return new DismissedAlertRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("dismissed_alert_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHostFolderRepository(): HostFolderRepository {
|
||||
return new HostFolderRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { notificationChannels } from "../db/schema.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
type NotificationChannelRecord = typeof notificationChannels.$inferSelect;
|
||||
|
||||
export interface NotificationChannelRow {
|
||||
id: number;
|
||||
user_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export class NotificationChannelRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Channel configs carry ntfy tokens and webhook auth headers, so they are
|
||||
* field-encrypted like any other secret. The key is derived from the row id,
|
||||
* which does not exist until after the insert, so a new channel is written
|
||||
* once and then re-encrypted in place with its real id.
|
||||
*/
|
||||
private userDataKey(userId: string): Buffer | null {
|
||||
try {
|
||||
return DataCrypto.getUserDataKey(userId);
|
||||
} catch {
|
||||
// Crypto is not initialized (tests, early boot); leave the value as is.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private encryptConfig(
|
||||
config: string,
|
||||
userId: string,
|
||||
recordId: number | string,
|
||||
): string {
|
||||
const userDataKey = this.userDataKey(userId);
|
||||
if (!userDataKey) return config;
|
||||
return DataCrypto.encryptRecord(
|
||||
"notification_channels",
|
||||
{ id: recordId, config },
|
||||
userId,
|
||||
userDataKey,
|
||||
).config;
|
||||
}
|
||||
|
||||
private decryptConfig(
|
||||
config: string,
|
||||
userId: string,
|
||||
recordId: number | string,
|
||||
): string {
|
||||
const userDataKey = this.userDataKey(userId);
|
||||
if (!userDataKey) return config;
|
||||
try {
|
||||
return DataCrypto.decryptRecord(
|
||||
"notification_channels",
|
||||
{ id: recordId, config },
|
||||
userId,
|
||||
userDataKey,
|
||||
).config;
|
||||
} catch {
|
||||
// Rows written before channel configs were encrypted are still plaintext.
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
async listNotificationChannels(
|
||||
userId: string,
|
||||
): Promise<NotificationChannelRow[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(notificationChannels)
|
||||
.where(eq(notificationChannels.userId, userId))
|
||||
.orderBy(notificationChannels.id);
|
||||
|
||||
return rows.map((row) => {
|
||||
const mapped = mapChannelRow(row);
|
||||
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
|
||||
return mapped;
|
||||
});
|
||||
}
|
||||
|
||||
async findNotificationChannelForUser(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<NotificationChannelRow | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(notificationChannels)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationChannels.id, id),
|
||||
eq(notificationChannels.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!rows[0]) return null;
|
||||
const mapped = mapChannelRow(rows[0]);
|
||||
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async createNotificationChannel(input: {
|
||||
userId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: boolean;
|
||||
}): Promise<NotificationChannelRow> {
|
||||
const [created] = await insertReturning(
|
||||
this.context,
|
||||
notificationChannels,
|
||||
{
|
||||
userId: input.userId,
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
config: input.config,
|
||||
enabled: input.enabled,
|
||||
},
|
||||
);
|
||||
|
||||
const encrypted = this.encryptConfig(
|
||||
input.config,
|
||||
input.userId,
|
||||
created.id,
|
||||
);
|
||||
if (encrypted !== input.config) {
|
||||
await this.context.drizzle
|
||||
.update(notificationChannels)
|
||||
.set({ config: encrypted })
|
||||
.where(eq(notificationChannels.id, created.id));
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
const mapped = mapChannelRow(created);
|
||||
mapped.config = input.config;
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async updateNotificationChannel(
|
||||
id: number,
|
||||
userId: string,
|
||||
input: {
|
||||
name?: string;
|
||||
type?: string;
|
||||
config?: string;
|
||||
enabled?: boolean;
|
||||
},
|
||||
): Promise<NotificationChannelRow | null> {
|
||||
if (Object.keys(input).length === 0) {
|
||||
return this.findNotificationChannelForUser(id, userId);
|
||||
}
|
||||
|
||||
const values =
|
||||
input.config !== undefined
|
||||
? { ...input, config: this.encryptConfig(input.config, userId, id) }
|
||||
: input;
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
notificationChannels,
|
||||
values,
|
||||
and(
|
||||
eq(notificationChannels.id, id),
|
||||
eq(notificationChannels.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (!updated) return null;
|
||||
await this.afterWrite();
|
||||
const mapped = mapChannelRow(updated);
|
||||
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async deleteNotificationChannel(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(notificationChannels)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationChannels.id, id),
|
||||
eq(notificationChannels.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) === 0) return false;
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<{ channelsDeleted: number }> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(notificationChannels)
|
||||
.where(eq(notificationChannels.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return { channelsDeleted: rowsAffected(result) };
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
|
||||
function mapChannelRow(row: NotificationChannelRecord): NotificationChannelRow {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.userId,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
config: row.config,
|
||||
enabled: row.enabled ? 1 : 0,
|
||||
created_at: row.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type {
|
||||
AuthenticatedRequest,
|
||||
CacheEntry,
|
||||
TermixAlert,
|
||||
} from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { getProxyAgent } from "../../utils/proxy-agent.js";
|
||||
import { createCurrentDismissedAlertRepository } from "../repositories/factory.js";
|
||||
|
||||
class AlertCache {
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
private readonly CACHE_DURATION = 5 * 60 * 1000;
|
||||
|
||||
set<T>(key: string, data: T): void {
|
||||
const now = Date.now();
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: now,
|
||||
expiresAt: now + this.CACHE_DURATION,
|
||||
});
|
||||
}
|
||||
|
||||
get<T>(key: string): T | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data as T;
|
||||
}
|
||||
}
|
||||
|
||||
const alertCache = new AlertCache();
|
||||
|
||||
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com";
|
||||
const REPO_OWNER = "Termix-SSH";
|
||||
const REPO_NAME = "Docs";
|
||||
const ALERTS_FILE = "main/termix-alerts.json";
|
||||
|
||||
async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
||||
const cacheKey = "termix_alerts";
|
||||
const cachedData = alertCache.get<TermixAlert[]>(cacheKey);
|
||||
if (cachedData) {
|
||||
return cachedData;
|
||||
}
|
||||
try {
|
||||
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "TermixAlertChecker/1.0",
|
||||
},
|
||||
dispatcher: getProxyAgent(url),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
authLogger.warn("GitHub API returned error status", {
|
||||
operation: "alerts_fetch",
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
throw new Error(
|
||||
`GitHub raw content error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const alerts: TermixAlert[] = (await response.json()) as TermixAlert[];
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const validAlerts = alerts.filter((alert) => {
|
||||
const expiryDate = new Date(alert.expiresAt);
|
||||
const isValid = expiryDate > now;
|
||||
return isValid;
|
||||
});
|
||||
|
||||
alertCache.set(cacheKey, validAlerts);
|
||||
return validAlerts;
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to fetch alerts from GitHub", {
|
||||
operation: "alerts_fetch",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alerts:
|
||||
* get:
|
||||
* summary: Get active alerts
|
||||
* description: Fetches active alerts for the authenticated user, excluding those that have been dismissed.
|
||||
* tags:
|
||||
* - Alerts
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of active alerts.
|
||||
* 500:
|
||||
* description: Failed to fetch alerts.
|
||||
*/
|
||||
router.get("/", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
const allAlerts = await fetchAlertsFromGitHub();
|
||||
|
||||
const dismissedAlertIds = new Set(
|
||||
await createCurrentDismissedAlertRepository().listAlertIdsByUserId(
|
||||
userId,
|
||||
),
|
||||
);
|
||||
|
||||
const activeAlertsForUser = allAlerts.filter(
|
||||
(alert) => !dismissedAlertIds.has(alert.id),
|
||||
);
|
||||
|
||||
res.json({
|
||||
alerts: activeAlertsForUser,
|
||||
cached: alertCache.get("termix_alerts") !== null,
|
||||
total_count: activeAlertsForUser.length,
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to get user alerts", error);
|
||||
res.status(500).json({ error: "Failed to fetch alerts" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alerts/dismiss:
|
||||
* post:
|
||||
* summary: Dismiss an alert
|
||||
* description: Marks an alert as dismissed for the authenticated user.
|
||||
* tags:
|
||||
* - Alerts
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* alertId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Alert dismissed successfully.
|
||||
* 400:
|
||||
* description: Alert ID is required.
|
||||
* 409:
|
||||
* description: Alert already dismissed.
|
||||
* 500:
|
||||
* description: Failed to dismiss alert.
|
||||
*/
|
||||
router.post("/dismiss", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const { alertId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!alertId) {
|
||||
authLogger.warn("Missing alertId in dismiss request", { userId });
|
||||
return res.status(400).json({ error: "Alert ID is required" });
|
||||
}
|
||||
|
||||
const existingDismissal =
|
||||
await createCurrentDismissedAlertRepository().findForUser(
|
||||
userId,
|
||||
alertId,
|
||||
);
|
||||
|
||||
if (existingDismissal) {
|
||||
authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
|
||||
return res.status(409).json({ error: "Alert already dismissed" });
|
||||
}
|
||||
|
||||
await createCurrentDismissedAlertRepository().create(userId, alertId);
|
||||
|
||||
res.json({ message: "Alert dismissed successfully" });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to dismiss alert", error);
|
||||
res.status(500).json({ error: "Failed to dismiss alert" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alerts/dismissed:
|
||||
* get:
|
||||
* summary: Get dismissed alerts
|
||||
* description: Fetches a list of alerts that have been dismissed by the authenticated user.
|
||||
* tags:
|
||||
* - Alerts
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of dismissed alerts.
|
||||
* 500:
|
||||
* description: Failed to fetch dismissed alerts.
|
||||
*/
|
||||
router.get("/dismissed", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
const dismissedAlertRecords =
|
||||
await createCurrentDismissedAlertRepository().listByUserId(userId);
|
||||
|
||||
res.json({
|
||||
dismissed_alerts: dismissedAlertRecords,
|
||||
total_count: dismissedAlertRecords.length,
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to get dismissed alerts", error);
|
||||
res.status(500).json({ error: "Failed to fetch dismissed alerts" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alerts/dismiss:
|
||||
* delete:
|
||||
* summary: Undismiss an alert
|
||||
* description: Removes an alert from the dismissed list for the authenticated user.
|
||||
* tags:
|
||||
* - Alerts
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* alertId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Alert undismissed successfully.
|
||||
* 400:
|
||||
* description: Alert ID is required.
|
||||
* 404:
|
||||
* description: Dismissed alert not found.
|
||||
* 500:
|
||||
* description: Failed to undismiss alert.
|
||||
*/
|
||||
router.delete("/dismiss", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const { alertId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!alertId) {
|
||||
return res.status(400).json({ error: "Alert ID is required" });
|
||||
}
|
||||
|
||||
const deleted = await createCurrentDismissedAlertRepository().deleteForUser(
|
||||
userId,
|
||||
alertId,
|
||||
);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: "Dismissed alert not found" });
|
||||
}
|
||||
res.json({ message: "Alert undismissed successfully" });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to undismiss alert", error);
|
||||
res.status(500).json({ error: "Failed to undismiss alert" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,10 +1,8 @@
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
createCurrentNotificationChannelRepository,
|
||||
createCurrentApiKeyRepository,
|
||||
createCurrentAuditLogRepository,
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentDismissedAlertRepository,
|
||||
createCurrentHostFolderRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentHostSidebarPreferenceRepository,
|
||||
@@ -62,13 +60,11 @@ export async function deleteUserAndRelatedData(
|
||||
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentRoleRepository().removeAllRolesFromUser(userId);
|
||||
await createCurrentNotificationChannelRepository().deleteByUserId(userId);
|
||||
await createCurrentAuditLogRepository().anonymizeByUserId(userId);
|
||||
|
||||
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentRecentActivityRepository().deleteByUserId(userId);
|
||||
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentHostFolderRepository().deleteByUserId(userId);
|
||||
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { createCurrentNotificationChannelRepository } from "../repositories/factory.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { deliverNotification } from "../../utils/notification-sender.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
router.use(authenticateJWT);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels:
|
||||
* get:
|
||||
* summary: List notification channels for the current user
|
||||
* tags:
|
||||
* - Notifications
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of notification channels.
|
||||
*/
|
||||
router.get("/notification-channels", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const rows =
|
||||
await createCurrentNotificationChannelRepository().listNotificationChannels(
|
||||
userId,
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to list notification channels", {
|
||||
operation: "list_channels",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list channels" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels:
|
||||
* post:
|
||||
* summary: Create a notification channel
|
||||
* tags:
|
||||
* - Notifications
|
||||
*/
|
||||
router.post("/notification-channels", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, type, config, enabled } = req.body as {
|
||||
name: string;
|
||||
type: string;
|
||||
config: unknown;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
return res.status(400).json({ error: "name is required" });
|
||||
}
|
||||
if (type !== "webhook" && type !== "ntfy" && type !== "discord") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
|
||||
}
|
||||
if (!config || typeof config !== "object") {
|
||||
return res.status(400).json({ error: "config is required" });
|
||||
}
|
||||
if (type === "ntfy") {
|
||||
const c = config as Record<string, unknown>;
|
||||
if (!c.url || typeof c.url !== "string")
|
||||
return res.status(400).json({ error: "ntfy config requires url" });
|
||||
if (!c.topic || typeof c.topic !== "string")
|
||||
return res.status(400).json({ error: "ntfy config requires topic" });
|
||||
}
|
||||
if (type === "webhook") {
|
||||
const c = config as Record<string, unknown>;
|
||||
if (!c.url || typeof c.url !== "string")
|
||||
return res.status(400).json({ error: "webhook config requires url" });
|
||||
}
|
||||
if (type === "discord") {
|
||||
const c = config as Record<string, unknown>;
|
||||
if (!c.url || typeof c.url !== "string")
|
||||
return res.status(400).json({ error: "discord config requires url" });
|
||||
if (
|
||||
!/^https:\/\/(?:canary\.|ptb\.)?(?:discord\.com|discordapp\.com)\/api\/webhooks\/.+/i.test(
|
||||
c.url,
|
||||
)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"discord config requires a valid Discord webhook URL (https://discord.com/api/webhooks/...)",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const row =
|
||||
await createCurrentNotificationChannelRepository().createNotificationChannel(
|
||||
{
|
||||
userId,
|
||||
name: name.trim(),
|
||||
type,
|
||||
config: JSON.stringify(config),
|
||||
enabled: enabled !== false,
|
||||
},
|
||||
);
|
||||
res.status(201).json(row);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to create notification channel", {
|
||||
operation: "create_channel",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create channel" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels/{id}:
|
||||
* put:
|
||||
* summary: Update a notification channel
|
||||
* tags:
|
||||
* - Notifications
|
||||
*/
|
||||
router.put(
|
||||
"/notification-channels/:id",
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const channelId = Number(req.params.id);
|
||||
const { name, type, config, enabled } = req.body as {
|
||||
name?: string;
|
||||
type?: string;
|
||||
config?: unknown;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
const repository = createCurrentNotificationChannelRepository();
|
||||
const existing = await repository.findNotificationChannelForUser(
|
||||
channelId,
|
||||
userId,
|
||||
);
|
||||
if (!existing) return res.status(404).json({ error: "Channel not found" });
|
||||
|
||||
if (type && type !== "webhook" && type !== "ntfy" && type !== "discord") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
|
||||
}
|
||||
if (
|
||||
name === undefined &&
|
||||
type === undefined &&
|
||||
config === undefined &&
|
||||
enabled === undefined
|
||||
) {
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const row = await repository.updateNotificationChannel(
|
||||
channelId,
|
||||
userId,
|
||||
{
|
||||
...(name !== undefined ? { name: name.trim() } : {}),
|
||||
...(type !== undefined ? { type } : {}),
|
||||
...(config !== undefined ? { config: JSON.stringify(config) } : {}),
|
||||
...(enabled !== undefined ? { enabled } : {}),
|
||||
},
|
||||
);
|
||||
if (!row) return res.status(404).json({ error: "Channel not found" });
|
||||
res.json(row);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to update notification channel", {
|
||||
operation: "update_channel",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update channel" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels/{id}:
|
||||
* delete:
|
||||
* summary: Delete a notification channel
|
||||
* tags:
|
||||
* - Notifications
|
||||
*/
|
||||
router.delete(
|
||||
"/notification-channels/:id",
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const channelId = Number(req.params.id);
|
||||
const deleted =
|
||||
await createCurrentNotificationChannelRepository().deleteNotificationChannel(
|
||||
channelId,
|
||||
userId,
|
||||
);
|
||||
if (!deleted) return res.status(404).json({ error: "Channel not found" });
|
||||
res.json({ success: true });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels/{id}/test:
|
||||
* post:
|
||||
* summary: Send a test notification
|
||||
* tags:
|
||||
* - Notifications
|
||||
*/
|
||||
router.post(
|
||||
"/notification-channels/:id/test",
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const channelId = Number(req.params.id);
|
||||
const row =
|
||||
await createCurrentNotificationChannelRepository().findNotificationChannelForUser(
|
||||
channelId,
|
||||
userId,
|
||||
);
|
||||
if (!row) return res.status(404).json({ error: "Channel not found" });
|
||||
|
||||
try {
|
||||
// The same path an automation's notify step takes, so a test proves
|
||||
// the channel works for real, private-network opt-in included.
|
||||
await deliverNotification(row, {
|
||||
title: "Termix",
|
||||
body: "This is a test notification from Termix",
|
||||
severity: "info",
|
||||
context: { sourceName: "Test", triggerType: "test" },
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.json({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -7,7 +7,6 @@ import { authLogger } from "../../utils/logger.js";
|
||||
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentDismissedAlertRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentRecentActivityRepository,
|
||||
createCurrentSettingsRepository,
|
||||
@@ -70,7 +69,6 @@ export async function resetUserPassword(
|
||||
|
||||
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
|
||||
await createCurrentRecentActivityRepository().deleteByUserId(userId);
|
||||
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
|
||||
await createCurrentHostRepository().deleteByUserId(userId);
|
||||
await createCurrentCredentialRepository().deleteByUserId(userId);
|
||||
// A plugin holding this user's data (snippets and anything else keyed by
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
/**
|
||||
* ctx.notify and ctx.fetch: core's notification channels and core's guarded
|
||||
* outbound HTTP. Channels belong to a user, so notify always runs as the
|
||||
* acting user and never sees a channel's config.
|
||||
* ctx.notify and ctx.fetch: alerts through whichever plugin serves as the
|
||||
* hub, and core's guarded outbound HTTP. Core resolves who an alert is for,
|
||||
* so a plugin names an audience and never a list of users it could not see.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginFetch,
|
||||
PluginNotification,
|
||||
PluginNotificationAudience,
|
||||
PluginNotify,
|
||||
PluginNotifyResult,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
import { assertCapability, capabilityRefused } from "./permissions.js";
|
||||
import { getActor } from "./actor.js";
|
||||
import { activeNotifyHub, registerNotifyHub } from "./notify-hub.js";
|
||||
|
||||
type AuditFn = (
|
||||
action: string,
|
||||
@@ -22,8 +25,16 @@ type AuditFn = (
|
||||
interface Deps {
|
||||
manifest: PluginManifest;
|
||||
audit: AuditFn;
|
||||
/** Where serve's revoke goes, so the hub goes away with the plugin. */
|
||||
bag?: { add: (dispose: () => void, label: string) => void };
|
||||
}
|
||||
|
||||
const EMPTY_RESULT: PluginNotifyResult = {
|
||||
recipients: 0,
|
||||
delivered: 0,
|
||||
failures: [],
|
||||
};
|
||||
|
||||
function actingUser(): string {
|
||||
const actor = getActor();
|
||||
if (!actor) {
|
||||
@@ -60,14 +71,43 @@ async function audited<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/** The user ids an audience names right now. */
|
||||
export async function resolveAudience(
|
||||
audience: PluginNotificationAudience | undefined,
|
||||
): Promise<string[]> {
|
||||
if (!audience) return [actingUser()];
|
||||
const { createCurrentUserRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const users = createCurrentUserRepository();
|
||||
if (audience === "admins") {
|
||||
return (await users.listAll())
|
||||
.filter((user) => user.isAdmin)
|
||||
.map((user) => user.id);
|
||||
}
|
||||
if ("userId" in audience) {
|
||||
return (await users.findById(audience.userId)) ? [audience.userId] : [];
|
||||
}
|
||||
const { PermissionManager } = await import("../utils/permission-manager.js");
|
||||
const permissions = PermissionManager.getInstance();
|
||||
const recipients: string[] = [];
|
||||
for (const user of await users.listAll()) {
|
||||
if (await permissions.hasPermission(user.id, audience.permission)) {
|
||||
recipients.push(user.id);
|
||||
}
|
||||
}
|
||||
return recipients;
|
||||
}
|
||||
|
||||
function describeAudience(notification: PluginNotification): string {
|
||||
const { audience } = notification;
|
||||
if (!audience) return "acting user";
|
||||
if (audience === "admins") return "admins";
|
||||
if ("userId" in audience) return `user ${audience.userId}`;
|
||||
return `permission ${audience.permission}`;
|
||||
}
|
||||
|
||||
export function createPluginNotify(deps: Deps): PluginNotify {
|
||||
const listOwn = async (userId: string) => {
|
||||
const { createCurrentNotificationChannelRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentNotificationChannelRepository().listNotificationChannels(
|
||||
userId,
|
||||
);
|
||||
};
|
||||
const pluginId = deps.manifest.id;
|
||||
|
||||
return {
|
||||
channels: () =>
|
||||
@@ -77,48 +117,39 @@ export function createPluginNotify(deps: Deps): PluginNotify {
|
||||
"notify_channels",
|
||||
"listed channels",
|
||||
async () => {
|
||||
const rows = await listOwn(actingUser());
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
enabled: !!row.enabled,
|
||||
}));
|
||||
const userId = actingUser();
|
||||
const hub = await activeNotifyHub();
|
||||
return hub ? hub.channels(userId) : [];
|
||||
},
|
||||
),
|
||||
|
||||
send: (channelIds, notification) =>
|
||||
send: (notification) =>
|
||||
audited(
|
||||
deps,
|
||||
"notify:send",
|
||||
"notify_send",
|
||||
`${channelIds.length} channel(s)`,
|
||||
`${notification.category ?? pluginId} to ${describeAudience(notification)}`,
|
||||
async () => {
|
||||
const rows = await listOwn(actingUser());
|
||||
const { deliverNotification } =
|
||||
await import("../utils/notification-sender.js");
|
||||
const result: PluginNotifyResult = { delivered: 0, failures: [] };
|
||||
for (const row of rows) {
|
||||
if (!channelIds.includes(row.id) || !row.enabled) continue;
|
||||
try {
|
||||
await deliverNotification(row, {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
severity: notification.severity ?? "warning",
|
||||
context: notification.context,
|
||||
});
|
||||
result.delivered++;
|
||||
} catch (error) {
|
||||
result.failures.push({
|
||||
channelId: row.id,
|
||||
name: row.name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
const recipients = await resolveAudience(notification.audience);
|
||||
const hub = await activeNotifyHub();
|
||||
if (!hub || recipients.length === 0) return { ...EMPTY_RESULT };
|
||||
return hub.deliver({ source: pluginId, recipients, notification });
|
||||
},
|
||||
),
|
||||
|
||||
serve: (hub) => {
|
||||
if (!deps.manifest.capabilities.includes("notify:hub")) {
|
||||
throw capabilityRefused(pluginId, "notify:hub", "notify_serve");
|
||||
}
|
||||
const revoke = registerNotifyHub({
|
||||
pluginId,
|
||||
declared: deps.manifest.capabilities,
|
||||
hub,
|
||||
});
|
||||
deps.bag?.add(revoke, "alert hub");
|
||||
void deps.audit("notify_serve", "serving alerts", { success: true });
|
||||
return revoke;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -858,7 +858,11 @@ export function createPluginContext(
|
||||
),
|
||||
),
|
||||
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
notify: createPluginNotify({ manifest, audit: auditCall }),
|
||||
notify: createPluginNotify({
|
||||
manifest,
|
||||
audit: auditCall,
|
||||
bag: handle.bag,
|
||||
}),
|
||||
fetch: createPluginFetch({ manifest, audit: auditCall }),
|
||||
process: createPluginProcess({
|
||||
manifest,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The one plugin that stores alerts and delivers them to channels. Core only
|
||||
* resolves who an alert is for and hands it over; with no hub running an
|
||||
* alert reaches nobody.
|
||||
*/
|
||||
|
||||
import type { PluginNotifyHub } from "@termix/plugin-sdk/backend";
|
||||
import { hasCapability } from "./permissions.js";
|
||||
|
||||
interface HubRegistration {
|
||||
pluginId: string;
|
||||
declared: readonly string[];
|
||||
hub: PluginNotifyHub;
|
||||
}
|
||||
|
||||
let current: HubRegistration | null = null;
|
||||
|
||||
export function registerNotifyHub(registration: HubRegistration): () => void {
|
||||
if (current && current.pluginId !== registration.pluginId) {
|
||||
throw new Error(
|
||||
`Plugin ${registration.pluginId} cannot serve alerts: ${current.pluginId} already does`,
|
||||
);
|
||||
}
|
||||
current = registration;
|
||||
return () => {
|
||||
if (current === registration) current = null;
|
||||
};
|
||||
}
|
||||
|
||||
/** The running hub, or null while none is registered or its grant is gone. */
|
||||
export async function activeNotifyHub(): Promise<PluginNotifyHub | null> {
|
||||
const registration = current;
|
||||
if (!registration) return null;
|
||||
const granted = await hasCapability(
|
||||
registration.pluginId,
|
||||
"notify:hub",
|
||||
registration.declared,
|
||||
);
|
||||
return granted ? registration.hub : null;
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearNotifyHub(): void {
|
||||
current = null;
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { DismissedAlertRepository } from "../../../database/repositories/dismissed-alert-repository.js";
|
||||
|
||||
describe("DismissedAlertRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<DismissedAlertRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
return new DismissedAlertRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates, lists, and finds dismissed alerts by user", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.create("user-1", "alert-1");
|
||||
await repo.create("user-1", "alert-2");
|
||||
await repo.create("user-2", "alert-3");
|
||||
|
||||
expect(await repo.listAlertIdsByUserId("user-1")).toEqual([
|
||||
"alert-1",
|
||||
"alert-2",
|
||||
]);
|
||||
expect((await repo.findForUser("user-1", "alert-1"))?.alertId).toBe(
|
||||
"alert-1",
|
||||
);
|
||||
expect(await repo.findForUser("user-1", "alert-3")).toBeNull();
|
||||
expect(
|
||||
(await repo.listByUserId("user-1")).map((row) => row.alertId),
|
||||
).toEqual(["alert-1", "alert-2"]);
|
||||
});
|
||||
|
||||
it("creates import alerts without duplicating user alert pairs", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.createForImport("user-1", "alert-1", "2026-01-01T00:00:00.000Z"),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
repo.createForImport("user-1", "alert-1", "2026-01-02T00:00:00.000Z"),
|
||||
).resolves.toBe(false);
|
||||
|
||||
const alerts = await repo.listByUserId("user-1");
|
||||
expect(alerts).toHaveLength(1);
|
||||
expect(alerts[0]).toMatchObject({
|
||||
alertId: "alert-1",
|
||||
dismissedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("deletes dismissed alerts and only triggers writes for changed rows", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.create("user-1", "alert-1");
|
||||
await repo.create("user-1", "alert-2");
|
||||
await repo.create("user-2", "alert-3");
|
||||
expect(writeCount).toBe(3);
|
||||
|
||||
expect(await repo.deleteForUser("user-1", "missing")).toBe(false);
|
||||
expect(writeCount).toBe(3);
|
||||
|
||||
expect(await repo.deleteForUser("user-1", "alert-1")).toBe(true);
|
||||
expect(writeCount).toBe(4);
|
||||
expect(await repo.listAlertIdsByUserId("user-1")).toEqual(["alert-2"]);
|
||||
|
||||
expect(await repo.deleteByUserId("user-1")).toBe(1);
|
||||
expect(writeCount).toBe(5);
|
||||
expect(await repo.deleteByUserId("user-1")).toBe(0);
|
||||
expect(writeCount).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* POST /notification-channels/:id/test sends through deliverNotification,
|
||||
* the same path an automation's notify step takes.
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
deliver: vi.fn(),
|
||||
channel: null as null | { id: number; type: string; config: string },
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() => (req: { userId?: string }, _res: unknown, next: () => void) => {
|
||||
req.userId = "user-1";
|
||||
next();
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentNotificationChannelRepository: () => ({
|
||||
findNotificationChannelForUser: async () => h.channel,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../utils/notification-sender.js", () => ({
|
||||
deliverNotification: h.deliver,
|
||||
}));
|
||||
|
||||
const { default: router } =
|
||||
await import("../../../database/routes/notification-channels-routes.js");
|
||||
const app = express().use(express.json()).use(router);
|
||||
|
||||
beforeEach(() => {
|
||||
h.deliver.mockReset();
|
||||
h.channel = { id: 3, type: "ntfy", config: '{"url":"https://ntfy.sh"}' };
|
||||
});
|
||||
|
||||
describe("POST /notification-channels/:id/test", () => {
|
||||
it("delivers a test notification to the channel", async () => {
|
||||
h.deliver.mockResolvedValue(undefined);
|
||||
const response = await request(app).post("/notification-channels/3/test");
|
||||
expect(response.body).toEqual({ success: true });
|
||||
expect(h.deliver).toHaveBeenCalledWith(
|
||||
h.channel,
|
||||
expect.objectContaining({ severity: "info" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the delivery error", async () => {
|
||||
h.deliver.mockRejectedValue(new Error("ntfy channel is missing a topic"));
|
||||
const response = await request(app).post("/notification-channels/3/test");
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
error: "ntfy channel is missing a topic",
|
||||
});
|
||||
});
|
||||
|
||||
it("answers 404 for a channel the user does not own", async () => {
|
||||
h.channel = null;
|
||||
const response = await request(app).post("/notification-channels/9/test");
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,6 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({}),
|
||||
createCurrentSshCredentialUsageRepository: deletingRepo("usage"),
|
||||
createCurrentRecentActivityRepository: deletingRepo("activity"),
|
||||
createCurrentDismissedAlertRepository: deletingRepo("alerts"),
|
||||
createCurrentHostRepository: deletingRepo("hosts"),
|
||||
createCurrentCredentialRepository: deletingRepo("credentials"),
|
||||
}));
|
||||
@@ -126,7 +125,6 @@ describe("resetUserPassword", () => {
|
||||
expect(calls.deletedFor).toEqual([
|
||||
"usage:user-1",
|
||||
"activity:user-1",
|
||||
"alerts:user-1",
|
||||
"hosts:user-1",
|
||||
"credentials:user-1",
|
||||
]);
|
||||
|
||||
BIN
Binary file not shown.
+8
-1
@@ -412,6 +412,14 @@ export const ROWS: Record<string, Row[]> = {
|
||||
enabled: 1,
|
||||
},
|
||||
],
|
||||
dismissed_alerts: [
|
||||
{
|
||||
id: 1,
|
||||
user_id: USERS.admin,
|
||||
alert_id: "d4-dismissed-announcement",
|
||||
dismissed_at: T,
|
||||
},
|
||||
],
|
||||
|
||||
snippet_folders: [
|
||||
{
|
||||
@@ -839,6 +847,5 @@ export const CORE_TABLES = new Set([
|
||||
"ssh_data",
|
||||
"host_access",
|
||||
"user_preferences",
|
||||
"notification_channels",
|
||||
"settings",
|
||||
]);
|
||||
@@ -337,6 +337,14 @@ ALTER TABLE `user_preferences` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT
|
||||
-- would delete the data first. The columns stay in place, unused, until 3.0.0.
|
||||
SELECT 1;
|
||||
|
||||
-- ==== core 0058_boring_avengers ====
|
||||
-- dismissed_alerts and notification_channels moved to the alerts plugin. It
|
||||
-- renames the first into its own namespace and copies the second at
|
||||
-- activation, both after this migration, so a real DROP TABLE here would
|
||||
-- delete them first. notification_channels stays in place, unused, until
|
||||
-- 3.0.0.
|
||||
SELECT 1;
|
||||
|
||||
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
@@ -634,6 +642,23 @@ CREATE TABLE IF NOT EXISTS `automation_channels` (
|
||||
);
|
||||
RENAME TABLE `automation_channels` TO `p_automations_channels`;
|
||||
|
||||
-- ==== plugin automations 0002_unlink_channels.sql ====
|
||||
-- automations 0002: unlink_channels
|
||||
-- Hand-written: channels moved from core's notification_channels to the
|
||||
-- alerts plugin, so the link table drops its foreign key to the old table.
|
||||
-- The constraint's name differs between installs, so the table is rebuilt.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `p_automations_channels_v2` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`automation_id` int NOT NULL,
|
||||
`channel_id` int NOT NULL,
|
||||
FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO `p_automations_channels_v2` (`id`, `automation_id`, `channel_id`) SELECT `id`, `automation_id`, `channel_id` FROM `p_automations_channels`;
|
||||
DROP TABLE `p_automations_channels`;
|
||||
RENAME TABLE `p_automations_channels_v2` TO `p_automations_channels`;
|
||||
CREATE UNIQUE INDEX `idx_automation_channels_pair` ON `p_automations_channels` (`automation_id`, `channel_id`);
|
||||
|
||||
-- ==== plugin homepage 0001_adopt_homepage_tables.sql ====
|
||||
-- homepage 0001: adopt_homepage_tables
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
@@ -729,6 +754,62 @@ CREATE TABLE IF NOT EXISTS `ai_proposals` (
|
||||
);
|
||||
RENAME TABLE `ai_proposals` TO `p_ai_proposals`;
|
||||
|
||||
-- ==== plugin alerts 0001_create_alerts.sql ====
|
||||
-- alerts 0001: create_alerts
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `p_alerts_channels` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`type` varchar(32) NOT NULL,
|
||||
`config` text,
|
||||
`enabled` boolean NOT NULL DEFAULT true,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX `idx_alerts_channels_user` ON `p_alerts_channels` (`user_id`);
|
||||
CREATE TABLE IF NOT EXISTS `p_alerts_items` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`source` varchar(64) NOT NULL,
|
||||
`category` varchar(128) NOT NULL,
|
||||
`severity` varchar(16) NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`body` text,
|
||||
`link` text,
|
||||
`context` text,
|
||||
`dedupe_key` varchar(255),
|
||||
`deliveries` text,
|
||||
`read_at` varchar(255),
|
||||
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX `idx_alerts_items_user` ON `p_alerts_items` (`user_id`, `created_at`);
|
||||
CREATE INDEX `idx_alerts_items_unread` ON `p_alerts_items` (`user_id`, `read_at`);
|
||||
CREATE INDEX `idx_alerts_items_dedupe` ON `p_alerts_items` (`user_id`, `dedupe_key`);
|
||||
CREATE TABLE IF NOT EXISTS `p_alerts_rules` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`pattern` varchar(128) NOT NULL DEFAULT '*',
|
||||
`min_severity` varchar(16) NOT NULL DEFAULT 'warning',
|
||||
`channel_ids` text NOT NULL,
|
||||
`enabled` boolean NOT NULL DEFAULT true,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX `idx_alerts_rules_user` ON `p_alerts_rules` (`user_id`);
|
||||
CREATE TABLE IF NOT EXISTS `dismissed_alerts` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`alert_id` text NOT NULL,
|
||||
`dismissed_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `dismissed_alerts` TO `p_alerts_dismissed`;
|
||||
|
||||
-- ==== plugin file-manager 0001_adopt_file_manager_tables.sql ====
|
||||
-- file-manager 0001: adopt_file_manager_tables
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -363,6 +363,14 @@ ALTER TABLE "user_preferences" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIM
|
||||
-- would delete the data first. The columns stay in place, unused, until 3.0.0.
|
||||
SELECT 1;
|
||||
|
||||
-- ==== core 0059_square_korg ====
|
||||
-- dismissed_alerts and notification_channels moved to the alerts plugin. It
|
||||
-- renames the first into its own namespace and copies the second at
|
||||
-- activation, both after this migration, so a real DROP TABLE here would
|
||||
-- delete them first. notification_channels stays in place, unused, until
|
||||
-- 3.0.0.
|
||||
SELECT 1;
|
||||
|
||||
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
@@ -682,6 +690,24 @@ CREATE TABLE IF NOT EXISTS "automation_channels" (
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "automation_channels" ("automation_id", "channel_id");
|
||||
ALTER TABLE "automation_channels" RENAME TO "p_automations_channels";
|
||||
|
||||
-- ==== plugin automations 0002_unlink_channels.sql ====
|
||||
-- automations 0002: unlink_channels
|
||||
-- Hand-written: channels moved from core's notification_channels to the
|
||||
-- alerts plugin, so the link table drops its foreign key to the old table.
|
||||
-- The constraint's name differs between installs, so the table is rebuilt.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "p_automations_channels_v2" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"automation_id" integer NOT NULL,
|
||||
"channel_id" integer NOT NULL,
|
||||
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO "p_automations_channels_v2" ("id", "automation_id", "channel_id") SELECT "id", "automation_id", "channel_id" FROM "p_automations_channels";
|
||||
DROP TABLE "p_automations_channels";
|
||||
ALTER TABLE "p_automations_channels_v2" RENAME TO "p_automations_channels";
|
||||
SELECT setval(pg_get_serial_sequence('p_automations_channels', 'id'), COALESCE((SELECT MAX("id") FROM "p_automations_channels"), 1));
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "p_automations_channels" ("automation_id", "channel_id");
|
||||
|
||||
-- ==== plugin homepage 0001_adopt_homepage_tables.sql ====
|
||||
-- homepage 0001: adopt_homepage_tables
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
@@ -784,6 +810,63 @@ CREATE INDEX IF NOT EXISTS "idx_ai_proposals_user" ON "ai_proposals" ("user_id",
|
||||
CREATE INDEX IF NOT EXISTS "idx_ai_proposals_conversation" ON "ai_proposals" ("conversation_id");
|
||||
ALTER TABLE "ai_proposals" RENAME TO "p_ai_proposals";
|
||||
|
||||
-- ==== plugin alerts 0001_create_alerts.sql ====
|
||||
-- alerts 0001: create_alerts
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "p_alerts_channels" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"type" varchar(32) NOT NULL,
|
||||
"config" text,
|
||||
"enabled" boolean NOT NULL DEFAULT true,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_alerts_channels_user" ON "p_alerts_channels" ("user_id");
|
||||
CREATE TABLE IF NOT EXISTS "p_alerts_items" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"source" varchar(64) NOT NULL,
|
||||
"category" varchar(128) NOT NULL,
|
||||
"severity" varchar(16) NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"body" text,
|
||||
"link" text,
|
||||
"context" text,
|
||||
"dedupe_key" varchar(255),
|
||||
"deliveries" text,
|
||||
"read_at" varchar(255),
|
||||
"created_at" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_alerts_items_user" ON "p_alerts_items" ("user_id", "created_at");
|
||||
CREATE INDEX IF NOT EXISTS "idx_alerts_items_unread" ON "p_alerts_items" ("user_id", "read_at");
|
||||
CREATE INDEX IF NOT EXISTS "idx_alerts_items_dedupe" ON "p_alerts_items" ("user_id", "dedupe_key");
|
||||
CREATE TABLE IF NOT EXISTS "p_alerts_rules" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"pattern" varchar(128) NOT NULL DEFAULT '*',
|
||||
"min_severity" varchar(16) NOT NULL DEFAULT 'warning',
|
||||
"channel_ids" text NOT NULL,
|
||||
"enabled" boolean NOT NULL DEFAULT true,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_alerts_rules_user" ON "p_alerts_rules" ("user_id");
|
||||
CREATE TABLE IF NOT EXISTS "dismissed_alerts" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"alert_id" text NOT NULL,
|
||||
"dismissed_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_dismissed_alerts_user_id" ON "dismissed_alerts" ("user_id");
|
||||
ALTER TABLE "dismissed_alerts" RENAME TO "p_alerts_dismissed";
|
||||
|
||||
-- ==== plugin file-manager 0001_adopt_file_manager_tables.sql ====
|
||||
-- file-manager 0001: adopt_file_manager_tables
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
/**
|
||||
* ctx.notify and ctx.fetch: each is gated on its capability and audited,
|
||||
* notify only ever reaches the acting user's own channels, and fetch goes
|
||||
* through the SSRF guard with only the hosts the caller listed.
|
||||
* ctx.notify and ctx.fetch: each is gated on its capability and audited.
|
||||
* notify resolves the audience in core and hands the hub plain user ids, and
|
||||
* fetch goes through the SSRF guard with only the hosts the caller listed.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
PluginNotification,
|
||||
PluginNotifyHub,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
|
||||
const grants = new Map<string, string[]>();
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
channels: new Map<
|
||||
string,
|
||||
Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: number;
|
||||
}>
|
||||
>(),
|
||||
delivered: [] as Array<{ channelId: number; title: string }>,
|
||||
failChannel: null as number | null,
|
||||
users: [
|
||||
{ id: "alice", isAdmin: true },
|
||||
{ id: "bob", isAdmin: false },
|
||||
{ id: "carol", isAdmin: false },
|
||||
],
|
||||
permissions: new Map<string, string[]>([
|
||||
["bob", ["docker.view"]],
|
||||
["carol", ["docker.view"]],
|
||||
]),
|
||||
fetchCalls: [] as Array<{ url: string; allowlist: readonly string[] }>,
|
||||
lastTls: null as Record<string, unknown> | null,
|
||||
lastSignal: null as AbortSignal | null,
|
||||
@@ -35,28 +36,28 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
capability,
|
||||
})),
|
||||
}),
|
||||
createCurrentNotificationChannelRepository: () => ({
|
||||
listNotificationChannels: async (userId: string) =>
|
||||
state.channels.get(userId) ?? [],
|
||||
createCurrentUserRepository: () => ({
|
||||
listAll: async () => state.users,
|
||||
findById: async (id: string) =>
|
||||
state.users.find((user) => user.id === id) ?? null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
hasPermission: async (userId: string, permission: string) =>
|
||||
state.permissions.get(userId)?.includes(permission) ?? false,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/notification-sender.js", () => ({
|
||||
deliverNotification: async (
|
||||
channel: { id: number },
|
||||
notification: { title: string },
|
||||
) => {
|
||||
if (channel.id === state.failChannel) throw new Error("HTTP 500");
|
||||
state.delivered.push({ channelId: channel.id, title: notification.title });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
||||
safeOutboundFetch: async (
|
||||
url: string,
|
||||
@@ -74,13 +75,18 @@ vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { runAsActor } from "../../plugins/actor.js";
|
||||
import { clearNotifyHub } from "../../plugins/notify-hub.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
function contextFor(capabilities: string[]) {
|
||||
grants.set("demo", capabilities);
|
||||
function contextFor(
|
||||
capabilities: string[],
|
||||
id = "demo",
|
||||
granted: string[] = capabilities,
|
||||
) {
|
||||
grants.set(id, granted);
|
||||
const manifest = {
|
||||
id: "demo",
|
||||
name: "demo",
|
||||
id,
|
||||
name: id,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
@@ -89,26 +95,38 @@ function contextFor(capabilities: string[]) {
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities,
|
||||
} as PluginManifest;
|
||||
const handle = createPluginHandle("demo", { activate: () => {} });
|
||||
const handle = createPluginHandle(id, { activate: () => {} });
|
||||
return createPluginContext(manifest, handle);
|
||||
}
|
||||
|
||||
function fakeHub() {
|
||||
const calls: Array<{
|
||||
source: string;
|
||||
recipients: string[];
|
||||
notification: PluginNotification;
|
||||
}> = [];
|
||||
const hub: PluginNotifyHub = {
|
||||
deliver: async (input) => {
|
||||
calls.push(input);
|
||||
return {
|
||||
recipients: input.recipients.length,
|
||||
delivered: 0,
|
||||
failures: [],
|
||||
};
|
||||
},
|
||||
channels: async (userId) => [
|
||||
{ id: 1, name: `channel of ${userId}`, type: "webhook", enabled: true },
|
||||
],
|
||||
};
|
||||
return { hub, calls };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
grants.clear();
|
||||
auditEntries.length = 0;
|
||||
invalidatePluginPermissionCache();
|
||||
state.channels.clear();
|
||||
state.delivered.length = 0;
|
||||
state.failChannel = null;
|
||||
clearNotifyHub();
|
||||
state.fetchCalls.length = 0;
|
||||
state.channels.set("alice", [
|
||||
{ id: 1, name: "ops", type: "webhook", config: "{}", enabled: 1 },
|
||||
{ id: 2, name: "muted", type: "ntfy", config: "{}", enabled: 0 },
|
||||
{ id: 3, name: "pager", type: "discord", config: "{}", enabled: 1 },
|
||||
]);
|
||||
state.channels.set("bob", [
|
||||
{ id: 9, name: "bob's", type: "webhook", config: "{}", enabled: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
describe("ctx.notify", () => {
|
||||
@@ -123,43 +141,118 @@ describe("ctx.notify", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a call with no acting user", async () => {
|
||||
it("refuses a send with neither an acting user nor an audience", async () => {
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
await expect(
|
||||
ctx.notify.send([1], { title: "t", body: "b" }),
|
||||
).rejects.toThrow(/acting user/);
|
||||
});
|
||||
|
||||
it("lists the actor's channels without their config", async () => {
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
const channels = await runAsActor("alice", "request", () =>
|
||||
ctx.notify.channels(),
|
||||
await expect(ctx.notify.send({ title: "t" })).rejects.toThrow(
|
||||
/acting user/,
|
||||
);
|
||||
expect(channels).toEqual([
|
||||
{ id: 1, name: "ops", type: "webhook", enabled: true },
|
||||
{ id: 2, name: "muted", type: "ntfy", enabled: false },
|
||||
{ id: 3, name: "pager", type: "discord", enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends only to the actor's enabled channels and reports failures", async () => {
|
||||
it("reaches nobody while no hub is running", async () => {
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
const result = await runAsActor("alice", "request", () =>
|
||||
ctx.notify.send({ title: "Disk" }),
|
||||
);
|
||||
expect(result).toEqual({ recipients: 0, delivered: 0, failures: [] });
|
||||
expect(
|
||||
await runAsActor("alice", "request", () => ctx.notify.channels()),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("hands the hub the acting user and the sending plugin by default", async () => {
|
||||
const { hub, calls } = fakeHub();
|
||||
contextFor(["notify:hub"], "hub").notify.serve(hub);
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
state.failChannel = 3;
|
||||
|
||||
const result = await runAsActor("alice", "request", () =>
|
||||
ctx.notify.send([1, 2, 3, 9], { title: "Disk", body: "full" }),
|
||||
ctx.notify.send({ title: "Disk", severity: "critical" }),
|
||||
);
|
||||
|
||||
expect(state.delivered).toEqual([{ channelId: 1, title: "Disk" }]);
|
||||
expect(result).toEqual({
|
||||
delivered: 1,
|
||||
failures: [{ channelId: 3, name: "pager", error: "HTTP 500" }],
|
||||
});
|
||||
expect(result.recipients).toBe(1);
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
source: "demo",
|
||||
recipients: ["alice"],
|
||||
notification: { title: "Disk", severity: "critical" },
|
||||
},
|
||||
]);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_notify_send",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a named user, admins and a permission in core", async () => {
|
||||
const { hub, calls } = fakeHub();
|
||||
contextFor(["notify:hub"], "hub").notify.serve(hub);
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
|
||||
await ctx.notify.send({ title: "a", audience: { userId: "bob" } });
|
||||
await ctx.notify.send({ title: "b", audience: "admins" });
|
||||
await ctx.notify.send({
|
||||
title: "c",
|
||||
audience: { permission: "docker.view" },
|
||||
});
|
||||
|
||||
expect(calls.map((call) => call.recipients)).toEqual([
|
||||
["bob"],
|
||||
["alice"],
|
||||
["bob", "carol"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not call the hub for an audience that names nobody", async () => {
|
||||
const { hub, calls } = fakeHub();
|
||||
contextFor(["notify:hub"], "hub").notify.serve(hub);
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
|
||||
const result = await ctx.notify.send({
|
||||
title: "x",
|
||||
audience: { userId: "ghost" },
|
||||
});
|
||||
|
||||
expect(result.recipients).toBe(0);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("asks the hub for the acting user's channels", async () => {
|
||||
const { hub } = fakeHub();
|
||||
contextFor(["notify:hub"], "hub").notify.serve(hub);
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
expect(
|
||||
await runAsActor("bob", "request", () => ctx.notify.channels()),
|
||||
).toEqual([
|
||||
{ id: 1, name: "channel of bob", type: "webhook", enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("only serves a hub the manifest declares notify:hub for", () => {
|
||||
const { hub } = fakeHub();
|
||||
expect(() =>
|
||||
contextFor(["notify:send"], "rogue").notify.serve(hub),
|
||||
).toThrow(/notify:hub/);
|
||||
});
|
||||
|
||||
it("refuses a second hub from another plugin", () => {
|
||||
contextFor(["notify:hub"], "hub").notify.serve(fakeHub().hub);
|
||||
expect(() =>
|
||||
contextFor(["notify:hub"], "other").notify.serve(fakeHub().hub),
|
||||
).toThrow(/already/);
|
||||
});
|
||||
|
||||
it("stops using a hub once it is revoked or its grant is gone", async () => {
|
||||
const first = fakeHub();
|
||||
const revoke = contextFor(["notify:hub"], "hub").notify.serve(first.hub);
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
revoke();
|
||||
await ctx.notify.send({ title: "x", audience: "admins" });
|
||||
expect(first.calls).toEqual([]);
|
||||
|
||||
const second = fakeHub();
|
||||
contextFor(["notify:hub"], "ungranted", []).notify.serve(second.hub);
|
||||
await ctx.notify.send({ title: "y", audience: "admins" });
|
||||
expect(second.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.fetch", () => {
|
||||
|
||||
@@ -265,6 +265,7 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
["/plugin-api/secret-sources", "d4-source"],
|
||||
["/plugin-api/session-sharing/rooms", "d4-room"],
|
||||
["/plugin-api/webauthn/credentials", "d4-passkey", "passkey"],
|
||||
["/plugin-api/alerts/channels", "d4-channel"],
|
||||
];
|
||||
const failures: string[] = [];
|
||||
for (const [url, marker, as] of checks) {
|
||||
@@ -500,6 +501,17 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
).toBe("d4-source-token");
|
||||
});
|
||||
|
||||
it("moves every notification channel into alerts, config readable", async () => {
|
||||
const response = await get(current(), "/plugin-api/alerts/channels/1");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
name: "d4-channel",
|
||||
type: "ntfy",
|
||||
usable: true,
|
||||
config: { url: "https://ntfy.d4.example/d4" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps every role's permissions", async () => {
|
||||
const { PermissionManager } =
|
||||
await import("../../utils/permission-manager.js");
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* The 2.8 to 2.9 channel move. Every notification channel reaches the alerts
|
||||
* plugin's table with its id, its config sealed with the installation key once
|
||||
* the owner's data key opens, and nothing left readable in the old table.
|
||||
* A closed or wrong key leaves the config as it was, and a later run for that
|
||||
* user finishes the job. Running it twice changes nothing.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
db: null as unknown,
|
||||
deks: new Map<string, Buffer>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
|
||||
vi.mock("../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: { getUserDataKey: (userId: string) => h.deks.get(userId) },
|
||||
}));
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
encryptSystemSecret: async (value: string) => `sysenc:v1:${value}`,
|
||||
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
return { databaseLogger: log };
|
||||
});
|
||||
|
||||
const { runNotificationChannelMigration } =
|
||||
await import("../../upgrade/notification-channel-migration.js");
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
const SOURCE_DDL = `
|
||||
CREATE TABLE notification_channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
const TARGET_DDL = `
|
||||
CREATE TABLE "p_alerts_channels" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"config" text,
|
||||
"enabled" integer NOT NULL DEFAULT 1,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
function setup({ target }: { target: boolean }) {
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(SOURCE_DDL);
|
||||
if (target) sqlite.exec(TARGET_DDL);
|
||||
h.db = drizzle(sqlite);
|
||||
h.deks.clear();
|
||||
}
|
||||
|
||||
function addChannel(
|
||||
id: number,
|
||||
userId: string,
|
||||
config: Record<string, unknown>,
|
||||
options: { encrypt?: boolean; enabled?: boolean } = {},
|
||||
) {
|
||||
let dek = h.deks.get(userId);
|
||||
if (!dek) {
|
||||
dek = crypto.randomBytes(32);
|
||||
h.deks.set(userId, dek);
|
||||
}
|
||||
const text = JSON.stringify(config);
|
||||
const stored =
|
||||
options.encrypt === false
|
||||
? text
|
||||
: FieldCrypto.encryptField(text, dek, String(id), "config");
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO notification_channels (id, user_id, name, type, config, enabled) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
userId,
|
||||
`channel-${id}`,
|
||||
"ntfy",
|
||||
stored,
|
||||
options.enabled === false ? 0 : 1,
|
||||
);
|
||||
}
|
||||
|
||||
const targetRows = () =>
|
||||
sqlite
|
||||
.prepare(
|
||||
'SELECT id, user_id, name, type, config, enabled FROM "p_alerts_channels" ORDER BY id',
|
||||
)
|
||||
.all() as Array<{
|
||||
id: number;
|
||||
user_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: number;
|
||||
}>;
|
||||
|
||||
const sealed = (config: Record<string, unknown>) =>
|
||||
`sysenc:v1:${JSON.stringify(config)}`;
|
||||
|
||||
describe("runNotificationChannelMigration", () => {
|
||||
beforeEach(() => setup({ target: true }));
|
||||
|
||||
it("does nothing until the plugin's table exists", async () => {
|
||||
setup({ target: false });
|
||||
addChannel(1, "alice", { url: "https://ntfy.sh", topic: "a" });
|
||||
expect(await runNotificationChannelMigration()).toEqual({
|
||||
moved: 0,
|
||||
sealed: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("copies every channel with its id and seals its config", async () => {
|
||||
addChannel(4, "alice", { url: "https://ntfy.sh", topic: "a", token: "tk" });
|
||||
addChannel(7, "bob", { url: "https://hooks.example" }, { enabled: false });
|
||||
|
||||
expect(await runNotificationChannelMigration()).toEqual({
|
||||
moved: 2,
|
||||
sealed: 2,
|
||||
});
|
||||
|
||||
expect(targetRows()).toEqual([
|
||||
{
|
||||
id: 4,
|
||||
user_id: "alice",
|
||||
name: "channel-4",
|
||||
type: "ntfy",
|
||||
config: sealed({ url: "https://ntfy.sh", topic: "a", token: "tk" }),
|
||||
enabled: 1,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
user_id: "bob",
|
||||
name: "channel-7",
|
||||
type: "ntfy",
|
||||
config: sealed({ url: "https://hooks.example" }),
|
||||
enabled: 0,
|
||||
},
|
||||
]);
|
||||
// No token is left readable in the old table.
|
||||
expect(
|
||||
sqlite.prepare("SELECT config FROM notification_channels").all(),
|
||||
).toEqual([{ config: "" }, { config: "" }]);
|
||||
});
|
||||
|
||||
it("seals a config 2.8 never encrypted", async () => {
|
||||
addChannel(
|
||||
2,
|
||||
"alice",
|
||||
{ url: "https://ntfy.sh", topic: "p" },
|
||||
{
|
||||
encrypt: false,
|
||||
},
|
||||
);
|
||||
await runNotificationChannelMigration();
|
||||
expect(targetRows()[0].config).toBe(
|
||||
sealed({ url: "https://ntfy.sh", topic: "p" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for a closed key, then seals at that user's login", async () => {
|
||||
addChannel(3, "carol", { url: "https://ntfy.sh", topic: "c" });
|
||||
const dek = h.deks.get("carol")!;
|
||||
h.deks.delete("carol");
|
||||
|
||||
expect(await runNotificationChannelMigration()).toEqual({
|
||||
moved: 1,
|
||||
sealed: 0,
|
||||
});
|
||||
const raw = targetRows()[0].config;
|
||||
expect(raw.startsWith("sysenc:")).toBe(false);
|
||||
expect(FieldCrypto.isEncrypted(raw)).toBe(true);
|
||||
|
||||
h.deks.set("carol", dek);
|
||||
expect(await runNotificationChannelMigration("carol")).toEqual({
|
||||
moved: 0,
|
||||
sealed: 1,
|
||||
});
|
||||
expect(targetRows()[0].config).toBe(
|
||||
sealed({ url: "https://ntfy.sh", topic: "c" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a config the key cannot open as it was", async () => {
|
||||
addChannel(5, "dave", { url: "https://ntfy.sh", topic: "d" });
|
||||
h.deks.set("dave", crypto.randomBytes(32));
|
||||
|
||||
await runNotificationChannelMigration();
|
||||
|
||||
const raw = targetRows()[0].config;
|
||||
expect(raw.startsWith("sysenc:")).toBe(false);
|
||||
expect(FieldCrypto.isEncrypted(raw)).toBe(true);
|
||||
});
|
||||
|
||||
it("changes nothing the second time", async () => {
|
||||
addChannel(1, "alice", { url: "https://ntfy.sh", topic: "a" });
|
||||
await runNotificationChannelMigration();
|
||||
const first = targetRows();
|
||||
|
||||
expect(await runNotificationChannelMigration()).toEqual({
|
||||
moved: 0,
|
||||
sealed: 0,
|
||||
});
|
||||
expect(targetRows()).toEqual(first);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import { runAcmeSslSettingsMigration } from "./acme-ssl-settings-migration.js";
|
||||
import { runVaultSettingsMigration } from "./vault-settings-migration.js";
|
||||
import { runSecretSourcesTokenMigration } from "./secret-sources-token-migration.js";
|
||||
import { runTotpMigration } from "./totp-migration.js";
|
||||
import { runNotificationChannelMigration } from "./notification-channel-migration.js";
|
||||
import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js";
|
||||
|
||||
const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
|
||||
@@ -55,6 +56,7 @@ const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
|
||||
["runVaultSettingsMigration", runVaultSettingsMigration],
|
||||
["runSecretSourcesTokenMigration", runSecretSourcesTokenMigration],
|
||||
["runTotpMigration", runTotpMigration],
|
||||
["runNotificationChannelMigration", runNotificationChannelMigration],
|
||||
["runTermixIdentityCaMigration", runTermixIdentityCaMigration],
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Moves 2.8 notification channels into the alerts plugin.
|
||||
*
|
||||
* 2.8 kept them in core's notification_channels, each config encrypted under
|
||||
* its owner's data key. This copies every row into p_alerts_channels with the
|
||||
* same id, because automations' notify steps and links name it, then empties
|
||||
* the old config column so no webhook token or ntfy key stays behind.
|
||||
*
|
||||
* The plugin reads configs sealed with the installation key, so it can send
|
||||
* an alert with no user signed in. A config is resealed as soon as its
|
||||
* owner's data key opens: at boot for most users, at their next password
|
||||
* login for a 2.8 key only the password unwraps. Until then the channel shows
|
||||
* as needing to be saved again.
|
||||
*
|
||||
* The old table stays until 3.0.0, like the old columns. Idempotent.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import { resolveDatabaseDialect } from "../database/db/dialect.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { LazyFieldEncryption } from "../utils/lazy-field-encryption.js";
|
||||
import {
|
||||
encryptSystemSecret,
|
||||
isSystemEncrypted,
|
||||
} from "../utils/system-secret-crypto.js";
|
||||
import {
|
||||
runStatement,
|
||||
selectRows,
|
||||
} from "../utils/crypto-migration/raw-rows.js";
|
||||
|
||||
const TARGET = "p_alerts_channels";
|
||||
const SOURCE = "notification_channels";
|
||||
|
||||
interface LegacyChannel {
|
||||
id: number;
|
||||
user_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
config: string | null;
|
||||
enabled: unknown;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationChannelMigrationResult {
|
||||
/** Channels copied into the plugin's table. */
|
||||
moved: number;
|
||||
/** Configs sealed with the installation key. */
|
||||
sealed: number;
|
||||
}
|
||||
|
||||
async function tableExists(name: string): Promise<boolean> {
|
||||
try {
|
||||
await selectRows(sql`SELECT 1 FROM ${sql.identifier(name)} WHERE 1 = 0`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isOn(value: unknown): boolean {
|
||||
return value === true || value === 1 || value === "1" || value === "t";
|
||||
}
|
||||
|
||||
/** Explicit ids leave a Postgres sequence behind them. */
|
||||
async function syncSequence(): Promise<void> {
|
||||
if (resolveDatabaseDialect() !== "postgres") return;
|
||||
await runStatement(
|
||||
sql`SELECT setval(pg_get_serial_sequence(${TARGET}, 'id'), (SELECT COALESCE(MAX(id), 1) FROM ${sql.identifier(TARGET)}))`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The config in plain JSON, or null while the owner's key is closed. */
|
||||
function openLegacyConfig(row: {
|
||||
id: number;
|
||||
user_id: string;
|
||||
config: string;
|
||||
}): string | null {
|
||||
const dek = DataCrypto.getUserDataKey(row.user_id);
|
||||
if (!dek) return null;
|
||||
const plain = LazyFieldEncryption.safeGetFieldValue(
|
||||
row.config,
|
||||
dek,
|
||||
String(row.id),
|
||||
"config",
|
||||
);
|
||||
if (!plain) return null;
|
||||
try {
|
||||
JSON.parse(plain);
|
||||
return plain;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyChannels(
|
||||
result: NotificationChannelMigrationResult,
|
||||
): Promise<void> {
|
||||
if (!(await tableExists(SOURCE))) return;
|
||||
const rows = await selectRows<LegacyChannel>(
|
||||
sql`SELECT id, user_id, name, type, config, enabled, created_at FROM ${sql.identifier(SOURCE)} ORDER BY id`,
|
||||
);
|
||||
const sqlite = resolveDatabaseDialect() === "sqlite";
|
||||
let copied = false;
|
||||
for (const row of rows) {
|
||||
// Emptied once copied.
|
||||
if (!row.config) continue;
|
||||
const taken = await selectRows(
|
||||
sql`SELECT id FROM ${sql.identifier(TARGET)} WHERE id = ${row.id}`,
|
||||
);
|
||||
if (taken.length > 0) continue;
|
||||
const enabled = isOn(row.enabled);
|
||||
const createdAt = row.created_at ?? new Date().toISOString();
|
||||
await runStatement(
|
||||
sql`INSERT INTO ${sql.identifier(TARGET)} (id, user_id, name, type, config, enabled, created_at, updated_at) VALUES (${row.id}, ${row.user_id}, ${row.name}, ${row.type}, ${row.config}, ${sqlite ? (enabled ? 1 : 0) : enabled}, ${createdAt}, ${createdAt})`,
|
||||
);
|
||||
await runStatement(
|
||||
sql`UPDATE ${sql.identifier(SOURCE)} SET config = '' WHERE id = ${row.id}`,
|
||||
);
|
||||
copied = true;
|
||||
result.moved++;
|
||||
}
|
||||
if (copied) await syncSequence();
|
||||
}
|
||||
|
||||
async function sealConfigs(
|
||||
result: NotificationChannelMigrationResult,
|
||||
onlyUserId?: string,
|
||||
): Promise<void> {
|
||||
const rows = await selectRows<{
|
||||
id: number;
|
||||
user_id: string;
|
||||
config: string | null;
|
||||
}>(
|
||||
onlyUserId
|
||||
? sql`SELECT id, user_id, config FROM ${sql.identifier(TARGET)} WHERE user_id = ${onlyUserId}`
|
||||
: sql`SELECT id, user_id, config FROM ${sql.identifier(TARGET)}`,
|
||||
);
|
||||
for (const row of rows) {
|
||||
if (!row.config || isSystemEncrypted(row.config)) continue;
|
||||
try {
|
||||
const plain = openLegacyConfig({
|
||||
id: Number(row.id),
|
||||
user_id: row.user_id,
|
||||
config: row.config,
|
||||
});
|
||||
if (!plain) continue;
|
||||
await runStatement(
|
||||
sql`UPDATE ${sql.identifier(TARGET)} SET config = ${await encryptSystemSecret(plain)} WHERE id = ${row.id}`,
|
||||
);
|
||||
result.sealed++;
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Could not seal a notification channel's config", {
|
||||
operation: "notification_channel_migration",
|
||||
channelId: row.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass a user id to reseal only that user's channels, as a password login
|
||||
* does once it has opened a data key the boot run could not.
|
||||
*/
|
||||
export async function runNotificationChannelMigration(
|
||||
onlyUserId?: string,
|
||||
): Promise<NotificationChannelMigrationResult> {
|
||||
const result: NotificationChannelMigrationResult = { moved: 0, sealed: 0 };
|
||||
try {
|
||||
if (!(await tableExists(TARGET))) return result;
|
||||
if (!onlyUserId) await copyChannels(result);
|
||||
await sealConfigs(result, onlyUserId);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Notification channel migration failed", error, {
|
||||
operation: "notification_channel_migration",
|
||||
});
|
||||
}
|
||||
if (result.moved > 0 || result.sealed > 0) {
|
||||
databaseLogger.info("Moved notification channels into the alerts plugin", {
|
||||
operation: "notification_channel_migration",
|
||||
...result,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { runOpksshConfigMigration } from "./opkssh-config-migration.js";
|
||||
import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js";
|
||||
import { runSessionRecordingDataMigration } from "./session-recording-data-migration.js";
|
||||
import { runHostMetricsSettingsMigration } from "./host-metrics-settings-migration.js";
|
||||
import { runNotificationChannelMigration } from "./notification-channel-migration.js";
|
||||
|
||||
export async function runPluginDataMoves(): Promise<void> {
|
||||
await runLdapProviderMigration();
|
||||
@@ -18,4 +19,6 @@ export async function runPluginDataMoves(): Promise<void> {
|
||||
// Both set how long history is kept, which the plugin prunes by on activate.
|
||||
await runSessionRecordingDataMigration();
|
||||
await runHostMetricsSettingsMigration();
|
||||
// Automations name channels by id, so they are in place before it starts.
|
||||
await runNotificationChannelMigration();
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { notificationChannels } from "../../database/db/schema.js";
|
||||
import { DataCrypto } from "../data-crypto.js";
|
||||
import { FieldCrypto } from "../field-crypto.js";
|
||||
|
||||
const MIGRATION_FLAG = "notification_channel_config_encrypted_v1";
|
||||
|
||||
export interface ChannelConfigEncryptionResult {
|
||||
encrypted: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification channel configs hold ntfy tokens, webhook auth headers and
|
||||
* Discord webhook URLs, but shipped as plaintext JSON while every other secret
|
||||
* in the app was field-encrypted. This encrypts the rows already on disk.
|
||||
*
|
||||
* Rows whose owner has no usable data key are left alone and retried on a later
|
||||
* boot; the repository reads tolerate both shapes, so a partial run is safe.
|
||||
*/
|
||||
export async function runChannelConfigEncryptionMigration(): Promise<ChannelConfigEncryptionResult | null> {
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
|
||||
try {
|
||||
if ((await settingsRepository.get(MIGRATION_FLAG)) === "done") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { drizzle } = createCurrentRepositoryContext();
|
||||
const rows = await drizzle
|
||||
.select({
|
||||
id: notificationChannels.id,
|
||||
userId: notificationChannels.userId,
|
||||
config: notificationChannels.config,
|
||||
})
|
||||
.from(notificationChannels);
|
||||
|
||||
let encrypted = 0;
|
||||
let skipped = 0;
|
||||
let deferred = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.config || FieldCrypto.isEncrypted(row.config)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let userDataKey: Buffer | null = null;
|
||||
try {
|
||||
userDataKey = DataCrypto.getUserDataKey(row.userId);
|
||||
} catch {
|
||||
userDataKey = null;
|
||||
}
|
||||
|
||||
// No usable key yet (legacy DEK pending migration); retry on a later boot.
|
||||
if (!userDataKey) {
|
||||
deferred++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = DataCrypto.encryptRecord(
|
||||
"notification_channels",
|
||||
{ id: row.id, config: row.config },
|
||||
row.userId,
|
||||
userDataKey,
|
||||
).config;
|
||||
|
||||
await drizzle
|
||||
.update(notificationChannels)
|
||||
.set({ config: value })
|
||||
.where(eq(notificationChannels.id, row.id));
|
||||
encrypted++;
|
||||
}
|
||||
|
||||
// Only close the migration out once no row is still waiting on a key, so a
|
||||
// boot that could not reach some users' keys retries those rows next time.
|
||||
if (deferred === 0) {
|
||||
await settingsRepository.set(MIGRATION_FLAG, "done");
|
||||
}
|
||||
|
||||
if (encrypted > 0 || deferred > 0) {
|
||||
databaseLogger.info(
|
||||
`Encrypted ${encrypted} notification channel config(s)`,
|
||||
{
|
||||
operation: "channel_config_encryption_migration",
|
||||
encrypted,
|
||||
skipped,
|
||||
deferred,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return { encrypted, skipped: skipped + deferred };
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Notification channel config encryption failed", {
|
||||
operation: "channel_config_encryption_error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,6 @@ class FieldCrypto {
|
||||
]),
|
||||
// Channel configs hold ntfy tokens, webhook auth headers and Discord
|
||||
// webhook URLs, which are credentials like any other.
|
||||
notification_channels: new Set(["config"]),
|
||||
};
|
||||
|
||||
static encryptField(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
|
||||
|
||||
export const NOTIFICATION_PRIVATE_ALLOWLIST_KEY =
|
||||
"notification_private_endpoint_allowlist";
|
||||
|
||||
@@ -16,10 +14,3 @@ export function parseNotificationAllowlist(raw: string | null): string[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function readNotificationPrivateAllowlist(): Promise<string[]> {
|
||||
const raw = await createCurrentSettingsRepository().get(
|
||||
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
||||
);
|
||||
return parseNotificationAllowlist(raw);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { safeOutboundFetch } from "./safe-outbound-fetch.js";
|
||||
import { readNotificationPrivateAllowlist } from "./notification-egress.js";
|
||||
|
||||
const NTFY_PRIORITY: Record<string, number> = {
|
||||
info: 2,
|
||||
warning: 3,
|
||||
critical: 5,
|
||||
};
|
||||
|
||||
export interface ChannelNotification {
|
||||
title: string;
|
||||
body: string;
|
||||
severity: "info" | "warning" | "critical";
|
||||
context?: {
|
||||
hostId?: number;
|
||||
hostName?: string;
|
||||
sourceId?: number | string;
|
||||
sourceName?: string;
|
||||
triggerType?: string;
|
||||
value?: unknown;
|
||||
threshold?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
const NTFY_TAGS: Record<string, string> = {
|
||||
info: "information_source",
|
||||
warning: "warning",
|
||||
critical: "rotating_light",
|
||||
};
|
||||
|
||||
const DISCORD_COLORS: Record<string, number> = {
|
||||
info: 3066993,
|
||||
warning: 16753920,
|
||||
critical: 15158332,
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends one notification to one channel and throws on failure, so the caller
|
||||
* can report which channel failed. Private and loopback targets need both the
|
||||
* channel's allowPrivateNetwork opt-in and an exact host in the admin
|
||||
* allowlist.
|
||||
*/
|
||||
export async function deliverNotification(
|
||||
channel: { id: number; type: string; config: string },
|
||||
notification: ChannelNotification,
|
||||
): Promise<void> {
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = JSON.parse(channel.config) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new Error("Channel configuration is not valid JSON");
|
||||
}
|
||||
|
||||
const allowlist =
|
||||
config.allowPrivateNetwork === true
|
||||
? await readNotificationPrivateAllowlist()
|
||||
: [];
|
||||
const post = async (
|
||||
url: string,
|
||||
init: { method: string; headers: Record<string, string>; body: string },
|
||||
) => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 30_000);
|
||||
try {
|
||||
const response = await safeOutboundFetch(
|
||||
url,
|
||||
{ ...init, signal: controller.signal },
|
||||
allowlist,
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`HTTP ${response.status} ${response.statusText}${detail ? `: ${detail.slice(0, 200)}` : ""}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
const url = typeof config.url === "string" ? config.url.trim() : "";
|
||||
if (!url) throw new Error("Channel is missing a URL");
|
||||
const context = notification.context ?? {};
|
||||
|
||||
switch (channel.type) {
|
||||
case "webhook": {
|
||||
const headers =
|
||||
config.headers && typeof config.headers === "object"
|
||||
? (config.headers as Record<string, string>)
|
||||
: {};
|
||||
return post(url, {
|
||||
method: config.method === "PUT" ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify({
|
||||
title: notification.title,
|
||||
hostName: context.hostName,
|
||||
hostId: context.hostId,
|
||||
ruleName: context.sourceName ?? notification.title,
|
||||
ruleId: context.sourceId,
|
||||
triggerType: context.triggerType,
|
||||
value: context.value,
|
||||
threshold: context.threshold,
|
||||
message: notification.body,
|
||||
severity: notification.severity,
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
case "ntfy": {
|
||||
const topic = typeof config.topic === "string" ? config.topic.trim() : "";
|
||||
if (!topic) throw new Error("ntfy channel is missing a topic");
|
||||
const headers: Record<string, string> = {
|
||||
Title: notification.title || "Termix",
|
||||
Priority: String(NTFY_PRIORITY[notification.severity] ?? 3),
|
||||
Tags: NTFY_TAGS[notification.severity] ?? "information_source",
|
||||
};
|
||||
if (typeof config.token === "string" && config.token) {
|
||||
headers.Authorization = `Bearer ${config.token}`;
|
||||
}
|
||||
return post(`${url.replace(/\/$/, "")}/${topic}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: notification.body || notification.title,
|
||||
});
|
||||
}
|
||||
case "discord": {
|
||||
const payload: Record<string, unknown> = {
|
||||
embeds: [
|
||||
{
|
||||
title: notification.title || "Termix",
|
||||
description: notification.body || undefined,
|
||||
color: DISCORD_COLORS[notification.severity] ?? 3447003,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
if (typeof config.username === "string" && config.username) {
|
||||
payload.username = config.username;
|
||||
}
|
||||
if (typeof config.avatar_url === "string" && config.avatar_url) {
|
||||
payload.avatar_url = config.avatar_url;
|
||||
}
|
||||
return post(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported channel type: ${channel.type}`);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
createCurrentDismissedAlertRepository,
|
||||
createCurrentUserDataExportRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
@@ -17,7 +16,6 @@ interface UserExportData {
|
||||
sshCredentials: unknown[];
|
||||
/** The user's rows in plugin tables, keyed by table name. */
|
||||
pluginData: Record<string, unknown[]>;
|
||||
dismissedAlerts: unknown[];
|
||||
};
|
||||
metadata: {
|
||||
totalRecords: number;
|
||||
@@ -91,9 +89,6 @@ class UserDataExport {
|
||||
0,
|
||||
);
|
||||
|
||||
const alerts =
|
||||
await createCurrentDismissedAlertRepository().listByUserId(userId);
|
||||
|
||||
const exportData: UserExportData = {
|
||||
version: this.EXPORT_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
@@ -103,14 +98,12 @@ class UserDataExport {
|
||||
sshHosts: processedSshHosts,
|
||||
sshCredentials: sshCredentialsData,
|
||||
pluginData,
|
||||
dismissedAlerts: alerts,
|
||||
},
|
||||
metadata: {
|
||||
totalRecords:
|
||||
processedSshHosts.length +
|
||||
sshCredentialsData.length +
|
||||
pluginRowCount +
|
||||
alerts.length,
|
||||
pluginRowCount,
|
||||
encrypted: format === "encrypted",
|
||||
exportType: scope,
|
||||
},
|
||||
@@ -182,7 +175,7 @@ class UserDataExport {
|
||||
|
||||
if (dataObj.userData) {
|
||||
const userData = dataObj.userData as Record<string, unknown>;
|
||||
const requiredFields = ["sshHosts", "sshCredentials", "dismissedAlerts"];
|
||||
const requiredFields = ["sshHosts", "sshCredentials"];
|
||||
for (const field of requiredFields) {
|
||||
if (!Array.isArray(userData[field])) {
|
||||
errors.push(`Missing or invalid userData.${field} field`);
|
||||
@@ -213,7 +206,6 @@ class UserDataExport {
|
||||
sshHosts: number;
|
||||
sshCredentials: number;
|
||||
pluginRows: number;
|
||||
dismissedAlerts: number;
|
||||
};
|
||||
encrypted: boolean;
|
||||
} {
|
||||
@@ -229,7 +221,6 @@ class UserDataExport {
|
||||
(total, rows) => total + rows.length,
|
||||
0,
|
||||
),
|
||||
dismissedAlerts: data.userData.dismissedAlerts.length,
|
||||
},
|
||||
encrypted: data.metadata.encrypted,
|
||||
};
|
||||
|
||||
@@ -341,21 +341,6 @@ export interface Tab {
|
||||
dirty?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ALERT TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface TermixAlert {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
expiresAt: string;
|
||||
priority?: "low" | "medium" | "high" | "critical";
|
||||
type?: "info" | "warning" | "error" | "success";
|
||||
actionUrl?: string;
|
||||
actionText?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TERMINAL CONFIGURATION TYPES
|
||||
// ============================================================================
|
||||
@@ -527,7 +512,6 @@ export interface ExportSummary {
|
||||
sshHostsImported: number;
|
||||
sshCredentialsImported: number;
|
||||
pluginItemsImported: number;
|
||||
dismissedAlertsImported: number;
|
||||
credentialUsageImported: number;
|
||||
settingsImported: number;
|
||||
skippedItems: number;
|
||||
|
||||
@@ -72,11 +72,6 @@ const SplitScreenPanel = lazy(() =>
|
||||
default: m.SplitScreenPanel,
|
||||
})),
|
||||
);
|
||||
const AlertManager = lazy(() =>
|
||||
import("@/dashboard/panels/alerts/AlertManager").then((m) => ({
|
||||
default: m.AlertManager,
|
||||
})),
|
||||
);
|
||||
|
||||
// Secondary rail panels — load on first open, not with the shell critical path.
|
||||
const SshToolsPanel = lazy(() =>
|
||||
@@ -259,7 +254,6 @@ export function AppShell({
|
||||
// The standalone desktop backend still owns system settings such as the
|
||||
// Tailscale API key, even though it has only one implicit user.
|
||||
const showAdminUI = isAdmin;
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [showDonationModal, setShowDonationModal] = useState(false);
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const [backgroundTabRecords, setBackgroundTabRecords] = useState<
|
||||
@@ -396,7 +390,6 @@ export function AppShell({
|
||||
getUserInfo()
|
||||
.then((info) => {
|
||||
setIsAdmin(info.is_admin);
|
||||
setUserId(info.userId);
|
||||
setShowDonationModal(!!info.show_donation_modal);
|
||||
})
|
||||
.catch(() => setIsAdmin(false));
|
||||
@@ -2913,9 +2906,6 @@ export function AppShell({
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
<Suspense fallback={null}>
|
||||
<AlertManager userId={userId} loggedIn={!!username} />
|
||||
</Suspense>
|
||||
<OnboardingDialog
|
||||
open={showOnboarding}
|
||||
context={{}}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { rbacApi } from "@/main-axios";
|
||||
|
||||
export interface NotificationChannel {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
type: "webhook" | "ntfy" | "discord";
|
||||
config: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function getNotificationChannels(): Promise<
|
||||
NotificationChannel[]
|
||||
> {
|
||||
const res = await rbacApi.get("/notification-channels");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export type NotificationChannelPayload = Partial<
|
||||
Omit<NotificationChannel, "config">
|
||||
> & {
|
||||
// When creating/updating the channel the UI may pass a parsed object
|
||||
// for `config` (e.g., { url, username }) — the backend stores it as
|
||||
// a JSON string. Accept either a string or any structured object here.
|
||||
// Use `unknown` to allow the component-local config types to be passed
|
||||
// without importing them into this module.
|
||||
config: string | unknown;
|
||||
};
|
||||
|
||||
export async function createNotificationChannel(
|
||||
data: NotificationChannelPayload,
|
||||
): Promise<NotificationChannel> {
|
||||
const res = await rbacApi.post("/notification-channels", data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateNotificationChannel(
|
||||
id: number,
|
||||
data: NotificationChannelPayload,
|
||||
): Promise<NotificationChannel> {
|
||||
const res = await rbacApi.put(`/notification-channels/${id}`, data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteNotificationChannel(id: number): Promise<void> {
|
||||
await rbacApi.delete(`/notification-channels/${id}`);
|
||||
}
|
||||
|
||||
export async function testNotificationChannel(id: number): Promise<void> {
|
||||
const res = await rbacApi.post(`/notification-channels/${id}/test`);
|
||||
const data = res.data as { success?: boolean; error?: string };
|
||||
if (data && data.success === false) {
|
||||
throw new Error(data.error || "Test notification failed");
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,5 @@
|
||||
import type { TermixAlert } from "@/types";
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
// ALERTS
|
||||
// ============================================================================
|
||||
|
||||
export async function getUserAlerts(): Promise<{
|
||||
alerts: TermixAlert[];
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get(`/alerts`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch user alerts");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function dismissAlert(
|
||||
alertId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/alerts/dismiss", { alertId });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "dismiss alert");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UPDATES & RELEASES
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Badge } from "@/components/badge.tsx";
|
||||
import {
|
||||
X,
|
||||
ExternalLink,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TermixAlert } from "@/types";
|
||||
|
||||
interface AlertCardProps {
|
||||
alert: TermixAlert;
|
||||
onDismiss: (alertId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const getAlertIcon = (type?: string) => {
|
||||
switch (type) {
|
||||
case "warning":
|
||||
return <AlertTriangle className="h-5 w-5 text-accent-brand" />;
|
||||
case "error":
|
||||
return <AlertCircle className="h-5 w-5 text-destructive" />;
|
||||
case "success":
|
||||
return <CheckCircle className="h-5 w-5 text-green-400" />;
|
||||
case "info":
|
||||
default:
|
||||
return <Info className="h-5 w-5 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getAccentBorderClass = (priority?: string, type?: string): string => {
|
||||
if (priority === "critical" || type === "error")
|
||||
return "border-t-2 border-t-destructive";
|
||||
if (priority === "high" || type === "warning")
|
||||
return "border-t-2 border-t-accent-brand";
|
||||
if (priority === "medium" || type === "success")
|
||||
return "border-t-2 border-t-green-400";
|
||||
return "";
|
||||
};
|
||||
|
||||
const getPriorityBadgeVariant = (
|
||||
priority?: string,
|
||||
): "destructive" | "secondary" | "default" => {
|
||||
switch (priority) {
|
||||
case "critical":
|
||||
case "high":
|
||||
return "destructive";
|
||||
case "medium":
|
||||
return "secondary";
|
||||
case "low":
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeBadgeVariant = (
|
||||
type?: string,
|
||||
): "destructive" | "secondary" | "default" => {
|
||||
switch (type) {
|
||||
case "error":
|
||||
return "destructive";
|
||||
case "warning":
|
||||
return "secondary";
|
||||
case "success":
|
||||
case "info":
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
export function AlertCard({
|
||||
alert,
|
||||
onDismiss,
|
||||
onClose,
|
||||
}: AlertCardProps): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!alert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
onDismiss(alert.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full border border-foreground/10 rounded-none bg-card overflow-hidden ${getAccentBorderClass(alert.priority, alert.type)}`}
|
||||
>
|
||||
<div className="flex items-start justify-between px-4 pt-4 pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{getAlertIcon(alert.type)}
|
||||
<span className="text-sm font-semibold">{alert.title}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" onClick={onClose}>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(alert.priority || alert.type) && (
|
||||
<div className="flex items-center gap-2 px-4 pb-3">
|
||||
{alert.priority && (
|
||||
<Badge variant={getPriorityBadgeVariant(alert.priority)}>
|
||||
{alert.priority.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
{alert.type && (
|
||||
<Badge
|
||||
variant={getTypeBadgeVariant(alert.type)}
|
||||
className={
|
||||
alert.type === "success" ? "text-green-400" : undefined
|
||||
}
|
||||
>
|
||||
{alert.type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-3 mb-3 border border-foreground/10 px-3 py-3 bg-background">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{alert.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 pb-4">
|
||||
<Button variant="outline" size="sm" onClick={handleDismiss}>
|
||||
{t("common.dismiss")}
|
||||
</Button>
|
||||
{alert.actionUrl && alert.actionText && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
window.open(alert.actionUrl, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
className="gap-1.5 text-accent-brand border-accent-brand/30 hover:border-accent-brand/60"
|
||||
>
|
||||
{alert.actionText}
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { AlertCard } from "./AlertCard.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { getUserAlerts, dismissAlert } from "@/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TermixAlert } from "@/types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface AlertManagerProps {
|
||||
userId: string | null;
|
||||
loggedIn: boolean;
|
||||
}
|
||||
|
||||
export function AlertManager({
|
||||
userId,
|
||||
loggedIn,
|
||||
}: AlertManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [alerts, setAlerts] = useState<TermixAlert[]>([]);
|
||||
const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
|
||||
const [, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (loggedIn && userId) {
|
||||
fetchUserAlerts();
|
||||
}
|
||||
}, [loggedIn, userId]);
|
||||
|
||||
const fetchUserAlerts = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await getUserAlerts();
|
||||
const userAlerts = response.alerts || [];
|
||||
|
||||
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
|
||||
const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
|
||||
const aPriority =
|
||||
priorityOrder[a.priority as keyof typeof priorityOrder] || 0;
|
||||
const bPriority =
|
||||
priorityOrder[b.priority as keyof typeof priorityOrder] || 0;
|
||||
|
||||
if (aPriority !== bPriority) {
|
||||
return bPriority - aPriority;
|
||||
}
|
||||
|
||||
return (
|
||||
new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
setAlerts(sortedAlerts);
|
||||
setCurrentAlertIndex(0);
|
||||
} catch {
|
||||
toast.error(t("alerts.failedToLoad"));
|
||||
setError(t("alerts.failedToLoad"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismissAlert = async (alertId: string) => {
|
||||
try {
|
||||
await dismissAlert(alertId);
|
||||
|
||||
setAlerts((prev) => {
|
||||
const newAlerts = prev.filter((alert) => alert.id !== alertId);
|
||||
return newAlerts;
|
||||
});
|
||||
|
||||
setCurrentAlertIndex((prevIndex) => {
|
||||
const newAlertsLength = alerts.length - 1;
|
||||
if (newAlertsLength === 0) return 0;
|
||||
if (prevIndex >= newAlertsLength)
|
||||
return Math.max(0, newAlertsLength - 1);
|
||||
return prevIndex;
|
||||
});
|
||||
} catch {
|
||||
setError(t("alerts.failedToDismiss"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseCurrentAlert = () => {
|
||||
if (alerts.length === 0) return;
|
||||
|
||||
if (currentAlertIndex < alerts.length - 1) {
|
||||
setCurrentAlertIndex(currentAlertIndex + 1);
|
||||
} else {
|
||||
setAlerts([]);
|
||||
setCurrentAlertIndex(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviousAlert = () => {
|
||||
if (currentAlertIndex > 0) {
|
||||
setCurrentAlertIndex(currentAlertIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextAlert = () => {
|
||||
if (currentAlertIndex < alerts.length - 1) {
|
||||
setCurrentAlertIndex(currentAlertIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (!loggedIn || !userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (alerts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentAlert = alerts[currentAlertIndex];
|
||||
|
||||
if (!currentAlert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasMultipleAlerts = alerts.length > 1;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background/80 z-[99999]">
|
||||
<div className="w-full max-w-2xl mx-4 flex flex-col gap-2">
|
||||
{hasMultipleAlerts && (
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{currentAlertIndex + 1} {t("common.of")} {alerts.length}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePreviousAlert}
|
||||
disabled={currentAlertIndex === 0}
|
||||
>
|
||||
{t("common.previous")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextAlert}
|
||||
disabled={currentAlertIndex === alerts.length - 1}
|
||||
>
|
||||
{t("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertCard
|
||||
alert={currentAlert}
|
||||
onDismiss={handleDismissAlert}
|
||||
onClose={handleCloseCurrentAlert}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="border border-destructive/50 rounded-md px-3 py-2 text-destructive text-xs">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+6
-23
@@ -2271,27 +2271,6 @@
|
||||
}
|
||||
},
|
||||
"terminalToolbar": {},
|
||||
"alerts": {
|
||||
"addChannel": "Add Channel",
|
||||
"editChannel": "Edit Channel",
|
||||
"channelName": "Name",
|
||||
"channelType": "Type",
|
||||
"channelNameRequired": "Name is required",
|
||||
"webhookUrl": "URL",
|
||||
"webhookUrlRequired": "Webhook URL is required",
|
||||
"webhookDesc": "POST JSON payload to this URL on each alert firing",
|
||||
"ntfyServer": "Server URL",
|
||||
"ntfyTopic": "Topic",
|
||||
"ntfyTopicRequired": "Topic is required",
|
||||
"ntfyToken": "Access Token (optional)",
|
||||
"channelSaveFailed": "Failed to save channel",
|
||||
"discordUrlInvalid": "Discord URL must be a valid Discord webhook URL",
|
||||
"discordWebhookDesc": "POST a Discord embed to this webhook URL on each alert firing",
|
||||
"discordUsername": "Display name (optional)",
|
||||
"discordAvatar": "Avatar URL (optional)",
|
||||
"failedToLoad": "Failed to load alerts",
|
||||
"failedToDismiss": "Failed to dismiss alert"
|
||||
},
|
||||
"macros": {
|
||||
"title": "Macros",
|
||||
"description": "Run a saved series of commands in a terminal, and react to what the server sends back.",
|
||||
@@ -2450,8 +2429,12 @@
|
||||
"consequence": "Including activity from other users, not just yours."
|
||||
},
|
||||
"notify:send": {
|
||||
"title": "Send notifications",
|
||||
"consequence": "Through the channels you have already set up."
|
||||
"title": "Send alerts",
|
||||
"consequence": "To you or other users, and through the channels they set up."
|
||||
},
|
||||
"notify:hub": {
|
||||
"title": "Receive every alert",
|
||||
"consequence": "Stores and delivers the alerts all plugins send, including their contents."
|
||||
},
|
||||
"audit:read": {
|
||||
"title": "Read the audit log",
|
||||
|
||||
@@ -1297,8 +1297,6 @@ export {
|
||||
} from "@/api/admin-user-data-api";
|
||||
|
||||
export {
|
||||
getUserAlerts,
|
||||
dismissAlert,
|
||||
getReleasesRSS,
|
||||
getVersionInfo,
|
||||
releaseUrlFrom,
|
||||
|
||||
@@ -130,6 +130,8 @@ export function createPluginApp(
|
||||
hidden: item.hidden,
|
||||
after: item.after,
|
||||
order: item.order,
|
||||
placement: item.placement,
|
||||
useBadge: item.useBadge,
|
||||
pluginId,
|
||||
permission: item.permission
|
||||
? resolvePluginPermission(pluginId, item.permission)
|
||||
|
||||
@@ -150,16 +150,6 @@ export {
|
||||
type VersionInfo,
|
||||
} from "@/api/system-status-api";
|
||||
|
||||
// Notification channels are core (ctx.notify on the backend). A plugin that
|
||||
// lets users pick or manage them shows core's own dialog.
|
||||
export { NotificationChannelDialog } from "@/sidebar/NotificationChannelDialog";
|
||||
export {
|
||||
getNotificationChannels,
|
||||
deleteNotificationChannel,
|
||||
testNotificationChannel,
|
||||
type NotificationChannel,
|
||||
} from "@/api/notification-channels-api";
|
||||
|
||||
// Keyboard hints, the connection log panel and a named console logger.
|
||||
export { Kbd, KbdKey, KbdSeparator } from "@/components/kbd";
|
||||
export { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel";
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { RailView } from "@/sidebar/AppRail";
|
||||
import { useRailItems } from "@/sidebar/rail-items";
|
||||
import type { SplitMode } from "@/types/ui-types";
|
||||
import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
|
||||
import { RailBadge } from "@/sidebar/RailBadge";
|
||||
|
||||
export function MobileBottomBar({
|
||||
railView,
|
||||
@@ -75,7 +76,15 @@ export function MobileBottomBar({
|
||||
className={`relative flex flex-col items-center justify-center flex-1 gap-0.5 py-2 min-h-[56px] transition-colors text-[10px] font-medium
|
||||
${active ? "text-accent-brand" : "text-muted-foreground"}`}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
<span className="relative">
|
||||
<Icon className="size-5" />
|
||||
{item.useBadge && (
|
||||
<RailBadge
|
||||
useBadge={item.useBadge}
|
||||
className="-top-1.5 -right-2.5"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="max-w-full truncate px-0.5">
|
||||
{t(item.labelKey)}
|
||||
</span>
|
||||
@@ -92,7 +101,19 @@ export function MobileBottomBar({
|
||||
className={`relative flex flex-col items-center justify-center flex-1 gap-0.5 py-2 min-h-[56px] transition-colors text-[10px] font-medium
|
||||
${moreActive ? "text-accent-brand" : "text-muted-foreground"}`}
|
||||
>
|
||||
<MoreHorizontal className="size-5" />
|
||||
<span className="relative">
|
||||
<MoreHorizontal className="size-5" />
|
||||
{moreItems.map((item) =>
|
||||
item.useBadge ? (
|
||||
<RailBadge
|
||||
key={item.id}
|
||||
useBadge={item.useBadge}
|
||||
variant="dot"
|
||||
className="-top-0.5 -right-1"
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</span>
|
||||
<span>{t("common.more")}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -115,6 +136,11 @@ export function MobileBottomBar({
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{t(item.labelKey)}
|
||||
{item.useBadge && (
|
||||
<span className="relative ml-auto w-5 h-3.5">
|
||||
<RailBadge useBadge={item.useBadge} className="right-0" />
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -775,7 +775,6 @@ export function AdminSettingsPanel({
|
||||
(s.sshHostsImported || 0) +
|
||||
(s.sshCredentialsImported || 0) +
|
||||
(s.pluginItemsImported || 0) +
|
||||
(s.dismissedAlertsImported || 0) +
|
||||
(s.settingsImported || 0);
|
||||
toast.success(
|
||||
t("admin.importCompleted", { total, skipped: s.skippedItems || 0 }),
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
|
||||
import { Skeleton } from "@/components/skeleton";
|
||||
import { readRailPreference, setRailPreference } from "./rail-preferences";
|
||||
import { useRailItems, type RailItemDef } from "./rail-items";
|
||||
import { RailBadge } from "./RailBadge";
|
||||
|
||||
/** Core rail views; plugins add their own ids at runtime. */
|
||||
export type CoreRailView =
|
||||
@@ -36,8 +37,15 @@ type RailItem =
|
||||
dot?: boolean;
|
||||
promotable?: boolean;
|
||||
rightDockable?: boolean;
|
||||
useBadge?: () => number | null | undefined;
|
||||
}
|
||||
| {
|
||||
kind: "tab";
|
||||
tabType: TabType;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
useBadge?: () => number | null | undefined;
|
||||
}
|
||||
| { kind: "tab"; tabType: TabType; icon: React.ReactNode; title: string }
|
||||
| { kind: "separator" };
|
||||
|
||||
function buildRailButtons(
|
||||
@@ -55,6 +63,7 @@ function buildRailButtons(
|
||||
tabType: item.id as TabType,
|
||||
icon: <Icon size={16} />,
|
||||
title: t(item.labelKey),
|
||||
useBadge: item.useBadge,
|
||||
});
|
||||
} else {
|
||||
all.push({
|
||||
@@ -64,6 +73,7 @@ function buildRailButtons(
|
||||
dot: item.id === "split-screen" ? splitMode !== "none" : undefined,
|
||||
promotable: item.promotable,
|
||||
rightDockable: item.rightDockable,
|
||||
useBadge: item.useBadge,
|
||||
});
|
||||
}
|
||||
if (item.separatorAfter) all.push({ kind: "separator" });
|
||||
@@ -202,7 +212,15 @@ export function AppRail({
|
||||
}, []);
|
||||
|
||||
const railExpanded = pinned || (expandOnHover && hovered);
|
||||
const railButtons = buildRailButtons(railItems, splitMode, t, hiddenTabs);
|
||||
const railButtons = buildRailButtons(
|
||||
railItems.filter((item) => item.placement !== "footer"),
|
||||
splitMode,
|
||||
t,
|
||||
hiddenTabs,
|
||||
);
|
||||
const footerItems = railItems.filter(
|
||||
(item) => item.placement === "footer" && !hiddenTabs.has(item.id),
|
||||
);
|
||||
const setRailPinned = (nextPinned: boolean) => {
|
||||
setPinned(nextPinned);
|
||||
localStorage.setItem("pinAppRail", String(nextPinned));
|
||||
@@ -265,10 +283,16 @@ export function AppRail({
|
||||
className={`${btnBase} text-muted-foreground hover:text-foreground hover:bg-muted/60`}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 flex items-center justify-center"
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: 16, height: 16 }}
|
||||
>
|
||||
{item.icon}
|
||||
{item.useBadge && (
|
||||
<RailBadge
|
||||
useBadge={item.useBadge}
|
||||
className="-top-1.5 -right-2"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
||||
@@ -318,10 +342,16 @@ export function AppRail({
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 flex items-center justify-center"
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: 16, height: 16 }}
|
||||
>
|
||||
{item.icon}
|
||||
{item.useBadge && (
|
||||
<RailBadge
|
||||
useBadge={item.useBadge}
|
||||
className="-top-1.5 -right-2"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
||||
@@ -373,6 +403,70 @@ export function AppRail({
|
||||
style={{ width: railExpanded ? "calc(100% - 16px)" : 20 }}
|
||||
/>
|
||||
)}
|
||||
{footerItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const title = t(item.labelKey);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={(e) => {
|
||||
if (item.kind === "tab") {
|
||||
onOpenTab?.(item.id as TabType);
|
||||
return;
|
||||
}
|
||||
if (item.promotable && (e.ctrlKey || e.metaKey)) {
|
||||
onOpenTab?.(item.id as TabType);
|
||||
return;
|
||||
}
|
||||
onRailClick(item.id as RailView);
|
||||
}}
|
||||
onAuxClick={(e) => {
|
||||
if (e.button !== 1 || !item.promotable) return;
|
||||
e.preventDefault();
|
||||
onOpenTab?.(item.id as TabType);
|
||||
}}
|
||||
onContextMenu={() => {
|
||||
if (item.promotable || item.rightDockable)
|
||||
setMenuTarget({
|
||||
view: item.id as RailView,
|
||||
title,
|
||||
promotable: item.promotable,
|
||||
rightDockable: item.rightDockable,
|
||||
});
|
||||
}}
|
||||
data-rail-promotable={
|
||||
item.promotable || item.rightDockable ? "" : undefined
|
||||
}
|
||||
title={
|
||||
item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title
|
||||
}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${
|
||||
sidebarOpen && railView === item.id
|
||||
? "text-accent-brand bg-accent-brand/10"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: 16, height: 16 }}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{item.useBadge && (
|
||||
<RailBadge
|
||||
useBadge={item.useBadge}
|
||||
className="-top-1.5 -right-2"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{(
|
||||
[
|
||||
{
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/dialog";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createNotificationChannel,
|
||||
updateNotificationChannel,
|
||||
type NotificationChannel,
|
||||
} from "@/api/notification-channels-api";
|
||||
|
||||
interface NotificationChannelDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
channel: NotificationChannel | null;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
type ChannelType = "webhook" | "ntfy" | "discord";
|
||||
|
||||
interface WebhookConfig {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface NtfyConfig {
|
||||
url: string;
|
||||
topic: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
interface DiscordConfig {
|
||||
url: string;
|
||||
username?: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
export function NotificationChannelDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
channel,
|
||||
onSaved,
|
||||
}: NotificationChannelDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState<ChannelType>("webhook");
|
||||
const [webhookUrl, setWebhookUrl] = useState("");
|
||||
const [ntfyUrl, setNtfyUrl] = useState("https://ntfy.sh");
|
||||
const [ntfyTopic, setNtfyTopic] = useState("");
|
||||
const [ntfyToken, setNtfyToken] = useState("");
|
||||
const [discordUsername, setDiscordUsername] = useState("");
|
||||
const [discordAvatarUrl, setDiscordAvatarUrl] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (channel) {
|
||||
setName(channel.name);
|
||||
setType(channel.type);
|
||||
try {
|
||||
const cfg = JSON.parse(channel.config);
|
||||
if (channel.type === "webhook") {
|
||||
setWebhookUrl((cfg as WebhookConfig).url ?? "");
|
||||
} else if (channel.type === "discord") {
|
||||
const dc = cfg as DiscordConfig;
|
||||
setWebhookUrl(dc.url ?? "");
|
||||
setDiscordUsername(dc.username ?? "");
|
||||
setDiscordAvatarUrl(dc.avatar_url ?? "");
|
||||
} else {
|
||||
const nc = cfg as NtfyConfig;
|
||||
setNtfyUrl(nc.url ?? "https://ntfy.sh");
|
||||
setNtfyTopic(nc.topic ?? "");
|
||||
setNtfyToken(nc.token ?? "");
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed config
|
||||
}
|
||||
} else {
|
||||
setName("");
|
||||
setType("webhook");
|
||||
setWebhookUrl("");
|
||||
setNtfyUrl("https://ntfy.sh");
|
||||
setNtfyTopic("");
|
||||
setNtfyToken("");
|
||||
}
|
||||
}, [open, channel]);
|
||||
|
||||
async function handleSave() {
|
||||
if (!name.trim()) {
|
||||
toast.error(t("alerts.channelNameRequired", "Name is required"));
|
||||
return;
|
||||
}
|
||||
let config: WebhookConfig | NtfyConfig | DiscordConfig;
|
||||
if (type === "webhook") {
|
||||
if (!webhookUrl.trim()) {
|
||||
toast.error(t("alerts.webhookUrlRequired", "Webhook URL is required"));
|
||||
return;
|
||||
}
|
||||
config = {
|
||||
url: webhookUrl.trim(),
|
||||
} satisfies WebhookConfig;
|
||||
} else if (type === "discord") {
|
||||
const trimmedUrl = webhookUrl.trim();
|
||||
if (!trimmedUrl) {
|
||||
toast.error(t("alerts.webhookUrlRequired", "Webhook URL is required"));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!/^https:\/\/(?:canary\.|ptb\.)?(?:discord\.com|discordapp\.com)\/api\/webhooks\/.+/i.test(
|
||||
trimmedUrl,
|
||||
)
|
||||
) {
|
||||
toast.error(
|
||||
t(
|
||||
"alerts.discordUrlInvalid",
|
||||
"Discord URL must be a valid Discord webhook URL",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const dc: DiscordConfig = { url: trimmedUrl };
|
||||
if (discordUsername.trim()) dc.username = discordUsername.trim();
|
||||
if (discordAvatarUrl.trim()) dc.avatar_url = discordAvatarUrl.trim();
|
||||
config = dc;
|
||||
} else {
|
||||
if (!ntfyTopic.trim()) {
|
||||
toast.error(t("alerts.ntfyTopicRequired", "Topic is required"));
|
||||
return;
|
||||
}
|
||||
const cfg: NtfyConfig = { url: ntfyUrl.trim(), topic: ntfyTopic.trim() };
|
||||
if (ntfyToken.trim()) cfg.token = ntfyToken.trim();
|
||||
config = cfg;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (channel) {
|
||||
await updateNotificationChannel(channel.id, {
|
||||
name: name.trim(),
|
||||
type,
|
||||
config,
|
||||
});
|
||||
} else {
|
||||
await createNotificationChannel({ name: name.trim(), type, config });
|
||||
}
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error(t("alerts.channelSaveFailed", "Failed to save channel"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{channel
|
||||
? t("alerts.editChannel", "Edit Channel")
|
||||
: t("alerts.addChannel", "Add Channel")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.channelName", "Name")}
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.channelType", "Type")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(["webhook", "ntfy", "discord"] as ChannelType[]).map((ct) => (
|
||||
<button
|
||||
key={ct}
|
||||
onClick={() => setType(ct)}
|
||||
className={`px-3 py-1 text-xs font-semibold border transition-colors ${type === ct ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:border-accent-brand/30"}`}
|
||||
>
|
||||
{ct === "webhook"
|
||||
? "Webhook"
|
||||
: ct === "ntfy"
|
||||
? "ntfy"
|
||||
: "Discord"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{type === "webhook" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.webhookUrl", "URL")}
|
||||
</label>
|
||||
<Input
|
||||
value={webhookUrl}
|
||||
onChange={(e) => setWebhookUrl(e.target.value)}
|
||||
placeholder="https://example.com/webhook"
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t(
|
||||
"alerts.webhookDesc",
|
||||
"POST JSON payload to this URL on each alert firing",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === "discord" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.webhookUrl", "Discord webhook URL")}
|
||||
</label>
|
||||
<Input
|
||||
value={webhookUrl}
|
||||
onChange={(e) => setWebhookUrl(e.target.value)}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t(
|
||||
"alerts.discordWebhookDesc",
|
||||
"POST a Discord embed to this webhook URL on each alert firing",
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col gap-1 pt-2">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.discordUsername", "Display name (optional)")}
|
||||
</label>
|
||||
<Input
|
||||
value={discordUsername}
|
||||
onChange={(e) => setDiscordUsername(e.target.value)}
|
||||
placeholder="Termix"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.discordAvatar", "Avatar URL (optional)")}
|
||||
</label>
|
||||
<Input
|
||||
value={discordAvatarUrl}
|
||||
onChange={(e) => setDiscordAvatarUrl(e.target.value)}
|
||||
placeholder="https://example.com/avatar.png"
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === "ntfy" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.ntfyServer", "Server URL")}
|
||||
</label>
|
||||
<Input
|
||||
value={ntfyUrl}
|
||||
onChange={(e) => setNtfyUrl(e.target.value)}
|
||||
placeholder="https://ntfy.sh"
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.ntfyTopic", "Topic")}
|
||||
</label>
|
||||
<Input
|
||||
value={ntfyTopic}
|
||||
onChange={(e) => setNtfyTopic(e.target.value)}
|
||||
placeholder="my-alerts"
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.ntfyToken", "Access Token (optional)")}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={ntfyToken}
|
||||
onChange={(e) => setNtfyToken(e.target.value)}
|
||||
placeholder="tk_..."
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
>
|
||||
{saving
|
||||
? t("common.saving", "Saving...")
|
||||
: t("common.save", "Save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* The count a rail item shows on its icon. Each badge is its own component so
|
||||
* the item's useBadge hook runs once per render of a stable element.
|
||||
*/
|
||||
export function RailBadge({
|
||||
useBadge,
|
||||
variant = "count",
|
||||
className = "",
|
||||
}: {
|
||||
useBadge: () => number | null | undefined;
|
||||
variant?: "count" | "dot";
|
||||
className?: string;
|
||||
}) {
|
||||
const count = useBadge();
|
||||
if (!count || count <= 0) return null;
|
||||
if (variant === "dot") {
|
||||
return (
|
||||
<span
|
||||
className={`absolute size-1.5 rounded-full bg-accent-brand ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`absolute min-w-[14px] h-[14px] px-[3px] rounded-full bg-accent-brand text-[9px] font-bold leading-[14px] text-center text-white tabular-nums ${className}`}
|
||||
>
|
||||
{count > 99 ? "99+" : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1270,7 +1270,6 @@ export function UserProfilePanel({
|
||||
(s.sshHostsImported || 0) +
|
||||
(s.sshCredentialsImported || 0) +
|
||||
(s.pluginItemsImported || 0) +
|
||||
(s.dismissedAlertsImported || 0) +
|
||||
(s.settingsImported || 0);
|
||||
toast.success(
|
||||
t("newUi.sidebar.userProfile.importCompleted", {
|
||||
|
||||
@@ -65,6 +65,10 @@ export interface RailItemDef {
|
||||
pluginId?: string;
|
||||
/** Role permission the user needs to see it at all, as a full id. */
|
||||
permission?: string;
|
||||
/** "footer" renders it at the bottom of the rail, above the profile. */
|
||||
placement?: "main" | "footer";
|
||||
/** Called as a hook by the rail; a positive number shows as a badge. */
|
||||
useBadge?: () => number | null | undefined;
|
||||
}
|
||||
|
||||
export const RAIL_ITEMS: RailItemDef[] = [
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Bell, Boxes } from "lucide-react";
|
||||
import { AppRail } from "../../sidebar/AppRail";
|
||||
import { RailBadge } from "../../sidebar/RailBadge";
|
||||
import {
|
||||
registerRailItem,
|
||||
resetRegisteredRailItems,
|
||||
} from "../../sidebar/rail-items";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-permissions", () => ({
|
||||
usePermissions: () => ({ has: () => true, loaded: true }),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
resetRegisteredRailItems();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
function renderRail(onRailClick = vi.fn()) {
|
||||
render(
|
||||
<AppRail
|
||||
railView="hosts"
|
||||
sidebarOpen={false}
|
||||
splitMode="none"
|
||||
username="alice"
|
||||
isAdmin={false}
|
||||
onRailClick={onRailClick}
|
||||
onLogout={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
return onRailClick;
|
||||
}
|
||||
|
||||
describe("AppRail", () => {
|
||||
it("puts a footer item above the profile with its badge", async () => {
|
||||
registerRailItem({
|
||||
id: "inbox",
|
||||
icon: Bell,
|
||||
labelKey: "test.inbox",
|
||||
placement: "footer",
|
||||
useBadge: () => 3,
|
||||
pluginId: "demo",
|
||||
});
|
||||
registerRailItem({
|
||||
id: "boxes",
|
||||
icon: Boxes,
|
||||
labelKey: "test.boxes",
|
||||
pluginId: "demo",
|
||||
});
|
||||
const onRailClick = renderRail();
|
||||
|
||||
const inbox = screen.getByTitle("test.inbox");
|
||||
const profile = screen.getAllByTitle("nav.userProfile")[0];
|
||||
const boxes = screen.getByTitle("test.boxes");
|
||||
// Footer items render after the main list and before the profile.
|
||||
expect(
|
||||
boxes.compareDocumentPosition(inbox) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
inbox.compareDocumentPosition(profile) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(inbox.textContent).toContain("3");
|
||||
|
||||
await userEvent.click(inbox);
|
||||
expect(onRailClick).toHaveBeenCalledWith("inbox");
|
||||
});
|
||||
|
||||
it("hides a footer item the user hid from Navigation", () => {
|
||||
localStorage.setItem("hiddenRailTabs", JSON.stringify(["inbox"]));
|
||||
registerRailItem({
|
||||
id: "inbox",
|
||||
icon: Bell,
|
||||
labelKey: "test.inbox",
|
||||
placement: "footer",
|
||||
pluginId: "demo",
|
||||
});
|
||||
renderRail();
|
||||
expect(screen.queryByTitle("test.inbox")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RailBadge", () => {
|
||||
it("shows nothing for zero", () => {
|
||||
const { container } = render(<RailBadge useBadge={() => 0} />);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("caps a large count", () => {
|
||||
render(<RailBadge useBadge={() => 150} />);
|
||||
expect(screen.getByText("99+")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user