generated from Labyricorn/labyricorn-project-template
Compare commits
2
Commits
7a6c840988
...
2b720d4b2b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b720d4b2b | ||
|
|
eafcde8143 |
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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} • ${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
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user