Files
CyberSim-OS/src/js/core/login.js
T

360 lines
14 KiB
JavaScript

/**
* CyberSim OS - Immersive Workstation Login & Network Selection Screen (Phase 2.5)
*
* Immersive fictional workstation lockscreen, runtime language switching,
* learner first & last 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* 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 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">
<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>
<!-- 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>
<!-- 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 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">
<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="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>
</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;
});
});
// 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
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 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 (firstNameInput) firstNameInput.focus();
return;
}
if (firstName.length > 50) {
this._showError(i18n.t('login.firstNameTooLong'));
if (firstNameInput) firstNameInput.focus();
return;
}
// 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'));
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,
lastName,
name: `${firstName} ${lastName}`,
scenarioId: selectedScenario.id,
scenarioPath: selectedScenario.path,
locale: currentLocale
});
}
}
}