feat: Hide secrets on api and channels

This commit is contained in:
2026-08-14 12:23:36 +00:00
parent ac61015cc0
commit aa1c8e4aa1
6 changed files with 156 additions and 14 deletions
+30 -12
View File
@@ -3,7 +3,14 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api, ChannelInput, ChannelType, NotificationChannel } from "@/lib/api";
import {
api,
CHANNEL_SECRET_FIELDS,
ChannelInput,
ChannelType,
NotificationChannel,
REDACTED_SECRET,
} from "@/lib/api";
import { Badge, Button, Card, ConfirmDialog, friendlyMessage, useToast } from "@/components/ui";
import { VulnAlertRulesCard } from "@/components/vulnerabilities/VulnAlertRulesCard";
@@ -67,17 +74,28 @@ function ChannelForm({ initial, onDone }: { initial?: NotificationChannel; onDon
))}
</select>
</div>
{CONFIG_FIELDS[type].map((field) => (
<div key={field}>
<label className={labelClass}>{field}</label>
<input
className={inputClass}
type={field === "password" ? "password" : "text"}
value={config[field] ?? ""}
onChange={(e) => setConfig({ ...config, [field]: e.target.value })}
/>
</div>
))}
{CONFIG_FIELDS[type].map((field) => {
// A secret comes back from the API as the sentinel, never as itself.
// The field renders empty rather than showing bullets in a URL box, and
// the sentinel is left sitting in state so an untouched save preserves
// the credential. Typing replaces it; clearing the field back to empty
// is how a credential is removed.
const secret = CHANNEL_SECRET_FIELDS[type].includes(field);
const value = config[field] ?? "";
const unchanged = secret && value === REDACTED_SECRET;
return (
<div key={field}>
<label className={labelClass}>{field}</label>
<input
className={inputClass}
type={field === "password" ? "password" : "text"}
value={unchanged ? "" : value}
placeholder={unchanged ? "unchanged — type to replace" : undefined}
onChange={(e) => setConfig({ ...config, [field]: e.target.value })}
/>
</div>
);
})}
{error && <p className="text-sm text-danger">{(error as Error).message}</p>}
<div className="flex gap-3">
<Button type="submit" variant="primary" loading={isPending}>
+19
View File
@@ -102,6 +102,25 @@ export interface Rollup {
export type ChannelType = "webhook" | "smtp" | "discord" | "slack" | "telegram";
/**
* What a channel's secret config values read as over the API. Writing it back
* unchanged preserves the stored credential; anything else, including "", is
* written verbatim.
*
* Mirrors `models.RedactedSecret` and `models.channelSecretKeys` in
* `server/internal/models/channel.go` — change both in the same commit, the
* same hazard as the mirrored token blocks.
*/
export const REDACTED_SECRET = "••••••••";
export const CHANNEL_SECRET_FIELDS: Record<ChannelType, string[]> = {
webhook: ["url"],
slack: ["url"],
discord: ["url"],
telegram: ["token"],
smtp: ["password"],
};
export interface NotificationChannel {
channel_id: string;
name: string;