🐛 fix(security): 完善密文升级导入覆盖与安全更新链路

- 完善连接恢复包与 legacy 导入覆盖语义及密文兼容处理

- 修复安全更新详情高亮反馈与相关前后端链路

- 补强 keyring 误判边界与安全更新回归测试
This commit is contained in:
tianqijiuyun-latiao
2026-04-11 16:53:03 +08:00
parent 070ff72ad8
commit 82e06bd94d
35 changed files with 2021 additions and 110 deletions

View File

@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import {
detectConnectionImportKind,
isConnectionPackageExportCanceled,
resolveConnectionPackageExportResult,
normalizeConnectionPackagePassword,
} from './connectionExport';
@@ -57,4 +59,54 @@ describe('connectionExport', () => {
expect(normalizeConnectionPackagePassword(' secret-pass ')).toBe('secret-pass');
expect(normalizeConnectionPackagePassword('\n\t \t')).toBe('');
});
it('treats export cancel as a non-error backend result', () => {
expect(isConnectionPackageExportCanceled({ success: false, message: '已取消' })).toBe(true);
expect(isConnectionPackageExportCanceled({ success: false, message: '导出失败' })).toBe(false);
expect(isConnectionPackageExportCanceled({ success: true, message: '已取消' })).toBe(false);
expect(isConnectionPackageExportCanceled(undefined)).toBe(false);
});
it('maps export results to dialog state transitions', () => {
const staleDialog = {
open: true,
mode: 'export' as const,
password: ' secret-pass ',
error: '上一次失败',
confirmLoading: false,
};
const canceledResult = resolveConnectionPackageExportResult(staleDialog, { success: false, message: '已取消' });
expect(canceledResult.kind).toBe('canceled');
if (canceledResult.kind === 'canceled') {
expect(typeof canceledResult.nextDialog).toBe('function');
expect((canceledResult.nextDialog as (current: typeof staleDialog) => typeof staleDialog)({
open: false,
mode: 'export',
password: 'secret-pass',
error: '更新后的错误',
confirmLoading: true,
})).toEqual({
open: false,
mode: 'export',
password: 'secret-pass',
error: '',
confirmLoading: false,
});
}
expect(resolveConnectionPackageExportResult(staleDialog, { success: true, message: '导出完成' })).toEqual({
kind: 'succeeded',
});
expect(resolveConnectionPackageExportResult(staleDialog, { success: false, message: '磁盘已满' })).toEqual({
kind: 'failed',
error: '磁盘已满',
});
expect(resolveConnectionPackageExportResult(staleDialog, undefined)).toEqual({
kind: 'failed',
error: '导出失败',
});
});
});

View File

@@ -1,10 +1,26 @@
import type { ConnectionConfig, SavedConnection } from '../types';
export type ConnectionImportKind = 'encrypted-package' | 'legacy-json' | 'invalid';
export type ConnectionPackageDialogSnapshot = {
open: boolean;
mode: 'export' | 'import';
password: string;
error: string;
confirmLoading: boolean;
};
export type ConnectionPackageDialogUpdater = (
current: ConnectionPackageDialogSnapshot,
) => ConnectionPackageDialogSnapshot;
export type ConnectionPackageExportResult =
| { kind: 'canceled'; nextDialog: ConnectionPackageDialogUpdater }
| { kind: 'succeeded' }
| { kind: 'failed'; error: string };
type JsonObject = Record<string, unknown>;
const CONNECTION_PACKAGE_KIND = 'gonavi_connection_package';
const CANCELED_MESSAGE = '已取消';
const isJsonObject = (value: unknown): value is JsonObject => (
typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -69,6 +85,39 @@ export const detectConnectionImportKind = (raw: unknown): ConnectionImportKind =
export const normalizeConnectionPackagePassword = (value: string): string => value.trim();
export const isConnectionPackageExportCanceled = (result: unknown): boolean => (
isJsonObject(result)
&& result.success === false
&& result.message === CANCELED_MESSAGE
);
export const resolveConnectionPackageExportResult = (
_currentDialog: ConnectionPackageDialogSnapshot,
result: unknown,
): ConnectionPackageExportResult => {
if (isConnectionPackageExportCanceled(result)) {
return {
kind: 'canceled',
nextDialog: (current) => ({
...current,
confirmLoading: false,
error: '',
}),
};
}
if (isJsonObject(result) && result.success === true) {
return { kind: 'succeeded' };
}
return {
kind: 'failed',
error: isJsonObject(result) && typeof result.message === 'string' && result.message.trim()
? result.message
: '导出失败',
};
};
const legacyExportRemovedError = (): never => {
throw new Error('Legacy connection JSON export has been removed. Use the recovery package flow instead.');
};

View File

@@ -220,6 +220,83 @@ describe('secureConfigBootstrap', () => {
expect(result.shouldShowBanner).toBe(true);
});
it('merges legacy pending items into rolled_back status without overwriting backend system issues', () => {
const status = mergeSecurityUpdateStatusWithLegacySource({
overallStatus: 'rolled_back',
summary: { total: 1, updated: 0, pending: 0, skipped: 0, failed: 1 },
issues: [
{
id: 'system-blocked',
scope: 'system',
title: '系统回滚',
severity: 'high',
status: 'failed',
reasonCode: 'environment_blocked',
action: 'view_details',
message: '后端已回滚本轮更新,需要处理后重试。',
},
],
}, legacyPayload);
expect(status.overallStatus).toBe('rolled_back');
expect(status.summary).toEqual({
total: 3,
updated: 0,
pending: 2,
skipped: 0,
failed: 1,
});
expect(status.issues).toEqual(expect.arrayContaining([
expect.objectContaining({ id: 'system-blocked', scope: 'system' }),
expect.objectContaining({ id: 'legacy-connection-legacy-1', scope: 'connection', refId: 'legacy-1' }),
expect.objectContaining({ id: 'legacy-global-proxy-default', scope: 'global_proxy' }),
]));
});
it('merges legacy pending items into needs_attention status without overwriting backend system issues', () => {
const status = mergeSecurityUpdateStatusWithLegacySource({
overallStatus: 'needs_attention',
summary: { total: 2, updated: 1, pending: 0, skipped: 0, failed: 1 },
issues: [
{
id: 'system-partial-failure',
scope: 'system',
title: '部分失败',
severity: 'high',
status: 'failed',
reasonCode: 'environment_blocked',
action: 'view_details',
message: '部分项目迁移失败,需要人工处理。',
},
{
id: 'ai-provider-openai-main',
scope: 'ai_provider',
refId: 'openai-main',
title: 'OpenAI',
severity: 'medium',
status: 'updated',
action: 'open_ai_settings',
message: 'AI 提供商配置已完成安全更新。',
},
],
}, legacyPayload);
expect(status.overallStatus).toBe('needs_attention');
expect(status.summary).toEqual({
total: 4,
updated: 1,
pending: 2,
skipped: 0,
failed: 1,
});
expect(status.issues).toEqual(expect.arrayContaining([
expect.objectContaining({ id: 'system-partial-failure', scope: 'system' }),
expect.objectContaining({ id: 'ai-provider-openai-main', scope: 'ai_provider', refId: 'openai-main' }),
expect.objectContaining({ id: 'legacy-connection-legacy-1', scope: 'connection', refId: 'legacy-1' }),
expect.objectContaining({ id: 'legacy-global-proxy-default', scope: 'global_proxy' }),
]));
});
it('loads backend secure config directly when no legacy source exists', async () => {
const storage = createMemoryStorage();
const replaceConnections = vi.fn();
@@ -440,18 +517,25 @@ describe('secureConfigBootstrap', () => {
});
it('reduces legacy pending issues after a single connection is repaired before the first round starts', () => {
const initialStatus = mergeSecurityUpdateStatusWithLegacySource({
overallStatus: 'not_detected',
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
issues: [],
}, legacyPayload);
const nextPayload = stripLegacyPersistedConnectionById(legacyPayload, 'legacy-1');
const status = mergeSecurityUpdateStatusWithLegacySource({
overallStatus: 'not_detected',
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
issues: [],
}, nextPayload);
}, nextPayload, {
previousStatus: initialStatus,
});
expect(status.overallStatus).toBe('pending');
expect(status.summary).toEqual({
total: 1,
updated: 0,
total: 2,
updated: 1,
pending: 1,
skipped: 0,
failed: 0,
@@ -463,4 +547,87 @@ describe('secureConfigBootstrap', () => {
}),
]);
});
it('accumulates pre-start repaired progress across multiple connection saves in the same round-free session', () => {
const multiConnectionPayload = JSON.stringify({
state: {
connections: [
{
id: 'legacy-1',
name: 'Legacy 1',
config: {
id: 'legacy-1',
type: 'postgres',
host: 'db-1.local',
port: 5432,
user: 'postgres',
password: 'secret-1',
},
},
{
id: 'legacy-2',
name: 'Legacy 2',
config: {
id: 'legacy-2',
type: 'postgres',
host: 'db-2.local',
port: 5432,
user: 'postgres',
password: 'secret-2',
},
},
{
id: 'legacy-3',
name: 'Legacy 3',
config: {
id: 'legacy-3',
type: 'postgres',
host: 'db-3.local',
port: 5432,
user: 'postgres',
password: 'secret-3',
},
},
],
},
});
const backendStatus = {
overallStatus: 'not_detected' as const,
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
issues: [],
};
const initialStatus = mergeSecurityUpdateStatusWithLegacySource(backendStatus, multiConnectionPayload);
const afterFirstRepairPayload = stripLegacyPersistedConnectionById(multiConnectionPayload, 'legacy-1');
const afterFirstRepairStatus = mergeSecurityUpdateStatusWithLegacySource(backendStatus, afterFirstRepairPayload, {
previousStatus: initialStatus,
});
const afterSecondRepairPayload = stripLegacyPersistedConnectionById(afterFirstRepairPayload, 'legacy-2');
const afterSecondRepairStatus = mergeSecurityUpdateStatusWithLegacySource(backendStatus, afterSecondRepairPayload, {
previousStatus: afterFirstRepairStatus,
});
expect(afterFirstRepairStatus.summary).toEqual({
total: 3,
updated: 1,
pending: 2,
skipped: 0,
failed: 0,
});
expect(afterSecondRepairStatus.summary).toEqual({
total: 3,
updated: 2,
pending: 1,
skipped: 0,
failed: 0,
});
expect(afterSecondRepairStatus.issues).toEqual([
expect.objectContaining({
id: 'legacy-connection-legacy-3',
scope: 'connection',
refId: 'legacy-3',
}),
]);
});
});

View File

@@ -54,6 +54,10 @@ type StartSecurityUpdateResult = {
error: Error | null;
};
type MergeSecurityUpdateStatusOptions = {
previousStatus?: Partial<SecurityUpdateStatus> | null;
};
const defaultSummary = () => ({
total: 0,
updated: 0,
@@ -129,9 +133,56 @@ const mergeSecurityUpdateIssues = (
};
};
const isLocalLegacyIssue = (issue: Partial<SecurityUpdateIssue> | null | undefined): boolean => {
const issueId = String(issue?.id || '').trim();
return issueId.startsWith('legacy-connection-') || issueId === 'legacy-global-proxy-default';
};
const countLocalLegacyIssues = (issues: SecurityUpdateIssue[]): number => (
issues.filter((issue) => isLocalLegacyIssue(issue)).length
);
const deriveLegacySummary = (
base: SecurityUpdateStatus,
currentLegacyCount: number,
previousStatus?: Partial<SecurityUpdateStatus> | null,
): {
summary: SecurityUpdateSummary;
hasContribution: boolean;
} => {
const previousSummary = previousStatus?.summary ?? defaultSummary();
const previousIssues = Array.isArray(previousStatus?.issues) ? previousStatus.issues : [];
const previousLegacyCount = countLocalLegacyIssues(previousIssues);
const previousLegacyTotal = Math.max(
0,
previousSummary.total - base.summary.total,
previousSummary.updated - base.summary.updated + previousLegacyCount,
previousLegacyCount,
);
const previousLegacyUpdated = Math.max(
0,
Math.min(previousLegacyTotal, previousSummary.updated - base.summary.updated),
);
const repairedSincePrevious = Math.max(0, previousLegacyCount - currentLegacyCount);
const nextLegacyUpdated = Math.min(previousLegacyTotal, previousLegacyUpdated + repairedSincePrevious);
const nextLegacyTotal = Math.max(previousLegacyTotal, nextLegacyUpdated + currentLegacyCount);
return {
summary: {
total: base.summary.total + nextLegacyTotal,
updated: base.summary.updated + nextLegacyUpdated,
pending: base.summary.pending + currentLegacyCount,
skipped: base.summary.skipped,
failed: base.summary.failed,
},
hasContribution: nextLegacyTotal > 0,
};
};
export const mergeSecurityUpdateStatusWithLegacySource = (
status: Partial<SecurityUpdateStatus> | undefined,
rawPayload: string | null,
options?: MergeSecurityUpdateStatusOptions,
): SecurityUpdateStatus => {
const base: SecurityUpdateStatus = {
...defaultStatus(),
@@ -142,46 +193,51 @@ export const mergeSecurityUpdateStatusWithLegacySource = (
},
issues: Array.isArray(status?.issues) ? status.issues : [],
};
const baseNonLegacyIssues = base.issues.filter((issue) => !isLocalLegacyIssue(issue));
const legacy = buildLegacyPendingDetails(rawPayload);
if (!legacy.hasLegacyItems) {
const legacySummary = deriveLegacySummary(base, legacy.issues.length, options?.previousStatus);
if (!legacySummary.hasContribution) {
return base;
}
const mergedIssues = mergeSecurityUpdateIssues(baseNonLegacyIssues, legacy.issues).issues;
if (base.overallStatus === 'not_detected') {
if (!legacy.hasLegacyItems) {
return base;
}
return {
...base,
overallStatus: 'pending',
reminderVisible: true,
canStart: true,
canPostpone: true,
summary: legacy.summary,
issues: legacy.issues,
summary: legacySummary.summary,
issues: mergedIssues,
};
}
if (base.overallStatus === 'pending' || base.overallStatus === 'postponed') {
const mergedIssues = mergeSecurityUpdateIssues(base.issues, legacy.issues);
const summary = hasMeaningfulSummary(base.summary)
? {
total: base.summary.total + mergedIssues.addedCount,
updated: base.summary.updated,
pending: base.summary.pending + mergedIssues.addedCount,
skipped: base.summary.skipped,
failed: base.summary.failed,
}
: legacy.summary;
return {
...base,
summary,
issues: mergedIssues.issues,
summary: hasMeaningfulSummary(base.summary) || legacy.hasLegacyItems ? legacySummary.summary : legacy.summary,
issues: mergedIssues,
canStart: true,
canPostpone: true,
reminderVisible: base.overallStatus === 'pending' ? true : base.reminderVisible,
};
}
if (base.overallStatus === 'rolled_back' || base.overallStatus === 'needs_attention') {
return {
...base,
summary: hasMeaningfulSummary(base.summary) || legacy.hasLegacyItems ? legacySummary.summary : legacy.summary,
issues: mergedIssues,
};
}
return base;
};
@@ -344,6 +400,7 @@ export async function startSecurityUpdateFromBootstrap(args: SecureConfigBootstr
export type {
BackendGlobalProxyResult,
MergeSecurityUpdateStatusOptions,
SecurityUpdateBackend,
SecureConfigBootstrapArgs,
SecureConfigBootstrapResult,

View File

@@ -1,8 +1,11 @@
import { describe, expect, it } from 'vitest';
import type { SavedConnection, SecurityUpdateIssue } from '../types';
import type { SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from '../types';
import {
hasSecurityUpdateRecentResult,
resolveSecurityUpdateFocusState,
resolveSecurityUpdateRepairEntry,
resolveSecurityUpdateSettingsFocusTarget,
shouldReopenSecurityUpdateDetails,
shouldRetrySecurityUpdateAfterRepairSave,
} from './securityUpdateRepairFlow';
@@ -19,6 +22,19 @@ const createConnection = (id: string): SavedConnection => ({
},
});
const createStatus = (overrides: Partial<SecurityUpdateStatus> = {}): SecurityUpdateStatus => ({
overallStatus: 'needs_attention',
summary: {
total: 1,
updated: 0,
pending: 1,
skipped: 0,
failed: 0,
},
issues: [],
...overrides,
});
describe('securityUpdateRepairFlow', () => {
it('opens the matching connection and preserves the return source for security update repairs', () => {
const target = createConnection('conn-1');
@@ -63,6 +79,50 @@ describe('securityUpdateRepairFlow', () => {
});
});
it('routes view_details actions to the latest result section when a recent result exists', () => {
const status = createStatus({
backupPath: '/tmp/gonavi-backup.json',
lastError: '写入新密钥失败',
});
expect(hasSecurityUpdateRecentResult(status)).toBe(true);
expect(resolveSecurityUpdateSettingsFocusTarget(status)).toBe('recent_result');
expect(resolveSecurityUpdateRepairEntry({ id: 'details', action: 'view_details' }, [], status)).toEqual({
type: 'details',
focusTarget: 'recent_result',
});
});
it('falls back to the status section when no recent result is available yet', () => {
const status = createStatus();
expect(hasSecurityUpdateRecentResult(status)).toBe(false);
expect(resolveSecurityUpdateSettingsFocusTarget(status)).toBe('status');
expect(resolveSecurityUpdateRepairEntry({ id: 'details', action: 'view_details' }, [], status)).toEqual({
type: 'details',
focusTarget: 'status',
});
});
it('builds a fresh focus pulse for repeated details clicks and clears it when the modal closes', () => {
expect(resolveSecurityUpdateFocusState(true, 'status', 1)).toEqual({
target: 'status',
pulseKey: 'status:1',
});
expect(resolveSecurityUpdateFocusState(true, 'status', 2)).toEqual({
target: 'status',
pulseKey: 'status:2',
});
expect(resolveSecurityUpdateFocusState(false, 'status', 2)).toEqual({
target: null,
pulseKey: null,
});
expect(resolveSecurityUpdateFocusState(true, null, 3)).toEqual({
target: null,
pulseKey: null,
});
});
it('reopens security update details after closing a repair entry opened from that page', () => {
expect(shouldReopenSecurityUpdateDetails('connection')).toBe(true);
expect(shouldReopenSecurityUpdateDetails('proxy')).toBe(true);

View File

@@ -1,6 +1,11 @@
import type { SavedConnection, SecurityUpdateIssue } from '../types';
import type { SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from '../types';
export type SecurityUpdateRepairSource = 'connection' | 'proxy' | 'ai';
export type SecurityUpdateSettingsFocusTarget = 'recent_result' | 'status';
export type SecurityUpdateFocusState = {
target: SecurityUpdateSettingsFocusTarget | null;
pulseKey: string | null;
};
export type SecurityUpdateRepairEntry =
| {
@@ -22,15 +27,45 @@ export type SecurityUpdateRepairEntry =
}
| {
type: 'details';
focusTarget: SecurityUpdateSettingsFocusTarget;
}
| {
type: 'warning';
message: string;
};
export const hasSecurityUpdateRecentResult = (
status?: Pick<SecurityUpdateStatus, 'backupPath' | 'lastError'> | null,
): boolean => Boolean(status?.backupPath || status?.lastError);
export const resolveSecurityUpdateSettingsFocusTarget = (
status?: Pick<SecurityUpdateStatus, 'backupPath' | 'lastError'> | null,
): SecurityUpdateSettingsFocusTarget => (
hasSecurityUpdateRecentResult(status) ? 'recent_result' : 'status'
);
export const resolveSecurityUpdateFocusState = (
open: boolean,
focusTarget: SecurityUpdateSettingsFocusTarget | null | undefined,
focusRequest: number,
): SecurityUpdateFocusState => {
if (!open || !focusTarget) {
return {
target: null,
pulseKey: null,
};
}
return {
target: focusTarget,
pulseKey: `${focusTarget}:${focusRequest}`,
};
};
export const resolveSecurityUpdateRepairEntry = (
issue: SecurityUpdateIssue,
connections: SavedConnection[],
status?: Pick<SecurityUpdateStatus, 'backupPath' | 'lastError'> | null,
): SecurityUpdateRepairEntry => {
if (issue.action === 'open_connection') {
const target = connections.find((connection) => connection.id === issue.refId);
@@ -70,6 +105,7 @@ export const resolveSecurityUpdateRepairEntry = (
return {
type: 'details',
focusTarget: resolveSecurityUpdateSettingsFocusTarget(status),
};
};

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { buildOverlayWorkbenchTheme } from './overlayWorkbenchTheme';
import {
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
SECURITY_UPDATE_BANNER_CLASS,
SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS,
SECURITY_UPDATE_RESULT_CARD_CLASS,
getSecurityUpdateActionButtonStyle,
getSecurityUpdateBannerSurfaceStyle,
getSecurityUpdateSectionSurfaceStyle,
getSecurityUpdateShellSurfaceStyle,
} from './securityUpdateVisuals';
describe('securityUpdateVisuals', () => {
it('builds action buttons without default ant focus glow shadow', () => {
expect(SECURITY_UPDATE_ACTION_BUTTON_CLASS).toBe('security-update-action-btn');
expect(SECURITY_UPDATE_BANNER_CLASS).toBe('security-update-banner');
expect(SECURITY_UPDATE_RESULT_CARD_CLASS).toBe('security-update-result-card');
expect(SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS).toBe('security-update-result-card-active');
expect(getSecurityUpdateActionButtonStyle()).toMatchObject({
height: 36,
borderRadius: 12,
boxShadow: 'none',
fontWeight: 600,
});
});
it('keeps the shell surface aligned with overlay shell tokens in light and dark mode', () => {
const lightTheme = buildOverlayWorkbenchTheme(false);
const darkTheme = buildOverlayWorkbenchTheme(true);
expect(getSecurityUpdateShellSurfaceStyle(lightTheme)).toMatchObject({
border: lightTheme.shellBorder,
background: lightTheme.shellBg,
boxShadow: lightTheme.shellShadow,
backdropFilter: lightTheme.shellBackdropFilter,
});
expect(getSecurityUpdateShellSurfaceStyle(darkTheme)).toMatchObject({
border: darkTheme.shellBorder,
background: darkTheme.shellBg,
boxShadow: darkTheme.shellShadow,
backdropFilter: darkTheme.shellBackdropFilter,
});
});
it('keeps the banner surface aligned with overlay shell tokens instead of translucent section tokens', () => {
const lightTheme = buildOverlayWorkbenchTheme(false);
const darkTheme = buildOverlayWorkbenchTheme(true);
expect(getSecurityUpdateBannerSurfaceStyle(lightTheme)).toMatchObject({
border: lightTheme.shellBorder,
background: lightTheme.shellBg,
boxShadow: 'none',
backdropFilter: lightTheme.shellBackdropFilter,
});
expect(getSecurityUpdateBannerSurfaceStyle(darkTheme)).toMatchObject({
border: darkTheme.shellBorder,
background: darkTheme.shellBg,
boxShadow: 'none',
backdropFilter: darkTheme.shellBackdropFilter,
});
});
it('can emphasize a section surface for transient focus and recent-result highlighting', () => {
const lightTheme = buildOverlayWorkbenchTheme(false);
const darkTheme = buildOverlayWorkbenchTheme(true);
expect(getSecurityUpdateSectionSurfaceStyle(lightTheme)).toMatchObject({
border: lightTheme.sectionBorder,
background: lightTheme.sectionBg,
boxShadow: 'none',
});
expect(getSecurityUpdateSectionSurfaceStyle(darkTheme)).toMatchObject({
border: darkTheme.sectionBorder,
background: darkTheme.sectionBg,
boxShadow: 'none',
});
const emphasizedLight = getSecurityUpdateSectionSurfaceStyle(lightTheme, { emphasized: true });
const emphasizedDark = getSecurityUpdateSectionSurfaceStyle(darkTheme, { emphasized: true });
expect(emphasizedLight.background).not.toBe(lightTheme.sectionBg);
expect(emphasizedLight.boxShadow).not.toBe('none');
expect(emphasizedDark.background).not.toBe(darkTheme.sectionBg);
expect(emphasizedDark.boxShadow).not.toBe('none');
});
});

View File

@@ -0,0 +1,65 @@
import type { CSSProperties } from 'react';
import type { OverlayWorkbenchTheme } from './overlayWorkbenchTheme';
export const SECURITY_UPDATE_ACTION_BUTTON_CLASS = 'security-update-action-btn';
export const SECURITY_UPDATE_BANNER_CLASS = 'security-update-banner';
export const SECURITY_UPDATE_MODAL_CLASS = 'security-update-modal';
export const SECURITY_UPDATE_RESULT_CARD_CLASS = 'security-update-result-card';
export const SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS = 'security-update-result-card-active';
type SecurityUpdateSectionSurfaceOptions = {
emphasized?: boolean;
};
const getSecurityUpdateHighlightBorder = (overlayTheme: OverlayWorkbenchTheme): string => (
overlayTheme.isDark
? '1px solid rgba(255,214,102,0.26)'
: '1px solid rgba(22,119,255,0.22)'
);
const getSecurityUpdateHighlightBackground = (overlayTheme: OverlayWorkbenchTheme): string => (
overlayTheme.isDark
? 'linear-gradient(180deg, rgba(255,214,102,0.14) 0%, rgba(255,255,255,0.05) 100%)'
: 'linear-gradient(180deg, rgba(22,119,255,0.12) 0%, rgba(255,255,255,0.96) 100%)'
);
const getSecurityUpdateHighlightShadow = (overlayTheme: OverlayWorkbenchTheme): string => (
overlayTheme.isDark
? '0 0 0 1px rgba(255,214,102,0.12), 0 12px 24px rgba(0,0,0,0.16)'
: '0 0 0 1px rgba(22,119,255,0.08), 0 10px 22px rgba(15,23,42,0.08)'
);
export const getSecurityUpdateActionButtonStyle = (): CSSProperties => ({
height: 36,
borderRadius: 12,
paddingInline: 16,
boxShadow: 'none',
fontWeight: 600,
});
export const getSecurityUpdateShellSurfaceStyle = (
overlayTheme: OverlayWorkbenchTheme,
): CSSProperties => ({
border: overlayTheme.shellBorder,
background: overlayTheme.shellBg,
boxShadow: overlayTheme.shellShadow,
backdropFilter: overlayTheme.shellBackdropFilter,
});
export const getSecurityUpdateBannerSurfaceStyle = (
overlayTheme: OverlayWorkbenchTheme,
): CSSProperties => ({
...getSecurityUpdateShellSurfaceStyle(overlayTheme),
boxShadow: 'none',
});
export const getSecurityUpdateSectionSurfaceStyle = (
overlayTheme: OverlayWorkbenchTheme,
options: SecurityUpdateSectionSurfaceOptions = {},
): CSSProperties => ({
border: options.emphasized ? getSecurityUpdateHighlightBorder(overlayTheme) : overlayTheme.sectionBorder,
background: options.emphasized ? getSecurityUpdateHighlightBackground(overlayTheme) : overlayTheme.sectionBg,
boxShadow: options.emphasized ? getSecurityUpdateHighlightShadow(overlayTheme) : 'none',
transition: 'background 180ms ease, border-color 180ms ease, box-shadow 180ms ease',
});