Added credentials panel and management

This commit is contained in:
hunterino-sec
2026-05-12 18:35:38 +02:00
parent 0617d75a7f
commit a008e396d4
6 changed files with 1222 additions and 16 deletions
+71
View File
@@ -543,6 +543,7 @@ export const GET_COMMANDS = gql`
default_value
choices
parameter_group_name
limit_credentials_by_type
}
}
}
@@ -958,3 +959,73 @@ export const GET_JOB_KILL_COMMAND = gql`
}
}
`
// ── Credentials ───────────────────────────────────────
const CREDENTIAL_FIELDS = gql`
fragment CredentialFields on credential {
id type account realm credential_text comment metadata timestamp deleted
operator { username }
task { display_id callback { host display_id } }
}
`
export const GET_CREDENTIALS = gql`
${CREDENTIAL_FIELDS}
query GetCredentials {
credential(
where: { deleted: { _eq: false } }
order_by: { timestamp: desc }
) { ...CredentialFields }
}
`
export const SUB_CREDENTIALS = gql`
${CREDENTIAL_FIELDS}
subscription SubCredentials($now: timestamp!) {
credential_stream(
batch_size: 50
cursor: { initial_value: { timestamp: $now } }
) { ...CredentialFields }
}
`
export const CREATE_CREDENTIAL = gql`
mutation CreateCredential(
$credential_type: String, $account: String, $realm: String,
$credential: String, $comment: String
) {
createCredential(
credential_type: $credential_type, account: $account, realm: $realm,
credential: $credential, comment: $comment
) {
status
error
}
}
`
export const UPDATE_CREDENTIAL = gql`
${CREDENTIAL_FIELDS}
mutation UpdateCredential(
$id: Int!, $type: String!, $account: String!, $realm: String!,
$credential: bytea!, $comment: String!, $metadata: String!
) {
update_credential_by_pk(
pk_columns: { id: $id }
_set: {
type: $type, account: $account, realm: $realm,
credential_raw: $credential, comment: $comment, metadata: $metadata
}
) { ...CredentialFields }
}
`
export const DELETE_CREDENTIAL = gql`
mutation DeleteCredential($id: Int!) {
update_credential_by_pk(
pk_columns: { id: $id }
_set: { deleted: true }
) { id deleted }
}
`
+4 -1
View File
@@ -134,8 +134,11 @@ export function CommandBar() {
const hasRequiredFile = cmdParams.some(
p => (p.type === 'File' || p.type === 'FileMultiple') && p.required
)
const hasRequiredCred = cmdParams.some(
p => p.type === 'CredentialJson' && p.required
)
if (hasRequiredFile) {
if (hasRequiredFile || hasRequiredCred) {
// Open modal — it handles upload + task creation
setHistory(h => [raw, ...h.slice(0, 99)])
setHistIdx(-1)
+58 -12
View File
@@ -5,19 +5,30 @@
═══════════════════════════════════════════════════ */
import { useState, useRef } from 'react'
import { useMutation } from '@apollo/client'
import { CREATE_TASK } from '@/apollo/operations'
import { useMutation, useQuery } from '@apollo/client'
import { CREATE_TASK, GET_CREDENTIALS } from '@/apollo/operations'
import { useStore } from '@/store'
import styles from './FileTaskModal.module.css'
interface CredentialOption {
id: number
type: string
account: string
realm: string
credential_text: string | null
comment: string
metadata: string
}
export interface CommandParam {
name: string
display_name: string
type: string
required: boolean
default_value: string | null
choices: string[] | null
parameter_group_name:string
name: string
display_name: string
type: string
required: boolean
default_value: string | null
choices: string[] | null
parameter_group_name: string
limit_credentials_by_type: string[] | null
}
interface Props {
@@ -69,10 +80,11 @@ async function uploadTaskFile(file: File, token: string): Promise<string | null>
export function FileTaskModal({ command, params, displayId, defaultCwd, onClose }: Props) {
const { token } = useStore()
// Pick the parameter group that contains the File-type param.
// Sending params from multiple groups causes Mythic to reject with "don't match any parameter group".
// Pick the group containing a File or CredentialJson param — sending params from multiple
// groups causes Mythic to reject with "don't match any parameter group".
const credParam = params.find(p => p.type === 'CredentialJson')
const fileParam = params.find(p => p.type === 'File' || p.type === 'FileMultiple')
const activeGroup = fileParam?.parameter_group_name ?? 'Default Parameter Group'
const activeGroup = (credParam ?? fileParam)?.parameter_group_name ?? 'Default Parameter Group'
const groupParams = params.filter(p => p.parameter_group_name === activeGroup)
// Within that group: skip None (crypto), skip filename params (auto-populated from file.name), dedupe by name.
@@ -109,6 +121,10 @@ export function FileTaskModal({ command, params, displayId, defaultCwd, onClose
const [createTask] = useMutation(CREATE_TASK)
const hasCredentialParam = visibleParams.some(p => p.type === 'CredentialJson')
const { data: credData } = useQuery(GET_CREDENTIALS, { skip: !hasCredentialParam })
const credentials: CredentialOption[] = credData?.credential ?? []
function setValue(name: string, val: string) {
setValues(v => ({ ...v, [name]: val }))
}
@@ -182,6 +198,8 @@ export function FileTaskModal({ command, params, displayId, defaultCwd, onClose
} else if (p.type === 'Array' || p.type === 'TypedArray' || p.type === 'ChooseMultiple') {
paramsObj[p.name] = (values[p.name] ?? '')
.split(',').map(s => s.trim()).filter(Boolean)
} else if (p.type === 'CredentialJson') {
try { paramsObj[p.name] = JSON.parse(values[p.name] ?? '{}') } catch { paramsObj[p.name] = {} }
} else if (p.type !== 'None') {
paramsObj[p.name] = values[p.name] ?? ''
}
@@ -290,6 +308,34 @@ export function FileTaskModal({ command, params, displayId, defaultCwd, onClose
))}
</select>
) : p.type === 'CredentialJson' ? (
<select
className={styles.select}
value={values[p.name] ?? ''}
onChange={e => setValue(p.name, e.target.value)}
disabled={loading}
>
<option value=""> select credential </option>
{credentials
.filter(c =>
!p.limit_credentials_by_type?.length ||
p.limit_credentials_by_type.includes(c.type)
)
.map(c => {
const preview = (c.credential_text ?? '').slice(0, 40)
const label = `${c.account}${c.realm ? `@${c.realm}` : ''}${preview}${(c.credential_text?.length ?? 0) > 40 ? '…' : ''}${c.comment ? ` (${c.comment})` : ''}`
const val = JSON.stringify({
type: c.type,
account: c.account,
realm: c.realm,
credential: c.credential_text ?? '',
comment: c.comment,
})
return <option key={c.id} value={val}>{label}</option>
})
}
</select>
) : (
<input
type={p.type === 'Number' ? 'number' : 'text'}
@@ -0,0 +1,511 @@
/* src/components/CredentialsPanel/CredentialsPanel.module.css */
/* ── Outer layout ── */
.panel {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
background: var(--bg-base);
}
/* ══════════════════════════════════════════════
LEFT PANE
══════════════════════════════════════════════ */
.listPane {
width: 340px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--beige-border);
background: var(--bg-void);
min-height: 0;
}
.listHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--beige-border);
background: var(--bg-raised);
flex-shrink: 0;
}
.listTitle {
flex: 1;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--beige);
display: flex;
align-items: center;
gap: 6px;
}
.count {
font-size: 9px;
background: rgba(181, 40, 40, 0.18);
color: var(--crimson-400);
border: 1px solid rgba(181, 40, 40, 0.3);
border-radius: 10px;
padding: 0 5px;
font-variant-numeric: tabular-nums;
}
.addBtn {
background: rgba(181, 40, 40, 0.15);
border: 1px solid rgba(181, 40, 40, 0.4);
color: var(--crimson-400);
font-family: var(--font-mono);
font-size: 9px;
padding: 3px 8px;
cursor: pointer;
border-radius: 2px;
transition: background var(--t-fast), color var(--t-fast);
white-space: nowrap;
}
.addBtn:hover {
background: rgba(181, 40, 40, 0.28);
color: var(--crimson-200);
}
/* ── Type filter pills ── */
.typeFilters {
display: flex;
gap: 4px;
padding: 8px 10px;
border-bottom: 1px solid var(--beige-border);
flex-shrink: 0;
flex-wrap: wrap;
}
.typePill {
background: transparent;
border: 1px solid var(--beige-border);
color: var(--bone-800);
font-family: var(--font-mono);
font-size: 8px;
text-transform: uppercase;
letter-spacing: 0.07em;
padding: 2px 7px;
border-radius: 10px;
cursor: pointer;
transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast);
display: flex;
align-items: center;
gap: 4px;
}
.typePill:hover { background: rgba(239, 239, 218, 0.06); color: var(--bone-400); }
.typePillActive {
background: rgba(239, 239, 218, 0.1);
color: var(--beige);
border-color: var(--beige-border);
}
.pillCount {
font-size: 8px;
opacity: 0.7;
}
/* ── Search ── */
.searchBar {
padding: 7px 10px;
border-bottom: 1px solid var(--beige-border);
flex-shrink: 0;
}
.searchInput {
width: 100%;
background: transparent;
border: 1px solid var(--beige-border);
color: var(--bone-400);
font-family: var(--font-mono);
font-size: 10px;
padding: 4px 8px;
outline: none;
box-sizing: border-box;
}
.searchInput::placeholder { color: var(--bone-800); }
.searchInput:focus { border-color: rgba(239, 239, 218, 0.4); color: var(--bone-200); }
/* ── Credential list ── */
.list {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 80px;
color: var(--bone-800);
font-size: 11px;
}
.row {
display: flex;
flex-direction: column;
gap: 3px;
width: 100%;
padding: 9px 14px;
background: transparent;
border: none;
border-bottom: 1px solid rgba(239, 239, 218, 0.05);
cursor: pointer;
text-align: left;
transition: background var(--t-fast);
}
.row:hover { background: rgba(239, 239, 218, 0.04); }
.rowSelected { background: rgba(239, 239, 218, 0.08) !important; }
.rowTop {
display: flex;
align-items: center;
gap: 7px;
}
.typeBadge {
font-family: var(--font-mono);
font-size: 8px;
text-transform: uppercase;
letter-spacing: 0.08em;
flex-shrink: 0;
opacity: 0.9;
}
.rowAccount {
font-family: var(--font-mono);
font-size: 11px;
color: var(--bone-200);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.rowBottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.rowPreview {
font-family: var(--font-mono);
font-size: 9px;
color: var(--bone-800);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.rowTs {
font-family: var(--font-mono);
font-size: 9px;
color: var(--bone-900);
white-space: nowrap;
flex-shrink: 0;
}
/* ══════════════════════════════════════════════
RIGHT PANE — detail
══════════════════════════════════════════════ */
.detailPane {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.detail {
display: flex;
flex-direction: column;
padding: 24px 28px;
gap: 20px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
/* ── Detail header ── */
.detailHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.detailTitle {
display: flex;
align-items: baseline;
gap: 0;
flex-wrap: wrap;
line-height: 1.3;
}
.detailAccount {
font-family: var(--font-mono);
font-size: 18px;
color: var(--bone-100);
letter-spacing: -0.01em;
}
.detailRealm {
font-family: var(--font-mono);
font-size: 18px;
color: var(--beige);
}
.detailTypeBadge {
font-family: var(--font-mono);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.1em;
border: 1px solid;
border-radius: 2px;
padding: 2px 7px;
flex-shrink: 0;
margin-top: 4px;
}
/* ── Credential value block ── */
.credBlock {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--bg-void);
border: 1px solid var(--beige-border);
padding: 14px 16px;
}
.credLabelRow {
display: flex;
align-items: center;
justify-content: space-between;
}
.credLabel {
font-size: 8px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--bone-800);
}
.credValue {
font-family: var(--font-mono);
font-size: 12px;
color: var(--bone-200);
word-break: break-all;
line-height: 1.6;
}
.credActions {
display: flex;
gap: 8px;
margin-top: 4px;
}
.credBtn {
background: transparent;
border: 1px solid var(--beige-border);
color: var(--bone-600);
font-family: var(--font-mono);
font-size: 9px;
padding: 3px 10px;
cursor: pointer;
transition: background var(--t-fast), color var(--t-fast);
}
.credBtn:hover { background: rgba(239, 239, 218, 0.08); color: var(--bone-200); }
/* ── Metadata grid ── */
.metaGrid {
display: flex;
flex-direction: column;
gap: 8px;
}
.metaRow {
display: flex;
gap: 16px;
align-items: baseline;
}
.metaKey {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--bone-800);
min-width: 70px;
flex-shrink: 0;
}
.metaVal {
font-family: var(--font-mono);
font-size: 11px;
color: var(--bone-400);
word-break: break-word;
}
/* ── Edit form ── */
.editForm {
display: flex;
flex-direction: column;
gap: 8px;
}
.editActions {
display: flex;
gap: 8px;
margin-top: 4px;
}
/* ── Detail footer actions ── */
.detailActions {
display: flex;
gap: 8px;
padding-top: 4px;
border-top: 1px solid rgba(239, 239, 218, 0.08);
}
/* ── Shared form elements ── */
.fieldLabel {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--bone-800);
display: block;
margin-bottom: 2px;
}
.fieldInput {
width: 100%;
background: var(--bg-void);
border: 1px solid var(--beige-border);
color: var(--bone-200);
font-family: var(--font-mono);
font-size: 11px;
padding: 6px 10px;
outline: none;
box-sizing: border-box;
transition: border-color var(--t-fast);
}
.fieldInput:focus { border-color: rgba(239, 239, 218, 0.4); }
.fieldInput option { background: var(--bg-raised); }
.fieldTextarea {
resize: vertical;
min-height: 64px;
line-height: 1.5;
}
/* ── Buttons ── */
.btnPrimary {
background: rgba(181, 40, 40, 0.2);
border: 1px solid rgba(181, 40, 40, 0.5);
color: var(--crimson-300);
font-family: var(--font-mono);
font-size: 10px;
padding: 5px 16px;
cursor: pointer;
transition: background var(--t-fast), color var(--t-fast);
}
.btnPrimary:hover:not(:disabled) { background: rgba(181, 40, 40, 0.35); color: var(--crimson-100); }
.btnPrimary:disabled { opacity: 0.45; cursor: not-allowed; }
.btnSecondary {
background: transparent;
border: 1px solid var(--beige-border);
color: var(--bone-600);
font-family: var(--font-mono);
font-size: 10px;
padding: 5px 16px;
cursor: pointer;
transition: background var(--t-fast), color var(--t-fast);
}
.btnSecondary:hover { background: rgba(239, 239, 218, 0.07); color: var(--bone-200); }
.btnDanger {
background: transparent;
border: 1px solid rgba(181, 40, 40, 0.35);
color: var(--crimson-500);
font-family: var(--font-mono);
font-size: 10px;
padding: 5px 16px;
cursor: pointer;
transition: background var(--t-fast), color var(--t-fast);
}
.btnDanger:hover:not(:disabled) { background: rgba(181, 40, 40, 0.18); color: var(--crimson-300); }
.btnDanger:disabled { opacity: 0.45; cursor: not-allowed; }
/* ══════════════════════════════════════════════
ADD CREDENTIAL MODAL
══════════════════════════════════════════════ */
.modalBackdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal {
background: var(--bg-raised);
border: 1px solid var(--beige-border);
width: 420px;
max-width: 90vw;
display: flex;
flex-direction: column;
gap: 0;
}
.modalHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--beige-border);
}
.modalTitle {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--beige);
}
.modalClose {
background: transparent;
border: none;
color: var(--bone-800);
font-size: 12px;
cursor: pointer;
padding: 0 2px;
line-height: 1;
transition: color var(--t-fast);
}
.modalClose:hover { color: var(--bone-200); }
.modalForm {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
}
.modalActions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 4px;
}
@@ -0,0 +1,573 @@
/* ═══════════════════════════════════════════════════
src/components/CredentialsPanel/CredentialsPanel.tsx
Full-panel credential vault. Lists all credentials
for the active operation with search, type filtering,
add/edit/delete, and masked reveal.
═══════════════════════════════════════════════════ */
import { useState, useMemo, useEffect, useRef, useCallback } from 'react'
import { useQuery, useSubscription, useMutation } from '@apollo/client'
import {
GET_CREDENTIALS, SUB_CREDENTIALS,
CREATE_CREDENTIAL, UPDATE_CREDENTIAL, DELETE_CREDENTIAL,
} from '@/apollo/operations'
import { parseTs } from '@/components/Sidebar/utils'
import styles from './CredentialsPanel.module.css'
// ── Types ─────────────────────────────────────────────
interface Credential {
id: number
type: string
account: string
realm: string
credential_text: string | null
comment: string
metadata: string
timestamp: string
deleted: boolean
operator: { username: string }
task: { display_id: number; callback: { host: string; display_id: number } } | null
}
// ── Helpers ───────────────────────────────────────────
const TYPE_COLORS: Record<string, string> = {
hash: 'var(--crimson-400)',
plaintext: '#6aaa64',
certificate: '#6b8fd4',
key: '#d4916b',
ticket: '#b06bd4',
cookie: '#4ec9b0',
}
function typeColor(type: string): string {
return TYPE_COLORS[type.toLowerCase()] ?? 'var(--bone-600)'
}
function fmtAgo(iso: string): string {
const diff = Date.now() - parseTs(iso).getTime()
if (diff < 60_000) return 'just now'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
return parseTs(iso).toLocaleDateString([], { month: 'short', day: 'numeric' })
}
function maskCredential(val: string): string {
if (!val) return '—'
return '•'.repeat(Math.min(val.length, 36)) + (val.length > 36 ? '…' : '')
}
const KNOWN_TYPES = ['hash', 'plaintext', 'certificate', 'key', 'ticket', 'cookie']
// ── AddModal ──────────────────────────────────────────
interface AddModalProps {
loading: boolean
onClose: () => void
onAdd: (form: CredForm) => void
}
interface CredForm {
type: string
account: string
realm: string
credential_text: string
comment: string
metadata: string
}
function AddModal({ loading, onClose, onAdd }: AddModalProps) {
const [form, setForm] = useState<CredForm>({
type: 'hash', account: '', realm: '', credential_text: '', comment: '', metadata: '',
})
function set(k: keyof CredForm, v: string) { setForm(p => ({ ...p, [k]: v })) }
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!form.account.trim() || !form.credential_text.trim()) return
onAdd(form)
}
return (
<div className={styles.modalBackdrop} onClick={e => e.target === e.currentTarget && onClose()}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<span className={styles.modalTitle}>Add Credential</span>
<button className={styles.modalClose} onClick={onClose}></button>
</div>
<form className={styles.modalForm} onSubmit={handleSubmit}>
<label className={styles.fieldLabel}>Type</label>
<select className={styles.fieldInput} value={form.type} onChange={e => set('type', e.target.value)}>
{KNOWN_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<label className={styles.fieldLabel}>Account *</label>
<input
className={styles.fieldInput}
value={form.account}
onChange={e => set('account', e.target.value)}
placeholder="username"
required
autoFocus
/>
<label className={styles.fieldLabel}>Realm</label>
<input
className={styles.fieldInput}
value={form.realm}
onChange={e => set('realm', e.target.value)}
placeholder="domain or host"
/>
<label className={styles.fieldLabel}>Credential *</label>
<textarea
className={`${styles.fieldInput} ${styles.fieldTextarea}`}
value={form.credential_text}
onChange={e => set('credential_text', e.target.value)}
placeholder="hash, password, key…"
required
rows={3}
/>
<label className={styles.fieldLabel}>Comment</label>
<input
className={styles.fieldInput}
value={form.comment}
onChange={e => set('comment', e.target.value)}
placeholder="optional"
/>
<div className={styles.modalActions}>
<button type="button" className={styles.btnSecondary} onClick={onClose}>Cancel</button>
<button type="submit" className={styles.btnPrimary} disabled={loading}>
{loading ? 'Adding…' : 'Add'}
</button>
</div>
</form>
</div>
</div>
)
}
// ── CredentialRow ─────────────────────────────────────
interface RowProps {
cred: Credential
selected: boolean
onClick: () => void
}
function CredentialRow({ cred, selected, onClick }: RowProps) {
const preview = (cred.credential_text ?? '').slice(0, 30)
const identity = cred.account + (cred.realm ? `@${cred.realm}` : '')
return (
<button
className={`${styles.row} ${selected ? styles.rowSelected : ''}`}
onClick={onClick}
>
<div className={styles.rowTop}>
<span className={styles.typeBadge} style={{ color: typeColor(cred.type) }}>
{cred.type}
</span>
<span className={styles.rowAccount}>{identity}</span>
</div>
<div className={styles.rowBottom}>
<span className={styles.rowPreview}>{preview || '—'}</span>
<span className={styles.rowTs}>{fmtAgo(cred.timestamp)}</span>
</div>
</button>
)
}
// ── CredentialDetail ──────────────────────────────────
interface DetailProps {
cred: Credential
onUpdate: (c: Credential) => void
onDelete: (id: number) => void
}
function CredentialDetail({ cred, onUpdate, onDelete }: DetailProps) {
const [editing, setEditing] = useState(false)
const [revealed, setRevealed] = useState(false)
const [editRevealed, setEditRevealed] = useState(false)
const [copied, setCopied] = useState(false)
const [form, setForm] = useState<CredForm>({
type: cred.type,
account: cred.account,
realm: cred.realm,
credential_text: cred.credential_text ?? '',
comment: cred.comment,
metadata: cred.metadata,
})
// Sync form when cred prop updates (e.g. after successful save)
useEffect(() => {
setForm({
type: cred.type,
account: cred.account,
realm: cred.realm,
credential_text: cred.credential_text ?? '',
comment: cred.comment,
metadata: cred.metadata,
})
setEditing(false)
setRevealed(false)
setEditRevealed(false)
}, [cred.id]) // eslint-disable-line react-hooks/exhaustive-deps
function set(k: keyof CredForm, v: string) { setForm(p => ({ ...p, [k]: v })) }
const [updateCred, { loading: updating }] = useMutation(UPDATE_CREDENTIAL)
const [deleteCred, { loading: deleting }] = useMutation(DELETE_CREDENTIAL)
async function handleSave() {
const res = await updateCred({
variables: {
id: cred.id,
type: form.type,
account: form.account,
realm: form.realm,
credential: form.credential_text,
comment: form.comment,
metadata: form.metadata,
},
})
const updated = res.data?.update_credential_by_pk
if (updated) { onUpdate(updated); setEditing(false) }
}
async function handleDelete() {
if (!confirm(`Delete credential for ${cred.account}?`)) return
await deleteCred({ variables: { id: cred.id } })
onDelete(cred.id)
}
function handleCopy() {
navigator.clipboard.writeText(cred.credential_text ?? '').then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
}
const credVal = cred.credential_text ?? ''
const displayVal = revealed ? credVal : maskCredential(credVal)
// Deduplicate type options in edit select
const typeOptions = Array.from(new Set([...KNOWN_TYPES, form.type]))
return (
<div className={styles.detail}>
{/* Header */}
<div className={styles.detailHeader}>
<div className={styles.detailTitle}>
<span className={styles.detailAccount}>{cred.account}</span>
{cred.realm && <span className={styles.detailRealm}>@{cred.realm}</span>}
</div>
<span
className={styles.detailTypeBadge}
style={{ color: typeColor(cred.type), borderColor: typeColor(cred.type) }}
>
{cred.type}
</span>
</div>
{/* Credential value */}
<div className={styles.credBlock}>
<div className={styles.credLabelRow}>
<span className={styles.credLabel}>CREDENTIAL</span>
{editing && (
<button className={styles.credBtn} onClick={() => setEditRevealed(r => !r)}>
{editRevealed ? 'Hide' : 'Show'}
</button>
)}
</div>
{editing ? (
<input
className={styles.fieldInput}
type={editRevealed ? 'text' : 'password'}
value={form.credential_text}
onChange={e => set('credential_text', e.target.value)}
autoComplete="off"
spellCheck={false}
/>
) : (
<>
<div className={styles.credValue}>{displayVal}</div>
<div className={styles.credActions}>
<button className={styles.credBtn} onClick={() => setRevealed(r => !r)}>
{revealed ? 'Hide' : 'Show'}
</button>
<button className={styles.credBtn} onClick={handleCopy}>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</>
)}
</div>
{/* Edit fields or metadata */}
{editing ? (
<div className={styles.editForm}>
<label className={styles.fieldLabel}>Type</label>
<select className={styles.fieldInput} value={form.type} onChange={e => set('type', e.target.value)}>
{typeOptions.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<label className={styles.fieldLabel}>Account</label>
<input className={styles.fieldInput} value={form.account} onChange={e => set('account', e.target.value)} />
<label className={styles.fieldLabel}>Realm</label>
<input className={styles.fieldInput} value={form.realm} onChange={e => set('realm', e.target.value)} />
<label className={styles.fieldLabel}>Comment</label>
<input className={styles.fieldInput} value={form.comment} onChange={e => set('comment', e.target.value)} />
<label className={styles.fieldLabel}>Metadata</label>
<input className={styles.fieldInput} value={form.metadata} onChange={e => set('metadata', e.target.value)} />
<div className={styles.editActions}>
<button className={styles.btnSecondary} onClick={() => setEditing(false)}>Cancel</button>
<button className={styles.btnPrimary} onClick={handleSave} disabled={updating}>
{updating ? 'Saving…' : 'Save'}
</button>
</div>
</div>
) : (
<div className={styles.metaGrid}>
{cred.task && (
<div className={styles.metaRow}>
<span className={styles.metaKey}>Source</span>
<span className={styles.metaVal}>
task #{cred.task.display_id} on {cred.task.callback.host}
</span>
</div>
)}
<div className={styles.metaRow}>
<span className={styles.metaKey}>Operator</span>
<span className={styles.metaVal}>{cred.operator?.username ?? '—'}</span>
</div>
<div className={styles.metaRow}>
<span className={styles.metaKey}>Captured</span>
<span className={styles.metaVal}>
{parseTs(cred.timestamp).toLocaleString([], { hour12: false })}
</span>
</div>
{cred.comment && (
<div className={styles.metaRow}>
<span className={styles.metaKey}>Comment</span>
<span className={styles.metaVal}>{cred.comment}</span>
</div>
)}
{cred.metadata && (
<div className={styles.metaRow}>
<span className={styles.metaKey}>Metadata</span>
<span className={styles.metaVal}>{cred.metadata}</span>
</div>
)}
</div>
)}
{/* Action bar */}
{!editing && (
<div className={styles.detailActions}>
<button className={styles.btnSecondary} onClick={() => setEditing(true)}>Edit</button>
<button className={styles.btnDanger} onClick={handleDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete'}
</button>
</div>
)}
</div>
)
}
// ── CredentialsPanel ──────────────────────────────────
export function CredentialsPanel() {
const [credentials, setCredentials] = useState<Credential[]>([])
const [selected, setSelected] = useState<number | null>(null)
const [query, setQuery] = useState('')
const [typeFilter, setTypeFilter] = useState('all')
const [showAdd, setShowAdd] = useState(false)
const nowRef = useRef(new Date().toISOString())
const { loading, refetch } = useQuery(GET_CREDENTIALS, {
onCompleted: data => {
if (data?.credential) setCredentials(data.credential)
},
})
useSubscription(SUB_CREDENTIALS, {
variables: { now: nowRef.current },
onData: ({ data }) => {
const incoming: Credential[] = data.data?.credential_stream ?? []
if (!incoming.length) return
setCredentials(prev => {
const map = new Map(prev.map(c => [c.id, c]))
incoming.forEach(c => {
if (c.deleted) map.delete(c.id)
else map.set(c.id, c)
})
return Array.from(map.values())
.sort((a, b) => parseTs(b.timestamp).getTime() - parseTs(a.timestamp).getTime())
})
},
})
const [createCred, { loading: inserting }] = useMutation(CREATE_CREDENTIAL)
// Build type pill list from actual data
const allTypes = useMemo(
() => ['all', ...Array.from(new Set(credentials.map(c => c.type)))],
[credentials],
)
const typeCounts = useMemo(() => {
const counts: Record<string, number> = {}
credentials.forEach(c => { counts[c.type] = (counts[c.type] ?? 0) + 1 })
return counts
}, [credentials])
const filtered = useMemo(() => {
let list = credentials
if (typeFilter !== 'all') list = list.filter(c => c.type === typeFilter)
if (query.trim()) {
const q = query.toLowerCase()
list = list.filter(c =>
c.account.toLowerCase().includes(q) ||
c.realm.toLowerCase().includes(q) ||
(c.credential_text ?? '').toLowerCase().includes(q) ||
c.comment.toLowerCase().includes(q),
)
}
return list
}, [credentials, typeFilter, query])
const selectedCred = selected !== null
? credentials.find(c => c.id === selected) ?? null
: null
const handleAdd = useCallback(async (form: CredForm) => {
const res = await createCred({
variables: {
credential_type: form.type,
account: form.account,
realm: form.realm,
credential: form.credential_text,
comment: form.comment,
},
})
const result = res.data?.createCredential
if (result?.status === 'success') {
setShowAdd(false)
const fresh = await refetch()
if (fresh.data?.credential) setCredentials(fresh.data.credential)
}
}, [createCred, refetch])
const handleUpdate = useCallback((updated: Credential) => {
setCredentials(prev => prev.map(c => c.id === updated.id ? updated : c))
}, [])
const handleDelete = useCallback((id: number) => {
setCredentials(prev => prev.filter(c => c.id !== id))
setSelected(null)
}, [])
return (
<div className={styles.panel}>
{/* ── LEFT PANE ── */}
<div className={styles.listPane}>
<div className={styles.listHeader}>
<span className={styles.listTitle}>
Credentials
<span className={styles.count}>{credentials.length}</span>
</span>
<button className={styles.addBtn} onClick={() => setShowAdd(true)}>+ Add</button>
</div>
{/* Type filter pills */}
<div className={styles.typeFilters}>
{allTypes.map(t => (
<button
key={t}
className={`${styles.typePill} ${typeFilter === t ? styles.typePillActive : ''}`}
style={
t !== 'all' && typeFilter === t
? { color: typeColor(t), borderColor: typeColor(t) }
: undefined
}
onClick={() => setTypeFilter(t)}
>
{t}
{t !== 'all' && typeCounts[t] != null && (
<span className={styles.pillCount}>{typeCounts[t]}</span>
)}
</button>
))}
</div>
{/* Search */}
<div className={styles.searchBar}>
<input
className={styles.searchInput}
placeholder="/ search credentials…"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Escape' && setQuery('')}
spellCheck={false}
/>
</div>
{/* List */}
<div className={styles.list}>
{loading ? (
<div className={styles.empty}>Loading</div>
) : filtered.length === 0 ? (
<div className={styles.empty}>
{query || typeFilter !== 'all' ? 'No matches' : 'No credentials yet'}
</div>
) : (
filtered.map(cred => (
<CredentialRow
key={cred.id}
cred={cred}
selected={selected === cred.id}
onClick={() => setSelected(cred.id)}
/>
))
)}
</div>
</div>
{/* ── RIGHT PANE ── */}
<div className={styles.detailPane}>
{selectedCred ? (
<CredentialDetail
key={selectedCred.id}
cred={selectedCred}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
) : (
<div className={styles.empty}>Select a credential to view details</div>
)}
</div>
{/* Add modal */}
{showAdd && (
<AddModal
loading={inserting}
onClose={() => setShowAdd(false)}
onAdd={handleAdd}
/>
)}
</div>
)
}
+5 -3
View File
@@ -15,7 +15,8 @@ import { ServicesPanel } from '@/components/ServicesPanel/ServicesPanel'
import { ReportPanel } from '@/components/ReportPanel/ReportPanel'
import { FilesPanel } from '@/components/FilesPanel/FilesPanel'
import { OverviewPanel } from '@/components/OverviewPanel/OverviewPanel'
import { OperationsPanel } from '@/components/OperationsPanel/OperationsPanel'
import { OperationsPanel } from '@/components/OperationsPanel/OperationsPanel'
import { CredentialsPanel } from '@/components/CredentialsPanel/CredentialsPanel'
import { CallbackToastContainer } from '@/components/Toast/CallbackToast'
import { SettingsPanel } from '@/components/SettingsPanel/SettingsPanel'
import { SUB_CALLBACKS } from '@/apollo/operations'
@@ -95,7 +96,7 @@ export function Dashboard() {
useCallbackSubscription()
const activeRailView = useStore((s) => s.activeRailView)
const isFullPanel = activeRailView === 'overview' || activeRailView === 'payloads' || activeRailView === 'services' || activeRailView === 'report' || activeRailView === 'files' || activeRailView === 'operations'
const isFullPanel = activeRailView === 'overview' || activeRailView === 'payloads' || activeRailView === 'services' || activeRailView === 'report' || activeRailView === 'files' || activeRailView === 'operations' || activeRailView === 'credentials'
return (
<div className={styles.root}>
@@ -114,7 +115,8 @@ export function Dashboard() {
{activeRailView === 'services' && <ServicesPanel />}
{activeRailView === 'report' && <ReportPanel />}
{activeRailView === 'files' && <FilesPanel />}
{activeRailView === 'operations' && <OperationsPanel />}
{activeRailView === 'operations' && <OperationsPanel />}
{activeRailView === 'credentials' && <CredentialsPanel />}
</div>
) : (
<>