generated from Labyricorn/labyricorn-project-template
360 lines
14 KiB
JavaScript
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, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
/**
|
|
* Initialize and render the login screen.
|
|
*/
|
|
async render() {
|
|
this.scenarios = branding.getScenariosCatalog();
|
|
if (this.scenarios.length > 0 && !this.selectedScenarioId) {
|
|
this.selectedScenarioId = this.scenarios[0].id;
|
|
}
|
|
|
|
this._drawUI();
|
|
|
|
// Listen to language changes to re-render login UI dynamically
|
|
if (this.unsubscribeI18n) this.unsubscribeI18n();
|
|
this.unsubscribeI18n = i18n.onChange(() => {
|
|
this._drawUI();
|
|
});
|
|
}
|
|
|
|
_drawUI() {
|
|
if (!this.container) return;
|
|
|
|
const org = branding.getOrganization();
|
|
const enabledLocales = i18n.getEnabledLocales();
|
|
const currentLocale = i18n.getLocale();
|
|
|
|
// Preserve entered first 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
|
|
});
|
|
}
|
|
}
|
|
}
|