mirror of
https://github.com/Whispergate/Hecate
synced 2026-06-06 16:57:58 +00:00
Added credentials panel and management
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user