Compare commits

...
2 Commits
10 changed files with 441 additions and 126 deletions
@@ -10,23 +10,23 @@ author: Labyricorn
---
summary:
Enhanced CyberSim OS with full runtime multilingual support (English and Spanish demonstration), deployment-level corporate branding customization, an immersive enterprise login and network selection screen, learner first-name personalization, and cross-locale offline certificate verification.
Enhanced CyberSim OS with full runtime multilingual support (English and Spanish demonstration), deployment-level corporate branding customization, an immersive enterprise login and network selection screen, learner full-name personalization, verifier certificate print rendering, and login verification shortcuts.
---
tags: localization, branding, login, personalization, i18n, certificates, offline
---
source_commit: e1299c0eca10625fe881a468e1b3a997ae3fba0f
source_commit: eafcde814394d9d248b2081c61eb49e6b0799407
---
body:
### Milestone Overview
CyberSim Phase 2.5 prepares the simulation platform for organizational deployment and Phase 3 scenario authoring. This milestone introduces a robust localization layer, configuration-driven enterprise branding, an immersive workstation login experience, learner first-name personalization, and cross-locale offline credential verification while preserving 100% offline execution and strict security boundaries.
CyberSim Phase 2.5 prepares the simulation platform for organizational deployment and Phase 3 scenario authoring. This milestone introduces a robust localization layer, configuration-driven enterprise branding, an immersive workstation login experience, learner first- and last-name personalization, standalone certificate rendering and printing, and cross-locale offline credential verification while preserving 100% offline execution and strict security boundaries.
### Key Deliverables Implemented
1. **CyberSim OS & Scenario Multilingual Localization (I18n)**:
- Built a standalone, offline-first localization engine (`i18n.js`) with deterministic fallback behavior (Selected Locale -> Deployment Default Locale -> English -> visible `[missing: key]` indicators).
- Created comprehensive English (`locales/en.json`) and Spanish (`locales/es.json`) OS UI catalogs.
- Embedded full English UI catalogs (`locales/en.json`) and Spanish demonstration catalogs (`locales/es.json`) for instant offline dictionary availability.
- Decoupled scenario content localization (`scenarios/[id]/locales/[lang].json`), enabling independent translations of emails, documents, web pages, notifications, and pedagogical feedback without altering scenario schemas or scoring logic.
2. **Deployment Configuration & Corporate Branding**:
@@ -35,17 +35,18 @@ CyberSim Phase 2.5 prepares the simulation platform for organizational deploymen
- Preserved simulation safety indicators to maintain clear simulation boundaries.
3. **Immersive Workstation Login & Dynamic Network Discovery**:
- Replaced raw scenario loading with an enterprise lockscreen interface (`login.js`) featuring real-time language switching, strict first-name input validation (required, trimmed, max length 50), and dynamic workplace network selection.
- Replaced raw scenario loading with an enterprise lockscreen interface (`login.js`) featuring real-time language switching, strict first- and last-name input validation (required, trimmed, max length 50), and dynamic workplace network selection.
- Added direct verification shortcut links on the login screen to allow verifying completion certificates without logging into a simulation.
- Avoided any credential or password entry requirements on the simulated OS login screen.
- Extended scenario manifest schemas to support `supportedLocales` declarations and `login` metadata (`networkName`, `networkDescription`, `networkIcon`).
4. **Learner First-Name Personalization & Security Boundaries**:
- Exposed `learner.firstName` and `learner.name` to scenario action dispatchers and simulated applications.
4. **Learner Full-Name Personalization & Security Boundaries**:
- Exposed `learner.firstName`, `learner.lastName`, and full `learner.name` to scenario action dispatchers, simulated workplace applications, and certificate generators.
- Implemented safe template interpolation with HTML entity escaping to prevent cross-site scripting (XSS) or HTML injection from user-provided names.
5. **Deterministic Cryptographic Verification Across Locales**:
5. **Deterministic Cryptographic Verification & Print Rendering**:
- Canonicalized SHA-256 fingerprinting (`fingerprint.js`) against the base scenario structure, ensuring that completion certificates issued in one language remain 100% machine-verifiable in any language.
- Localized the standalone offline certificate verifier (`verify.html`) while preserving SHA-256 cryptographic checksum integrity validation.
- Updated the standalone offline certificate verifier (`verify.html`) to render the full official Certificate of Competency upon successful validation, with dedicated print styling and export actions.
6. **Comprehensive Automated Verification & Zero External Dependencies**:
- Added an automated Node.js test suite (`tests/run_tests.js`) covering localization fallback, branding defaults, login validation, template escaping, cross-locale fingerprinting, and offline dependency verification.
+1 -1
View File
@@ -18,7 +18,7 @@
</div>
<!-- Start Menu -->
<div id="start-menu" style="display:none;">
<div id="start-menu">
<div class="start-header">
<div class="start-user-avatar" id="start-user-avatar">??</div>
<div class="start-user-info">
+128 -6
View File
@@ -4,17 +4,22 @@
* Offline-first, deterministic string translation with robust fallback policies.
*/
// Fallback embedded English catalog for instant initialization & environments without fetch
// Full 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.instructions": "Enter your first and last 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.lastName": "Last Name",
"login.lastNamePlaceholder": "e.g. Morgan",
"login.lastNameRequired": "Please enter your last name.",
"login.lastNameTooLong": "Last name must be 50 characters or less.",
"login.language": "Language",
"login.verifyCertificate": "Verify Certificate",
"login.availableNetworks": "Available Networks & Workplaces",
"login.networkUnavailable": "Unavailable in selected language",
"login.selectNetwork": "Select a workplace network to connect",
@@ -22,6 +27,7 @@ const EMBEDDED_EN = {
"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",
@@ -29,12 +35,127 @@ const EMBEDDED_EN = {
"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"
@@ -49,6 +170,7 @@ export class I18nService {
this.dictionaries = {
en: { ...EMBEDDED_EN }
};
this.loadedLocales = new Set();
this.listeners = new Set();
}
@@ -77,7 +199,7 @@ export class I18nService {
* Load locale bundle from JSON or dictionary object
*/
async loadLocale(locale) {
if (this.dictionaries[locale] && Object.keys(this.dictionaries[locale]).length > 10) {
if (this.loadedLocales.has(locale)) {
return this.dictionaries[locale];
}
@@ -87,6 +209,7 @@ export class I18nService {
if (res.ok) {
const data = await res.json();
this.dictionaries[locale] = { ...(this.dictionaries[locale] || {}), ...data };
this.loadedLocales.add(locale);
return this.dictionaries[locale];
}
}
@@ -105,6 +228,7 @@ export class I18nService {
...(this.dictionaries[locale] || {}),
...dictionary
};
this.loadedLocales.add(locale);
}
/**
@@ -112,9 +236,7 @@ export class I18nService {
*/
async setLocale(locale) {
const target = this.enabledLocales.includes(locale) ? locale : this.defaultLocale;
if (!this.dictionaries[target]) {
await this.loadLocale(target);
}
await this.loadLocale(target);
this.activeLocale = target;
this._notify();
return this.activeLocale;
+106 -48
View File
@@ -2,7 +2,7 @@
* 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.
* learner first & last name validation, and dynamic workplace network selection.
*/
import { i18n } from './i18n.js';
@@ -55,9 +55,11 @@ export class LoginScreen {
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 : '';
// Preserve entered first and last name if switching language
const existingFirstInput = this.container.querySelector('#login-firstname-input');
const existingFirstName = existingFirstInput ? existingFirstInput.value : '';
const existingLastInput = this.container.querySelector('#login-lastname-input');
const existingLastName = existingLastInput ? existingLastInput.value : '';
this.container.innerHTML = `
<div class="cs-login-backdrop">
@@ -80,16 +82,24 @@ export class LoginScreen {
</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>
<!-- Controls (Language & Verify Shortcut) -->
<div style="display:flex; align-items:center; gap:10px;">
<a href="verify.html" target="_blank" id="btn-login-verify-shortcut" class="cs-btn cs-btn-sm" style="display:inline-flex; align-items:center; gap:6px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.12); color:#cbd5e1; text-decoration:none; font-size:11px; padding:5px 10px; border-radius:var(--radius-sm);">
<svg width="14" height="14" 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>
<span>${i18n.t('login.verifyCertificate')}</span>
</a>
<!-- 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>
</div>
@@ -100,24 +110,42 @@ export class LoginScreen {
<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>
<!-- Learner Name Inputs -->
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom:18px;">
<div class="cs-form-group">
<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(existingFirstName)}"
autocomplete="off"
spellcheck="false"
required
/>
</div>
<div class="cs-form-group">
<label class="cs-form-label" for="login-lastname-input">
${i18n.t('login.lastName')} <span style="color:#ef4444;">*</span>
</label>
<input
type="text"
id="login-lastname-input"
class="cs-input"
placeholder="${i18n.t('login.lastNamePlaceholder')}"
maxlength="50"
value="${LoginScreen.escapeHtml(existingLastName)}"
autocomplete="off"
spellcheck="false"
required
/>
</div>
</div>
<div id="login-error-msg" class="cs-login-error" style="display:none; margin-bottom:12px;"></div>
<!-- Dynamic Network / Workplace Selection -->
<div class="cs-form-group">
@@ -168,8 +196,12 @@ export class LoginScreen {
</svg>
<span>${i18n.t('login.disclaimer')}</span>
</div>
<div style="margin-top:4px; font-size:10px; color:#64748b;">
${i18n.t('login.sessionInfo')}
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:6px; font-size:10px; color:#64748b;">
<div>${i18n.t('login.sessionInfo')}</div>
<a href="verify.html" target="_blank" style="color:#93c5fd; text-decoration:none; display:inline-flex; align-items:center; gap:4px; font-size:11px;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
${i18n.t('login.verifyCertificate')}
</a>
</div>
</div>
</div>
@@ -216,15 +248,24 @@ export class LoginScreen {
});
});
// 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();
// Inputs on Enter
const firstNameInput = this.container.querySelector('#login-firstname-input');
const lastNameInput = this.container.querySelector('#login-lastname-input');
[firstNameInput, lastNameInput].forEach(inp => {
if (inp) {
inp.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
this._handleConnect();
}
});
}
});
if (firstNameInput && !firstNameInput.value) {
firstNameInput.focus();
} else if (lastNameInput && !lastNameInput.value) {
lastNameInput.focus();
}
// Connect button click
@@ -252,24 +293,39 @@ export class LoginScreen {
_handleConnect() {
this._clearError();
const nameInput = this.container.querySelector('#login-firstname-input');
const rawName = nameInput ? nameInput.value : '';
const firstName = rawName.trim();
const firstNameInput = this.container.querySelector('#login-firstname-input');
const lastNameInput = this.container.querySelector('#login-lastname-input');
const firstName = firstNameInput ? firstNameInput.value.trim() : '';
const lastName = lastNameInput ? lastNameInput.value.trim() : '';
// 1. Validate First Name
if (!firstName) {
this._showError(i18n.t('login.firstNameRequired'));
if (nameInput) nameInput.focus();
if (firstNameInput) firstNameInput.focus();
return;
}
if (firstName.length > 50) {
this._showError(i18n.t('login.firstNameTooLong'));
if (nameInput) nameInput.focus();
if (firstNameInput) firstNameInput.focus();
return;
}
// 2. Validate Selected Network
// 2. Validate Last Name
if (!lastName) {
this._showError(i18n.t('login.lastNameRequired'));
if (lastNameInput) lastNameInput.focus();
return;
}
if (lastName.length > 50) {
this._showError(i18n.t('login.lastNameTooLong'));
if (lastNameInput) lastNameInput.focus();
return;
}
// 3. Validate Selected Network
const selectedScenario = this.scenarios.find(s => s.id === this.selectedScenarioId);
if (!selectedScenario) {
this._showError(i18n.t('login.selectNetwork'));
@@ -292,6 +348,8 @@ export class LoginScreen {
if (this.onConnect) {
this.onConnect({
firstName,
lastName,
name: `${firstName} ${lastName}`,
scenarioId: selectedScenario.id,
scenarioPath: selectedScenario.path,
locale: currentLocale
+2
View File
@@ -289,6 +289,7 @@ export class ActionDispatcher {
const fullName = learner.name || 'User';
const firstName = learner.firstName || (learner.name ? learner.name.split(' ')[0] : 'User');
const lastName = learner.lastName || (learner.name ? learner.name.split(' ').slice(1).join(' ') : '');
let timestamp = new Date().toLocaleTimeString();
if (this.scenarioState && typeof this.scenarioState.getCurrentTime === 'function') {
@@ -300,6 +301,7 @@ export class ActionDispatcher {
return {
'learner.firstName': ActionDispatcher.escapeHtml(firstName),
'learner.lastName': ActionDispatcher.escapeHtml(lastName),
'learner.name': ActionDispatcher.escapeHtml(fullName),
'learner.email': ActionDispatcher.escapeHtml(learner.email || '[email protected]'),
'learner.role': ActionDispatcher.escapeHtml(learner.role || 'Employee'),
+20 -13
View File
@@ -132,7 +132,10 @@ export class CyberSimEngine {
// Hide desktop shell during login
if (taskbarEl) taskbarEl.style.display = 'none';
if (startMenuEl) startMenuEl.style.display = 'none';
if (startMenuEl) {
startMenuEl.classList.remove('open');
startMenuEl.style.display = '';
}
const login = new LoginScreen({
containerElement: desktopEl,
@@ -142,8 +145,13 @@ export class CyberSimEngine {
login.render();
}
async launchScenario({ firstName, scenarioId, scenarioPath, locale }) {
async launchScenario({ firstName, lastName, name, scenarioId, scenarioPath, locale }) {
this.currentLearnerFirstName = firstName;
this.currentLearnerLastName = lastName;
const finalFirstName = firstName || 'User';
const finalLastName = lastName || '';
const finalFullName = name || (finalLastName ? `${finalFirstName} ${finalLastName}` : finalFirstName);
// Set active locale
await i18n.setLocale(locale || i18n.defaultLocale);
@@ -168,17 +176,13 @@ export class CyberSimEngine {
this.scenario._canonicalScenario = loadResult.scenario._canonicalScenario;
}
// Personalize learner state with first name
// Personalize learner state with first and last 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;
this.scenario.learner = { name: finalFullName, role: 'Employee', email: `${finalFirstName.toLowerCase()}@example.internal` };
}
this.scenario.learner.firstName = finalFirstName;
this.scenario.learner.lastName = finalLastName;
this.scenario.learner.name = finalFullName;
// Validate schema
const schemaResult = validateSchema(this.scenario);
@@ -215,11 +219,14 @@ export class CyberSimEngine {
// Initialize UI
await this.initializeUI();
// Show taskbar & start menu
// Show taskbar and ensure start menu starts closed
const taskbarEl = document.getElementById('taskbar');
const startMenuEl = document.getElementById('start-menu');
if (taskbarEl) taskbarEl.style.display = 'flex';
if (startMenuEl) startMenuEl.style.display = 'flex';
if (startMenuEl) {
startMenuEl.classList.remove('open');
startMenuEl.style.display = '';
}
// Start event scheduler
const conditionEvaluator = new ConditionEvaluator(this.scenarioState);
+6 -1
View File
@@ -2,12 +2,17 @@
"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.instructions": "Enter your first and last 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.lastName": "Last Name",
"login.lastNamePlaceholder": "e.g. Morgan",
"login.lastNameRequired": "Please enter your last name.",
"login.lastNameTooLong": "Last name must be 50 characters or less.",
"login.language": "Language",
"login.verifyCertificate": "Verify Certificate",
"login.availableNetworks": "Available Networks & Workplaces",
"login.networkUnavailable": "Unavailable in selected language",
"login.selectNetwork": "Select a workplace network to connect",
+8 -3
View File
@@ -2,12 +2,17 @@
"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.instructions": "Ingrese su nombre y apellido y seleccione una red disponible para comenzar su turno programado.",
"login.firstName": "Nombre",
"login.firstNamePlaceholder": "ej. Carlos",
"login.firstNameRequired": "Por favor, ingrese su nombre de pila.",
"login.firstNameRequired": "Por favor, ingrese su nombre.",
"login.firstNameTooLong": "El nombre debe tener 50 caracteres o menos.",
"login.lastName": "Apellido",
"login.lastNamePlaceholder": "ej. Morgan",
"login.lastNameRequired": "Por favor, ingrese su apellido.",
"login.lastNameTooLong": "El apellido debe tener 50 caracteres o menos.",
"login.language": "Idioma",
"login.verifyCertificate": "Verificar Certificado",
"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",
+141 -37
View File
@@ -6,6 +6,7 @@
<title>CyberSim OS - Standalone Certificate Verifier</title>
<link rel="stylesheet" href="css/theme-windows.css">
<link rel="stylesheet" href="css/components.css">
<link rel="stylesheet" href="css/apps.css">
<style>
body {
background: #0f172a;
@@ -22,18 +23,19 @@
background: #1e293b;
border: 1px solid #334155;
border-radius: var(--radius-lg);
padding: 30px;
max-width: 600px;
padding: 28px;
max-width: 700px;
width: 100%;
box-shadow: 0 20px 48px rgba(0,0,0,0.5);
transition: max-width 0.2s ease;
}
.dropzone {
border: 2px dashed #475569;
border-radius: var(--radius-md);
padding: 30px;
padding: 26px;
text-align: center;
cursor: pointer;
margin: 20px 0;
margin: 16px 0;
transition: all 0.15s ease;
background: rgba(15, 23, 42, 0.4);
}
@@ -42,39 +44,80 @@
background: rgba(37, 99, 235, 0.1);
}
.result-box {
margin-top: 20px;
padding: 16px;
margin-top: 16px;
border-radius: var(--radius-md);
font-size: 13px;
line-height: 1.5;
}
.result-box.valid {
.result-banner {
padding: 12px 16px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.result-banner.valid {
background: #064e3b;
border: 1px solid #059669;
color: #d1fae5;
}
.result-box.invalid {
.result-banner.invalid {
background: #7f1d1d;
border: 1px solid #dc2626;
color: #fee2e2;
}
.cert-field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
font-size: 11px;
margin-top: 12px;
font-family: var(--font-mono);
background: rgba(0,0,0,0.2);
padding: 10px;
border-radius: 4px;
}
.header-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.verify-cert-wrapper {
background: #f8fafc;
padding: 20px;
border-radius: var(--radius-md);
border: 1px solid #cbd5e1;
margin-bottom: 16px;
}
.verify-action-bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
@media print {
body {
background: #ffffff !important;
color: #000000 !important;
padding: 0 !important;
margin: 0 !important;
}
.verify-card {
background: transparent !important;
border: none !important;
box-shadow: none !important;
max-width: 100% !important;
padding: 0 !important;
}
.no-print, .header-bar, #lbl-subtitle, .dropzone, .result-banner, .verify-action-bar {
display: none !important;
}
.verify-cert-wrapper {
background: transparent !important;
border: none !important;
padding: 0 !important;
margin: 0 !important;
}
.cert-printable {
margin: 0 auto !important;
box-shadow: none !important;
border: 8px double #1e3a8a !important;
page-break-inside: avoid !important;
}
}
</style>
</head>
<body>
@@ -160,8 +203,15 @@
await renderResult(json);
} catch (err) {
resultContainer.style.display = 'block';
resultContainer.className = 'result-box invalid';
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>`;
resultContainer.innerHTML = `
<div class="result-banner invalid">
<div style="font-size:24px;"></div>
<div>
<div style="font-weight:700; font-size:14px;">${i18n.t('verify.invalidFormat')}</div>
<div style="font-size:12px;">The file could not be parsed as a valid CyberSim credential: ${err.message}</div>
</div>
</div>
`;
}
}
@@ -171,27 +221,81 @@
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>
const companyName = d.learner?.assigned_organization || 'Enterprise Workplace';
<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>
resultContainer.innerHTML = `
<div class="result-banner valid no-print">
<div style="font-size:24px;"></div>
<div>
<div style="font-weight:700; font-size:14px;">${i18n.t('verify.verifiedTitle')}</div>
<div style="font-size:12px;">${i18n.t('verify.verifiedDesc')}</div>
</div>
</div>
<div class="verify-cert-wrapper">
<div class="cert-printable" id="cert-printable-area">
<div class="cert-org">${companyName} &bull; ${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;">${i18n.t('cert.certifiesThat')}</div>
<div class="cert-recipient">${d.learner.name}</div>
<div class="cert-text">
${i18n.t('cert.statement', { scenarioTitle: `<strong>${d.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;">
${i18n.t('cert.finalScore', { score: d.evaluation.score })}
</div>
<div class="cert-meta-grid">
<div>
<strong>${i18n.t('cert.certId')}</strong> ${d.certificate_id}<br>
<strong>${i18n.t('cert.date')}</strong> ${new Date(d.issued_at).toLocaleDateString()}<br>
<strong>${i18n.t('cert.engineVersion')}</strong> ${d.engine_version}
</div>
<div style="word-break:break-all;">
<strong>${i18n.t('cert.scenarioHash')}</strong><br>${d.scenario_fingerprint.substring(0, 32)}...<br>
<strong>${i18n.t('cert.integrityHash')}</strong><br>${d.integrity_hash.substring(0, 32)}...
</div>
</div>
</div>
</div>
<div class="verify-action-bar no-print">
<button class="cs-btn" id="btn-verify-another">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg> Verify Another
</button>
<button class="cs-btn cs-btn-primary" id="btn-print-verified-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> ${i18n.t('cert.print')}
</button>
</div>
`;
const printBtn = resultContainer.querySelector('#btn-print-verified-cert');
if (printBtn) {
printBtn.addEventListener('click', () => window.print());
}
const resetBtn = resultContainer.querySelector('#btn-verify-another');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
lastLoadedJson = null;
resultContainer.style.display = 'none';
resultContainer.innerHTML = '';
fileInput.value = '';
});
}
} 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>
<div class="result-banner invalid">
<div style="font-size:24px;"></div>
<div>
<div style="font-weight:700; font-size:14px;">${i18n.t('verify.failedTitle')}</div>
<div style="font-size:12px;">${res.error || 'Integrity validation failed. This certificate may have been tampered with or corrupted.'}</div>
</div>
</div>
`;
}
}
+19 -8
View File
@@ -90,6 +90,16 @@ async function runAllTests() {
assert(res.includes('NexaCore'));
});
await test('Default embedded English catalog contains all cert.* keys without missing labels', async () => {
const rawI18n = new I18nService({ defaultLocale: 'en' });
const certTitle = rawI18n.t('cert.title');
const compTitle = rawI18n.t('cert.competencyTitle');
const env = rawI18n.t('cert.environment');
assert(!certTitle.startsWith('[missing:'));
assert(!compTitle.startsWith('[missing:'));
assert(!env.startsWith('[missing:'));
});
console.log('\n--- 2. Corporate Branding Tests ---');
const customConfig = {
organization: {
@@ -117,25 +127,26 @@ async function runAllTests() {
});
console.log('\n--- 3. Personalization & Safe Template Interpolation Tests ---');
await test('ActionDispatcher resolves {{learner.firstName}} and {{learner.name}}', async () => {
await test('ActionDispatcher resolves {{learner.firstName}}, {{learner.lastName}}, and {{learner.name}}', async () => {
const mockScenario = {
learner: { firstName: 'Alex', name: 'Alex Rivera', role: 'Specialist' },
learner: { firstName: 'Chris', lastName: 'Morgan', name: 'Chris Morgan', 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');
assert.equal(ctx['learner.firstName'], 'Chris');
assert.equal(ctx['learner.lastName'], 'Morgan');
assert.equal(ctx['learner.name'], 'Chris Morgan');
const tmpl = 'Hello {{learner.firstName}}, your full name is {{learner.name}}.';
const tmpl = 'Hello {{learner.firstName}} {{learner.lastName}}, full: {{learner.name}}.';
const interpolated = dispatcher.interpolate(tmpl, ctx);
assert.equal(interpolated, 'Hello Alex, your full name is Alex Rivera.');
assert.equal(interpolated, 'Hello Chris Morgan, full: Chris Morgan.');
});
await test('Malicious HTML entered as first name is escaped harmlessly', async () => {
await test('Malicious HTML entered as first/last name is escaped harmlessly', async () => {
const xssPayload = '<script>alert("pwned")</script><img src=x onerror=alert(1)>';
const mockScenario = {
learner: { firstName: xssPayload, name: xssPayload },
learner: { firstName: xssPayload, lastName: xssPayload, name: xssPayload },
organizations: [{ name: 'NexaCore' }]
};
const dispatcher = new ActionDispatcher({ scenario: mockScenario });