This repository has been archived on 2026-06-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
headlamp-sealed-secrets-plugin/src/components/SealedSecretDetail.tsx
T
DevContainer User 9d9bc5f22f fix: remove any types, dead code, unused exports; add comprehensive tests
- Fix handleRotate bug ignoring Result from rotateSealedSecret()
- Fix dead code branch in useControllerHealth
- Replace all `any` types with `unknown` + type guards
- Delete unused functions/exports (452 lines removed)
- Add 18 new test files covering all hooks, libs, and components
- 233 tests passing, zero tsc errors, zero lint issues

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:13:00 +00:00

384 lines
12 KiB
TypeScript

/**
* SealedSecret Detail View
*
* Shows detailed information about a specific SealedSecret including
* encrypted data, template, resulting Secret, and actions
*/
import { Icon } from '@iconify/react';
import { K8s } from '@kinvolk/headlamp-plugin/lib';
import { Link } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import {
NameValueTable,
SectionBox,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import {
Alert,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Drawer,
IconButton,
Typography,
} from '@mui/material';
import { useSnackbar } from 'notistack';
import React from 'react';
import { useParams } from 'react-router-dom';
import { usePermissions } from '../hooks/usePermissions';
import { getPluginConfig, rotateSealedSecret } from '../lib/controller';
import { canDecryptSecrets } from '../lib/rbac';
import { SealedSecret } from '../lib/SealedSecretCRD';
import { SealedSecretScope } from '../types';
import { DecryptDialog } from './DecryptDialog';
import { SealedSecretDetailSkeleton } from './LoadingSkeletons';
/**
* Format scope for display
*/
function formatScope(scope: SealedSecretScope): string {
switch (scope) {
case 'strict':
return 'Strict';
case 'namespace-wide':
return 'Namespace-wide';
case 'cluster-wide':
return 'Cluster-wide';
default:
return scope;
}
}
/**
* SealedSecret detail view component
*/
export function SealedSecretDetail() {
const { namespace = '', name = '' } = useParams<{ namespace: string; name: string }>();
const [sealedSecret, error] = SealedSecret.useGet(name || undefined, namespace || undefined);
const [secret] = K8s.ResourceClasses.Secret.useGet(name || undefined, namespace || undefined);
const [decryptKey, setDecryptKey] = React.useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
const [rotating, setRotating] = React.useState(false);
const [canDecrypt, setCanDecrypt] = React.useState(false);
const { enqueueSnackbar } = useSnackbar();
const { permissions } = usePermissions(namespace || undefined);
// Check if user can decrypt secrets (requires get permission on Secrets)
React.useEffect(() => {
let cancelled = false;
if (namespace) {
canDecryptSecrets(namespace).then(result => {
if (!cancelled) setCanDecrypt(result);
});
}
return () => {
cancelled = true;
};
}, [namespace]);
// Wait for required params before rendering
if (!namespace || !name) {
return <SealedSecretDetailSkeleton />;
}
// Show error if fetch failed
if (error) {
return (
<Box p={3}>
<Alert severity="error">
<Typography variant="h6" gutterBottom>
Failed to load SealedSecret
</Typography>
<Typography variant="body2">{String(error)}</Typography>
</Alert>
</Box>
);
}
// Show loading skeleton while data is being fetched
if (!sealedSecret) {
return <SealedSecretDetailSkeleton />;
}
// Memoize callbacks to prevent re-renders
const handleDelete = React.useCallback(async () => {
try {
await sealedSecret.delete();
enqueueSnackbar('SealedSecret deleted successfully', { variant: 'success' });
window.history.back();
} catch (error: unknown) {
enqueueSnackbar(
`Failed to delete SealedSecret: ${error instanceof Error ? error.message : String(error)}`,
{ variant: 'error' }
);
}
setDeleteDialogOpen(false);
}, [sealedSecret, enqueueSnackbar]);
const handleRotate = React.useCallback(async () => {
setRotating(true);
try {
const config = getPluginConfig();
const yaml = JSON.stringify(sealedSecret.jsonData);
const result = await rotateSealedSecret(config, yaml);
if (result.ok === false) {
enqueueSnackbar(`Failed to re-encrypt: ${result.error}`, { variant: 'error' });
} else {
enqueueSnackbar('SealedSecret re-encrypted successfully', { variant: 'success' });
}
} catch (error: unknown) {
enqueueSnackbar(
`Failed to re-encrypt: ${error instanceof Error ? error.message : String(error)}`,
{ variant: 'error' }
);
} finally {
setRotating(false);
}
}, [sealedSecret, enqueueSnackbar]);
// Safety check - should never happen due to early returns above, but be defensive
if (!sealedSecret?.spec?.encryptedData) {
return <SealedSecretDetailSkeleton />;
}
const encryptedKeys = Object.keys(sealedSecret.spec.encryptedData);
const handleClose = () => {
window.history.back();
};
return (
<>
<Drawer
anchor="right"
open
onClose={handleClose}
PaperProps={{
sx: {
width: { xs: '100%', sm: '600px', md: '800px' },
maxWidth: '100%',
},
}}
>
<Box sx={{ height: '100%', overflow: 'auto' }}>
<SectionBox
title={
<Box display="flex" alignItems="center" justifyContent="space-between">
<Box display="flex" alignItems="center" gap={1}>
<IconButton
onClick={handleClose}
edge="start"
size="small"
aria-label="Close detail panel"
>
<Icon icon="mdi:close" />
</IconButton>
<span>{sealedSecret.metadata.name}</span>
</Box>
<Box>
{permissions?.canUpdate && (
<Button
variant="outlined"
onClick={handleRotate}
disabled={rotating}
sx={{ mr: 1 }}
>
{rotating ? 'Re-encrypting...' : 'Re-encrypt'}
</Button>
)}
{permissions?.canDelete && (
<Button
variant="outlined"
color="error"
onClick={() => setDeleteDialogOpen(true)}
>
Delete
</Button>
)}
</Box>
</Box>
}
>
<NameValueTable
rows={[
{
name: 'Name',
value: String(sealedSecret.metadata.name || ''),
},
{
name: 'Namespace',
value: String(sealedSecret.metadata.namespace || ''),
},
{
name: 'Scope',
value: String(formatScope(sealedSecret.scope)),
},
{
name: 'Sync Status',
value: (
<StatusLabel status={sealedSecret.isSynced ? 'success' : 'error'}>
{sealedSecret.isSynced ? 'Synced' : 'Not Synced'}
</StatusLabel>
),
},
{
name: 'Status Message',
value: String(sealedSecret.syncMessage || 'Unknown'),
hide: !sealedSecret.syncCondition,
},
{
name: 'Age',
value: String(sealedSecret.getAge() || ''),
},
{
name: 'Created',
value: sealedSecret.metadata.creationTimestamp
? new Date(sealedSecret.metadata.creationTimestamp).toLocaleString()
: 'Unknown',
},
]}
/>
</SectionBox>
<SectionBox title="Encrypted Data">
<SimpleTable
data={encryptedKeys.map(key => ({
key,
value: sealedSecret.spec.encryptedData[key],
}))}
columns={[
{
label: 'Key',
getter: (row: { key: string; value: string }) => row.key,
},
{
label: 'Encrypted Value',
getter: (row: { key: string; value: string }) => {
const val = row.value;
return val.length > 40 ? val.substring(0, 40) + '...' : val;
},
},
{
label: 'Actions',
getter: (row: { key: string; value: string }) =>
canDecrypt ? (
<Button
size="small"
onClick={() => setDecryptKey(row.key)}
aria-label={`Decrypt ${row.key}`}
>
Decrypt
</Button>
) : (
<Button
size="small"
disabled
title="No permission to access Secrets"
aria-label={`Decrypt ${row.key} (no permission)`}
>
Decrypt
</Button>
),
},
]}
/>
</SectionBox>
{sealedSecret.spec.template && (
<SectionBox title="Template">
<NameValueTable
rows={[
{
name: 'Secret Type',
value: String(sealedSecret.spec.template.type || 'Opaque'),
},
{
name: 'Labels',
value: String(
JSON.stringify(sealedSecret.spec.template.metadata?.labels || {})
),
hide: !sealedSecret.spec.template.metadata?.labels,
},
{
name: 'Annotations',
value: String(
JSON.stringify(sealedSecret.spec.template.metadata?.annotations || {})
),
hide: !sealedSecret.spec.template.metadata?.annotations,
},
]}
/>
</SectionBox>
)}
<SectionBox title="Resulting Secret">
{secret ? (
<NameValueTable
rows={[
{
name: 'Status',
value: <StatusLabel status="success">Secret exists</StatusLabel>,
},
{
name: 'Keys',
value: String(Object.keys(secret.data || {}).join(', ') || 'None'),
},
{
name: 'Link',
value: (
<Link
routeName="secret"
params={{
namespace: String(secret.metadata.namespace || ''),
name: String(secret.metadata.name || ''),
}}
>
View Secret
</Link>
),
},
]}
/>
) : (
<Box p={2}>
<StatusLabel status="warning">Secret not yet created</StatusLabel>
<p>The controller will create the Secret once it processes this SealedSecret.</p>
</Box>
)}
</SectionBox>
</Box>
{decryptKey && (
<DecryptDialog
sealedSecret={sealedSecret}
secretKey={decryptKey}
onClose={() => setDecryptKey(null)}
/>
)}
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
aria-labelledby="delete-dialog-title"
>
<DialogTitle id="delete-dialog-title">Delete SealedSecret?</DialogTitle>
<DialogContent>
Are you sure you want to delete the SealedSecret <strong>{name}</strong>? This will also
delete the resulting Kubernetes Secret.
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button onClick={handleDelete} color="error">
Delete
</Button>
</DialogActions>
</Dialog>
</Drawer>
</>
);
}