feat(web): rebuild the console around the fused control plane
Render the Hub feature set in the existing Animal Island styling: fleet messages, notification centre, log centre, organisation panel, per-instance device module panels and the settings backup, connection and maintenance surfaces.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isSensitiveKey, maskTail, protectValue } from './sensitive-fields.js';
|
||||
|
||||
describe('sensitive field masking', () => {
|
||||
it('recognises the same field under every naming style', () => {
|
||||
expect(isSensitiveKey('iccid')).toBe(true);
|
||||
expect(isSensitiveKey('ICCID')).toBe(true);
|
||||
expect(isSensitiveKey('phone_number')).toBe(true);
|
||||
expect(isSensitiveKey('phoneNumber')).toBe(true);
|
||||
expect(isSensitiveKey('phone-number')).toBe(true);
|
||||
expect(isSensitiveKey('slot')).toBe(false);
|
||||
expect(isSensitiveKey('carrier')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the last four digits of a subscriber identifier', () => {
|
||||
expect(maskTail('89860112345678901234')).toBe('•••••••• 1234');
|
||||
expect(maskTail('490154203237518')).toBe('•••••••• 7518');
|
||||
expect(maskTail('123')).toBe('••••');
|
||||
});
|
||||
|
||||
it('masks only sensitive values and only while hidden', () => {
|
||||
expect(protectValue('iccid', '89860112345678901234', false)).toBe('•••••••• 1234');
|
||||
expect(protectValue('iccid', '89860112345678901234', true)).toBe('89860112345678901234');
|
||||
expect(protectValue('status', 'ready', false)).toBe('ready');
|
||||
});
|
||||
|
||||
it('uses the phone mask for subscriber numbers', () => {
|
||||
expect(protectValue('phone_number', '13800001234', false)).toBe('138 •••• 1234');
|
||||
expect(protectValue('msisdn', '13800001234', false)).toBe('138 •••• 1234');
|
||||
expect(protectValue('phoneNumber', '13800001234', true)).toBe('13800001234');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { maskPhoneNumber } from '../fleet/phone-privacy.js';
|
||||
|
||||
/**
|
||||
* Subscriber identifiers the console keeps out of screenshots and shoulder-surfing range by
|
||||
* default. The list is shared so a card, a table cell and a fleet row never disagree about what
|
||||
* counts as sensitive.
|
||||
*/
|
||||
export const SENSITIVE_KEYS: ReadonlySet<string> = new Set([
|
||||
'iccid',
|
||||
'imei',
|
||||
'imsi',
|
||||
'meid',
|
||||
'phone_number',
|
||||
'msisdn',
|
||||
'phonenumber',
|
||||
]);
|
||||
|
||||
const MASK = '••••';
|
||||
|
||||
/** `phone-number`, `phoneNumber` and `phone_number` all name the same field. */
|
||||
function normalize(key: string): string {
|
||||
return key.toLocaleLowerCase().replace(/[-\s]/gu, '_');
|
||||
}
|
||||
|
||||
const PHONE_KEYS: ReadonlySet<string> = new Set(['phone_number', 'msisdn', 'phonenumber']);
|
||||
|
||||
export function isSensitiveKey(key: string): boolean {
|
||||
return SENSITIVE_KEYS.has(normalize(key));
|
||||
}
|
||||
|
||||
/** Keeps the last four digits, which is usually all an operator needs to pick the right card. */
|
||||
export function maskTail(value: string): string {
|
||||
const digits = value.replace(/\D/gu, '');
|
||||
if (digits.length <= 4) return MASK;
|
||||
return `${'•'.repeat(Math.min(8, Math.max(4, digits.length - 4)))} ${digits.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function protectValue(key: string, value: string, revealed: boolean): string {
|
||||
if (revealed || !isSensitiveKey(key)) return value;
|
||||
if (PHONE_KEYS.has(normalize(key))) return maskPhoneNumber(value);
|
||||
return maskTail(value);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
SensitiveRevealProvider,
|
||||
SensitiveRevealToggle,
|
||||
useSensitiveReveal,
|
||||
} from './sensitive-reveal.js';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
function Probe() {
|
||||
const { revealed, toggle } = useSensitiveReveal();
|
||||
return (
|
||||
<>
|
||||
<span data-testid="state">{revealed ? 'revealed' : 'masked'}</span>
|
||||
<button type="button" onClick={toggle}>
|
||||
切换
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('SensitiveRevealProvider', () => {
|
||||
it('masks by default and shares one switch across the tree', async () => {
|
||||
render(
|
||||
<SensitiveRevealProvider>
|
||||
<Probe />
|
||||
<Probe />
|
||||
</SensitiveRevealProvider>,
|
||||
);
|
||||
const states = screen.getAllByTestId('state');
|
||||
expect(states[0]!.textContent).toBe('masked');
|
||||
expect(states[1]!.textContent).toBe('masked');
|
||||
|
||||
await userEvent.click(screen.getAllByRole('button', { name: '切换' })[0]!);
|
||||
expect(screen.getAllByTestId('state')[0]!.textContent).toBe('revealed');
|
||||
expect(screen.getAllByTestId('state')[1]!.textContent).toBe('revealed');
|
||||
});
|
||||
|
||||
it('exposes one labelled switch in the topbar', async () => {
|
||||
render(
|
||||
<SensitiveRevealProvider>
|
||||
<SensitiveRevealToggle />
|
||||
<Probe />
|
||||
</SensitiveRevealProvider>,
|
||||
);
|
||||
const toggle = screen.getByRole('button', { name: '显示敏感标识' });
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('false');
|
||||
|
||||
await userEvent.click(toggle);
|
||||
expect(screen.getByTestId('state').textContent).toBe('revealed');
|
||||
expect(screen.getByRole('button', { name: '隐藏敏感标识' }).getAttribute('aria-pressed')).toBe(
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('remembers the choice for the next page load', async () => {
|
||||
const view = render(
|
||||
<SensitiveRevealProvider>
|
||||
<Probe />
|
||||
</SensitiveRevealProvider>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: '切换' }));
|
||||
expect(localStorage.getItem('multi-simadmin.reveal-sensitive')).toBe('true');
|
||||
view.unmount();
|
||||
|
||||
render(
|
||||
<SensitiveRevealProvider>
|
||||
<Probe />
|
||||
</SensitiveRevealProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('state').textContent).toBe('revealed');
|
||||
});
|
||||
|
||||
it('starts masked again once the stored choice is turned off', async () => {
|
||||
localStorage.setItem('multi-simadmin.reveal-sensitive', 'false');
|
||||
render(
|
||||
<SensitiveRevealProvider>
|
||||
<Probe />
|
||||
</SensitiveRevealProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('state').textContent).toBe('masked');
|
||||
await userEvent.click(screen.getByRole('button', { name: '切换' }));
|
||||
expect(localStorage.getItem('multi-simadmin.reveal-sensitive')).toBe('true');
|
||||
});
|
||||
|
||||
it('leaves consumers harmless without a provider', () => {
|
||||
render(<Probe />);
|
||||
expect(screen.getByTestId('state').textContent).toBe('masked');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button } from 'animal-island-ui';
|
||||
|
||||
import { Icon } from '../ui/icon.js';
|
||||
|
||||
const STORAGE_KEY = 'multi-simadmin.reveal-sensitive';
|
||||
|
||||
export interface SensitiveRevealState {
|
||||
/** One switch for the whole console: masked by default, revealed only on purpose. */
|
||||
readonly revealed: boolean;
|
||||
readonly toggle: () => void;
|
||||
}
|
||||
|
||||
const SensitiveRevealContext = createContext<SensitiveRevealState>({
|
||||
revealed: false,
|
||||
toggle: () => undefined,
|
||||
});
|
||||
|
||||
function readStoredPreference(): boolean {
|
||||
if (typeof localStorage === 'undefined') return false;
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredPreference(revealed: boolean): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(revealed));
|
||||
} catch {
|
||||
// A blocked storage still keeps the switch working for this page view.
|
||||
}
|
||||
}
|
||||
|
||||
export function SensitiveRevealProvider({ children }: { readonly children: ReactNode }) {
|
||||
const [revealed, setRevealed] = useState(readStoredPreference);
|
||||
const toggle = useCallback(() => setRevealed((value) => !value), []);
|
||||
|
||||
useEffect(() => {
|
||||
writeStoredPreference(revealed);
|
||||
}, [revealed]);
|
||||
|
||||
const value = useMemo<SensitiveRevealState>(() => ({ revealed, toggle }), [revealed, toggle]);
|
||||
return (
|
||||
<SensitiveRevealContext.Provider value={value}>{children}</SensitiveRevealContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSensitiveReveal(): SensitiveRevealState {
|
||||
return useContext(SensitiveRevealContext);
|
||||
}
|
||||
|
||||
export function SensitiveRevealToggle() {
|
||||
const { revealed, toggle } = useSensitiveReveal();
|
||||
return (
|
||||
<Button
|
||||
htmlType="button"
|
||||
className="sensitive-reveal-toggle"
|
||||
size="small"
|
||||
ghost
|
||||
aria-pressed={revealed}
|
||||
title={revealed ? '恢复隐藏 ICCID、IMEI 与手机号' : '显示 ICCID、IMEI 与手机号'}
|
||||
icon={<Icon name={revealed ? 'eye-off' : 'eye'} aria-hidden="true" />}
|
||||
onClick={toggle}
|
||||
>
|
||||
{revealed ? '隐藏敏感标识' : '显示敏感标识'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user