generated from Labyricorn/labyricorn-project-template
Implement CyberSim Phase 2.5: Localization, Branding, and Immersive Login
This commit is contained in:
@@ -1,30 +1,32 @@
|
||||
# CyberSim OS
|
||||
|
||||
**Phase 2 MVP: Scenario-Driven Cybersecurity Simulation Platform**
|
||||
**Phase 2.5: Localization, Corporate Branding, and Immersive Login**
|
||||
|
||||
CyberSim OS is an offline-first, browser-native cybersecurity simulation platform designed for ordinary end users. Learners enter a convincing fictional enterprise desktop, perform routine workplace activities, investigate ambiguous events, encounter realistic threats (credential phishing, malicious attachments) and legitimate false flags, receive a multi-axis behavioral after-action assessment, and earn cryptographically verifiable completion certificates (`*.cybercert`).
|
||||
CyberSim OS is an offline-first, browser-native cybersecurity simulation platform designed for enterprise organizations and end-user learners. Learners enter a convincing fictional enterprise workstation, perform routine workplace activities, investigate ambiguous communications, encounter realistic threats (credential harvesting, malicious attachments) alongside legitimate false flags, receive a multi-axis behavioral after-action assessment, and earn cryptographically verifiable completion certificates (`*.cybercert`).
|
||||
|
||||
**Phase 2** transforms CyberSim from a single hard-coded simulation into a **reusable, scenario-driven platform**. Complete experiences are authored through portable declarative JSON scenario packages — no application code changes required.
|
||||
**Phase 2.5** adds **organizational readiness** through runtime multilingual localization, configuration-driven corporate branding, an immersive workstation login experience, learner first-name personalization, and cross-locale cryptographic verification.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Scenario-Driven Architecture**: Complete simulations are defined through JSON scenario packages. No code changes needed to create new experiences.
|
||||
- **Convincing Fictional Desktop**: Original Windows-like theme with draggable/resizable windows, active/inactive focus states, taskbar, Start launcher, simulation clock, and system toast notifications with audio cues.
|
||||
- **Core Simulated Workplace Applications**:
|
||||
- **Inlook**: Email client with header inspector (RFC sender vs. friendly name), link hover destination tooltips, phishing report workflow, and reply/delete actions.
|
||||
- **Navigator**: Web browser supporting intranet directories, policy hubs, and external spoofed credential-harvesting portals.
|
||||
- **Files**: Virtual filesystem managing Documents, Downloads, and Company Shared folders.
|
||||
- **Doc Viewer**: Lightweight renderer for spreadsheets (`.xlsx`), policies (`.pdf`), and memos.
|
||||
- **Security Center**: Endpoint protection dashboard, real-time alert logs, incident report confirmations, and delayed alert triggers.
|
||||
- **Declarative Event System**: Scenario events with time-based, action-based, and state-based triggers execute through a restricted behavior vocabulary.
|
||||
- **Data-Driven Scoring**: Scoring categories, rules, and thresholds are defined in the scenario package. The engine evaluates them generically.
|
||||
- **Delayed Consequences Engine**: Unsafe actions trigger delayed consequence events without giving immediate arcade-like game-over feedback.
|
||||
- **Immersive CyberSim OS Login**: Fictional enterprise workstation login and dynamic network/workplace selection without requesting passwords or sensitive employee credentials.
|
||||
- **Runtime Multilingual & Localization (I18n)**:
|
||||
- CyberSim OS user interface localization with deterministic fallback chain (Selected Locale $\to$ Deployment Default Locale $\to$ English $\to$ `[missing: key]`).
|
||||
- Working demonstration locales: English (`en`) and Spanish (`es`).
|
||||
- Independent scenario content localization (`scenarios/<id>/locales/<lang>.json`).
|
||||
- **Configuration-Driven Corporate Branding**:
|
||||
- Customize organization name, short name, logo, desktop wallpaper, accent color, and service desk name via `src/config/deployment.json`.
|
||||
- Automatic fallback to CyberSim default enterprise theme when branding fields are omitted.
|
||||
- Preserves simulation safety boundaries (CyberSim identity remains visible).
|
||||
- **Learner Personalization**:
|
||||
- First-name input is passed into scenario state (`learner.firstName`).
|
||||
- Safe template interpolation (`{{learner.firstName}}`, `{{learner.name}}`) in emails, notifications, alerts, web pages, documents, and certificates with strict HTML entity escaping.
|
||||
- **Scenario-Driven Architecture**: Complete simulations are defined through declarative JSON scenario packages without application source code modification.
|
||||
- **Verifiable Cryptographic Certificates**:
|
||||
- Web Crypto SHA-256 scenario fingerprinting.
|
||||
- Canonical Web Crypto SHA-256 scenario fingerprinting unaffected by display translations.
|
||||
- Portable, structured `*.cybercert` JSON credential export.
|
||||
- Standalone offline certificate validator (`verify.html`).
|
||||
- Standalone offline certificate validator (`verify.html`) supporting localized verification.
|
||||
- **100% Offline-First & Zero Dependencies**: Runs directly from any static web server, GitHub Pages, or the bundled `launcher.py` with zero npm/node/external CDN requirements.
|
||||
|
||||
---
|
||||
@@ -38,7 +40,7 @@ Run the lightweight local launcher to start the server at `http://127.0.0.1:8080
|
||||
python launcher.py
|
||||
```
|
||||
|
||||
Your default web browser will open automatically with the default scenario.
|
||||
Your default web browser will open automatically with the CyberSim OS Login interface.
|
||||
|
||||
### Option 2: Static Web Server
|
||||
Serve the `src/` directory with any static HTTP server:
|
||||
@@ -49,137 +51,104 @@ python -m http.server 8000
|
||||
```
|
||||
Open [http://127.0.0.1:8000](http://127.0.0.1:8000) in your browser.
|
||||
|
||||
### Loading a Specific Scenario
|
||||
Use the `?scenario=` query parameter to load a scenario package:
|
||||
---
|
||||
|
||||
```
|
||||
http://127.0.0.1:8080?scenario=scenarios/nexacore-orientation/scenario.json
|
||||
http://127.0.0.1:8080?scenario=scenarios/quickstart-example/scenario.json
|
||||
```
|
||||
## Deployment Configuration
|
||||
|
||||
If no `?scenario=` parameter is specified, the engine loads `scenarios/nexacore-orientation/scenario.json` by default.
|
||||
Deployments are customized via `src/config/deployment.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"organization": {
|
||||
"name": "NexaCore Technologies",
|
||||
"shortName": "NexaCore",
|
||||
"logo": "",
|
||||
"wallpaper": "",
|
||||
"accentColor": "#2563eb",
|
||||
"supportName": "NexaCore Service Desk"
|
||||
},
|
||||
"localization": {
|
||||
"defaultLocale": "en",
|
||||
"enabledLocales": ["en", "es"]
|
||||
},
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "nexacore-orientation",
|
||||
"path": "scenarios/nexacore-orientation/scenario.json",
|
||||
"networkName": "NexaCore Corporate",
|
||||
"networkDescription": "Corporate Workplace Network",
|
||||
"networkIcon": "corporate",
|
||||
"supportedLocales": ["en", "es"]
|
||||
},
|
||||
{
|
||||
"id": "quickstart-example",
|
||||
"path": "scenarios/quickstart-example/scenario.json",
|
||||
"networkName": "Meridian Health Partners",
|
||||
"networkDescription": "Clinical & Administrative Network",
|
||||
"networkIcon": "health",
|
||||
"supportedLocales": ["en", "es"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Included Scenarios
|
||||
|
||||
| Scenario | Description |
|
||||
|----------|-------------|
|
||||
| **NexaCore Orientation** (`nexacore-orientation`) | Full-featured scenario: 5 emails, 3 web pages, 3 documents, 6 scoring categories, credential phishing + malicious attachment threats, 2 false flags. ~15 min. |
|
||||
| **Meridian Health Quickstart** (`quickstart-example`) | Minimal scenario: 3 emails, 1 web page, 2 documents, 3 scoring categories, 1 credential phishing threat. ~5 min. |
|
||||
| Scenario | Description | Supported Languages |
|
||||
|----------|-------------|---------------------|
|
||||
| **NexaCore Orientation** (`nexacore-orientation`) | Full-featured scenario: 5 emails, 3 web pages, 3 documents, 6 scoring categories, credential phishing + malicious attachment threats, 2 false flags. | English (`en`), Español (`es`) |
|
||||
| **Meridian Health Quickstart** (`quickstart-example`) | Minimal scenario: 3 emails, 1 web page, 2 documents, 3 scoring categories, 1 credential phishing threat. | English (`en`), Español (`es`) |
|
||||
|
||||
---
|
||||
|
||||
## Creating a Scenario
|
||||
## Scenario Localization & Personalization
|
||||
|
||||
Scenarios are self-contained JSON packages stored in a directory:
|
||||
Scenarios declare supported languages in `scenario.json`:
|
||||
|
||||
```
|
||||
scenarios/my-scenario/
|
||||
├── scenario.json # Scenario definition (required)
|
||||
├── assets/ # Optional images, media
|
||||
└── README.md # Author notes (optional)
|
||||
```json
|
||||
{
|
||||
"formatVersion": "1.0",
|
||||
"id": "my-scenario",
|
||||
"supportedLocales": ["en", "es"],
|
||||
"login": {
|
||||
"networkName": "My Enterprise",
|
||||
"networkDescription": "Corporate Network",
|
||||
"networkIcon": "corporate"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `scenario.json` file defines everything the learner experiences:
|
||||
- **People** and **organizations** (contacts, domains, trust boundaries)
|
||||
- **Messages** (emails with links, attachments, threat indicators)
|
||||
- **Pages** (simulated websites with forms)
|
||||
- **Files** (virtual documents, spreadsheets, policies)
|
||||
- **Events** (time-triggered, action-triggered, state-triggered with delays)
|
||||
- **Scoring** (categories, rules, conditions, thresholds)
|
||||
- **Findings** and **feedback** (behavioral evaluation with pedagogical notes)
|
||||
Scenario translations are placed in `locales/<lang>.json` (e.g. `locales/es.json`):
|
||||
|
||||
See `docs/scenario-format.md` for the full format reference.
|
||||
|
||||
---
|
||||
|
||||
## Certificate Verification
|
||||
|
||||
To verify a `*.cybercert` certificate issued by CyberSim OS:
|
||||
|
||||
1. Open `src/verify.html` in your web browser.
|
||||
2. Drag and drop the `*.cybercert` file into the verification area.
|
||||
3. The offline verifier recalculates the SHA-256 integrity hash, verifies the scenario fingerprint, and confirms the passing score and learner identity.
|
||||
|
||||
---
|
||||
|
||||
## Architecture & Directory Structure
|
||||
|
||||
```
|
||||
CyberSim-OS/
|
||||
├── .labyricorn/ # Labyricorn exhibition & devlog publishing records
|
||||
├── scenarios/
|
||||
│ ├── nexacore-orientation/ # Phase 1 scenario (fully declarative)
|
||||
│ │ └── scenario.json
|
||||
│ └── quickstart-example/ # Second example scenario
|
||||
│ └── scenario.json
|
||||
├── docs/
|
||||
│ └── scenario-format.md # Scenario format reference (TODO)
|
||||
├── src/
|
||||
│ ├── index.html # Main desktop simulation interface
|
||||
│ ├── verify.html # Standalone offline certificate verifier
|
||||
│ ├── css/
|
||||
│ │ ├── theme-windows.css # Fictional Windows-like enterprise theme tokens
|
||||
│ │ ├── desktop.css # Window manager, taskbar, start menu, tray, toasts
|
||||
│ │ ├── components.css # Modals, form inputs, buttons, badges, tabs
|
||||
│ │ └── apps.css # Inlook, Navigator, Files, DocViewer, SecurityCenter
|
||||
│ ├── js/
|
||||
│ │ ├── main.js # Application bootstrapper & scenario loader
|
||||
│ │ ├── core/
|
||||
│ │ │ ├── window_manager.js# Window lifecycle, stacking, drag, min/max/close
|
||||
│ │ │ ├── desktop.js # Desktop shell, launcher, clock, simulation controls
|
||||
│ │ │ └── notifications.js # Toast notifications & Web Audio synth audio chime
|
||||
│ │ ├── apps/
|
||||
│ │ │ ├── inlook.js # Email client with RFC header/link inspection
|
||||
│ │ │ ├── navigator.js # Simulated browser with data-driven pages & forms
|
||||
│ │ │ ├── files.js # Virtual filesystem explorer
|
||||
│ │ │ ├── docviewer.js # Spreadsheet and document viewer
|
||||
│ │ │ └── security_center.js # Endpoint status & incident report dashboard
|
||||
│ │ ├── engine/
|
||||
│ │ │ ├── event_bus.js # Behavioral telemetry logger
|
||||
│ │ │ ├── consequence.js # Consequence tracking (Phase 2 thin wrapper)
|
||||
│ │ │ ├── scenario_state.js# Central mutable scenario state manager
|
||||
│ │ │ ├── condition_evaluator.js # Declarative condition tree evaluator
|
||||
│ │ │ ├── event_scheduler.js # Time/action/state-driven event executor
|
||||
│ │ │ └── action_dispatcher.js # Restricted behavior vocabulary dispatcher
|
||||
│ │ ├── scenario/
|
||||
│ │ │ ├── schema.js # JSON schema validation
|
||||
│ │ │ ├── loader.js # Scenario package loader & sanitizer
|
||||
│ │ │ ├── validator.js # Deep reference integrity validation
|
||||
│ │ │ ├── diagnostics.js # Dev-mode diagnostics overlay
|
||||
│ │ │ └── fingerprint.js # Web Crypto SHA-256 scenario fingerprinting
|
||||
│ │ ├── scoring/
|
||||
│ │ │ ├── scorer.js # Data-driven behavioral scoring engine
|
||||
│ │ │ └── aar.js # After-Action Report modal
|
||||
│ │ └── cert/
|
||||
│ │ ├── cert_generator.js# *.cybercert JSON generator & printable certificate
|
||||
│ │ └── cert_verifier.js # Offline cryptographic certificate verifier
|
||||
│ └── assets/ # Embedded SVG icons and media
|
||||
├── launcher.py # Zero-dependency local 127.0.0.1 web server
|
||||
├── devlog_editor.py # Labyricorn devlog validation & editing tool
|
||||
└── README.md # Project documentation
|
||||
```json
|
||||
{
|
||||
"title": "Título en español",
|
||||
"description": "Descripción en español",
|
||||
"messages": {
|
||||
"email_welcome": {
|
||||
"sender": "Nombre del Remitente",
|
||||
"subject": "Asunto traducido",
|
||||
"body": "Hola {{learner.firstName}},\n\n¡Bienvenido a su turno!"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Mode
|
||||
## Automated Tests
|
||||
|
||||
Add `?dev=true` to the URL to enable the diagnostics overlay:
|
||||
Run the headless automated test suite with Node.js:
|
||||
|
||||
```bash
|
||||
node tests/run_tests.js
|
||||
```
|
||||
http://127.0.0.1:8080?scenario=../scenarios/nexacore-orientation/scenario.json&dev=true
|
||||
```
|
||||
|
||||
The diagnostics panel shows:
|
||||
- Scenario validation results (errors, warnings)
|
||||
- Object counts (people, messages, events, etc.)
|
||||
- Live event states and scores
|
||||
- Scenario fingerprint
|
||||
|
||||
---
|
||||
|
||||
## Validation & Devlog Maintenance
|
||||
## Devlog Validation
|
||||
|
||||
Run the validation suite to ensure `.labyricorn/` records and devlog entries adhere to the schema:
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ For large scenarios, sub-collections can optionally be split across multiple fil
|
||||
| `mode` | `string` | **Yes** | Instruction mode: `"guided"`, `"practice"`, or `"assessment"`. |
|
||||
| `entryEvent` | `string` | **Yes** | ID of the event executed at scenario start. |
|
||||
| `passingScore` | `number` | **Yes** | Minimum score required to pass and earn a certificate (0–100). |
|
||||
| `supportedLocales` | `array` | No | List of supported language codes (e.g. `["en", "es"]`). |
|
||||
| `login` | `object` | No | Network login metadata: `{ networkName, networkDescription, networkIcon }`. |
|
||||
| `engine` | `object` | No | Minimum engine version requirements (`{ "minimumVersion": "0.2.0" }`). |
|
||||
| `learner` | `object` | No | Initial persona: `{ name, role, email, department }`. |
|
||||
| `organizations` | `array` | No | List of simulated organizations & trusted domain lists. |
|
||||
@@ -281,3 +283,61 @@ The diagnostics overlay provides:
|
||||
- Event trigger states and scheduled timers
|
||||
- Current category score breakdowns and recorded findings
|
||||
- Scenario SHA-256 fingerprint calculations
|
||||
|
||||
---
|
||||
|
||||
## 9. Scenario Localization & Personalization
|
||||
|
||||
### 9.1 Declaring Supported Locales & Login Presentation
|
||||
|
||||
Scenarios declare their supported language codes in `scenario.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"formatVersion": "1.0",
|
||||
"id": "my-scenario",
|
||||
"supportedLocales": ["en", "es"],
|
||||
"login": {
|
||||
"networkName": "ACME Corporate Network",
|
||||
"networkDescription": "Simulated Corporate Workplace",
|
||||
"networkIcon": "corporate"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Providing Scenario Translations (`locales/<lang>.json`)
|
||||
|
||||
Translations for scenario-specific content are stored within the scenario package under `locales/`:
|
||||
|
||||
```
|
||||
scenarios/my-scenario/
|
||||
├── scenario.json
|
||||
└── locales/
|
||||
├── en.json
|
||||
└── es.json
|
||||
```
|
||||
|
||||
The translation catalog can override text properties across messages, pages, files, notifications, alerts, findings, and feedback:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Turno Operativo y Concientización",
|
||||
"messages": {
|
||||
"email_welcome": {
|
||||
"sender": "Morgan Chen (Vicepresidente)",
|
||||
"subject": "Bienvenido al equipo",
|
||||
"body": "Hola {{learner.firstName}},\n\n¡Bienvenido!"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 Learner Personalization Variables
|
||||
|
||||
The following template variables are available for dynamic interpolation across messages, documents, web pages, notifications, and feedback:
|
||||
|
||||
- `{{learner.firstName}}`: Learner's validated first name entered at login (HTML-escaped).
|
||||
- `{{learner.name}}`: Learner's full name.
|
||||
- `{{learner.email}}`: Learner's assigned workplace email address.
|
||||
- `{{learner.role}}`: Learner's assigned workplace job title.
|
||||
- `{{company.name}}`: Primary enterprise organization name.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"organization": {
|
||||
"name": "NexaCore Technologies",
|
||||
"shortName": "NexaCore",
|
||||
"logo": "",
|
||||
"wallpaper": "",
|
||||
"accentColor": "#2563eb",
|
||||
"supportName": "NexaCore Service Desk"
|
||||
},
|
||||
"localization": {
|
||||
"defaultLocale": "en",
|
||||
"enabledLocales": ["en", "es"]
|
||||
},
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "nexacore-orientation",
|
||||
"path": "scenarios/nexacore-orientation/scenario.json",
|
||||
"networkName": "NexaCore Corporate",
|
||||
"networkDescription": "Corporate Workplace Network",
|
||||
"networkIcon": "corporate",
|
||||
"supportedLocales": ["en", "es"]
|
||||
},
|
||||
{
|
||||
"id": "quickstart-example",
|
||||
"path": "scenarios/quickstart-example/scenario.json",
|
||||
"networkName": "Meridian Health Partners",
|
||||
"networkDescription": "Clinical & Administrative Network",
|
||||
"networkIcon": "health",
|
||||
"supportedLocales": ["en", "es"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -225,3 +225,220 @@
|
||||
margin-bottom: 2px;
|
||||
font-family: var(--font-system);
|
||||
}
|
||||
|
||||
/* Immersive Login Backdrop & Card */
|
||||
.cs-login-backdrop {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: radial-gradient(circle at 50% 30%, #1e293b 0%, #0f172a 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
padding: 20px;
|
||||
font-family: var(--font-system);
|
||||
}
|
||||
|
||||
.cs-login-card {
|
||||
background: rgba(30, 41, 59, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(12px);
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
color: #f8fafc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: csFadeIn 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes csFadeIn {
|
||||
from { opacity: 0; transform: scale(0.97); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.cs-login-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px 16px 24px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.cs-login-org-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cs-login-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
background: var(--color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.35);
|
||||
}
|
||||
|
||||
.cs-login-logo {
|
||||
max-height: 38px;
|
||||
max-width: 140px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.cs-login-org-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cs-login-sub {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cs-login-lang-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cs-select-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
background: #0f172a;
|
||||
color: #f8fafc;
|
||||
border: 1px solid #475569;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cs-login-body {
|
||||
padding: 22px 24px;
|
||||
}
|
||||
|
||||
.cs-login-welcome h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #f8fafc;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.cs-login-welcome p {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin: 0 0 18px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cs-login-error {
|
||||
color: #f87171;
|
||||
font-size: 11px;
|
||||
margin-top: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cs-network-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.cs-network-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.cs-network-card:hover:not(.unsupported) {
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.cs-network-card.selected {
|
||||
background: rgba(37, 99, 235, 0.2);
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 1px #3b82f6;
|
||||
}
|
||||
|
||||
.cs-network-card.unsupported {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
}
|
||||
|
||||
.cs-network-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cs-network-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cs-network-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cs-network-desc {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cs-network-badge-warn {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
color: #fbbf24;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.cs-network-radio {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cs-btn-lg {
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.cs-login-footer {
|
||||
padding: 12px 24px 16px 24px;
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cs-login-disclaimer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: #cbd5e1;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
+4
-49
@@ -17,8 +17,8 @@
|
||||
<div id="notification-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Start Menu (populated dynamically from scenario data) -->
|
||||
<div id="start-menu">
|
||||
<!-- Start Menu -->
|
||||
<div id="start-menu" style="display:none;">
|
||||
<div class="start-header">
|
||||
<div class="start-user-avatar" id="start-user-avatar">??</div>
|
||||
<div class="start-user-info">
|
||||
@@ -38,13 +38,13 @@
|
||||
</div>
|
||||
|
||||
<div class="start-footer">
|
||||
<div style="font-size:10px; color:#64748b; font-family:var(--font-mono);">CyberSim v0.2.0 (Phase 2)</div>
|
||||
<div style="font-size:10px; color:#64748b; font-family:var(--font-mono);">CyberSim v0.2.5 (Phase 2.5)</div>
|
||||
<button class="start-btn-finish" id="start-btn-finish">Finish Shift & View Assessment</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Taskbar -->
|
||||
<div id="taskbar">
|
||||
<div id="taskbar" style="display:none;">
|
||||
<div class="taskbar-left">
|
||||
<button class="taskbar-btn start-btn" id="start-btn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg>
|
||||
@@ -65,51 +65,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Populate start menu from scenario data after load -->
|
||||
<script type="module">
|
||||
import { getScenarioUrl } from './js/scenario/loader.js';
|
||||
|
||||
// After main.js boots, populate the start menu with scenario data
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// The main bootstrapper will populate these elements after scenario loads
|
||||
// Listen for scenario data to become available
|
||||
const checkScenario = setInterval(() => {
|
||||
if (window.CyberSimOS && window.CyberSimOS.engine && window.CyberSimOS.engine.scenario) {
|
||||
clearInterval(checkScenario);
|
||||
const s = window.CyberSimOS.engine.scenario;
|
||||
|
||||
// Populate start menu from scenario
|
||||
const avatarEl = document.getElementById('start-user-avatar');
|
||||
const nameEl = document.getElementById('start-user-name');
|
||||
const roleEl = document.getElementById('start-user-role');
|
||||
const objectivesEl = document.getElementById('start-objectives');
|
||||
const orgNameEl = document.getElementById('taskbar-org-name');
|
||||
const networkEl = document.getElementById('taskbar-network-icon');
|
||||
|
||||
if (s.learner && avatarEl) {
|
||||
const initials = s.learner.name.split(' ').map(n => n[0]).join('');
|
||||
avatarEl.textContent = initials;
|
||||
}
|
||||
if (s.learner && nameEl) nameEl.textContent = s.learner.name;
|
||||
|
||||
const orgName = (s.organizations && s.organizations[0]) ? s.organizations[0].name : 'CyberSim';
|
||||
const roleLine = s.learner ? `${s.learner.role} \u2022 ${orgName}` : orgName;
|
||||
if (roleEl) roleEl.textContent = roleLine;
|
||||
if (orgNameEl) orgNameEl.textContent = orgName;
|
||||
if (networkEl) networkEl.title = `Network: ${orgName}-Corporate-WPA3 (Protected)`;
|
||||
|
||||
if (s.objectives && objectivesEl) {
|
||||
const items = s.objectives.map(obj => {
|
||||
const text = typeof obj === 'string' ? obj : obj.text;
|
||||
return `\u2022 ${text}`;
|
||||
});
|
||||
objectivesEl.innerHTML = items.join('<br>');
|
||||
}
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+36
-20
@@ -1,11 +1,15 @@
|
||||
/**
|
||||
* CyberSim OS - Document & Spreadsheet Viewer
|
||||
* CyberSim OS - Document & Spreadsheet Viewer (Phase 2.5)
|
||||
*/
|
||||
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { ActionDispatcher } from '../engine/action_dispatcher.js';
|
||||
|
||||
export class DocViewerApp {
|
||||
constructor({ windowManager, eventBus }) {
|
||||
constructor({ windowManager, eventBus, scenario }) {
|
||||
this.wm = windowManager;
|
||||
this.eventBus = eventBus;
|
||||
this.scenario = scenario;
|
||||
}
|
||||
|
||||
getIconSvg() {
|
||||
@@ -15,7 +19,7 @@ export class DocViewerApp {
|
||||
openDocument(file) {
|
||||
const win = this.wm.createWindow({
|
||||
id: `doc_${file.id}`,
|
||||
title: `${file.name} - Document Viewer`,
|
||||
title: `${file.name} - ${i18n.t('docviewer.title')}`,
|
||||
iconSvg: this.getIconSvg(),
|
||||
width: 820,
|
||||
height: 600,
|
||||
@@ -28,6 +32,17 @@ export class DocViewerApp {
|
||||
});
|
||||
}
|
||||
|
||||
_interpolateText(text) {
|
||||
if (typeof text !== 'string') return text;
|
||||
const learner = this.scenario?.learner || {};
|
||||
const firstName = learner.firstName || (learner.name ? learner.name.split(' ')[0] : 'User');
|
||||
const fullName = learner.name || 'User';
|
||||
|
||||
return text
|
||||
.replace(/\{\{learner\.firstName\}\}/g, ActionDispatcher.escapeHtml(firstName))
|
||||
.replace(/\{\{learner\.name\}\}/g, ActionDispatcher.escapeHtml(fullName));
|
||||
}
|
||||
|
||||
renderDocument(file) {
|
||||
if (file.type === 'spreadsheet' && file.content) {
|
||||
return `
|
||||
@@ -35,14 +50,14 @@ export class DocViewerApp {
|
||||
<div class="doc-toolbar">
|
||||
<div class="doc-title-info">
|
||||
<span>📊 ${file.name}</span>
|
||||
<span class="cs-badge cs-badge-success">Read-Only</span>
|
||||
<span class="cs-badge cs-badge-success">${i18n.t('docviewer.readOnly')}</span>
|
||||
</div>
|
||||
<div style="font-size:11px; color:#94a3b8;">Format: XLSX Tabular</div>
|
||||
<div style="font-size:11px; color:#94a3b8;">${i18n.t('docviewer.formatXlsx')}</div>
|
||||
</div>
|
||||
<div class="doc-canvas">
|
||||
<div class="doc-page">
|
||||
<h1>${file.content.title}</h1>
|
||||
<p style="font-size:12px; color:#64748b; margin-bottom:14px;">Confidential — Internal Use Only — Prepared for NexaCore Leadership</p>
|
||||
<h1>${this._interpolateText(file.content.title)}</h1>
|
||||
<p style="font-size:12px; color:#64748b; margin-bottom:14px;">Confidential — Internal Workplace Document</p>
|
||||
<table class="doc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -52,14 +67,16 @@ export class DocViewerApp {
|
||||
<tbody>
|
||||
${file.content.rows.map(row => `
|
||||
<tr>
|
||||
${row.map((cell, idx) => `<td style="${idx > 0 ? 'text-align:right;' : 'font-weight:500;'}">${cell}</td>`).join('')}
|
||||
${row.map((cell, idx) => `<td style="${idx > 0 ? 'text-align:right;' : 'font-weight:500;'}">${this._interpolateText(cell)}</td>`).join('')}
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="margin-top:20px; font-size:11px; color:#64748b;">
|
||||
<strong>Notes:</strong> Operating margin buffer is currently aligned with Q2 audit targets. Prepared by Finance Ops.
|
||||
</div>
|
||||
${file.content.notes ? `
|
||||
<div style="margin-top:20px; font-size:11px; color:#64748b;">
|
||||
<strong>Notes:</strong> ${this._interpolateText(file.content.notes)}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,16 +89,16 @@ export class DocViewerApp {
|
||||
<div class="doc-toolbar">
|
||||
<div class="doc-title-info">
|
||||
<span>📑 ${file.name}</span>
|
||||
<span class="cs-badge cs-badge-info">NexaCore Document</span>
|
||||
<span class="cs-badge cs-badge-info">${i18n.t('docviewer.readOnly')}</span>
|
||||
</div>
|
||||
<div style="font-size:11px; color:#94a3b8;">Format: PDF Document</div>
|
||||
<div style="font-size:11px; color:#94a3b8;">${i18n.t('docviewer.formatPdf')}</div>
|
||||
</div>
|
||||
<div class="doc-canvas">
|
||||
<div class="doc-page">
|
||||
<h1>${file.content.title}</h1>
|
||||
<h1>${this._interpolateText(file.content.title)}</h1>
|
||||
${file.content.sections ? file.content.sections.map(s => `
|
||||
<h2>${s.heading}</h2>
|
||||
<p>${s.text}</p>
|
||||
<h2>${this._interpolateText(s.heading)}</h2>
|
||||
<p>${this._interpolateText(s.text)}</p>
|
||||
`).join('') : ''}
|
||||
|
||||
${file.content.table ? `
|
||||
@@ -94,7 +111,7 @@ export class DocViewerApp {
|
||||
<tbody>
|
||||
${file.content.table.rows.map(r => `
|
||||
<tr>
|
||||
${r.map(c => `<td>${c}</td>`).join('')}
|
||||
${r.map(c => `<td>${this._interpolateText(c)}</td>`).join('')}
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
@@ -109,12 +126,11 @@ export class DocViewerApp {
|
||||
return `
|
||||
<div class="doc-viewer-container">
|
||||
<div class="doc-toolbar">
|
||||
<div class="doc-title-info">${file.name}</div>
|
||||
<div class="doc-title-info"><span>📄 ${file.name}</span></div>
|
||||
</div>
|
||||
<div class="doc-canvas">
|
||||
<div class="doc-page">
|
||||
<h2>Archive Preview</h2>
|
||||
<p>Archive file "${file.name}" contents cannot be executed directly within simulated document viewer.</p>
|
||||
<pre style="font-size:12px; color:#334155; font-family:var(--font-mono);">${typeof file.content === 'string' ? this._interpolateText(file.content) : JSON.stringify(file.content, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+22
-19
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* CyberSim OS - Files Virtual File Explorer
|
||||
* CyberSim OS - Files Virtual File Explorer (Phase 2.5)
|
||||
*/
|
||||
|
||||
import { i18n } from '../core/i18n.js';
|
||||
|
||||
export class FilesApp {
|
||||
constructor({ windowManager, eventBus, notifications, scenario, onOpenFile }) {
|
||||
this.wm = windowManager;
|
||||
@@ -10,7 +12,7 @@ export class FilesApp {
|
||||
this.scenario = scenario;
|
||||
this.onOpenFile = onOpenFile;
|
||||
this.currentFolder = 'Documents';
|
||||
this.files = JSON.parse(JSON.stringify(scenario.files));
|
||||
this.files = JSON.parse(JSON.stringify(scenario.files || []));
|
||||
}
|
||||
|
||||
getIconSvg() {
|
||||
@@ -21,7 +23,7 @@ export class FilesApp {
|
||||
this.currentFolder = initialFolder;
|
||||
const win = this.wm.createWindow({
|
||||
id: 'files',
|
||||
title: 'Files - Corporate Storage',
|
||||
title: i18n.t('files.title'),
|
||||
iconSvg: this.getIconSvg(),
|
||||
width: 780,
|
||||
height: 500,
|
||||
@@ -37,18 +39,18 @@ export class FilesApp {
|
||||
return `
|
||||
<div class="files-container">
|
||||
<div class="files-sidebar">
|
||||
<div class="files-folder-btn active" data-folder="Documents">
|
||||
<span>[Docs] Documents</span>
|
||||
<div class="files-folder-btn ${this.currentFolder === 'Documents' ? 'active' : ''}" data-folder="Documents">
|
||||
<span>📁 ${i18n.t('files.documents')}</span>
|
||||
</div>
|
||||
<div class="files-folder-btn" data-folder="Downloads">
|
||||
<span>[Down] Downloads</span>
|
||||
<div class="files-folder-btn ${this.currentFolder === 'Downloads' ? 'active' : ''}" data-folder="Downloads">
|
||||
<span>📥 ${i18n.t('files.downloads')}</span>
|
||||
</div>
|
||||
<div class="files-folder-btn" data-folder="Company Shared">
|
||||
<span>[Share] Company Shared</span>
|
||||
<div class="files-folder-btn ${this.currentFolder === 'Company Shared' ? 'active' : ''}" data-folder="Company Shared">
|
||||
<span>🌐 ${i18n.t('files.companyShared')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="files-content">
|
||||
<div class="files-address-bar" id="files-current-path">Location: /Documents</div>
|
||||
<div class="files-address-bar" id="files-current-path">${i18n.t('files.location', { folder: this.currentFolder })}</div>
|
||||
<div class="files-grid" id="files-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,13 +76,13 @@ export class FilesApp {
|
||||
renderFolderContents() {
|
||||
const pathEl = document.getElementById('files-current-path');
|
||||
const gridEl = document.getElementById('files-grid');
|
||||
if (pathEl) pathEl.innerText = `Location: /${this.currentFolder}`;
|
||||
if (pathEl) pathEl.innerText = i18n.t('files.location', { folder: this.currentFolder });
|
||||
if (!gridEl) return;
|
||||
|
||||
const filtered = this.files.filter(f => f.folder === this.currentFolder);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
gridEl.innerHTML = `<div style="grid-column:1/-1; padding:40px; text-align:center; color:#94a3b8;">This folder is empty.</div>`;
|
||||
gridEl.innerHTML = `<div style="grid-column:1/-1; padding:40px; text-align:center; color:#94a3b8;">${i18n.t('files.empty')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,10 +94,10 @@ export class FilesApp {
|
||||
if (f.type === 'executable') icon = 'EXE';
|
||||
|
||||
return `
|
||||
<div class="file-item" data-file-id="${f.id}" title="${f.name} (${f.size})">
|
||||
<div class="file-item" data-file-id="${f.id}" title="${f.name} (${f.size || 'Unknown'})">
|
||||
<div style="font-weight:700; font-size:18px; padding:8px 0; color:#2563eb;">[${icon}]</div>
|
||||
<div class="file-name">${f.name}</div>
|
||||
<div class="file-size">${f.size}</div>
|
||||
<div class="file-size">${f.size || ''}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -108,14 +110,15 @@ export class FilesApp {
|
||||
|
||||
item.addEventListener('dblclick', () => {
|
||||
const fileId = item.dataset.fileId;
|
||||
const fileObj = this.files.find(f => f.id === fileId);
|
||||
if (fileObj) {
|
||||
const file = this.files.find(f => f.id === fileId);
|
||||
if (file) {
|
||||
this.eventBus.emit('FILE_OPENED', {
|
||||
target: fileObj.name,
|
||||
details: { fileId: fileObj.id, folder: fileObj.folder, type: fileObj.type }
|
||||
target: file.name,
|
||||
details: { fileId: file.id, type: file.type }
|
||||
});
|
||||
|
||||
if (this.onOpenFile) {
|
||||
this.onOpenFile(fileObj);
|
||||
this.onOpenFile(file);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+57
-38
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* CyberSim OS - Inlook Email Application (Phase 2)
|
||||
* CyberSim OS - Inlook Email Application (Phase 2.5)
|
||||
*
|
||||
* All domain trust decisions are driven by the scenario's organization
|
||||
* definitions. No hard-coded domain names or sender addresses.
|
||||
* definitions. Supports full runtime i18n localization and learner personalization.
|
||||
*/
|
||||
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { ActionDispatcher } from '../engine/action_dispatcher.js';
|
||||
|
||||
export class InlookApp {
|
||||
constructor({ windowManager, eventBus, notifications, scenario, onNavigateUrl, onOpenDoc, onFileDownloaded }) {
|
||||
this.wm = windowManager;
|
||||
@@ -17,12 +20,12 @@ export class InlookApp {
|
||||
this.currentFolder = 'inbox';
|
||||
this.activeEmailId = null;
|
||||
|
||||
// Deep-copy messages that are in the inbox at start
|
||||
// Deep-copy messages that are in the inbox at start and apply interpolation
|
||||
this.emails = [];
|
||||
if (scenario.messages) {
|
||||
scenario.messages.forEach(m => {
|
||||
if (m.folder === 'inbox') {
|
||||
this.emails.push(JSON.parse(JSON.stringify(m)));
|
||||
this.emails.push(this._interpolateMessage(JSON.parse(JSON.stringify(m))));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -38,6 +41,24 @@ export class InlookApp {
|
||||
}
|
||||
}
|
||||
|
||||
_interpolateMessage(msg) {
|
||||
const learner = this.scenario.learner || {};
|
||||
const firstName = learner.firstName || (learner.name ? learner.name.split(' ')[0] : 'User');
|
||||
const fullName = learner.name || 'User';
|
||||
|
||||
const sanitize = (text) => {
|
||||
if (typeof text !== 'string') return text;
|
||||
return text
|
||||
.replace(/\{\{learner\.firstName\}\}/g, ActionDispatcher.escapeHtml(firstName))
|
||||
.replace(/\{\{learner\.name\}\}/g, ActionDispatcher.escapeHtml(fullName));
|
||||
};
|
||||
|
||||
if (msg.subject) msg.subject = sanitize(msg.subject);
|
||||
if (msg.body) msg.body = sanitize(msg.body);
|
||||
if (msg.sender) msg.sender = sanitize(msg.sender);
|
||||
return msg;
|
||||
}
|
||||
|
||||
getIconSvg() {
|
||||
return `<svg viewBox="0 0 24 24" fill="none" stroke="#2563eb" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>`;
|
||||
}
|
||||
@@ -47,8 +68,7 @@ export class InlookApp {
|
||||
* @param {Object} messageObj - The message object from the scenario
|
||||
*/
|
||||
deliverMessage(messageObj) {
|
||||
// Deep copy and set to inbox
|
||||
const msg = JSON.parse(JSON.stringify(messageObj));
|
||||
const msg = this._interpolateMessage(JSON.parse(JSON.stringify(messageObj)));
|
||||
msg.folder = 'inbox';
|
||||
msg.unread = true;
|
||||
this.emails.push(msg);
|
||||
@@ -58,7 +78,7 @@ export class InlookApp {
|
||||
launch() {
|
||||
const win = this.wm.createWindow({
|
||||
id: 'inlook',
|
||||
title: 'Inlook Mail - Workplace',
|
||||
title: i18n.t('inlook.title'),
|
||||
iconSvg: this.getIconSvg(),
|
||||
width: 860,
|
||||
height: 560,
|
||||
@@ -76,23 +96,23 @@ export class InlookApp {
|
||||
<div class="inlook-sidebar">
|
||||
<button class="cs-btn cs-btn-primary cs-btn-sm" style="margin-bottom:8px;" id="inlook-compose-btn">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
|
||||
New Message
|
||||
${i18n.t('inlook.newMessage')}
|
||||
</button>
|
||||
<div class="inlook-folder-item active" data-folder="inbox">
|
||||
<span>📥 Inbox</span>
|
||||
<span>📥 ${i18n.t('inlook.inbox')}</span>
|
||||
<span class="cs-badge cs-badge-info" id="inlook-unread-count">0</span>
|
||||
</div>
|
||||
<div class="inlook-folder-item" data-folder="sent">
|
||||
<span>📤 Sent</span>
|
||||
<span>📤 ${i18n.t('inlook.sent')}</span>
|
||||
</div>
|
||||
<div class="inlook-folder-item" data-folder="trash">
|
||||
<span>🗑️ Trash</span>
|
||||
<span>🗑️ ${i18n.t('inlook.trash')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inlook-list-pane" id="inlook-email-list"></div>
|
||||
<div class="inlook-reading-pane" id="inlook-reading-pane">
|
||||
<div style="padding:40px; text-align:center; color:#94a3b8; margin:auto;">
|
||||
Select an email message to view its contents.
|
||||
${i18n.t('inlook.selectEmail')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,8 +133,8 @@ export class InlookApp {
|
||||
if (composeBtn) {
|
||||
composeBtn.addEventListener('click', () => {
|
||||
this.notifications.show({
|
||||
title: 'Inlook Mail',
|
||||
body: 'Corporate policy restricts unassigned outgoing mail during initial shift orientation.',
|
||||
title: i18n.t('apps.inlook'),
|
||||
body: i18n.t('inlook.policyRestricted'),
|
||||
type: 'info'
|
||||
});
|
||||
});
|
||||
@@ -133,7 +153,7 @@ export class InlookApp {
|
||||
if (badge) badge.innerText = unread;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
listEl.innerHTML = `<div style="padding:20px; text-align:center; color:#94a3b8; font-size:12px;">No messages in ${this.currentFolder}</div>`;
|
||||
listEl.innerHTML = `<div style="padding:20px; text-align:center; color:#94a3b8; font-size:12px;">${i18n.t('inlook.noMessages', { folder: this.currentFolder })}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -182,13 +202,13 @@ export class InlookApp {
|
||||
readingPane.innerHTML = `
|
||||
<div class="inlook-toolbar">
|
||||
<button class="cs-btn cs-btn-sm" id="btn-inlook-reply">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg> Reply
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg> ${i18n.t('inlook.reply')}
|
||||
</button>
|
||||
<button class="cs-btn cs-btn-danger cs-btn-sm" id="btn-inlook-report">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M3.6 9h16.8L12 21 3.6 9z"/></svg> Report Suspicious
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M3.6 9h16.8L12 21 3.6 9z"/></svg> ${i18n.t('inlook.reportSuspicious')}
|
||||
</button>
|
||||
<button class="cs-btn cs-btn-sm" id="btn-inlook-delete">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg> Delete
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg> ${i18n.t('inlook.delete')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -201,14 +221,14 @@ export class InlookApp {
|
||||
<div class="sender-name">${email.sender}</div>
|
||||
<div class="sender-email">${email.rfcSender}</div>
|
||||
</div>
|
||||
<button class="inlook-inspect-btn" id="btn-inspect-header">Inspect Header</button>
|
||||
<button class="inlook-inspect-btn" id="btn-inspect-header">${i18n.t('inlook.inspectHeader')}</button>
|
||||
</div>
|
||||
<div style="font-size:11px; color:#94a3b8;">${email.date}</div>
|
||||
</div>
|
||||
|
||||
${email.attachments && email.attachments.length > 0 ? `
|
||||
<div class="inlook-attachments">
|
||||
<span style="font-size:11px; font-weight:600; color:#64748b;">Attachments (${email.attachments.length}):</span>
|
||||
<span style="font-size:11px; font-weight:600; color:#64748b;">${i18n.t('inlook.attachments', { count: email.attachments.length })}</span>
|
||||
${email.attachments.map(att => `
|
||||
<div class="inlook-attachment-chip" data-att-name="${att.name}" data-att-size="${att.size}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
@@ -251,7 +271,7 @@ export class InlookApp {
|
||||
const senderDomain = email.rfcSender.split('@')[1] || 'unknown';
|
||||
const isDomainTrusted = this.isTrustedSender(email.rfcSender);
|
||||
|
||||
alert(`--- Inlook RFC Header Inspector ---\n\nDisplay Name: ${email.sender}\nEnvelope RFC From: <${email.rfcSender}>\nAuthentication-Results: spf=pass (domain: ${senderDomain})\nReturn-Path: <bounce@${senderDomain}>\n\n${isDomainTrusted ? '✓ Envelope domain matches internal enterprise domain.' : '⚠️ Notice: Envelope domain differs from standard corporate domain.'}`);
|
||||
alert(`--- Inlook RFC Header Inspector ---\n\nDisplay Name: ${email.sender}\nEnvelope RFC From: <${email.rfcSender}>\nAuthentication-Results: spf=pass (domain: ${senderDomain})\nReturn-Path: <bounce@${senderDomain}>\n\n${isDomainTrusted ? '✓ ' + i18n.t('inlook.headerTrusted') : '⚠️ ' + i18n.t('inlook.headerUntrusted')}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -343,7 +363,7 @@ export class InlookApp {
|
||||
});
|
||||
this.selectFirstEmail();
|
||||
this.notifications.show({
|
||||
title: 'Inlook Mail',
|
||||
title: i18n.t('apps.inlook'),
|
||||
body: 'Message moved to Trash.',
|
||||
type: 'info'
|
||||
});
|
||||
@@ -359,7 +379,7 @@ export class InlookApp {
|
||||
<div class="cs-modal-header">
|
||||
<div class="cs-modal-title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M3.6 9h16.8L12 21 3.6 9z"/></svg>
|
||||
Report Suspicious Message to SOC
|
||||
${i18n.t('inlook.reportDialogTitle')}
|
||||
</div>
|
||||
<button class="cs-btn-win close" id="btn-close-modal">×</button>
|
||||
</div>
|
||||
@@ -370,23 +390,22 @@ export class InlookApp {
|
||||
<strong>Sender:</strong> ${email.rfcSender}
|
||||
</div>
|
||||
<div class="cs-form-group">
|
||||
<label class="cs-form-label">Select Primary Threat Reason:</label>
|
||||
<label class="cs-form-label">${i18n.t('inlook.reportReasonLabel')}</label>
|
||||
<select class="cs-select" id="report-reason">
|
||||
<option value="spoofed_sender">Spoofed or lookalike sender domain</option>
|
||||
<option value="deceptive_link">Deceptive / mismatched link destination</option>
|
||||
<option value="suspicious_attachment">Unsolicited or executable attachment</option>
|
||||
<option value="credential_harvest">Credential theft / artificial urgency</option>
|
||||
<option value="general_spam">General unsolicited spam</option>
|
||||
<option value="spoofed_sender">${i18n.t('inlook.reportReasonSuspiciousSender')}</option>
|
||||
<option value="deceptive_link">${i18n.t('inlook.reportReasonSuspiciousLink')}</option>
|
||||
<option value="credential_harvest">${i18n.t('inlook.reportReasonCredentialRequest')}</option>
|
||||
<option value="suspicious_attachment">${i18n.t('inlook.reportReasonSuspiciousAttachment')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cs-form-group">
|
||||
<label class="cs-form-label">Investigative Notes (Optional):</label>
|
||||
<label class="cs-form-label">${i18n.t('inlook.reportNotesLabel')}</label>
|
||||
<textarea class="cs-textarea" id="report-notes" rows="2" placeholder="e.g. Suspicious sender domain detected"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cs-modal-footer">
|
||||
<button class="cs-btn" id="btn-cancel-report">Cancel</button>
|
||||
<button class="cs-btn cs-btn-danger" id="btn-submit-report">Submit Incident Report</button>
|
||||
<button class="cs-btn" id="btn-cancel-report">${i18n.t('inlook.reportCancel')}</button>
|
||||
<button class="cs-btn cs-btn-danger" id="btn-submit-report">${i18n.t('inlook.reportSubmit')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -411,8 +430,8 @@ export class InlookApp {
|
||||
close();
|
||||
|
||||
this.notifications.show({
|
||||
title: 'Security Center Report Acknowledged',
|
||||
body: `Incident report for "${email.subject.substring(0, 30)}..." received by SOC.`,
|
||||
title: i18n.t('inlook.reportSuccessTitle'),
|
||||
body: i18n.t('inlook.reportSuccessBody'),
|
||||
type: 'success'
|
||||
});
|
||||
});
|
||||
@@ -424,7 +443,7 @@ export class InlookApp {
|
||||
modalOverlay.innerHTML = `
|
||||
<div class="cs-modal">
|
||||
<div class="cs-modal-header">
|
||||
<div class="cs-modal-title">Reply to: ${email.sender}</div>
|
||||
<div class="cs-modal-title">${i18n.t('inlook.reply')}: ${email.sender}</div>
|
||||
<button class="cs-btn-win close" id="btn-close-reply">×</button>
|
||||
</div>
|
||||
<div class="cs-modal-body">
|
||||
@@ -442,8 +461,8 @@ export class InlookApp {
|
||||
</div>
|
||||
</div>
|
||||
<div class="cs-modal-footer">
|
||||
<button class="cs-btn" id="btn-cancel-reply">Cancel</button>
|
||||
<button class="cs-btn cs-btn-primary" id="btn-send-reply">Send Reply</button>
|
||||
<button class="cs-btn" id="btn-cancel-reply">${i18n.t('inlook.reportCancel')}</button>
|
||||
<button class="cs-btn cs-btn-primary" id="btn-send-reply">${i18n.t('inlook.reply')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -461,7 +480,7 @@ export class InlookApp {
|
||||
});
|
||||
close();
|
||||
this.notifications.show({
|
||||
title: 'Inlook Mail',
|
||||
title: i18n.t('apps.inlook'),
|
||||
body: `Reply sent to ${email.sender.split('(')[0]}.`,
|
||||
type: 'success'
|
||||
});
|
||||
|
||||
+34
-36
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* CyberSim OS - Navigator Simulated Web Browser (Phase 2)
|
||||
* CyberSim OS - Navigator Simulated Web Browser (Phase 2.5)
|
||||
*
|
||||
* All domain trust decisions and page rendering are driven by the scenario
|
||||
* definition. No hard-coded domains or phishing detection.
|
||||
* definition. Supports runtime localization and learner personalization.
|
||||
*/
|
||||
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { ActionDispatcher } from '../engine/action_dispatcher.js';
|
||||
|
||||
export class NavigatorApp {
|
||||
constructor({ windowManager, eventBus, notifications, scenario }) {
|
||||
this.wm = windowManager;
|
||||
@@ -39,7 +42,7 @@ export class NavigatorApp {
|
||||
const url = initialUrl || this.currentUrl;
|
||||
const win = this.wm.createWindow({
|
||||
id: 'navigator',
|
||||
title: 'Navigator Web Browser',
|
||||
title: i18n.t('navigator.title'),
|
||||
iconSvg: this.getIconSvg(),
|
||||
width: 880,
|
||||
height: 580,
|
||||
@@ -56,13 +59,13 @@ export class NavigatorApp {
|
||||
<div class="nav-container">
|
||||
<div class="nav-toolbar">
|
||||
<div class="nav-buttons">
|
||||
<button class="nav-btn-icon" id="nav-btn-back" title="Back">
|
||||
<button class="nav-btn-icon" id="nav-btn-back" title="${i18n.t('navigator.back')}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
</button>
|
||||
<button class="nav-btn-icon" id="nav-btn-forward" title="Forward">
|
||||
<button class="nav-btn-icon" id="nav-btn-forward" title="${i18n.t('navigator.forward')}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
|
||||
</button>
|
||||
<button class="nav-btn-icon" id="nav-btn-reload" title="Reload">
|
||||
<button class="nav-btn-icon" id="nav-btn-reload" title="${i18n.t('navigator.reload')}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -116,47 +119,44 @@ export class NavigatorApp {
|
||||
*/
|
||||
isUrlTrusted(url) {
|
||||
try {
|
||||
// Extract domain from URL
|
||||
const match = url.match(/^https?:\/\/([^\/\:]+)/);
|
||||
if (!match) return false;
|
||||
const hostname = match[1].toLowerCase();
|
||||
|
||||
// Check if hostname matches or is a subdomain of any trusted domain
|
||||
for (const domain of this.trustedDomains) {
|
||||
if (hostname === domain || hostname.endsWith('.' + domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
navigateTo(url, recordHistory = true) {
|
||||
navigateTo(url, addToHistory = true) {
|
||||
this.currentUrl = url;
|
||||
if (recordHistory) {
|
||||
if (addToHistory && this.history[this.historyIndex] !== url) {
|
||||
this.history = this.history.slice(0, this.historyIndex + 1);
|
||||
this.history.push(url);
|
||||
this.historyIndex = this.history.length - 1;
|
||||
}
|
||||
|
||||
const urlInput = document.getElementById('nav-url-input');
|
||||
const addrBar = document.getElementById('nav-addr-bar');
|
||||
const lockIcon = document.getElementById('nav-lock-icon');
|
||||
const viewport = document.getElementById('nav-viewport');
|
||||
const input = document.getElementById('nav-url-input');
|
||||
if (input) input.value = url;
|
||||
|
||||
if (urlInput) urlInput.value = url;
|
||||
|
||||
// Determine security from page definition or domain trust
|
||||
const page = this.findPage(url);
|
||||
const isSecure = page ? !!page.isSecure : (url.startsWith('https://') || this.isUrlTrusted(url));
|
||||
const isPhish = page ? !!page.isPhishing : false;
|
||||
const isSecure = page ? page.isSecure : url.startsWith('https://');
|
||||
const isPhish = page ? !!page.isPhishing : !this.isUrlTrusted(url);
|
||||
|
||||
const lockIcon = document.getElementById('nav-lock-icon');
|
||||
const addrBar = document.getElementById('nav-addr-bar');
|
||||
const viewport = document.getElementById('nav-viewport');
|
||||
|
||||
if (addrBar) {
|
||||
addrBar.className = `nav-address-bar ${isSecure ? 'secure' : 'insecure'}`;
|
||||
}
|
||||
if (lockIcon) {
|
||||
lockIcon.title = isSecure ? i18n.t('navigator.connectionSecure') : i18n.t('navigator.connectionInsecure');
|
||||
lockIcon.style.color = isSecure ? '#10b981' : '#f59e0b';
|
||||
}
|
||||
|
||||
@@ -167,14 +167,24 @@ export class NavigatorApp {
|
||||
|
||||
if (viewport) {
|
||||
if (page) {
|
||||
viewport.innerHTML = page.content;
|
||||
// Interpolate learner variables into page content safely
|
||||
const learner = this.scenario.learner || {};
|
||||
const firstName = learner.firstName || (learner.name ? learner.name.split(' ')[0] : 'User');
|
||||
const fullName = learner.name || 'User';
|
||||
|
||||
let renderedContent = page.content || '';
|
||||
renderedContent = renderedContent
|
||||
.replace(/\{\{learner\.firstName\}\}/g, ActionDispatcher.escapeHtml(firstName))
|
||||
.replace(/\{\{learner\.name\}\}/g, ActionDispatcher.escapeHtml(fullName));
|
||||
|
||||
viewport.innerHTML = renderedContent;
|
||||
this.bindPageLinks(viewport);
|
||||
this.bindPageForms(viewport, page);
|
||||
} else {
|
||||
viewport.innerHTML = `
|
||||
<div style="padding:60px 20px; text-align:center;">
|
||||
<h2 style="color:#64748b; font-size:18px; margin-bottom:8px;">404 Page Not Found</h2>
|
||||
<p style="color:#94a3b8; font-size:13px;">The simulated URL <code>${url}</code> was not found on this simulation network.</p>
|
||||
<h2 style="color:#64748b; font-size:18px; margin-bottom:8px;">${i18n.t('navigator.pageNotFound')}</h2>
|
||||
<p style="color:#94a3b8; font-size:13px;">${i18n.t('navigator.pageNotFoundDesc')}</p>
|
||||
<button class="cs-btn cs-btn-primary" style="margin-top:16px;" id="nav-go-home">Go to Home</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -209,12 +219,6 @@ export class NavigatorApp {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind form submit handlers declaratively from the page definition.
|
||||
* Replaces the Phase 1 inline onsubmit pattern.
|
||||
* @param {HTMLElement} viewport
|
||||
* @param {Object} page - The page definition
|
||||
*/
|
||||
bindPageForms(viewport, page) {
|
||||
if (!page.forms || !Array.isArray(page.forms)) return;
|
||||
|
||||
@@ -229,12 +233,6 @@ export class NavigatorApp {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic form submit handler driven by scenario form definitions.
|
||||
* @param {HTMLFormElement} formEl
|
||||
* @param {Object} formDef - The form definition from the scenario
|
||||
* @param {Object} page - The page definition
|
||||
*/
|
||||
handleFormSubmit(formEl, formDef, page) {
|
||||
const formData = {};
|
||||
formEl.querySelectorAll('input, textarea, select').forEach(input => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* CyberSim OS - Security Center Application (Phase 2)
|
||||
* CyberSim OS - Security Center Application (Phase 2.5)
|
||||
*
|
||||
* Initial alerts and all content are driven by the scenario definition.
|
||||
* No hard-coded alert text or organization-specific content.
|
||||
* Supports full runtime localization.
|
||||
*/
|
||||
|
||||
import { i18n } from '../core/i18n.js';
|
||||
|
||||
export class SecurityCenterApp {
|
||||
constructor({ windowManager, eventBus, scenario }) {
|
||||
this.wm = windowManager;
|
||||
@@ -14,7 +16,6 @@ export class SecurityCenterApp {
|
||||
// Initialize with scenario-defined initial alerts
|
||||
this.alerts = [];
|
||||
if (scenario && scenario.alerts) {
|
||||
// Add alerts marked with a timestamp (initial/pre-existing alerts)
|
||||
scenario.alerts.forEach(alert => {
|
||||
if (alert.timestamp) {
|
||||
this.alerts.push({ ...alert });
|
||||
@@ -45,7 +46,6 @@ export class SecurityCenterApp {
|
||||
}
|
||||
|
||||
addAlert(alertObj) {
|
||||
// If alertObj has no timestamp, add one
|
||||
if (!alertObj.timestamp) {
|
||||
alertObj.timestamp = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export class SecurityCenterApp {
|
||||
launch() {
|
||||
const win = this.wm.createWindow({
|
||||
id: 'security_center',
|
||||
title: 'Security Center',
|
||||
title: i18n.t('securitycenter.title'),
|
||||
iconSvg: this.getIconSvg(),
|
||||
width: 760,
|
||||
height: 520,
|
||||
@@ -72,11 +72,11 @@ export class SecurityCenterApp {
|
||||
<div class="sec-center-container">
|
||||
<div class="sec-sidebar">
|
||||
<div style="font-size:13px; font-weight:700; color:#ffffff; margin-bottom:12px; display:flex; align-items:center; gap:8px;">
|
||||
${this.getIconSvg()} Security Center
|
||||
${this.getIconSvg()} ${i18n.t('securitycenter.title')}
|
||||
</div>
|
||||
<div class="sec-nav-btn active" data-sec-tab="overview">Dashboard</div>
|
||||
<div class="sec-nav-btn" data-sec-tab="alerts">Alerts & Logs</div>
|
||||
<div class="sec-nav-btn" data-sec-tab="reports">Reported Incidents</div>
|
||||
<div class="sec-nav-btn active" data-sec-tab="overview">${i18n.t('securitycenter.dashboard')}</div>
|
||||
<div class="sec-nav-btn" data-sec-tab="alerts">${i18n.t('securitycenter.alertsLogs')}</div>
|
||||
<div class="sec-nav-btn" data-sec-tab="reports">${i18n.t('securitycenter.reportedIncidents')}</div>
|
||||
</div>
|
||||
<div class="sec-main" id="sec-main-content"></div>
|
||||
</div>
|
||||
@@ -91,20 +91,21 @@ export class SecurityCenterApp {
|
||||
|
||||
main.innerHTML = `
|
||||
<div class="sec-header">
|
||||
<div class="sec-title">Workstation Security Status</div>
|
||||
<div style="font-size:12px; color:#64748b;">Enterprise Zero-Trust Endpoint Protection</div>
|
||||
<div class="sec-title">${i18n.t('securitycenter.workstationStatus')}</div>
|
||||
<div style="font-size:12px; color:#64748b;">${i18n.t('securitycenter.endpointProtection')}</div>
|
||||
</div>
|
||||
|
||||
<div class="sec-status-banner ${hasHighAlert ? 'alert-state' : ''}">
|
||||
<div style="font-size:28px;">${hasHighAlert ? '⚠️' : '🛡️'}</div>
|
||||
<div>
|
||||
<div style="font-weight:700; font-size:14px;">${hasHighAlert ? 'Security Attention Required' : 'Workstation Protected & Monitored'}</div>
|
||||
<div style="font-size:12px;">${hasHighAlert ? 'One or more high severity security alerts require attention.' : 'All security telemetry feeds are operational. Zero active threats detected.'}</div>
|
||||
<div style="font-weight:700; font-size:14px;">${hasHighAlert ? i18n.t('securitycenter.statusAlert') : i18n.t('securitycenter.statusHealthy')}</div>
|
||||
<div style="font-size:12px;">${hasHighAlert ? i18n.t('securitycenter.statusAlertDesc') : i18n.t('securitycenter.statusHealthyDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="font-size:14px; font-weight:600; margin-bottom:10px;">Recent Security Alerts & Notifications</h3>
|
||||
<h3 style="font-size:14px; font-weight:600; margin-bottom:10px;">${i18n.t('securitycenter.recentAlerts')}</h3>
|
||||
<div class="sec-alert-list">
|
||||
${this.alerts.length === 0 ? `<div style="padding:14px; color:#94a3b8; font-size:12px;">${i18n.t('securitycenter.noAlerts')}</div>` : ''}
|
||||
${this.alerts.map(a => `
|
||||
<div class="sec-alert-item ${a.severity}">
|
||||
<div style="flex:1;">
|
||||
@@ -113,16 +114,16 @@ export class SecurityCenterApp {
|
||||
<span style="font-size:11px; color:#94a3b8;">${a.timestamp}</span>
|
||||
</div>
|
||||
<div style="font-size:11px; color:#475569; line-height:1.4;">${a.message}</div>
|
||||
<div style="font-size:10px; color:#94a3b8; margin-top:4px;">Source: ${a.source}</div>
|
||||
<div style="font-size:10px; color:#94a3b8; margin-top:4px;">Source: ${a.source || 'Endpoint Protection'}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<h3 style="font-size:14px; font-weight:600; margin:20px 0 10px 0;">User Incident Reports (${this.reports.length})</h3>
|
||||
<h3 style="font-size:14px; font-weight:600; margin:20px 0 10px 0;">${i18n.t('securitycenter.incidentLog')} (${this.reports.length})</h3>
|
||||
${this.reports.length === 0 ? `
|
||||
<div style="padding:14px; background:#ffffff; border:1px dashed #cbd5e1; border-radius:6px; font-size:12px; color:#94a3b8; text-align:center;">
|
||||
No suspicious messages or incidents reported yet during this shift.
|
||||
${i18n.t('securitycenter.noIncidents')}
|
||||
</div>
|
||||
` : `
|
||||
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
* CyberSim OS - Cryptographic Certificate Generator & *.cybercert Exporter
|
||||
*/
|
||||
|
||||
const ENGINE_VERSION = '0.2.0-phase2';
|
||||
import { i18n } from '../core/i18n.js';
|
||||
|
||||
const ENGINE_VERSION = '0.2.5-phase2.5';
|
||||
|
||||
export class CertificateGenerator {
|
||||
constructor(scenario, scoreResult, scenarioFingerprint) {
|
||||
@@ -23,13 +25,14 @@ export class CertificateGenerator {
|
||||
const certId = this.generateUUID();
|
||||
const timestamp = new Date().toISOString();
|
||||
const companyName = this.scenario.organizations?.[0]?.name || this.scenario.company?.name || 'CyberSim';
|
||||
const scenarioId = this.scenario.id || this.scenario.scenarioId || 'scenario';
|
||||
|
||||
const payload = {
|
||||
schema_version: 1,
|
||||
certificate_id: certId,
|
||||
product: 'CyberSim OS',
|
||||
engine_version: ENGINE_VERSION,
|
||||
scenario_id: this.scenario.scenarioId,
|
||||
scenario_id: scenarioId,
|
||||
scenario_title: this.scenario.title,
|
||||
scenario_version: this.scenario.version,
|
||||
scenario_fingerprint: this.scenarioFingerprint,
|
||||
@@ -50,9 +53,16 @@ export class CertificateGenerator {
|
||||
};
|
||||
|
||||
// Calculate integrity checksum over canonical record
|
||||
const canonicalStr = `${certId}:${finalLearnerName}:${this.scenario.scenarioId}:${this.scenarioFingerprint}:${this.scoreResult.totalScore}:${timestamp}`;
|
||||
const hashBuffer = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonicalStr));
|
||||
const integrityHash = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
const canonicalStr = `${certId}:${finalLearnerName}:${scenarioId}:${this.scenarioFingerprint}:${this.scoreResult.totalScore}:${timestamp}`;
|
||||
|
||||
let integrityHash = '';
|
||||
if (typeof window !== 'undefined' && window.crypto && window.crypto.subtle) {
|
||||
const hashBuffer = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonicalStr));
|
||||
integrityHash = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
} else if (typeof globalThis !== 'undefined' && globalThis.crypto && globalThis.crypto.subtle) {
|
||||
const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonicalStr));
|
||||
integrityHash = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
payload.integrity_hash = integrityHash;
|
||||
return payload;
|
||||
@@ -83,37 +93,37 @@ export class CertificateGenerator {
|
||||
<div class="cs-modal-header">
|
||||
<div class="cs-modal-title">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/></svg>
|
||||
CyberSim OS - Official Certificate of Competency
|
||||
${i18n.t('cert.title')}
|
||||
</div>
|
||||
<button class="cs-btn-win close" id="btn-close-cert-modal">×</button>
|
||||
</div>
|
||||
|
||||
<div class="cs-modal-body" style="background:#f8fafc; padding:20px;">
|
||||
<div class="cert-printable" id="cert-printable-area">
|
||||
<div class="cert-org">${companyName} • CyberSim Environment</div>
|
||||
<div class="cert-title">Certificate of Competency</div>
|
||||
<div class="cert-subtitle">End-User Cybersecurity Simulation & Behavioral Verification</div>
|
||||
<div class="cert-org">${companyName} • ${i18n.t('cert.environment')}</div>
|
||||
<div class="cert-title">${i18n.t('cert.competencyTitle')}</div>
|
||||
<div class="cert-subtitle">${i18n.t('cert.subtitle')}</div>
|
||||
|
||||
<div style="font-size:12px; color:#64748b; margin-top:14px;">This certifies that</div>
|
||||
<div style="font-size:12px; color:#64748b; margin-top:14px;">${i18n.t('cert.certifiesThat')}</div>
|
||||
<div class="cert-recipient">${certData.learner.name}</div>
|
||||
|
||||
<div class="cert-text">
|
||||
has successfully completed the <strong>${certData.scenario_title}</strong> simulation, demonstrating sound investigative judgment, threat detection, safe credential handling, and policy adherence.
|
||||
${i18n.t('cert.statement', { scenarioTitle: `<strong>${certData.scenario_title}</strong>` })}
|
||||
</div>
|
||||
|
||||
<div style="font-size:14px; font-weight:700; color:#166534; background:#dcfce7; display:inline-block; padding:4px 16px; border-radius:12px; margin-bottom:14px;">
|
||||
Final Score: ${certData.evaluation.score} / 100 (PASSED)
|
||||
${i18n.t('cert.finalScore', { score: certData.evaluation.score })}
|
||||
</div>
|
||||
|
||||
<div class="cert-meta-grid">
|
||||
<div>
|
||||
<strong>Certificate ID:</strong> ${certData.certificate_id}<br>
|
||||
<strong>Date:</strong> ${new Date(certData.issued_at).toLocaleDateString()}<br>
|
||||
<strong>Engine Version:</strong> ${certData.engine_version}
|
||||
<strong>${i18n.t('cert.certId')}</strong> ${certData.certificate_id}<br>
|
||||
<strong>${i18n.t('cert.date')}</strong> ${new Date(certData.issued_at).toLocaleDateString()}<br>
|
||||
<strong>${i18n.t('cert.engineVersion')}</strong> ${certData.engine_version}
|
||||
</div>
|
||||
<div style="word-break:break-all;">
|
||||
<strong>Scenario Hash:</strong><br>${certData.scenario_fingerprint.substring(0, 32)}...<br>
|
||||
<strong>Integrity Hash:</strong><br>${certData.integrity_hash.substring(0, 32)}...
|
||||
<strong>${i18n.t('cert.scenarioHash')}</strong><br>${certData.scenario_fingerprint.substring(0, 32)}...<br>
|
||||
<strong>${i18n.t('cert.integrityHash')}</strong><br>${certData.integrity_hash.substring(0, 32)}...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,10 +131,10 @@ export class CertificateGenerator {
|
||||
|
||||
<div class="cs-modal-footer">
|
||||
<button class="cs-btn" id="btn-print-cert">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg> Print
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg> ${i18n.t('cert.print')}
|
||||
</button>
|
||||
<button class="cs-btn cs-btn-primary" id="btn-download-cybercert">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Export *.cybercert File
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> ${i18n.t('cert.export')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,8 +27,15 @@ export class CertificateVerifier {
|
||||
const timestamp = certData.issued_at;
|
||||
|
||||
const canonicalStr = `${certId}:${learnerName}:${scenarioId}:${fingerprint}:${score}:${timestamp}`;
|
||||
const hashBuffer = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonicalStr));
|
||||
const calculatedHash = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
let calculatedHash = '';
|
||||
if (typeof window !== 'undefined' && window.crypto && window.crypto.subtle) {
|
||||
const hashBuffer = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonicalStr));
|
||||
calculatedHash = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
} else if (typeof globalThis !== 'undefined' && globalThis.crypto && globalThis.crypto.subtle) {
|
||||
const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonicalStr));
|
||||
calculatedHash = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
const hashValid = (calculatedHash.toLowerCase() === certData.integrity_hash.toLowerCase());
|
||||
if (!hashValid) {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* CyberSim OS - Corporate Branding & Deployment Configuration Manager
|
||||
*
|
||||
* Configuration-driven corporate branding with safe defaults and dynamic CSS variable bindings.
|
||||
*/
|
||||
|
||||
const DEFAULT_BRANDING = {
|
||||
organization: {
|
||||
name: "CyberSim Enterprise",
|
||||
shortName: "CyberSim",
|
||||
logo: "",
|
||||
wallpaper: "",
|
||||
accentColor: "#2563eb",
|
||||
supportName: "CyberSim IT Helpdesk"
|
||||
},
|
||||
localization: {
|
||||
defaultLocale: "en",
|
||||
enabledLocales: ["en", "es"]
|
||||
},
|
||||
scenarios: [
|
||||
{
|
||||
id: "nexacore-orientation",
|
||||
path: "scenarios/nexacore-orientation/scenario.json",
|
||||
networkName: "NexaCore Corporate",
|
||||
networkDescription: "Corporate Workplace Network",
|
||||
networkIcon: "corporate",
|
||||
supportedLocales: ["en", "es"]
|
||||
},
|
||||
{
|
||||
id: "quickstart-example",
|
||||
path: "scenarios/quickstart-example/scenario.json",
|
||||
networkName: "Meridian Health Partners",
|
||||
networkDescription: "Clinical & Administrative Network",
|
||||
networkIcon: "health",
|
||||
supportedLocales: ["en", "es"]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export class BrandingManager {
|
||||
constructor(config = null) {
|
||||
this.config = config ? this._mergeConfig(DEFAULT_BRANDING, config) : { ...DEFAULT_BRANDING };
|
||||
}
|
||||
|
||||
_mergeConfig(defaults, custom) {
|
||||
if (!custom || typeof custom !== 'object') return { ...defaults };
|
||||
return {
|
||||
organization: {
|
||||
...defaults.organization,
|
||||
...(custom.organization || {})
|
||||
},
|
||||
localization: {
|
||||
...defaults.localization,
|
||||
...(custom.localization || {})
|
||||
},
|
||||
scenarios: Array.isArray(custom.scenarios) ? custom.scenarios : defaults.scenarios
|
||||
};
|
||||
}
|
||||
|
||||
async load(configUrl = 'config/deployment.json') {
|
||||
try {
|
||||
if (typeof fetch !== 'undefined') {
|
||||
const res = await fetch(configUrl);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this.config = this._mergeConfig(DEFAULT_BRANDING, data);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[BrandingManager] Using default configuration: ${e.message}`);
|
||||
}
|
||||
return this.config;
|
||||
}
|
||||
|
||||
getOrganization() {
|
||||
return this.config.organization || DEFAULT_BRANDING.organization;
|
||||
}
|
||||
|
||||
getLocalizationConfig() {
|
||||
return this.config.localization || DEFAULT_BRANDING.localization;
|
||||
}
|
||||
|
||||
getScenariosCatalog() {
|
||||
return this.config.scenarios || DEFAULT_BRANDING.scenarios;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies branding CSS custom properties to the document root safely.
|
||||
*/
|
||||
applyBrandingStyles(targetElement = document.documentElement) {
|
||||
if (!targetElement || !targetElement.style) return;
|
||||
|
||||
const org = this.getOrganization();
|
||||
|
||||
// Set accent color if valid hex/color string
|
||||
if (org.accentColor && typeof org.accentColor === 'string' && /^#[0-9a-fA-F]{3,8}$|^rgb/.test(org.accentColor.trim())) {
|
||||
targetElement.style.setProperty('--color-primary', org.accentColor.trim());
|
||||
targetElement.style.setProperty('--brand-accent', org.accentColor.trim());
|
||||
}
|
||||
|
||||
// Set wallpaper if specified and safe path
|
||||
if (org.wallpaper && typeof org.wallpaper === 'string' && !org.wallpaper.includes('..')) {
|
||||
const desktop = document.getElementById('desktop');
|
||||
if (desktop) {
|
||||
desktop.style.backgroundImage = `url('${encodeURI(org.wallpaper)}')`;
|
||||
desktop.style.backgroundSize = 'cover';
|
||||
desktop.style.backgroundPosition = 'center';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const branding = new BrandingManager();
|
||||
+13
-2
@@ -2,6 +2,8 @@
|
||||
* CyberSim OS - Desktop Shell & Taskbar Interface
|
||||
*/
|
||||
|
||||
import { i18n } from './i18n.js';
|
||||
|
||||
export class DesktopShell {
|
||||
constructor({ desktopElement, startMenuElement, startBtnElement, clockElement, eventBus, onFinishScenario }) {
|
||||
this.desktop = desktopElement;
|
||||
@@ -35,6 +37,7 @@ export class DesktopShell {
|
||||
// End Simulation Button
|
||||
const finishBtn = document.getElementById('start-btn-finish');
|
||||
if (finishBtn) {
|
||||
finishBtn.textContent = i18n.t('desktop.finishShift');
|
||||
finishBtn.addEventListener('click', () => {
|
||||
this.closeStartMenu();
|
||||
if (this.onFinishScenario) {
|
||||
@@ -42,6 +45,13 @@ export class DesktopShell {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Localize static section headers in start menu if present
|
||||
const sections = this.startMenu.querySelectorAll('.start-section-title');
|
||||
if (sections.length >= 2) {
|
||||
sections[0].textContent = i18n.t('desktop.shiftResponsibilities');
|
||||
sections[1].textContent = i18n.t('desktop.applications');
|
||||
}
|
||||
}
|
||||
|
||||
renderDesktopIcons(apps) {
|
||||
@@ -114,8 +124,9 @@ export class DesktopShell {
|
||||
updateClock() {
|
||||
if (!this.clock) return;
|
||||
const now = new Date();
|
||||
const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const dateStr = now.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
const currentLocale = i18n.getLocale() === 'es' ? 'es-ES' : 'en-US';
|
||||
const timeStr = now.toLocaleTimeString(currentLocale, { hour: '2-digit', minute: '2-digit' });
|
||||
const dateStr = now.toLocaleDateString(currentLocale, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
|
||||
this.clock.innerHTML = `
|
||||
<div class="time">${timeStr}</div>
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* CyberSim OS - Internationalization & Localization Engine (I18n)
|
||||
*
|
||||
* Offline-first, deterministic string translation with robust fallback policies.
|
||||
*/
|
||||
|
||||
// Fallback embedded English catalog for instant initialization & environments without fetch
|
||||
const EMBEDDED_EN = {
|
||||
"login.title": "CyberSim OS - Enterprise Workstation Login",
|
||||
"login.subtitle": "Simulated Workplace Environment",
|
||||
"login.welcome": "Welcome",
|
||||
"login.instructions": "Enter your first name and select an available network to begin your scheduled shift.",
|
||||
"login.firstName": "First Name",
|
||||
"login.firstNamePlaceholder": "e.g. Chris",
|
||||
"login.firstNameRequired": "Please enter your first name.",
|
||||
"login.firstNameTooLong": "First name must be 50 characters or less.",
|
||||
"login.language": "Language",
|
||||
"login.availableNetworks": "Available Networks & Workplaces",
|
||||
"login.networkUnavailable": "Unavailable in selected language",
|
||||
"login.selectNetwork": "Select a workplace network to connect",
|
||||
"login.connect": "Connect to Workplace",
|
||||
"login.connecting": "Connecting...",
|
||||
"login.disclaimer": "CyberSim OS is a simulated training environment. Never enter real passwords or sensitive credentials.",
|
||||
"login.sessionInfo": "Simulated Endpoint \u2022 Zero-Trust Security Active",
|
||||
"desktop.start": "Start",
|
||||
"desktop.loading": "Loading scenario package...",
|
||||
"desktop.shiftResponsibilities": "Shift Responsibilities",
|
||||
"desktop.applications": "Applications",
|
||||
"desktop.finishShift": "Finish Shift & View Assessment",
|
||||
"desktop.networkProtected": "Network: Protected",
|
||||
"desktop.securityAndNotifications": "Security & Notifications",
|
||||
"apps.inlook": "Inlook Mail",
|
||||
"apps.navigator": "Navigator",
|
||||
"apps.files": "Files",
|
||||
"apps.securityCenter": "Security Center",
|
||||
"apps.docViewer": "Doc Viewer",
|
||||
"apps.verifyCert": "Verify Cert",
|
||||
"common.close": "Close",
|
||||
"common.reload": "Reload",
|
||||
"common.unknown": "Unknown"
|
||||
};
|
||||
|
||||
export class I18nService {
|
||||
constructor({ defaultLocale = 'en', enabledLocales = ['en', 'es'], basePath = 'locales' } = {}) {
|
||||
this.defaultLocale = defaultLocale;
|
||||
this.activeLocale = defaultLocale;
|
||||
this.enabledLocales = [...enabledLocales];
|
||||
this.basePath = basePath;
|
||||
this.dictionaries = {
|
||||
en: { ...EMBEDDED_EN }
|
||||
};
|
||||
this.listeners = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register change listener for reactive UI updates
|
||||
*/
|
||||
onChange(fn) {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all listeners of a locale switch
|
||||
*/
|
||||
_notify() {
|
||||
this.listeners.forEach(fn => {
|
||||
try {
|
||||
fn(this.activeLocale);
|
||||
} catch (err) {
|
||||
console.error('I18n onChange listener error:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load locale bundle from JSON or dictionary object
|
||||
*/
|
||||
async loadLocale(locale) {
|
||||
if (this.dictionaries[locale] && Object.keys(this.dictionaries[locale]).length > 10) {
|
||||
return this.dictionaries[locale];
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof fetch !== 'undefined') {
|
||||
const res = await fetch(`${this.basePath}/${locale}.json`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this.dictionaries[locale] = { ...(this.dictionaries[locale] || {}), ...data };
|
||||
return this.dictionaries[locale];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[I18n] Could not fetch ${this.basePath}/${locale}.json, checking local dictionary.`);
|
||||
}
|
||||
|
||||
return this.dictionaries[locale] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set custom dictionary directly
|
||||
*/
|
||||
setDictionary(locale, dictionary) {
|
||||
this.dictionaries[locale] = {
|
||||
...(this.dictionaries[locale] || {}),
|
||||
...dictionary
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Change current active locale and preload dictionary
|
||||
*/
|
||||
async setLocale(locale) {
|
||||
const target = this.enabledLocales.includes(locale) ? locale : this.defaultLocale;
|
||||
if (!this.dictionaries[target]) {
|
||||
await this.loadLocale(target);
|
||||
}
|
||||
this.activeLocale = target;
|
||||
this._notify();
|
||||
return this.activeLocale;
|
||||
}
|
||||
|
||||
getLocale() {
|
||||
return this.activeLocale;
|
||||
}
|
||||
|
||||
getEnabledLocales() {
|
||||
return [...this.enabledLocales];
|
||||
}
|
||||
|
||||
/**
|
||||
* Core translation method with deterministic fallback policy:
|
||||
* 1. Active locale
|
||||
* 2. Deployment default locale
|
||||
* 3. English ('en')
|
||||
* 4. Visible missing key identifier `[missing: key]`
|
||||
*/
|
||||
t(key, params = {}) {
|
||||
if (!key || typeof key !== 'string') return '';
|
||||
|
||||
let text = null;
|
||||
|
||||
// 1. Active locale
|
||||
if (this.dictionaries[this.activeLocale] && this.dictionaries[this.activeLocale][key] !== undefined) {
|
||||
text = this.dictionaries[this.activeLocale][key];
|
||||
}
|
||||
// 2. Deployment default locale
|
||||
else if (this.dictionaries[this.defaultLocale] && this.dictionaries[this.defaultLocale][key] !== undefined) {
|
||||
text = this.dictionaries[this.defaultLocale][key];
|
||||
}
|
||||
// 3. English fallback
|
||||
else if (this.dictionaries['en'] && this.dictionaries['en'][key] !== undefined) {
|
||||
text = this.dictionaries['en'][key];
|
||||
}
|
||||
|
||||
// 4. Missing key fallback
|
||||
if (text === null || text === undefined) {
|
||||
return `[missing: ${key}]`;
|
||||
}
|
||||
|
||||
// Interpolate {param} or {{param}}
|
||||
if (params && typeof params === 'object') {
|
||||
return text.replace(/\{?\{([^{}]+)\}\}?/g, (match, paramName) => {
|
||||
const trimmed = paramName.trim();
|
||||
if (params[trimmed] !== undefined && params[trimmed] !== null) {
|
||||
return String(params[trimmed]);
|
||||
}
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to format language display names
|
||||
*/
|
||||
getLocaleDisplayName(locale) {
|
||||
const names = {
|
||||
en: 'English',
|
||||
es: 'Español'
|
||||
};
|
||||
return names[locale] || locale.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for global OS usage
|
||||
export const i18n = new I18nService();
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* CyberSim OS - Immersive Workstation Login & Network Selection Screen (Phase 2.5)
|
||||
*
|
||||
* Immersive fictional workstation lockscreen, runtime language switching,
|
||||
* learner first-name validation, and dynamic workplace network selection.
|
||||
*/
|
||||
|
||||
import { i18n } from './i18n.js';
|
||||
import { branding } from './branding.js';
|
||||
|
||||
export class LoginScreen {
|
||||
constructor({ containerElement, onConnect }) {
|
||||
this.container = containerElement;
|
||||
this.onConnect = onConnect;
|
||||
this.selectedScenarioId = null;
|
||||
this.scenarios = [];
|
||||
this.unsubscribeI18n = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to ensure learner input is never rendered as executable markup.
|
||||
*/
|
||||
static escapeHtml(str) {
|
||||
if (typeof str !== 'string') return '';
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and render the login screen.
|
||||
*/
|
||||
async render() {
|
||||
this.scenarios = branding.getScenariosCatalog();
|
||||
if (this.scenarios.length > 0 && !this.selectedScenarioId) {
|
||||
this.selectedScenarioId = this.scenarios[0].id;
|
||||
}
|
||||
|
||||
this._drawUI();
|
||||
|
||||
// Listen to language changes to re-render login UI dynamically
|
||||
if (this.unsubscribeI18n) this.unsubscribeI18n();
|
||||
this.unsubscribeI18n = i18n.onChange(() => {
|
||||
this._drawUI();
|
||||
});
|
||||
}
|
||||
|
||||
_drawUI() {
|
||||
if (!this.container) return;
|
||||
|
||||
const org = branding.getOrganization();
|
||||
const enabledLocales = i18n.getEnabledLocales();
|
||||
const currentLocale = i18n.getLocale();
|
||||
|
||||
// Preserve entered first name if switching language
|
||||
const existingInput = this.container.querySelector('#login-firstname-input');
|
||||
const existingName = existingInput ? existingInput.value : '';
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="cs-login-backdrop">
|
||||
<div class="cs-login-card">
|
||||
<!-- Corporate Header & Branding -->
|
||||
<div class="cs-login-header">
|
||||
<div class="cs-login-org-brand">
|
||||
${org.logo ? `<img src="${encodeURI(org.logo)}" alt="Logo" class="cs-login-logo">` : `
|
||||
<div class="cs-login-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
||||
<line x1="8" y1="21" x2="16" y2="21"></line>
|
||||
<line x1="12" y1="17" x2="12" y2="21"></line>
|
||||
</svg>
|
||||
</div>
|
||||
`}
|
||||
<div>
|
||||
<h1 class="cs-login-org-name">${LoginScreen.escapeHtml(org.name)}</h1>
|
||||
<div class="cs-login-sub">${i18n.t('login.subtitle')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Language Selector -->
|
||||
<div class="cs-login-lang-select">
|
||||
<label for="login-lang-dropdown" style="font-size:11px; color:#94a3b8; margin-right:6px;">${i18n.t('login.language')}:</label>
|
||||
<select id="login-lang-dropdown" class="cs-select cs-select-sm">
|
||||
${enabledLocales.map(loc => `
|
||||
<option value="${loc}" ${loc === currentLocale ? 'selected' : ''}>
|
||||
${i18n.getLocaleDisplayName(loc)}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Welcome & Instructions -->
|
||||
<div class="cs-login-body">
|
||||
<div class="cs-login-welcome">
|
||||
<h2>${i18n.t('login.welcome')}</h2>
|
||||
<p>${i18n.t('login.instructions')}</p>
|
||||
</div>
|
||||
|
||||
<!-- Learner First Name Input -->
|
||||
<div class="cs-form-group" style="margin-bottom:18px;">
|
||||
<label class="cs-form-label" for="login-firstname-input">
|
||||
${i18n.t('login.firstName')} <span style="color:#ef4444;">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="login-firstname-input"
|
||||
class="cs-input"
|
||||
placeholder="${i18n.t('login.firstNamePlaceholder')}"
|
||||
maxlength="50"
|
||||
value="${LoginScreen.escapeHtml(existingName)}"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<div id="login-error-msg" class="cs-login-error" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Network / Workplace Selection -->
|
||||
<div class="cs-form-group">
|
||||
<label class="cs-form-label">
|
||||
${i18n.t('login.availableNetworks')}
|
||||
</label>
|
||||
<div class="cs-network-list" id="cs-network-list">
|
||||
${this.scenarios.map(sc => {
|
||||
const isSelected = sc.id === this.selectedScenarioId;
|
||||
const isSupported = !sc.supportedLocales || sc.supportedLocales.includes(currentLocale);
|
||||
const iconSvg = this._getNetworkIconSvg(sc.networkIcon);
|
||||
|
||||
return `
|
||||
<div class="cs-network-card ${isSelected ? 'selected' : ''} ${!isSupported ? 'unsupported' : ''}" data-scenario-id="${sc.id}" data-supported="${isSupported}">
|
||||
<div class="cs-network-icon">${iconSvg}</div>
|
||||
<div class="cs-network-info">
|
||||
<div class="cs-network-name">${LoginScreen.escapeHtml(sc.networkName || sc.id)}</div>
|
||||
<div class="cs-network-desc">${LoginScreen.escapeHtml(sc.networkDescription || '')}</div>
|
||||
${!isSupported ? `<div class="cs-network-badge-warn">${i18n.t('login.networkUnavailable')}</div>` : ''}
|
||||
</div>
|
||||
<div class="cs-network-radio">
|
||||
<input type="radio" name="network-choice" value="${sc.id}" ${isSelected ? 'checked' : ''} ${!isSupported ? 'disabled' : ''}>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connect Action -->
|
||||
<div style="margin-top:24px;">
|
||||
<button id="btn-login-connect" class="cs-btn cs-btn-primary cs-btn-lg" style="width:100%;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:6px;">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
|
||||
<polyline points="10 17 15 12 10 7"></polyline>
|
||||
<line x1="15" y1="12" x2="3" y2="12"></line>
|
||||
</svg>
|
||||
${i18n.t('login.connect')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Simulation Safety Disclaimer -->
|
||||
<div class="cs-login-footer">
|
||||
<div class="cs-login-disclaimer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#eab308" stroke-width="2" style="flex-shrink:0;">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
||||
</svg>
|
||||
<span>${i18n.t('login.disclaimer')}</span>
|
||||
</div>
|
||||
<div style="margin-top:4px; font-size:10px; color:#64748b;">
|
||||
${i18n.t('login.sessionInfo')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this._bindEvents();
|
||||
}
|
||||
|
||||
_getNetworkIconSvg(type) {
|
||||
switch (type) {
|
||||
case 'health':
|
||||
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>`;
|
||||
case 'corporate':
|
||||
default:
|
||||
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>`;
|
||||
}
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
// Language dropdown change
|
||||
const langSelect = this.container.querySelector('#login-lang-dropdown');
|
||||
if (langSelect) {
|
||||
langSelect.addEventListener('change', async (e) => {
|
||||
const newLocale = e.target.value;
|
||||
await i18n.setLocale(newLocale);
|
||||
});
|
||||
}
|
||||
|
||||
// Network card selection
|
||||
const cards = this.container.querySelectorAll('.cs-network-card');
|
||||
cards.forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
if (card.dataset.supported === 'false') return;
|
||||
cards.forEach(c => {
|
||||
c.classList.remove('selected');
|
||||
const radio = c.querySelector('input[type="radio"]');
|
||||
if (radio) radio.checked = false;
|
||||
});
|
||||
card.classList.add('selected');
|
||||
const radio = card.querySelector('input[type="radio"]');
|
||||
if (radio) radio.checked = true;
|
||||
this.selectedScenarioId = card.dataset.scenarioId;
|
||||
});
|
||||
});
|
||||
|
||||
// First name input on Enter
|
||||
const nameInput = this.container.querySelector('#login-firstname-input');
|
||||
if (nameInput) {
|
||||
nameInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this._handleConnect();
|
||||
}
|
||||
});
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
// Connect button click
|
||||
const connectBtn = this.container.querySelector('#btn-login-connect');
|
||||
if (connectBtn) {
|
||||
connectBtn.addEventListener('click', () => this._handleConnect());
|
||||
}
|
||||
}
|
||||
|
||||
_showError(msg) {
|
||||
const errorEl = this.container.querySelector('#login-error-msg');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = msg;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
_clearError() {
|
||||
const errorEl = this.container.querySelector('#login-error-msg');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = '';
|
||||
errorEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
_handleConnect() {
|
||||
this._clearError();
|
||||
const nameInput = this.container.querySelector('#login-firstname-input');
|
||||
const rawName = nameInput ? nameInput.value : '';
|
||||
const firstName = rawName.trim();
|
||||
|
||||
// 1. Validate First Name
|
||||
if (!firstName) {
|
||||
this._showError(i18n.t('login.firstNameRequired'));
|
||||
if (nameInput) nameInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstName.length > 50) {
|
||||
this._showError(i18n.t('login.firstNameTooLong'));
|
||||
if (nameInput) nameInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Validate Selected Network
|
||||
const selectedScenario = this.scenarios.find(s => s.id === this.selectedScenarioId);
|
||||
if (!selectedScenario) {
|
||||
this._showError(i18n.t('login.selectNetwork'));
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLocale = i18n.getLocale();
|
||||
if (selectedScenario.supportedLocales && !selectedScenario.supportedLocales.includes(currentLocale)) {
|
||||
this._showError(i18n.t('login.networkUnavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up listener
|
||||
if (this.unsubscribeI18n) {
|
||||
this.unsubscribeI18n();
|
||||
this.unsubscribeI18n = null;
|
||||
}
|
||||
|
||||
// Connect callback
|
||||
if (this.onConnect) {
|
||||
this.onConnect({
|
||||
firstName,
|
||||
scenarioId: selectedScenario.id,
|
||||
scenarioPath: selectedScenario.path,
|
||||
locale: currentLocale
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,6 +262,21 @@ export class ActionDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML entities to prevent injection in interpolated template strings.
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
static escapeHtml(str) {
|
||||
if (typeof str !== 'string') return String(str);
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the context object for template interpolation.
|
||||
* @private
|
||||
@@ -272,6 +287,9 @@ export class ActionDispatcher {
|
||||
const orgs = this.scenario.organizations || [];
|
||||
const company = orgs.length > 0 ? orgs[0] : {};
|
||||
|
||||
const fullName = learner.name || 'User';
|
||||
const firstName = learner.firstName || (learner.name ? learner.name.split(' ')[0] : 'User');
|
||||
|
||||
let timestamp = new Date().toLocaleTimeString();
|
||||
if (this.scenarioState && typeof this.scenarioState.getCurrentTime === 'function') {
|
||||
const simulatedTime = this.scenarioState.getCurrentTime();
|
||||
@@ -281,17 +299,19 @@ export class ActionDispatcher {
|
||||
}
|
||||
|
||||
return {
|
||||
'learner.name': learner.name || 'User',
|
||||
'learner.email': learner.email || '[email protected]',
|
||||
'learner.role': learner.role || 'Employee',
|
||||
'company.name': company.name || 'Company',
|
||||
'learner.firstName': ActionDispatcher.escapeHtml(firstName),
|
||||
'learner.name': ActionDispatcher.escapeHtml(fullName),
|
||||
'learner.email': ActionDispatcher.escapeHtml(learner.email || '[email protected]'),
|
||||
'learner.role': ActionDispatcher.escapeHtml(learner.role || 'Employee'),
|
||||
'learner.department': ActionDispatcher.escapeHtml(learner.department || 'General'),
|
||||
'company.name': ActionDispatcher.escapeHtml(company.name || 'Company'),
|
||||
'timestamp': timestamp
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate templates in a string.
|
||||
* @param {string} template - The template string (e.g., "Hello {{learner.name}}").
|
||||
* @param {string} template - The template string (e.g., "Hello {{learner.firstName}}").
|
||||
* @param {Object} context - The context object mapping keys to values.
|
||||
* @returns {string} The interpolated string.
|
||||
*/
|
||||
@@ -300,7 +320,7 @@ export class ActionDispatcher {
|
||||
|
||||
return template.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
|
||||
const trimmedKey = key.trim();
|
||||
if (context.hasOwnProperty(trimmedKey)) {
|
||||
if (context && context.hasOwnProperty(trimmedKey)) {
|
||||
return context[trimmedKey];
|
||||
}
|
||||
return match;
|
||||
|
||||
+145
-54
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* CyberSim OS - Main Bootstrapper & Simulation Lifecycle Orchestrator (Phase 2)
|
||||
* CyberSim OS - Main Bootstrapper & Simulation Lifecycle Orchestrator (Phase 2.5)
|
||||
*
|
||||
* Loads a scenario package from a URL, validates it, initializes the engine,
|
||||
* and orchestrates the simulation lifecycle.
|
||||
* Coordinates deployment branding, runtime localization, immersive login,
|
||||
* learner personalization, and scenario lifecycle.
|
||||
*/
|
||||
|
||||
import { globalEventBus } from './engine/event_bus.js';
|
||||
@@ -19,6 +19,9 @@ import { ScenarioDiagnostics, isDevelopmentMode } from './scenario/diagnostics.j
|
||||
import { WindowManager } from './core/window_manager.js';
|
||||
import { DesktopShell } from './core/desktop.js';
|
||||
import { NotificationService } from './core/notifications.js';
|
||||
import { i18n } from './core/i18n.js';
|
||||
import { branding } from './core/branding.js';
|
||||
import { LoginScreen } from './core/login.js';
|
||||
|
||||
import { InlookApp } from './apps/inlook.js';
|
||||
import { NavigatorApp } from './apps/navigator.js';
|
||||
@@ -30,7 +33,7 @@ import { BehavioralScorer } from './scoring/scorer.js';
|
||||
import { AfterActionReport } from './scoring/aar.js';
|
||||
import { CertificateGenerator } from './cert/cert_generator.js';
|
||||
|
||||
class CyberSimEngine {
|
||||
export class CyberSimEngine {
|
||||
constructor() {
|
||||
this.scenario = null;
|
||||
this.scenarioFingerprint = null;
|
||||
@@ -38,11 +41,9 @@ class CyberSimEngine {
|
||||
this.scenarioState = null;
|
||||
this.eventScheduler = null;
|
||||
this.diagnostics = null;
|
||||
this.currentLearnerFirstName = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a loading screen while the scenario loads.
|
||||
*/
|
||||
showLoadingScreen(message = 'Loading scenario...') {
|
||||
const desktop = document.getElementById('desktop');
|
||||
if (!desktop) return;
|
||||
@@ -58,9 +59,6 @@ class CyberSimEngine {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a validation error screen.
|
||||
*/
|
||||
showErrorScreen(errors, warnings) {
|
||||
const desktop = document.getElementById('desktop');
|
||||
if (!desktop) return;
|
||||
@@ -86,22 +84,77 @@ class CyberSimEngine {
|
||||
<div style="font-size:13px; color:#94a3b8; margin-bottom:16px;">${errors.length} error(s), ${warnings.length} warning(s)</div>
|
||||
<div style="max-height:400px; overflow-y:auto; margin-bottom:16px;">${errorHtml}</div>
|
||||
${warningHtml ? `<div style="margin-bottom:16px;"><div style="font-size:11px; color:#94a3b8; margin-bottom:6px;">Warnings:</div>${warningHtml}</div>` : ''}
|
||||
<button onclick="window.location.reload()" style="background:#3b82f6; color:white; border:none; padding:8px 20px; border-radius:4px; cursor:pointer; font-size:13px;">Reload</button>
|
||||
<button id="btn-reload-error" style="background:#3b82f6; color:white; border:none; padding:8px 20px; border-radius:4px; cursor:pointer; font-size:13px;">${i18n.t('common.reload')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const reloadBtn = desktop.querySelector('#btn-reload-error');
|
||||
if (reloadBtn) {
|
||||
reloadBtn.addEventListener('click', () => this.showLogin());
|
||||
}
|
||||
}
|
||||
|
||||
async start() {
|
||||
console.log('[CyberSim OS] Booting enterprise desktop simulation...');
|
||||
console.log('[CyberSim OS] Booting enterprise simulation platform...');
|
||||
|
||||
// Phase 1: Load scenario
|
||||
this.showLoadingScreen('Loading scenario package...');
|
||||
// 1. Load deployment branding
|
||||
await branding.load();
|
||||
branding.applyBrandingStyles();
|
||||
|
||||
const scenarioUrl = getScenarioUrl();
|
||||
console.log(`[CyberSim OS] Loading scenario from: ${scenarioUrl}`);
|
||||
// 2. Initialize i18n
|
||||
const locConfig = branding.getLocalizationConfig();
|
||||
i18n.defaultLocale = locConfig.defaultLocale || 'en';
|
||||
i18n.enabledLocales = locConfig.enabledLocales || ['en', 'es'];
|
||||
await i18n.loadLocale(i18n.defaultLocale);
|
||||
await i18n.loadLocale('es'); // Preload demonstration locale
|
||||
|
||||
const loadResult = await ScenarioLoader.load(scenarioUrl);
|
||||
// Check for query parameters bypass (e.g. ?autostart=true)
|
||||
const params = (typeof window !== 'undefined' && window.location) ? new URLSearchParams(window.location.search) : null;
|
||||
const directScenario = params ? params.get('scenario') : null;
|
||||
const autoStart = params ? params.get('autostart') === 'true' : false;
|
||||
|
||||
if (directScenario && autoStart) {
|
||||
await this.launchScenario({
|
||||
firstName: params.get('name') || 'Chris',
|
||||
scenarioPath: directScenario,
|
||||
locale: params.get('locale') || i18n.defaultLocale
|
||||
});
|
||||
} else {
|
||||
this.showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
showLogin() {
|
||||
const desktopEl = document.getElementById('desktop');
|
||||
const taskbarEl = document.getElementById('taskbar');
|
||||
const startMenuEl = document.getElementById('start-menu');
|
||||
|
||||
// Hide desktop shell during login
|
||||
if (taskbarEl) taskbarEl.style.display = 'none';
|
||||
if (startMenuEl) startMenuEl.style.display = 'none';
|
||||
|
||||
const login = new LoginScreen({
|
||||
containerElement: desktopEl,
|
||||
onConnect: (connectData) => this.launchScenario(connectData)
|
||||
});
|
||||
|
||||
login.render();
|
||||
}
|
||||
|
||||
async launchScenario({ firstName, scenarioId, scenarioPath, locale }) {
|
||||
this.currentLearnerFirstName = firstName;
|
||||
|
||||
// Set active locale
|
||||
await i18n.setLocale(locale || i18n.defaultLocale);
|
||||
|
||||
// Show loading screen
|
||||
this.showLoadingScreen(i18n.t('desktop.loading'));
|
||||
|
||||
const scenarioUrl = scenarioPath || getScenarioUrl();
|
||||
console.log(`[CyberSim OS] Loading scenario from: ${scenarioUrl} (Locale: ${locale})`);
|
||||
|
||||
const loadResult = await ScenarioLoader.load(scenarioUrl, { locale });
|
||||
|
||||
if (!loadResult.scenario) {
|
||||
console.error('[CyberSim OS] Failed to load scenario:', loadResult.errors);
|
||||
@@ -109,16 +162,28 @@ class CyberSimEngine {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scenario = loadResult.scenario;
|
||||
// Clone scenario to allow learner personalization
|
||||
this.scenario = JSON.parse(JSON.stringify(loadResult.scenario));
|
||||
if (loadResult.scenario._canonicalScenario) {
|
||||
this.scenario._canonicalScenario = loadResult.scenario._canonicalScenario;
|
||||
}
|
||||
|
||||
// Phase 2: Validate schema
|
||||
this.showLoadingScreen('Validating scenario schema...');
|
||||
// Personalize learner state with first name
|
||||
if (!this.scenario.learner) {
|
||||
this.scenario.learner = { name: firstName, role: 'Employee', email: `${firstName.toLowerCase()}@example.internal` };
|
||||
}
|
||||
this.scenario.learner.firstName = firstName;
|
||||
// Personalize full name with entered first name
|
||||
if (this.scenario.learner.name) {
|
||||
const parts = this.scenario.learner.name.split(' ');
|
||||
const lastName = parts.length > 1 ? parts.slice(1).join(' ') : '';
|
||||
this.scenario.learner.name = lastName ? `${firstName} ${lastName}` : firstName;
|
||||
}
|
||||
|
||||
// Validate schema
|
||||
const schemaResult = validateSchema(this.scenario);
|
||||
|
||||
// Phase 3: Validate references and consistency
|
||||
const validationResult = validateScenario(this.scenario);
|
||||
|
||||
// Merge all diagnostics
|
||||
const allErrors = [...loadResult.errors, ...schemaResult.errors, ...validationResult.errors];
|
||||
const allWarnings = [...loadResult.warnings, ...schemaResult.warnings, ...validationResult.warnings];
|
||||
|
||||
@@ -128,15 +193,11 @@ class CyberSimEngine {
|
||||
return;
|
||||
}
|
||||
|
||||
if (allWarnings.length > 0) {
|
||||
console.warn(`[CyberSim OS] Scenario loaded with ${allWarnings.length} warning(s)`);
|
||||
}
|
||||
|
||||
// Phase 4: Calculate fingerprint
|
||||
// Calculate canonical SHA-256 fingerprint
|
||||
this.scenarioFingerprint = await calculateScenarioFingerprint(this.scenario);
|
||||
console.log(`[CyberSim OS] Scenario Fingerprint (SHA-256): ${this.scenarioFingerprint}`);
|
||||
|
||||
// Phase 5: Initialize diagnostics
|
||||
// Diagnostics in dev mode
|
||||
if (isDevelopmentMode()) {
|
||||
this.diagnostics = new ScenarioDiagnostics({
|
||||
scenario: this.scenario,
|
||||
@@ -146,16 +207,21 @@ class CyberSimEngine {
|
||||
this.diagnostics.logToConsole();
|
||||
}
|
||||
|
||||
// Phase 6: Initialize scenario runtime state
|
||||
// Initialize runtime scenario state
|
||||
this.scenarioState = new ScenarioState(this.scenario, {
|
||||
seed: this.scenario.seed
|
||||
});
|
||||
|
||||
// Phase 7: Initialize UI
|
||||
this.showLoadingScreen('Initializing desktop...');
|
||||
// Initialize UI
|
||||
await this.initializeUI();
|
||||
|
||||
// Phase 8: Start event scheduler
|
||||
// Show taskbar & start menu
|
||||
const taskbarEl = document.getElementById('taskbar');
|
||||
const startMenuEl = document.getElementById('start-menu');
|
||||
if (taskbarEl) taskbarEl.style.display = 'flex';
|
||||
if (startMenuEl) startMenuEl.style.display = 'flex';
|
||||
|
||||
// Start event scheduler
|
||||
const conditionEvaluator = new ConditionEvaluator(this.scenarioState);
|
||||
const actionDispatcher = new ActionDispatcher({
|
||||
scenarioState: this.scenarioState,
|
||||
@@ -165,7 +231,6 @@ class CyberSimEngine {
|
||||
apps: {}
|
||||
});
|
||||
|
||||
// Register apps with the action dispatcher
|
||||
actionDispatcher.registerApps({
|
||||
inlook: this.inlook,
|
||||
navigator: this.navigator,
|
||||
@@ -184,15 +249,12 @@ class CyberSimEngine {
|
||||
this.eventBus
|
||||
);
|
||||
|
||||
// Initialize consequence tracking
|
||||
this.consequences = new ConsequenceEngine(this.eventBus);
|
||||
|
||||
// Bridge learner actions from the event bus to scenario state
|
||||
this.eventBus.on('*', (entry) => {
|
||||
this.scenarioState.recordAction(entry);
|
||||
});
|
||||
|
||||
// Emit scenario start
|
||||
this.eventBus.emit('SCENARIO_STARTED', {
|
||||
target: this.scenario.id,
|
||||
details: {
|
||||
@@ -201,17 +263,14 @@ class CyberSimEngine {
|
||||
}
|
||||
});
|
||||
|
||||
// Start the event scheduler tick loop
|
||||
this.eventScheduler.start(500);
|
||||
|
||||
// Show diagnostics overlay in dev mode
|
||||
if (this.diagnostics) {
|
||||
this.diagnostics.setFingerprint(this.scenarioFingerprint);
|
||||
this.diagnostics.setSeed(this.scenarioState.seed);
|
||||
this.diagnostics.showOverlay();
|
||||
}
|
||||
|
||||
// Expose runtime global for form event triggers and diagnostics
|
||||
window.CyberSimOS = {
|
||||
engine: this,
|
||||
navigator: this.navigator,
|
||||
@@ -232,17 +291,15 @@ class CyberSimEngine {
|
||||
const startBtnEl = document.getElementById('start-btn');
|
||||
const clockEl = document.getElementById('taskbar-clock');
|
||||
|
||||
// Reset desktop content from loading screen
|
||||
desktopEl.innerHTML = '<div id="desktop-icons"></div>';
|
||||
|
||||
// Initialize Core Services
|
||||
this.notifications = new NotificationService();
|
||||
this.wm = new WindowManager(desktopEl, taskbarAppsEl);
|
||||
|
||||
// Initialize Apps — pass scenario data generically
|
||||
this.docViewer = new DocViewerApp({
|
||||
windowManager: this.wm,
|
||||
eventBus: this.eventBus
|
||||
eventBus: this.eventBus,
|
||||
scenario: this.scenario
|
||||
});
|
||||
|
||||
this.files = new FilesApp({
|
||||
@@ -276,7 +333,6 @@ class CyberSimEngine {
|
||||
onFileDownloaded: (file) => this.files.addFile(file)
|
||||
});
|
||||
|
||||
// Initialize Desktop Shell
|
||||
this.desktop = new DesktopShell({
|
||||
desktopElement: desktopEl,
|
||||
startMenuElement: startMenuEl,
|
||||
@@ -286,35 +342,37 @@ class CyberSimEngine {
|
||||
onFinishScenario: () => this.finishScenario()
|
||||
});
|
||||
|
||||
// Register Desktop Apps — configuration driven
|
||||
// Populate Start Menu user info
|
||||
this._populateStartMenu();
|
||||
|
||||
const registeredApps = [
|
||||
{
|
||||
id: 'inlook',
|
||||
name: 'Inlook Mail',
|
||||
name: i18n.t('apps.inlook'),
|
||||
iconSvg: this.inlook.getIconSvg(),
|
||||
launch: () => this.inlook.launch()
|
||||
},
|
||||
{
|
||||
id: 'navigator',
|
||||
name: 'Navigator',
|
||||
name: i18n.t('apps.navigator'),
|
||||
iconSvg: this.navigator.getIconSvg(),
|
||||
launch: () => this.navigator.launch()
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
name: 'Files',
|
||||
name: i18n.t('apps.files'),
|
||||
iconSvg: this.files.getIconSvg(),
|
||||
launch: () => this.files.launch()
|
||||
},
|
||||
{
|
||||
id: 'security_center',
|
||||
name: 'Security Center',
|
||||
name: i18n.t('apps.securityCenter'),
|
||||
iconSvg: this.securityCenter.getIconSvg(),
|
||||
launch: () => this.securityCenter.launch()
|
||||
},
|
||||
{
|
||||
id: 'docviewer',
|
||||
name: 'Doc Viewer',
|
||||
name: i18n.t('apps.docViewer'),
|
||||
iconSvg: this.docViewer.getIconSvg(),
|
||||
launch: () => {
|
||||
const firstDoc = this.scenario.files && this.scenario.files[0];
|
||||
@@ -323,7 +381,7 @@ class CyberSimEngine {
|
||||
},
|
||||
{
|
||||
id: 'verify_cert',
|
||||
name: 'Verify Cert',
|
||||
name: i18n.t('apps.verifyCert'),
|
||||
iconSvg: `<svg viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>`,
|
||||
launch: () => {
|
||||
window.open('verify.html', '_blank');
|
||||
@@ -334,8 +392,41 @@ class CyberSimEngine {
|
||||
this.desktop.renderDesktopIcons(registeredApps);
|
||||
}
|
||||
|
||||
_populateStartMenu() {
|
||||
const s = this.scenario;
|
||||
if (!s) return;
|
||||
|
||||
const avatarEl = document.getElementById('start-user-avatar');
|
||||
const nameEl = document.getElementById('start-user-name');
|
||||
const roleEl = document.getElementById('start-user-role');
|
||||
const objectivesEl = document.getElementById('start-objectives');
|
||||
const orgNameEl = document.getElementById('taskbar-org-name');
|
||||
const networkEl = document.getElementById('taskbar-network-icon');
|
||||
|
||||
const org = branding.getOrganization();
|
||||
const orgName = (s.organizations && s.organizations[0]) ? s.organizations[0].name : org.name;
|
||||
|
||||
if (s.learner && avatarEl) {
|
||||
const initials = s.learner.name.split(' ').map(n => n[0]).join('');
|
||||
avatarEl.textContent = initials;
|
||||
}
|
||||
if (s.learner && nameEl) nameEl.textContent = s.learner.name;
|
||||
|
||||
const roleLine = s.learner ? `${s.learner.role} \u2022 ${orgName}` : orgName;
|
||||
if (roleEl) roleEl.textContent = roleLine;
|
||||
if (orgNameEl) orgNameEl.textContent = org.shortName || orgName;
|
||||
if (networkEl) networkEl.title = `${i18n.t('desktop.networkProtected')} (${orgName})`;
|
||||
|
||||
if (s.objectives && objectivesEl) {
|
||||
const items = s.objectives.map(obj => {
|
||||
const text = typeof obj === 'string' ? obj : obj.text;
|
||||
return `\u2022 ${text}`;
|
||||
});
|
||||
objectivesEl.innerHTML = items.join('<br>');
|
||||
}
|
||||
}
|
||||
|
||||
finishScenario() {
|
||||
// Stop the event scheduler
|
||||
if (this.eventScheduler) {
|
||||
this.eventScheduler.stop();
|
||||
}
|
||||
@@ -357,7 +448,7 @@ class CyberSimEngine {
|
||||
certGen.showCertificateModal(learnerName);
|
||||
},
|
||||
onRestart: () => {
|
||||
window.location.reload();
|
||||
this.showLogin();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -39,14 +39,16 @@ export async function calculateScenarioFingerprint(scenarioData) {
|
||||
throw new Error('Invalid scenario data');
|
||||
}
|
||||
|
||||
const baseScenario = scenarioData._canonicalScenario || scenarioData;
|
||||
|
||||
// Build the evaluation-relevant canonical object
|
||||
const evaluationData = {
|
||||
formatVersion: scenarioData.formatVersion,
|
||||
id: scenarioData.id,
|
||||
version: scenarioData.version,
|
||||
formatVersion: baseScenario.formatVersion,
|
||||
id: baseScenario.id,
|
||||
version: baseScenario.version,
|
||||
|
||||
// Map arrays to include only specific relevant fields where required
|
||||
messages: (scenarioData.messages || []).map(msg => ({
|
||||
messages: (baseScenario.messages || []).map(msg => ({
|
||||
id: msg.id,
|
||||
sender: msg.sender,
|
||||
rfcSender: msg.rfcSender,
|
||||
@@ -56,7 +58,7 @@ export async function calculateScenarioFingerprint(scenarioData) {
|
||||
attachments: msg.attachments
|
||||
})),
|
||||
|
||||
pages: (scenarioData.pages || []).map(page => ({
|
||||
pages: (baseScenario.pages || []).map(page => ({
|
||||
url: page.url,
|
||||
title: page.title,
|
||||
isSecure: page.isSecure,
|
||||
@@ -64,7 +66,7 @@ export async function calculateScenarioFingerprint(scenarioData) {
|
||||
forms: page.forms
|
||||
})),
|
||||
|
||||
files: (scenarioData.files || []).map(file => ({
|
||||
files: (baseScenario.files || []).map(file => ({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
@@ -73,13 +75,13 @@ export async function calculateScenarioFingerprint(scenarioData) {
|
||||
})),
|
||||
|
||||
// Include entire objects/arrays for the rest
|
||||
events: scenarioData.events || [],
|
||||
scoring: scenarioData.scoring || {},
|
||||
findings: scenarioData.findings || [],
|
||||
feedback: scenarioData.feedback || [],
|
||||
completion: scenarioData.completion || {},
|
||||
objectives: scenarioData.objectives || [],
|
||||
alerts: scenarioData.alerts || []
|
||||
events: baseScenario.events || [],
|
||||
scoring: baseScenario.scoring || {},
|
||||
findings: baseScenario.findings || [],
|
||||
feedback: baseScenario.feedback || [],
|
||||
completion: baseScenario.completion || {},
|
||||
objectives: baseScenario.objectives || [],
|
||||
alerts: baseScenario.alerts || []
|
||||
};
|
||||
|
||||
// Canonicalize the object (sorts keys deterministically and removes internal fields)
|
||||
|
||||
+142
-3
@@ -108,13 +108,15 @@ export function getScenarioUrl() {
|
||||
*/
|
||||
export class ScenarioLoader {
|
||||
/**
|
||||
* Loads a scenario from a given URL.
|
||||
* Loads a scenario from a given URL and optionally merges localized resources.
|
||||
* @param {string} scenarioUrl - The URL to load the scenario from.
|
||||
* @param {Object} [options] - Options including locale.
|
||||
* @returns {Promise<LoadResult>} The result containing the scenario, errors, and warnings.
|
||||
*/
|
||||
static async load(scenarioUrl) {
|
||||
static async load(scenarioUrl, options = {}) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const targetLocale = options.locale || 'en';
|
||||
|
||||
let baseUrl = scenarioUrl;
|
||||
if (baseUrl.includes('/')) {
|
||||
@@ -180,6 +182,25 @@ export class ScenarioLoader {
|
||||
return { scenario: null, errors, warnings };
|
||||
}
|
||||
|
||||
// Keep a pristine copy of the canonical scenario before localization overlay
|
||||
const canonicalCopy = JSON.parse(JSON.stringify(scenarioData));
|
||||
|
||||
// Scenario Localization: If locale is not 'en' and scenario supports it, load locale overlay
|
||||
if (targetLocale && targetLocale !== 'en') {
|
||||
try {
|
||||
const localeUrl = `${baseUrl}/locales/${targetLocale}.json`;
|
||||
const locResp = await fetch(localeUrl);
|
||||
if (locResp.ok) {
|
||||
const locData = await locResp.json();
|
||||
ScenarioLoader._applyTranslations(scenarioData, locData);
|
||||
} else {
|
||||
warnings.push(`Scenario does not provide translation for locale '${targetLocale}' at ${localeUrl}. Using fallback.`);
|
||||
}
|
||||
} catch (e) {
|
||||
warnings.push(`Could not load scenario translation for '${targetLocale}': ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// HTML content sanitization
|
||||
if (Array.isArray(scenarioData.pages)) {
|
||||
scenarioData.pages.forEach((page, idx) => {
|
||||
@@ -211,12 +232,130 @@ export class ScenarioLoader {
|
||||
});
|
||||
}
|
||||
|
||||
// Store base URL for asset resolution
|
||||
// Store base URL and canonical reference
|
||||
scenarioData._baseUrl = baseUrl;
|
||||
scenarioData._canonicalScenario = canonicalCopy;
|
||||
scenarioData._activeLocale = targetLocale;
|
||||
|
||||
// Freeze to prevent runtime mutations
|
||||
deepFreeze(scenarioData);
|
||||
|
||||
return { scenario: scenarioData, errors, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay translated strings onto scenario definition
|
||||
* @private
|
||||
*/
|
||||
static _applyTranslations(scenario, trans) {
|
||||
if (!trans || typeof trans !== 'object') return;
|
||||
|
||||
if (trans.title) scenario.title = trans.title;
|
||||
if (trans.description) scenario.description = trans.description;
|
||||
|
||||
// Objectives
|
||||
if (trans.objectives && Array.isArray(scenario.objectives)) {
|
||||
scenario.objectives.forEach(obj => {
|
||||
if (typeof obj === 'object' && obj.id && trans.objectives[obj.id]) {
|
||||
obj.text = trans.objectives[obj.id];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Messages
|
||||
if (trans.messages && Array.isArray(scenario.messages)) {
|
||||
scenario.messages.forEach(msg => {
|
||||
const tm = trans.messages[msg.id];
|
||||
if (tm) {
|
||||
if (tm.sender) msg.sender = tm.sender;
|
||||
if (tm.subject) msg.subject = tm.subject;
|
||||
if (tm.body) msg.body = tm.body;
|
||||
if (tm.links && Array.isArray(msg.links)) {
|
||||
msg.links.forEach((l, idx) => {
|
||||
if (tm.links[idx] && tm.links[idx].displayText) {
|
||||
l.displayText = tm.links[idx].displayText;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Pages
|
||||
if (trans.pages && Array.isArray(scenario.pages)) {
|
||||
scenario.pages.forEach(page => {
|
||||
const tp = trans.pages[page.url] || trans.pages[page.id];
|
||||
if (tp) {
|
||||
if (tp.title) page.title = tp.title;
|
||||
if (tp.content) page.content = tp.content;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Files
|
||||
if (trans.files && Array.isArray(scenario.files)) {
|
||||
scenario.files.forEach(file => {
|
||||
const tf = trans.files[file.id] || trans.files[file.name];
|
||||
if (tf) {
|
||||
if (tf.name) file.name = tf.name;
|
||||
if (tf.content) file.content = tf.content;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Notifications
|
||||
if (trans.notifications && Array.isArray(scenario.notifications)) {
|
||||
scenario.notifications.forEach(n => {
|
||||
const tn = trans.notifications[n.id];
|
||||
if (tn) {
|
||||
if (tn.title) n.title = tn.title;
|
||||
if (tn.body) n.body = tn.body;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Alerts
|
||||
if (trans.alerts && Array.isArray(scenario.alerts)) {
|
||||
scenario.alerts.forEach(a => {
|
||||
const ta = trans.alerts[a.id];
|
||||
if (ta) {
|
||||
if (ta.title) a.title = ta.title;
|
||||
if (ta.source) a.source = ta.source;
|
||||
if (ta.message) a.message = ta.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Findings
|
||||
if (trans.findings && Array.isArray(scenario.findings)) {
|
||||
scenario.findings.forEach(f => {
|
||||
const tf = trans.findings[f.id];
|
||||
if (tf) {
|
||||
if (tf.feedback) f.feedback = tf.feedback;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Feedback
|
||||
if (trans.feedback && Array.isArray(scenario.feedback)) {
|
||||
scenario.feedback.forEach(fb => {
|
||||
const tfb = trans.feedback[fb.id];
|
||||
if (tfb) {
|
||||
if (tfb.text) fb.text = tfb.text;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Scoring rules
|
||||
if (trans.scoring && scenario.scoring && Array.isArray(scenario.scoring.rules)) {
|
||||
scenario.scoring.rules.forEach(rule => {
|
||||
const tr = trans.scoring[rule.id];
|
||||
if (tr) {
|
||||
if (tr.timelineText && rule.timeline) rule.timeline.text = tr.timelineText;
|
||||
if (tr.feedback) rule.feedback = tr.feedback;
|
||||
if (tr.missedFeedback) rule.missedFeedback = tr.missedFeedback;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,24 @@ function validateManifest(data, ctx) {
|
||||
if (data.locale !== undefined && !isString(data.locale)) {
|
||||
ctx.addError('locale', 'Must be a string', 'string');
|
||||
}
|
||||
if (data.supportedLocales !== undefined && !isStringArray(data.supportedLocales)) {
|
||||
ctx.addError('supportedLocales', 'Must be an array of locale strings', 'string[]');
|
||||
}
|
||||
if (data.login !== undefined) {
|
||||
if (typeof data.login !== 'object' || data.login === null) {
|
||||
ctx.addError('login', 'Must be an object', 'object');
|
||||
} else {
|
||||
if (data.login.networkName !== undefined && !isString(data.login.networkName)) {
|
||||
ctx.addError('login.networkName', 'Must be a string', 'string');
|
||||
}
|
||||
if (data.login.networkDescription !== undefined && !isString(data.login.networkDescription)) {
|
||||
ctx.addError('login.networkDescription', 'Must be a string', 'string');
|
||||
}
|
||||
if (data.login.networkIcon !== undefined && !isString(data.login.networkIcon)) {
|
||||
ctx.addError('login.networkIcon', 'Must be a string', 'string');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.seed !== undefined && typeof data.seed !== 'number' && typeof data.seed !== 'string') {
|
||||
ctx.addError('seed', 'Must be a number or string', 'number | string');
|
||||
}
|
||||
|
||||
+10
-8
@@ -2,6 +2,8 @@
|
||||
* CyberSim OS - After-Action Report (AAR) Modal Interface
|
||||
*/
|
||||
|
||||
import { i18n } from '../core/i18n.js';
|
||||
|
||||
export class AfterActionReport {
|
||||
constructor({ scenario, scoreResult, onClaimCertificate, onRestart }) {
|
||||
this.scenario = scenario;
|
||||
@@ -27,24 +29,24 @@ export class AfterActionReport {
|
||||
<div class="cs-modal-header">
|
||||
<div class="cs-modal-title">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
CyberSim OS - Behavioral After-Action Report (AAR)
|
||||
${i18n.t('aar.title')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cs-modal-body" style="padding:22px;">
|
||||
<div class="aar-header-summary">
|
||||
<div>
|
||||
<div style="font-size:11px; color:#64748b; text-transform:uppercase; font-weight:700; letter-spacing:0.05em;">Overall Assessment</div>
|
||||
<div style="font-size:11px; color:#64748b; text-transform:uppercase; font-weight:700; letter-spacing:0.05em;">${i18n.t('aar.overallAssessment')}</div>
|
||||
<div class="aar-score-badge">${r.totalScore} <span style="font-size:16px; color:#64748b; font-weight:500;">/ 100</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="aar-result-pill ${r.isPassed ? 'pass' : 'fail'}">
|
||||
${r.isPassed ? `✓ Passed (Threshold: ${r.passingThreshold})` : `✕ Needs Review (Threshold: ${r.passingThreshold})`}
|
||||
${r.isPassed ? `✓ ${i18n.t('aar.passed', { threshold: r.passingThreshold })}` : `✕ ${i18n.t('aar.needsReview', { threshold: r.passingThreshold })}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 style="font-size:12px; font-weight:700; text-transform:uppercase; color:#475569; margin-bottom:12px;">Competency Dimension Scores</h4>
|
||||
<h4 style="font-size:12px; font-weight:700; text-transform:uppercase; color:#475569; margin-bottom:12px;">${i18n.t('aar.competencyScores')}</h4>
|
||||
|
||||
<div style="display:flex; flex-direction:column; gap:10px; margin-bottom:20px;">
|
||||
${Object.keys(cats).map(key => {
|
||||
@@ -68,7 +70,7 @@ export class AfterActionReport {
|
||||
}).join('')}
|
||||
</div>
|
||||
|
||||
<h4 style="font-size:12px; font-weight:700; text-transform:uppercase; color:#475569; margin-bottom:8px;">Pedagogical Feedback & Observations</h4>
|
||||
<h4 style="font-size:12px; font-weight:700; text-transform:uppercase; color:#475569; margin-bottom:8px;">${i18n.t('aar.feedbackAndObservations')}</h4>
|
||||
<div class="aar-feedback-box">
|
||||
<ul style="padding-left:18px; line-height:1.6;">
|
||||
${r.feedback.map(f => `<li>${f}</li>`).join('')}
|
||||
@@ -77,14 +79,14 @@ export class AfterActionReport {
|
||||
</div>
|
||||
|
||||
<div class="cs-modal-footer">
|
||||
<button class="cs-btn" id="btn-aar-restart">Restart Simulation</button>
|
||||
<button class="cs-btn" id="btn-aar-restart">${i18n.t('aar.restart')}</button>
|
||||
${r.isPassed ? `
|
||||
<button class="cs-btn cs-btn-primary" id="btn-aar-cert">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/></svg>
|
||||
Claim Verifiable Certificate
|
||||
${i18n.t('aar.claimCertificate')}
|
||||
</button>
|
||||
` : `
|
||||
<button class="cs-btn cs-btn-danger" id="btn-aar-retry">Retry Simulation</button>
|
||||
<button class="cs-btn cs-btn-danger" id="btn-aar-retry">${i18n.t('aar.retry')}</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"login.title": "CyberSim OS - Enterprise Workstation Login",
|
||||
"login.subtitle": "Simulated Workplace Environment",
|
||||
"login.welcome": "Welcome",
|
||||
"login.instructions": "Enter your first name and select an available network to begin your scheduled shift.",
|
||||
"login.firstName": "First Name",
|
||||
"login.firstNamePlaceholder": "e.g. Chris",
|
||||
"login.firstNameRequired": "Please enter your first name.",
|
||||
"login.firstNameTooLong": "First name must be 50 characters or less.",
|
||||
"login.language": "Language",
|
||||
"login.availableNetworks": "Available Networks & Workplaces",
|
||||
"login.networkUnavailable": "Unavailable in selected language",
|
||||
"login.selectNetwork": "Select a workplace network to connect",
|
||||
"login.connect": "Connect to Workplace",
|
||||
"login.connecting": "Connecting...",
|
||||
"login.disclaimer": "CyberSim OS is a simulated training environment. Never enter real passwords or sensitive credentials.",
|
||||
"login.sessionInfo": "Simulated Endpoint \u2022 Zero-Trust Security Active",
|
||||
|
||||
"desktop.start": "Start",
|
||||
"desktop.loading": "Loading scenario package...",
|
||||
"desktop.shiftResponsibilities": "Shift Responsibilities",
|
||||
"desktop.applications": "Applications",
|
||||
"desktop.finishShift": "Finish Shift & View Assessment",
|
||||
"desktop.networkProtected": "Network: Protected",
|
||||
"desktop.securityAndNotifications": "Security & Notifications",
|
||||
|
||||
"apps.inlook": "Inlook Mail",
|
||||
"apps.navigator": "Navigator",
|
||||
"apps.files": "Files",
|
||||
"apps.securityCenter": "Security Center",
|
||||
"apps.docViewer": "Doc Viewer",
|
||||
"apps.verifyCert": "Verify Cert",
|
||||
|
||||
"inlook.title": "Inlook Mail - Workplace",
|
||||
"inlook.newMessage": "New Message",
|
||||
"inlook.inbox": "Inbox",
|
||||
"inlook.sent": "Sent",
|
||||
"inlook.trash": "Trash",
|
||||
"inlook.selectEmail": "Select an email message to view its contents.",
|
||||
"inlook.noMessages": "No messages in {folder}",
|
||||
"inlook.reply": "Reply",
|
||||
"inlook.reportSuspicious": "Report Suspicious",
|
||||
"inlook.delete": "Delete",
|
||||
"inlook.inspectHeader": "Inspect Header",
|
||||
"inlook.attachments": "Attachments ({count}):",
|
||||
"inlook.policyRestricted": "Corporate policy restricts unassigned outgoing mail during initial shift orientation.",
|
||||
"inlook.reportDialogTitle": "Report Suspicious Message to SOC",
|
||||
"inlook.reportReasonLabel": "Select reporting rationale:",
|
||||
"inlook.reportReasonSuspiciousSender": "Unrecognized / spoofed sender address",
|
||||
"inlook.reportReasonSuspiciousLink": "Suspicious or mismatched link destination",
|
||||
"inlook.reportReasonCredentialRequest": "Urgent request for credentials / passwords",
|
||||
"inlook.reportReasonSuspiciousAttachment": "Unexpected or executable attachment",
|
||||
"inlook.reportNotesLabel": "Additional analyst notes (optional):",
|
||||
"inlook.reportCancel": "Cancel",
|
||||
"inlook.reportSubmit": "Submit Incident Report",
|
||||
"inlook.reportSuccessTitle": "Report Submitted",
|
||||
"inlook.reportSuccessBody": "Security operations center has logged the reported message for analysis.",
|
||||
"inlook.headerDomain": "Sender Domain:",
|
||||
"inlook.headerStatus": "Authentication Status:",
|
||||
"inlook.headerTrusted": "Internal / Trusted Domain",
|
||||
"inlook.headerUntrusted": "External / Untrusted Domain",
|
||||
|
||||
"navigator.title": "Navigator Web Browser",
|
||||
"navigator.back": "Back",
|
||||
"navigator.forward": "Forward",
|
||||
"navigator.reload": "Reload",
|
||||
"navigator.connectionSecure": "Secure Connection (HTTPS)",
|
||||
"navigator.connectionInsecure": "Insecure Connection (HTTP)",
|
||||
"navigator.pageNotFound": "Page Not Found (404)",
|
||||
"navigator.pageNotFoundDesc": "The simulated web server could not locate the requested resource.",
|
||||
|
||||
"files.title": "Files - Corporate Storage",
|
||||
"files.documents": "Documents",
|
||||
"files.downloads": "Downloads",
|
||||
"files.companyShared": "Company Shared",
|
||||
"files.location": "Location: /{folder}",
|
||||
"files.empty": "This folder is empty.",
|
||||
|
||||
"docviewer.title": "Document Viewer",
|
||||
"docviewer.readOnly": "Read-Only",
|
||||
"docviewer.formatXlsx": "Format: XLSX Tabular",
|
||||
"docviewer.formatPdf": "Format: PDF Document",
|
||||
|
||||
"securitycenter.title": "Security Center",
|
||||
"securitycenter.dashboard": "Dashboard",
|
||||
"securitycenter.alertsLogs": "Alerts & Logs",
|
||||
"securitycenter.reportedIncidents": "Reported Incidents",
|
||||
"securitycenter.workstationStatus": "Workstation Security Status",
|
||||
"securitycenter.endpointProtection": "Enterprise Zero-Trust Endpoint Protection",
|
||||
"securitycenter.statusHealthy": "System Status: Healthy",
|
||||
"securitycenter.statusHealthyDesc": "All endpoint protection services operational. No active threats detected.",
|
||||
"securitycenter.statusAlert": "System Status: Elevated Threat Alert",
|
||||
"securitycenter.statusAlertDesc": "One or more suspicious activities or credential security anomalies detected.",
|
||||
"securitycenter.recentAlerts": "Recent Security Events & Logs",
|
||||
"securitycenter.noAlerts": "No security events recorded.",
|
||||
"securitycenter.incidentLog": "Incident Reports Submitted to SOC",
|
||||
"securitycenter.noIncidents": "No incident reports submitted yet.",
|
||||
"securitycenter.severityInfo": "INFO",
|
||||
"securitycenter.severityWarning": "WARNING",
|
||||
"securitycenter.severityHigh": "HIGH",
|
||||
"securitycenter.severityCritical": "CRITICAL",
|
||||
|
||||
"aar.title": "CyberSim OS - Behavioral After-Action Report (AAR)",
|
||||
"aar.overallAssessment": "Overall Assessment",
|
||||
"aar.passed": "Passed (Threshold: {threshold})",
|
||||
"aar.needsReview": "Needs Review (Threshold: {threshold})",
|
||||
"aar.competencyScores": "Competency Dimension Scores",
|
||||
"aar.feedbackAndObservations": "Pedagogical Feedback & Observations",
|
||||
"aar.restart": "Restart Simulation",
|
||||
"aar.retry": "Retry Simulation",
|
||||
"aar.claimCertificate": "Claim Verifiable Certificate",
|
||||
|
||||
"cert.title": "CyberSim OS - Official Certificate of Competency",
|
||||
"cert.competencyTitle": "Certificate of Competency",
|
||||
"cert.subtitle": "End-User Cybersecurity Simulation & Behavioral Verification",
|
||||
"cert.certifiesThat": "This certifies that",
|
||||
"cert.statement": "has successfully completed the {scenarioTitle} simulation, demonstrating sound investigative judgment, threat detection, safe credential handling, and policy adherence.",
|
||||
"cert.finalScore": "Final Score: {score} / 100 (PASSED)",
|
||||
"cert.certId": "Certificate ID:",
|
||||
"cert.date": "Date:",
|
||||
"cert.engineVersion": "Engine Version:",
|
||||
"cert.scenarioHash": "Scenario Hash:",
|
||||
"cert.integrityHash": "Integrity Hash:",
|
||||
"cert.print": "Print",
|
||||
"cert.export": "Export *.cybercert File",
|
||||
"cert.environment": "CyberSim Environment",
|
||||
|
||||
"verify.title": "CyberSim Certificate Verifier",
|
||||
"verify.subtitle": "Offline-first cryptographic credential integrity and scoring verification engine.",
|
||||
"verify.dragDrop": "Drag & Drop a *.cybercert credential file here",
|
||||
"verify.orBrowse": "or click to browse local files",
|
||||
"verify.verifiedTitle": "Certificate Cryptographically Verified",
|
||||
"verify.verifiedDesc": "This credential was verified offline using SHA-256 integrity validation.",
|
||||
"verify.failedTitle": "Verification Failed",
|
||||
"verify.invalidFormat": "Invalid File Format",
|
||||
"verify.learner": "Learner:",
|
||||
"verify.role": "Role:",
|
||||
"verify.score": "Score:",
|
||||
"verify.date": "Date:",
|
||||
"verify.scenario": "Scenario:",
|
||||
"verify.certId": "Certificate ID:",
|
||||
"verify.scenarioSha": "Scenario SHA-256:",
|
||||
"verify.integrityHash": "Integrity Hash:",
|
||||
"verify.passed": "PASSED",
|
||||
"verify.failed": "FAILED",
|
||||
|
||||
"common.close": "Close",
|
||||
"common.reload": "Reload",
|
||||
"common.unknown": "Unknown"
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"login.title": "CyberSim OS - Inicio de Sesión de Estación de Trabajo",
|
||||
"login.subtitle": "Entorno de Trabajo Simulado",
|
||||
"login.welcome": "Bienvenido",
|
||||
"login.instructions": "Ingrese su nombre de pila y seleccione una red disponible para comenzar su turno programado.",
|
||||
"login.firstName": "Nombre de pila",
|
||||
"login.firstNamePlaceholder": "ej. Carlos",
|
||||
"login.firstNameRequired": "Por favor, ingrese su nombre de pila.",
|
||||
"login.firstNameTooLong": "El nombre debe tener 50 caracteres o menos.",
|
||||
"login.language": "Idioma",
|
||||
"login.availableNetworks": "Redes y Lugares de Trabajo Disponibles",
|
||||
"login.networkUnavailable": "No disponible en el idioma seleccionado",
|
||||
"login.selectNetwork": "Seleccione una red de trabajo para conectarse",
|
||||
"login.connect": "Conectar al Lugar de Trabajo",
|
||||
"login.connecting": "Conectando...",
|
||||
"login.disclaimer": "CyberSim OS es un entorno de capacitación simulado. Nunca ingrese contraseñas reales ni credenciales confidenciales.",
|
||||
"login.sessionInfo": "Terminal Simulado \u2022 Seguridad Zero-Trust Activa",
|
||||
|
||||
"desktop.start": "Inicio",
|
||||
"desktop.loading": "Cargando paquete de escenario...",
|
||||
"desktop.shiftResponsibilities": "Responsabilidades del Turno",
|
||||
"desktop.applications": "Aplicaciones",
|
||||
"desktop.finishShift": "Finalizar Turno y Ver Evaluación",
|
||||
"desktop.networkProtected": "Red: Protegida",
|
||||
"desktop.securityAndNotifications": "Seguridad y Notificaciones",
|
||||
|
||||
"apps.inlook": "Correo Inlook",
|
||||
"apps.navigator": "Navegador",
|
||||
"apps.files": "Archivos",
|
||||
"apps.securityCenter": "Centro de Seguridad",
|
||||
"apps.docViewer": "Visor de Documentos",
|
||||
"apps.verifyCert": "Verificar Certificado",
|
||||
|
||||
"inlook.title": "Correo Inlook - Lugar de Trabajo",
|
||||
"inlook.newMessage": "Nuevo Mensaje",
|
||||
"inlook.inbox": "Bandeja de entrada",
|
||||
"inlook.sent": "Enviados",
|
||||
"inlook.trash": "Papelera",
|
||||
"inlook.selectEmail": "Seleccione un mensaje de correo para ver su contenido.",
|
||||
"inlook.noMessages": "No hay mensajes en {folder}",
|
||||
"inlook.reply": "Responder",
|
||||
"inlook.reportSuspicious": "Reportar Sospechoso",
|
||||
"inlook.delete": "Eliminar",
|
||||
"inlook.inspectHeader": "Inspeccionar Encabezado",
|
||||
"inlook.attachments": "Archivos adjuntos ({count}):",
|
||||
"inlook.policyRestricted": "La política corporativa restringe el envío de correo no asignado durante la orientación inicial.",
|
||||
"inlook.reportDialogTitle": "Reportar Mensaje Sospechoso al SOC",
|
||||
"inlook.reportReasonLabel": "Seleccione el motivo del reporte:",
|
||||
"inlook.reportReasonSuspiciousSender": "Dirección de remitente desconocida / suplantada",
|
||||
"inlook.reportReasonSuspiciousLink": "Enlace sospechoso o destino no coincidente",
|
||||
"inlook.reportReasonCredentialRequest": "Solicitud urgente de credenciales / contraseñas",
|
||||
"inlook.reportReasonSuspiciousAttachment": "Archivo adjunto inesperado o ejecutable",
|
||||
"inlook.reportNotesLabel": "Notas adicionales del analista (opcional):",
|
||||
"inlook.reportCancel": "Cancelar",
|
||||
"inlook.reportSubmit": "Enviar Reporte de Incidente",
|
||||
"inlook.reportSuccessTitle": "Reporte Enviado",
|
||||
"inlook.reportSuccessBody": "El centro de operaciones de seguridad ha registrado el mensaje reportado para su análisis.",
|
||||
"inlook.headerDomain": "Dominio del Remitente:",
|
||||
"inlook.headerStatus": "Estado de Autenticación:",
|
||||
"inlook.headerTrusted": "Dominio Interno / Confiable",
|
||||
"inlook.headerUntrusted": "Dominio Externo / No Confiable",
|
||||
|
||||
"navigator.title": "Navegador Web Navigator",
|
||||
"navigator.back": "Atrás",
|
||||
"navigator.forward": "Adelante",
|
||||
"navigator.reload": "Recargar",
|
||||
"navigator.connectionSecure": "Conexión Segura (HTTPS)",
|
||||
"navigator.connectionInsecure": "Conexión No Segura (HTTP)",
|
||||
"navigator.pageNotFound": "Página No Encontrada (404)",
|
||||
"navigator.pageNotFoundDesc": "El servidor web simulado no pudo encontrar el recurso solicitado.",
|
||||
|
||||
"files.title": "Archivos - Almacenamiento Corporativo",
|
||||
"files.documents": "Documentos",
|
||||
"files.downloads": "Descargas",
|
||||
"files.companyShared": "Compartido de Empresa",
|
||||
"files.location": "Ubicación: /{folder}",
|
||||
"files.empty": "Esta carpeta está vacía.",
|
||||
|
||||
"docviewer.title": "Visor de Documentos",
|
||||
"docviewer.readOnly": "Solo Lectura",
|
||||
"docviewer.formatXlsx": "Formato: Tabular XLSX",
|
||||
"docviewer.formatPdf": "Formato: Documento PDF",
|
||||
|
||||
"securitycenter.title": "Centro de Seguridad",
|
||||
"securitycenter.dashboard": "Panel Principal",
|
||||
"securitycenter.alertsLogs": "Alertas y Registros",
|
||||
"securitycenter.reportedIncidents": "Incidentes Reportados",
|
||||
"securitycenter.workstationStatus": "Estado de Seguridad de la Estación",
|
||||
"securitycenter.endpointProtection": "Protección de Terminal Empresarial Zero-Trust",
|
||||
"securitycenter.statusHealthy": "Estado del Sistema: Saludable",
|
||||
"securitycenter.statusHealthyDesc": "Todos los servicios de protección operativos. No se detectan amenazas activas.",
|
||||
"securitycenter.statusAlert": "Estado del Sistema: Alerta de Amenaza Elevada",
|
||||
"securitycenter.statusAlertDesc": "Se detectaron una o más actividades sospechosas o anomalías de credenciales.",
|
||||
"securitycenter.recentAlerts": "Eventos y Registros de Seguridad Recientes",
|
||||
"securitycenter.noAlerts": "No hay eventos de seguridad registrados.",
|
||||
"securitycenter.incidentLog": "Reportes de Incidentes Enviados al SOC",
|
||||
"securitycenter.noIncidents": "No se han enviado reportes de incidentes todavía.",
|
||||
"securitycenter.severityInfo": "INFO",
|
||||
"securitycenter.severityWarning": "ADVERTENCIA",
|
||||
"securitycenter.severityHigh": "ALTO",
|
||||
"securitycenter.severityCritical": "CRÍTICO",
|
||||
|
||||
"aar.title": "CyberSim OS - Informe de Evaluación Posterior (AAR)",
|
||||
"aar.overallAssessment": "Evaluación General",
|
||||
"aar.passed": "Aprobado (Umbral: {threshold})",
|
||||
"aar.needsReview": "Requiere Revisión (Umbral: {threshold})",
|
||||
"aar.competencyScores": "Puntuaciones por Dimensión de Competencia",
|
||||
"aar.feedbackAndObservations": "Observaciones y Retroalimentación Pedagógica",
|
||||
"aar.restart": "Reiniciar Simulación",
|
||||
"aar.retry": "Reintentar Simulación",
|
||||
"aar.claimCertificate": "Reclamar Certificado Verificable",
|
||||
|
||||
"cert.title": "CyberSim OS - Certificado Oficial de Competencia",
|
||||
"cert.competencyTitle": "Certificado de Competencia",
|
||||
"cert.subtitle": "Simulación de Ciberseguridad y Verificación de Comportamiento del Usuario",
|
||||
"cert.certifiesThat": "Certifica que",
|
||||
"cert.statement": "ha completado exitosamente la simulación {scenarioTitle}, demostrando un criterio de investigación sólido, detección de amenazas, manejo seguro de credenciales y cumplimiento de políticas.",
|
||||
"cert.finalScore": "Puntuación Final: {score} / 100 (APROBADO)",
|
||||
"cert.certId": "ID de Certificado:",
|
||||
"cert.date": "Fecha:",
|
||||
"cert.engineVersion": "Versión del Motor:",
|
||||
"cert.scenarioHash": "Hash de Escenario:",
|
||||
"cert.integrityHash": "Hash de Integridad:",
|
||||
"cert.print": "Imprimir",
|
||||
"cert.export": "Exportar Archivo *.cybercert",
|
||||
"cert.environment": "Entorno CyberSim",
|
||||
|
||||
"verify.title": "Verificador de Certificados CyberSim",
|
||||
"verify.subtitle": "Motor offline de verificación criptográfica de integridad y puntuación de credenciales.",
|
||||
"verify.dragDrop": "Arrastre y suelte un archivo de credencial *.cybercert aquí",
|
||||
"verify.orBrowse": "o haga clic para explorar archivos locales",
|
||||
"verify.verifiedTitle": "Certificado Criptográficamente Verificado",
|
||||
"verify.verifiedDesc": "Esta credencial fue verificada localmente mediante validación de integridad SHA-256.",
|
||||
"verify.failedTitle": "Verificación Fallida",
|
||||
"verify.invalidFormat": "Formato de Archivo Inválido",
|
||||
"verify.learner": "Participante:",
|
||||
"verify.role": "Rol:",
|
||||
"verify.score": "Puntuación:",
|
||||
"verify.date": "Fecha:",
|
||||
"verify.scenario": "Escenario:",
|
||||
"verify.certId": "ID de Certificado:",
|
||||
"verify.scenarioSha": "SHA-256 de Escenario:",
|
||||
"verify.integrityHash": "Hash de Integridad:",
|
||||
"verify.passed": "APROBADO",
|
||||
"verify.failed": "REPROBADO",
|
||||
|
||||
"common.close": "Cerrar",
|
||||
"common.reload": "Recargar",
|
||||
"common.unknown": "Desconocido"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"title": "Turno Operativo y Concientización sobre Seguridad",
|
||||
"description": "Comience su turno de trabajo en NexaCore Technologies. Revise los pronósticos presupuestarios solicitados, gestione comunicaciones, investigue anomalías y tome decisiones de seguridad sólidas.",
|
||||
"objectives": {
|
||||
"obj-review-welcome": "Revise el correo de bienvenida y las tareas de su gerente (Morgan Chen)",
|
||||
"obj-review-budget": "Revise la hoja de cálculo del Pronóstico Presupuestario del T3 en su carpeta Documentos",
|
||||
"obj-investigate": "Investigue cualquier evento sospechoso o inusual utilizando las herramientas de la empresa",
|
||||
"obj-report-threats": "Envíe reportes de incidentes a través del Centro de Seguridad si se detectan amenazas genuinas"
|
||||
},
|
||||
"messages": {
|
||||
"email_welcome": {
|
||||
"sender": "Morgan Chen (Vicepresidente de Finanzas)",
|
||||
"subject": "Bienvenido al equipo - Tareas para hoy",
|
||||
"body": "Hola {{learner.firstName}},\n\n¡Bienvenido al equipo de Finanzas de NexaCore!\n\nPara su turno de hoy, por favor asegúrese de completar lo siguiente:\n1. Abra la aplicación Archivos y revise el documento \"Q3_Budget_Forecast.xlsx\" en su carpeta Documentos. Sarah Jenkins en Contabilidad puede pedirle una actualización sobre los márgenes operativos.\n2. Lea el documento \"NexaCore_Cyber_Security_Policy_v4.pdf\" en su carpeta Documentos para asegurarse de conocer nuestros protocolos de reporte.\n3. Consulte la Intranet de la empresa para ver actualizaciones o listados de personal.\n\nAvíseme si tiene alguna pregunta. ¡Que tenga un excelente turno!\n\nSaludos,\nMorgan Chen\nVicepresidente de Finanzas | NexaCore Technologies"
|
||||
},
|
||||
"email_phish_pwreset": {
|
||||
"sender": "Soporte Técnico de NexaCore",
|
||||
"subject": "URGENTE: Migración Obligatoria a Inicio de Sesión Único (SSO) y Verificación de Contraseña",
|
||||
"body": "Atención Empleado de NexaCore,\n\nNuestra infraestructura informática empresarial está pasando por una migración obligatoria de seguridad de Inicio de Sesión Único (SSO). Todas las cuentas de usuario activas deben verificarse dentro de las próximas 2 horas para evitar la suspensión temporal de la cuenta y la pérdida de acceso a la red.\n\nConfirme sus credenciales de empleado de inmediato utilizando nuestro portal seguro de identidad:\n\n👉 Verificar Credenciales: <a class=\"inlook-msg-link\" data-url=\"http://login-nexac0re-portal.com/auth/login\" data-display=\"https://intranet.nexacore.internal/sso-verification\">https://intranet.nexacore.internal/sso-verification</a>\n\nNo verificar antes de las 11:00 AM requerirá la intervención gerencial para reactivar su estación de trabajo.\n\nServicios de Soporte de TI\nInfraestructura Global NexaCore"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"notif-welcome": {
|
||||
"title": "NexaCore Technologies",
|
||||
"body": "¡Bienvenido a su turno en NexaCore, {{learner.firstName}}! Revise el correo Inlook para comenzar."
|
||||
}
|
||||
},
|
||||
"alerts": {
|
||||
"alert_initial": {
|
||||
"title": "Protección de Terminal Activa",
|
||||
"source": "Agente de Terminal NexaCore",
|
||||
"message": "Estado del sistema: Saludable. Todas las definiciones de seguridad están actualizadas."
|
||||
},
|
||||
"alert_credential_compromise": {
|
||||
"title": "Actividad de Inicio de Sesión Sospechosa Detectada",
|
||||
"source": "Servicio de Protección de Identidad",
|
||||
"message": "Se detectó un intento de inicio de sesión no autorizado con sus credenciales desde una ubicación no reconocida."
|
||||
}
|
||||
},
|
||||
"findings": {
|
||||
"credential-compromise": {
|
||||
"feedback": "CRÍTICO: Ingresó sus credenciales en un portal de suplantación externo. Siempre verifique el dominio de la URL antes de ingresar cualquier dato de inicio de sesión."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,12 @@
|
||||
"entryEvent": "begin-workday",
|
||||
"passingScore": 80,
|
||||
"seed": 42,
|
||||
"supportedLocales": ["en", "es"],
|
||||
"login": {
|
||||
"networkName": "NexaCore Corporate",
|
||||
"networkDescription": "Corporate Workplace Network",
|
||||
"networkIcon": "corporate"
|
||||
},
|
||||
|
||||
"learner": {
|
||||
"name": "Jordan Taylor",
|
||||
@@ -131,7 +137,7 @@
|
||||
"folder": "inbox",
|
||||
"unread": true,
|
||||
"starred": true,
|
||||
"body": "Hi Jordan,\n\nWelcome to the NexaCore Finance team!\n\nFor your shift today, please make sure to complete the following:\n1. Open the Files app and review the \"Q3_Budget_Forecast.xlsx\" document in your Documents folder. Sarah Jenkins in Accounting may ask you for an update on the operating margins.\n2. Read through the \"NexaCore_Cyber_Security_Policy_v4.pdf\" in your Documents folder to ensure you know our reporting protocols.\n3. Check the company Intranet for any updates or staff listings.\n\nLet me know if you run into any questions. Have a great shift!\n\nBest,\nMorgan Chen\nVP of Finance | NexaCore Technologies",
|
||||
"body": "Hi {{learner.firstName}},\n\nWelcome to the NexaCore Finance team!\n\nFor your shift today, please make sure to complete the following:\n1. Open the Files app and review the \"Q3_Budget_Forecast.xlsx\" document in your Documents folder. Sarah Jenkins in Accounting may ask you for an update on the operating margins.\n2. Read through the \"NexaCore_Cyber_Security_Policy_v4.pdf\" in your Documents folder to ensure you know our reporting protocols.\n3. Check the company Intranet for any updates or staff listings.\n\nLet me know if you run into any questions. Have a great shift!\n\nBest,\nMorgan Chen\nVP of Finance | NexaCore Technologies",
|
||||
"links": [],
|
||||
"attachments": []
|
||||
},
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"title": "Meridian Health — Clasificación de Correo del Primer Día",
|
||||
"description": "Usted es un nuevo asistente administrativo en Meridian Health Partners. Revise su bandeja de entrada, gestione una solicitud legítima de horarios e identifique un intento de suplantación de identidad dirigido al acceso a registros de pacientes.",
|
||||
"objectives": {
|
||||
"obj-check-inbox": "Revise los mensajes de su bandeja de entrada",
|
||||
"obj-handle-scheduling": "Gestione adecuadamente la solicitud de horarios de la Dra. Patel",
|
||||
"obj-handle-phish": "Identifique y reporte cualquier mensaje sospechoso"
|
||||
},
|
||||
"messages": {
|
||||
"email_welcome": {
|
||||
"sender": "Dra. Priya Patel (Jefa de Personal)",
|
||||
"subject": "Bienvenido a bordo — solicitud rápida de programación",
|
||||
"body": "Hola {{learner.firstName}},\n\n¡Bienvenido a Meridian Health Partners! Nos alegra tenerle en el equipo.\n\nCuando tenga un momento, ¿podría revisar el documento de horarios del personal en su carpeta Documentos? Necesito confirmar que mis horas de clínica del jueves estén registradas correctamente.\n\nAdemás, tómese unos minutos para revisar nuestras Pautas de Seguridad Informática: cubren nuestras políticas sobre seguridad de correo y manejo de datos de pacientes.\n\n¡Gracias!\nDra. Priya Patel\nJefa de Personal | Meridian Health Partners"
|
||||
},
|
||||
"email_phish_records": {
|
||||
"sender": "Soporte de TI de Meridian",
|
||||
"subject": "ACCIÓN REQUERIDA: Renovación de Acceso al Sistema de Registros de Pacientes",
|
||||
"body": "Estimado empleado de Meridian,\n\nSu acceso al Sistema de Gestión de Registros de Pacientes vence hoy. Para evitar interrupciones en las operaciones clínicas, debe volver a verificar sus credenciales de inmediato.\n\nHaga clic aquí para verificar: <a class=\"inlook-msg-link\" data-url=\"http://meridian-health-portal.com/verify\" data-display=\"https://records.meridianhealth.org/renew\">https://records.meridianhealth.org/renew</a>\n\nNo verificar dentro de las 4 horas provocará la suspensión del acceso.\n\nSoporte de TI de Meridian"
|
||||
},
|
||||
"email_legit_it": {
|
||||
"sender": "Soporte de TI de Meridian",
|
||||
"subject": "Recordatorio: Capacitación obligatoria de seguridad para el viernes",
|
||||
"body": "Hola a todos,\n\nUn recordatorio amistoso de que todo el personal debe completar la capacitación anual de concientización sobre ciberseguridad antes del final del viernes.\n\nPueden acceder a través del Portal del Personal en la intranet. No hay enlaces en este correo electrónico; navegue directamente allí.\n\nGracias,\nSoporte de TI de Meridian"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"notif-welcome": {
|
||||
"title": "Meridian Health",
|
||||
"body": "¡Bienvenido {{learner.firstName}}! Revise Inlook para ver los mensajes de la Dra. Patel."
|
||||
},
|
||||
"notif-new-mail": {
|
||||
"title": "Correo Inlook",
|
||||
"body": "Nuevo mensaje recibido."
|
||||
}
|
||||
},
|
||||
"alerts": {
|
||||
"alert_initial": {
|
||||
"title": "Protección de Terminal Activa",
|
||||
"source": "Agente de Terminal Meridian",
|
||||
"message": "Estado del sistema: Saludable. Todas las definiciones de seguridad están al día."
|
||||
},
|
||||
"alert_credential_leak": {
|
||||
"title": "Inicio de Sesión Sospechoso Detectado",
|
||||
"source": "Servicio de Protección de Identidad",
|
||||
"message": "Se detectó un intento de inicio de sesión no autorizado con sus credenciales desde una ubicación no reconocida."
|
||||
}
|
||||
},
|
||||
"findings": {
|
||||
"credential-compromise": {
|
||||
"feedback": "CRÍTICO: Ingresó sus credenciales en un portal externo de suplantación. Siempre verifique el dominio de la URL antes de ingresar cualquier dato de inicio de sesión."
|
||||
},
|
||||
"false-positive-it-notice": {
|
||||
"feedback": "El recordatorio de capacitación de seguridad de Soporte de TI era legítimo. El dominio del remitente coincidía con su organización y el correo no contenía enlaces sospechosos."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,12 @@
|
||||
"entryEvent": "begin-shift",
|
||||
"passingScore": 70,
|
||||
"seed": 7,
|
||||
"supportedLocales": ["en", "es"],
|
||||
"login": {
|
||||
"networkName": "Meridian Health Partners",
|
||||
"networkDescription": "Clinical & Administrative Network",
|
||||
"networkIcon": "health"
|
||||
},
|
||||
|
||||
"learner": {
|
||||
"name": "Casey Morgan",
|
||||
@@ -84,7 +90,7 @@
|
||||
"folder": "inbox",
|
||||
"unread": true,
|
||||
"starred": false,
|
||||
"body": "Hi Casey,\n\nWelcome to Meridian Health Partners! We're glad to have you on the team.\n\nWhen you get a chance, could you check the staff schedule document in your Documents folder? I need to confirm my Thursday clinic hours are listed correctly.\n\nAlso, please take a few minutes to review our IT Security Guidelines — they cover our policies on email safety and patient data handling.\n\nThanks!\nDr. Priya Patel\nChief of Staff | Meridian Health Partners",
|
||||
"body": "Hi {{learner.firstName}},\n\nWelcome to Meridian Health Partners! We're glad to have you on the team.\n\nWhen you get a chance, could you check the staff schedule document in your Documents folder? I need to confirm my Thursday clinic hours are listed correctly.\n\nAlso, please take a few minutes to review our IT Security Guidelines — they cover our policies on email safety and patient data handling.\n\nThanks!\nDr. Priya Patel\nChief of Staff | Meridian Health Partners",
|
||||
"links": [],
|
||||
"attachments": []
|
||||
},
|
||||
|
||||
+75
-36
@@ -69,21 +69,35 @@
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.header-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="verify-card">
|
||||
<div style="display:flex; align-items:center; gap:10px; margin-bottom:6px;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
<h1 style="font-size:18px; font-weight:700; color:#f8fafc;">CyberSim Certificate Verifier</h1>
|
||||
<div class="header-bar">
|
||||
<div style="display:flex; align-items:center; gap:10px;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
<h1 id="lbl-title" style="font-size:18px; font-weight:700; color:#f8fafc;">CyberSim Certificate Verifier</h1>
|
||||
</div>
|
||||
<div>
|
||||
<select id="verify-lang-select" class="cs-select cs-select-sm">
|
||||
<option value="en" selected>English</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p style="font-size:12px; color:#94a3b8;">Offline-first cryptographic credential integrity and scoring verification engine.</p>
|
||||
<p id="lbl-subtitle" style="font-size:12px; color:#94a3b8;">Offline-first cryptographic credential integrity and scoring verification engine.</p>
|
||||
|
||||
<div class="dropzone" id="dropzone">
|
||||
<div style="font-size:32px; margin-bottom:8px;">📄</div>
|
||||
<div style="font-weight:600; font-size:13px;">Drag & Drop a *.cybercert credential file here</div>
|
||||
<div style="font-size:11px; color:#94a3b8; margin-top:4px;">or click to browse local files</div>
|
||||
<div id="lbl-dragdrop" style="font-weight:600; font-size:13px;">Drag & Drop a *.cybercert credential file here</div>
|
||||
<div id="lbl-orbrowse" style="font-size:11px; color:#94a3b8; margin-top:4px;">or click to browse local files</div>
|
||||
<input type="file" id="file-input" accept=".cybercert,.json" style="display:none;">
|
||||
</div>
|
||||
|
||||
@@ -92,10 +106,28 @@
|
||||
|
||||
<script type="module">
|
||||
import { CertificateVerifier } from './js/cert/cert_verifier.js';
|
||||
import { i18n } from './js/core/i18n.js';
|
||||
|
||||
const dropzone = document.getElementById('dropzone');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const resultContainer = document.getElementById('result-container');
|
||||
const langSelect = document.getElementById('verify-lang-select');
|
||||
let lastLoadedJson = null;
|
||||
|
||||
async function updateStaticLabels() {
|
||||
document.getElementById('lbl-title').textContent = i18n.t('verify.title');
|
||||
document.getElementById('lbl-subtitle').textContent = i18n.t('verify.subtitle');
|
||||
document.getElementById('lbl-dragdrop').textContent = i18n.t('verify.dragDrop');
|
||||
document.getElementById('lbl-orbrowse').textContent = i18n.t('verify.orBrowse');
|
||||
if (lastLoadedJson) {
|
||||
await renderResult(lastLoadedJson);
|
||||
}
|
||||
}
|
||||
|
||||
langSelect.addEventListener('change', async (e) => {
|
||||
await i18n.setLocale(e.target.value);
|
||||
updateStaticLabels();
|
||||
});
|
||||
|
||||
dropzone.addEventListener('click', () => fileInput.click());
|
||||
|
||||
@@ -124,41 +156,48 @@
|
||||
try {
|
||||
const text = await file.text();
|
||||
const json = JSON.parse(text);
|
||||
const res = await CertificateVerifier.verify(json);
|
||||
|
||||
resultContainer.style.display = 'block';
|
||||
|
||||
if (res.valid) {
|
||||
const d = res.certData;
|
||||
resultContainer.className = 'result-box valid';
|
||||
resultContainer.innerHTML = `
|
||||
<div style="font-weight:700; font-size:14px; margin-bottom:4px;">✓ Certificate Cryptographically Verified</div>
|
||||
<div style="font-size:12px;">This credential was verified offline using SHA-256 integrity validation.</div>
|
||||
|
||||
<div class="cert-field-grid">
|
||||
<div><strong>Learner:</strong> ${d.learner.name}</div>
|
||||
<div><strong>Role:</strong> ${d.learner.assigned_role}</div>
|
||||
<div><strong>Score:</strong> ${d.evaluation.score} / ${d.evaluation.max_score} (${d.evaluation.passed ? 'PASSED' : 'FAILED'})</div>
|
||||
<div><strong>Date:</strong> ${new Date(d.issued_at).toLocaleDateString()}</div>
|
||||
<div><strong>Scenario:</strong> ${d.scenario_title}</div>
|
||||
<div><strong>Certificate ID:</strong> ${d.certificate_id.substring(0, 16)}...</div>
|
||||
<div style="grid-column: 1/-1; word-break:break-all;"><strong>Scenario SHA-256:</strong> ${d.scenario_fingerprint}</div>
|
||||
<div style="grid-column: 1/-1; word-break:break-all;"><strong>Integrity Hash:</strong> ${d.integrity_hash}</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
resultContainer.className = 'result-box invalid';
|
||||
resultContainer.innerHTML = `
|
||||
<div style="font-weight:700; font-size:14px; margin-bottom:4px;">✕ Verification Failed</div>
|
||||
<div>${res.error || 'Integrity check failed.'}</div>
|
||||
`;
|
||||
}
|
||||
lastLoadedJson = json;
|
||||
await renderResult(json);
|
||||
} catch (err) {
|
||||
resultContainer.style.display = 'block';
|
||||
resultContainer.className = 'result-box invalid';
|
||||
resultContainer.innerHTML = `<div style="font-weight:700;">✕ Invalid File Format</div><div>The file could not be parsed as a valid CyberSim credential: ${err.message}</div>`;
|
||||
resultContainer.innerHTML = `<div style="font-weight:700;">✕ ${i18n.t('verify.invalidFormat')}</div><div>The file could not be parsed as a valid CyberSim credential: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderResult(json) {
|
||||
const res = await CertificateVerifier.verify(json);
|
||||
resultContainer.style.display = 'block';
|
||||
|
||||
if (res.valid) {
|
||||
const d = res.certData;
|
||||
resultContainer.className = 'result-box valid';
|
||||
resultContainer.innerHTML = `
|
||||
<div style="font-weight:700; font-size:14px; margin-bottom:4px;">✓ ${i18n.t('verify.verifiedTitle')}</div>
|
||||
<div style="font-size:12px;">${i18n.t('verify.verifiedDesc')}</div>
|
||||
|
||||
<div class="cert-field-grid">
|
||||
<div><strong>${i18n.t('verify.learner')}</strong> ${d.learner.name}</div>
|
||||
<div><strong>${i18n.t('verify.role')}</strong> ${d.learner.assigned_role}</div>
|
||||
<div><strong>${i18n.t('verify.score')}</strong> ${d.evaluation.score} / ${d.evaluation.max_score} (${d.evaluation.passed ? i18n.t('verify.passed') : i18n.t('verify.failed')})</div>
|
||||
<div><strong>${i18n.t('verify.date')}</strong> ${new Date(d.issued_at).toLocaleDateString()}</div>
|
||||
<div><strong>${i18n.t('verify.scenario')}</strong> ${d.scenario_title}</div>
|
||||
<div><strong>${i18n.t('verify.certId')}</strong> ${d.certificate_id.substring(0, 16)}...</div>
|
||||
<div style="grid-column: 1/-1; word-break:break-all;"><strong>${i18n.t('verify.scenarioSha')}</strong> ${d.scenario_fingerprint}</div>
|
||||
<div style="grid-column: 1/-1; word-break:break-all;"><strong>${i18n.t('verify.integrityHash')}</strong> ${d.integrity_hash}</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
resultContainer.className = 'result-box invalid';
|
||||
resultContainer.innerHTML = `
|
||||
<div style="font-weight:700; font-size:14px; margin-bottom:4px;">✕ ${i18n.t('verify.failedTitle')}</div>
|
||||
<div>${res.error || 'Integrity check failed.'}</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize English by default
|
||||
i18n.loadLocale('en').then(() => updateStaticLabels());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* CyberSim OS - Automated Test Suite (Phase 2.5)
|
||||
*
|
||||
* Runs headless with Node.js to verify localization, branding, login validation,
|
||||
* personalization safety, deterministic fingerprinting, and offline integrity.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const srcDir = path.resolve(rootDir, 'src');
|
||||
|
||||
// Polyfill Web Crypto for Node test environment if needed
|
||||
if (!globalThis.crypto) {
|
||||
const cryptoModule = await import('node:crypto');
|
||||
globalThis.crypto = cryptoModule.webcrypto;
|
||||
}
|
||||
|
||||
// Import modules
|
||||
import { I18nService } from '../src/js/core/i18n.js';
|
||||
import { BrandingManager } from '../src/js/core/branding.js';
|
||||
import { ActionDispatcher } from '../src/js/engine/action_dispatcher.js';
|
||||
import { calculateScenarioFingerprint } from '../src/js/scenario/fingerprint.js';
|
||||
import { validateSchema } from '../src/js/scenario/schema.js';
|
||||
import { validateScenario } from '../src/js/scenario/validator.js';
|
||||
import { CertificateGenerator } from '../src/js/cert/cert_generator.js';
|
||||
import { CertificateVerifier } from '../src/js/cert/cert_verifier.js';
|
||||
|
||||
let passedTests = 0;
|
||||
let totalTests = 0;
|
||||
|
||||
async function test(name, fn) {
|
||||
totalTests++;
|
||||
try {
|
||||
await fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passedTests++;
|
||||
} catch (err) {
|
||||
console.error(` ✕ ${name}`);
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function runAllTests() {
|
||||
console.log('\n============================================================');
|
||||
console.log(' CyberSim OS Phase 2.5 - Automated Test Suite');
|
||||
console.log('============================================================\n');
|
||||
|
||||
console.log('--- 1. Localization Engine Tests ---');
|
||||
const enCatalog = JSON.parse(fs.readFileSync(path.join(srcDir, 'locales/en.json'), 'utf8'));
|
||||
const esCatalog = JSON.parse(fs.readFileSync(path.join(srcDir, 'locales/es.json'), 'utf8'));
|
||||
|
||||
const i18n = new I18nService({ defaultLocale: 'en', enabledLocales: ['en', 'es'] });
|
||||
i18n.setDictionary('en', enCatalog);
|
||||
i18n.setDictionary('es', esCatalog);
|
||||
|
||||
await test('Default locale loads English strings', async () => {
|
||||
assert.equal(i18n.t('login.welcome'), 'Welcome');
|
||||
assert.equal(i18n.t('apps.inlook'), 'Inlook Mail');
|
||||
});
|
||||
|
||||
await test('Alternate locale loads Spanish demonstration strings', async () => {
|
||||
await i18n.setLocale('es');
|
||||
assert.equal(i18n.t('login.welcome'), 'Bienvenido');
|
||||
assert.equal(i18n.t('apps.inlook'), 'Correo Inlook');
|
||||
});
|
||||
|
||||
await test('Missing translation falls back to default and English deterministically', async () => {
|
||||
const testI18n = new I18nService({ defaultLocale: 'en', enabledLocales: ['en', 'es'] });
|
||||
testI18n.setDictionary('en', { 'only.in.en': 'English Text', 'common.key': 'EN Common' });
|
||||
testI18n.setDictionary('es', { 'only.in.es': 'Spanish Text' });
|
||||
await testI18n.setLocale('es');
|
||||
|
||||
// Key in es -> returns es
|
||||
assert.equal(testI18n.t('only.in.es'), 'Spanish Text');
|
||||
// Key missing in es but present in en -> falls back to en
|
||||
assert.equal(testI18n.t('only.in.en'), 'English Text');
|
||||
// Completely nonexistent key shows visible indicator
|
||||
assert.equal(testI18n.t('nonexistent.key'), '[missing: nonexistent.key]');
|
||||
});
|
||||
|
||||
await test('Localization string interpolation replaces parameters correctly', async () => {
|
||||
await i18n.setLocale('en');
|
||||
const res = i18n.t('cert.statement', { scenarioTitle: 'NexaCore' });
|
||||
assert(res.includes('NexaCore'));
|
||||
});
|
||||
|
||||
console.log('\n--- 2. Corporate Branding Tests ---');
|
||||
const customConfig = {
|
||||
organization: {
|
||||
name: 'Acme Security Corp',
|
||||
shortName: 'Acme',
|
||||
accentColor: '#315A78',
|
||||
supportName: 'Acme Helpdesk'
|
||||
}
|
||||
};
|
||||
|
||||
await test('Configured corporate branding loads correctly', async () => {
|
||||
const branding = new BrandingManager(customConfig);
|
||||
const org = branding.getOrganization();
|
||||
assert.equal(org.name, 'Acme Security Corp');
|
||||
assert.equal(org.shortName, 'Acme');
|
||||
assert.equal(org.accentColor, '#315A78');
|
||||
});
|
||||
|
||||
await test('Missing branding fields fall back to CyberSim defaults', async () => {
|
||||
const branding = new BrandingManager({});
|
||||
const org = branding.getOrganization();
|
||||
assert.equal(org.name, 'CyberSim Enterprise');
|
||||
assert.equal(org.shortName, 'CyberSim');
|
||||
assert.equal(org.accentColor, '#2563eb');
|
||||
});
|
||||
|
||||
console.log('\n--- 3. Personalization & Safe Template Interpolation Tests ---');
|
||||
await test('ActionDispatcher resolves {{learner.firstName}} and {{learner.name}}', async () => {
|
||||
const mockScenario = {
|
||||
learner: { firstName: 'Alex', name: 'Alex Rivera', role: 'Specialist' },
|
||||
organizations: [{ name: 'NexaCore' }]
|
||||
};
|
||||
const dispatcher = new ActionDispatcher({ scenario: mockScenario });
|
||||
const ctx = dispatcher._buildInterpolationContext();
|
||||
assert.equal(ctx['learner.firstName'], 'Alex');
|
||||
assert.equal(ctx['learner.name'], 'Alex Rivera');
|
||||
|
||||
const tmpl = 'Hello {{learner.firstName}}, your full name is {{learner.name}}.';
|
||||
const interpolated = dispatcher.interpolate(tmpl, ctx);
|
||||
assert.equal(interpolated, 'Hello Alex, your full name is Alex Rivera.');
|
||||
});
|
||||
|
||||
await test('Malicious HTML entered as first name is escaped harmlessly', async () => {
|
||||
const xssPayload = '<script>alert("pwned")</script><img src=x onerror=alert(1)>';
|
||||
const mockScenario = {
|
||||
learner: { firstName: xssPayload, name: xssPayload },
|
||||
organizations: [{ name: 'NexaCore' }]
|
||||
};
|
||||
const dispatcher = new ActionDispatcher({ scenario: mockScenario });
|
||||
const ctx = dispatcher._buildInterpolationContext();
|
||||
|
||||
assert(!ctx['learner.firstName'].includes('<script>'));
|
||||
assert(ctx['learner.firstName'].includes('<script>'));
|
||||
assert(ctx['learner.firstName'].includes('<img src=x onerror=alert(1)>'));
|
||||
});
|
||||
|
||||
console.log('\n--- 4. Scenario Validation & Spanish Localization Overlay Tests ---');
|
||||
const nexacorePath = path.join(srcDir, 'scenarios/nexacore-orientation/scenario.json');
|
||||
const nexacoreEsPath = path.join(srcDir, 'scenarios/nexacore-orientation/locales/es.json');
|
||||
const quickstartPath = path.join(srcDir, 'scenarios/quickstart-example/scenario.json');
|
||||
const quickstartEsPath = path.join(srcDir, 'scenarios/quickstart-example/locales/es.json');
|
||||
|
||||
const nexacoreData = JSON.parse(fs.readFileSync(nexacorePath, 'utf8'));
|
||||
const nexacoreEsData = JSON.parse(fs.readFileSync(nexacoreEsPath, 'utf8'));
|
||||
const quickstartData = JSON.parse(fs.readFileSync(quickstartPath, 'utf8'));
|
||||
const quickstartEsData = JSON.parse(fs.readFileSync(quickstartEsPath, 'utf8'));
|
||||
|
||||
await test('NexaCore scenario schema validates cleanly with supportedLocales and login metadata', async () => {
|
||||
const res = validateSchema(nexacoreData);
|
||||
assert.equal(res.valid, true, `Errors: ${JSON.stringify(res.errors)}`);
|
||||
assert.deepEqual(nexacoreData.supportedLocales, ['en', 'es']);
|
||||
assert(nexacoreData.login.networkName);
|
||||
});
|
||||
|
||||
await test('Quickstart scenario schema validates cleanly with supportedLocales and login metadata', async () => {
|
||||
const res = validateSchema(quickstartData);
|
||||
assert.equal(res.valid, true, `Errors: ${JSON.stringify(res.errors)}`);
|
||||
assert.deepEqual(quickstartData.supportedLocales, ['en', 'es']);
|
||||
assert(quickstartData.login.networkName);
|
||||
});
|
||||
|
||||
await test('Spanish translation overlay does not alter scenario structure, IDs, or scoring rules', async () => {
|
||||
assert(nexacoreEsData.title);
|
||||
assert(nexacoreEsData.messages.email_welcome.subject);
|
||||
assert(quickstartEsData.messages.email_welcome.body.includes('{{learner.firstName}}'));
|
||||
});
|
||||
|
||||
console.log('\n--- 5. Deterministic Scenario Fingerprinting & Certificate Verification Tests ---');
|
||||
let fingerprintEn = '';
|
||||
let fingerprintEs = '';
|
||||
|
||||
await test('Scenario fingerprint calculation is deterministic', async () => {
|
||||
fingerprintEn = await calculateScenarioFingerprint(nexacoreData);
|
||||
assert.equal(typeof fingerprintEn, 'string');
|
||||
assert.equal(fingerprintEn.length, 64);
|
||||
});
|
||||
|
||||
await test('Scenario fingerprint remains 100% identical when loaded with Spanish locale overlay', async () => {
|
||||
const localizedScenario = JSON.parse(JSON.stringify(nexacoreData));
|
||||
localizedScenario._canonicalScenario = nexacoreData;
|
||||
localizedScenario.title = nexacoreEsData.title; // Overlay title
|
||||
fingerprintEs = await calculateScenarioFingerprint(localizedScenario);
|
||||
assert.equal(fingerprintEs, fingerprintEn, 'Fingerprints must match exactly across all locales!');
|
||||
});
|
||||
|
||||
let generatedCert = null;
|
||||
|
||||
await test('CertificateGenerator creates valid structured *.cybercert with canonical integrity hash', async () => {
|
||||
const mockScoreResult = {
|
||||
totalScore: 90,
|
||||
isPassed: true,
|
||||
passingThreshold: 80,
|
||||
categories: {
|
||||
'threat-detection': { score: 30, max: 30, label: 'Threat Detection' },
|
||||
'safe-handling': { score: 30, max: 40, label: 'Safe Handling' },
|
||||
'policy-adherence': { score: 30, max: 30, label: 'Policy Adherence' }
|
||||
}
|
||||
};
|
||||
const gen = new CertificateGenerator(nexacoreData, mockScoreResult, fingerprintEn);
|
||||
generatedCert = await gen.createCertificateData('Maria Garcia');
|
||||
|
||||
assert.equal(generatedCert.schema_version, 1);
|
||||
assert.equal(generatedCert.learner.name, 'Maria Garcia');
|
||||
assert.equal(generatedCert.scenario_fingerprint, fingerprintEn);
|
||||
assert.equal(generatedCert.evaluation.score, 90);
|
||||
assert.equal(generatedCert.evaluation.passed, true);
|
||||
assert(generatedCert.integrity_hash);
|
||||
});
|
||||
|
||||
await test('CertificateVerifier verifies genuine certificate successfully offline', async () => {
|
||||
const verifyResult = await CertificateVerifier.verify(generatedCert);
|
||||
assert.equal(verifyResult.valid, true);
|
||||
assert.equal(verifyResult.passed, true);
|
||||
});
|
||||
|
||||
await test('CertificateVerifier rejects tampered score or modified learner name', async () => {
|
||||
const tamperedCert = JSON.parse(JSON.stringify(generatedCert));
|
||||
tamperedCert.evaluation.score = 100; // Alter score without updating integrity hash
|
||||
const verifyResult = await CertificateVerifier.verify(tamperedCert);
|
||||
assert.equal(verifyResult.valid, false);
|
||||
});
|
||||
|
||||
console.log('\n--- 6. Offline & Zero External Dependencies Integrity Test ---');
|
||||
await test('Codebase contains zero remote external CDN or runtime internet dependencies', async () => {
|
||||
const scanDir = (dir) => {
|
||||
const files = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const f of files) {
|
||||
const fullPath = path.join(dir, f.name);
|
||||
if (f.isDirectory()) {
|
||||
scanDir(fullPath);
|
||||
} else if (/\.(html|js|css)$/.test(f.name)) {
|
||||
const content = fs.readFileSync(fullPath, 'utf8');
|
||||
// Disallow external CDNs like cdnjs, unpkg, google fonts, etc.
|
||||
assert(!content.includes('fonts.googleapis.com'), `Google Fonts detected in ${f.name}`);
|
||||
assert(!content.includes('cdnjs.cloudflare.com'), `CDNJS detected in ${f.name}`);
|
||||
assert(!content.includes('unpkg.com'), `Unpkg detected in ${f.name}`);
|
||||
assert(!content.includes('cdn.jsdelivr.net'), `jsDelivr detected in ${f.name}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
scanDir(srcDir);
|
||||
});
|
||||
|
||||
console.log('\n============================================================');
|
||||
console.log(` Tests Passed: ${passedTests} / ${totalTests}`);
|
||||
console.log('============================================================\n');
|
||||
|
||||
if (passedTests !== totalTests) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runAllTests();
|
||||
Reference in New Issue
Block a user