feat: identity protection UI, duplicate guards, name-lock enforcement (v2.4.6)

- Add password set/change/remove with loading states and double-click prevention
- Add protected identity deletion flow (server-side password removal first)
- Add restore flow for password-protected UUIDs (verify password before saving)
- Add UUID duplicate checks in setUuidForUser (prevent accidental overwrites)
- Add name-locked error handling in launch flow (server enforces registered name)
- Sync shield icon across all identity mutation paths
- Refresh identity dropdown after all password/identity operations
- Propagate force flag through IPC for legitimate overwrites

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
sanasol
2026-02-28 18:22:40 +01:00
parent 0b861904ba
commit 7347910fe9
7 changed files with 558 additions and 73 deletions

View File

@@ -193,7 +193,8 @@ window.switchProfile = async (id) => {
};
export async function launch() {
if (isDownloading || (playBtn && playBtn.disabled)) return;
const btn = homePlayBtn || playBtn;
if (isDownloading || (btn && btn.disabled)) return;
// ==========================================================================
// STEP 1: Check launch readiness from backend (single source of truth)
@@ -271,11 +272,7 @@ export async function launch() {
// STEP 3: Start launch process
// ==========================================================================
if (window.LauncherUI) window.LauncherUI.showProgress();
isDownloading = true;
if (playBtn) {
playBtn.disabled = true;
playText.textContent = 'LAUNCHING...';
}
lockPlayButton('LAUNCHING...');
try {
const startingMsg = window.i18n ? window.i18n.t('progress.startingGame') : 'Starting game...';
@@ -285,15 +282,11 @@ export async function launch() {
// Pass playerName from config - backend will validate again
const result = await window.electronAPI.launchGame(playerName, javaPath, '', gpuPreference);
isDownloading = false;
if (window.LauncherUI) {
window.LauncherUI.hideProgress();
}
resetPlayButton();
if (result.usernameTaken) {
// Username reserved by another player
isDownloading = false;
if (window.LauncherUI) window.LauncherUI.hideProgress();
resetPlayButton();
if (window.LauncherUI && window.LauncherUI.showError) {
window.LauncherUI.showError('This username is reserved by another player. Please change your player name in Identity settings.');
} else {
@@ -302,9 +295,57 @@ export async function launch() {
return;
}
if (result.nameLocked) {
// UUID is password-protected and locked to a specific name
isDownloading = false;
if (window.LauncherUI) window.LauncherUI.hideProgress();
resetPlayButton();
const msg = result.registeredName
? `This UUID is locked to username "${result.registeredName}". Change your identity name to "${result.registeredName}" in Settings.`
: 'This UUID is locked to a different username. Check your identity settings.';
if (window.LauncherUI && window.LauncherUI.showError) {
window.LauncherUI.showError(msg);
} else {
showNotification(msg, 'error');
}
return;
}
if (result.passwordRequired) {
// UUID has a password — show interactive password dialog
const launchResult = await promptForPasswordAndLaunch(playerName, javaPath, gpuPreference);
// Check for saved password first
let savedPw = null;
try {
const cfg = await window.electronAPI.loadConfig();
const uuid = result.uuid || '';
savedPw = cfg && cfg.savedPasswords && cfg.savedPasswords[uuid] ? cfg.savedPasswords[uuid] : null;
} catch (e) { /* ignore */ }
if (savedPw) {
// Try saved password silently
if (window.LauncherUI) window.LauncherUI.showProgress();
lockPlayButton('LAUNCHING...');
const autoResult = await window.electronAPI.launchGameWithPassword(playerName, javaPath, '', gpuPreference, savedPw);
if (autoResult.success) {
isDownloading = false;
if (window.LauncherUI) window.LauncherUI.hideProgress();
resetPlayButton();
if (window.electronAPI.minimizeWindow) setTimeout(() => { window.electronAPI.minimizeWindow(); }, 500);
return;
}
// Saved password failed — clear it and show popup
isDownloading = false;
if (window.LauncherUI) window.LauncherUI.hideProgress();
resetPlayButton();
try {
const cfg2 = await window.electronAPI.loadConfig();
const sp = cfg2.savedPasswords || {};
delete sp[result.uuid || ''];
await window.electronAPI.saveConfig({ savedPasswords: sp });
} catch (e) { /* ignore */ }
}
// Show interactive password dialog
const launchResult = await promptForPasswordAndLaunch(playerName, javaPath, gpuPreference, result.uuid);
if (launchResult && launchResult.success) {
if (window.electronAPI.minimizeWindow) setTimeout(() => { window.electronAPI.minimizeWindow(); }, 500);
}
@@ -312,12 +353,21 @@ export async function launch() {
}
if (result.success) {
// Keep button locked so user can't double-launch
if (window.LauncherUI) window.LauncherUI.hideProgress();
lockPlayButton('GAME RUNNING');
setTimeout(() => {
resetPlayButton();
}, 10000); // Reset after 10s (game should be visible by then)
if (window.electronAPI.minimizeWindow) {
setTimeout(() => {
window.electronAPI.minimizeWindow();
}, 500);
}
} else {
isDownloading = false;
if (window.LauncherUI) window.LauncherUI.hideProgress();
resetPlayButton();
console.error('[Launcher] Launch failed:', result.error);
// Handle specific error cases
@@ -373,7 +423,7 @@ export async function launch() {
}
}
function promptForPasswordAndLaunch(playerName, javaPath, gpuPreference) {
function promptForPasswordAndLaunch(playerName, javaPath, gpuPreference, uuid) {
return new Promise((resolve) => {
// Remove any existing password prompt
const existing = document.querySelector('.custom-confirm-modal');
@@ -427,6 +477,10 @@ function promptForPasswordAndLaunch(playerName, javaPath, gpuPreference) {
font-size: 0.95rem;
outline: none;
" placeholder="Password" autofocus />
<label style="display: flex; align-items: center; gap: 8px; margin-top: 12px; cursor: pointer; color: #9ca3af; font-size: 0.85rem; user-select: none;">
<input type="checkbox" id="pwRememberCheck" style="accent-color: #f59e0b; width: 16px; height: 16px; cursor: pointer;" />
Remember password
</label>
</div>
<div style="padding: 16px 24px; display: flex; gap: 10px; justify-content: flex-end; border-top: 1px solid rgba(255,255,255,0.1);">
<button id="pwCancelBtn" style="
@@ -458,6 +512,7 @@ function promptForPasswordAndLaunch(playerName, javaPath, gpuPreference) {
const confirmBtn = overlay.querySelector('#pwConfirmBtn');
const cancelBtn = overlay.querySelector('#pwCancelBtn');
const errorMsg = overlay.querySelector('#pwErrorMsg');
const rememberCheck = overlay.querySelector('#pwRememberCheck');
let busy = false;
@@ -494,15 +549,20 @@ function promptForPasswordAndLaunch(playerName, javaPath, gpuPreference) {
try {
if (window.LauncherUI) window.LauncherUI.showProgress();
isDownloading = true;
const playBtn = document.getElementById('play-btn');
const playText = playBtn?.querySelector('.play-text');
if (playBtn) { playBtn.disabled = true; }
if (playText) { playText.textContent = 'LAUNCHING...'; }
lockPlayButton('LAUNCHING...');
const result = await window.electronAPI.launchGameWithPassword(playerName, javaPath, '', gpuPreference, password);
if (result.success) {
// Save password if "Remember" checked
if (rememberCheck.checked && uuid) {
try {
const cfg = await window.electronAPI.loadConfig();
const sp = cfg.savedPasswords || {};
sp[uuid] = password;
await window.electronAPI.saveConfig({ savedPasswords: sp });
} catch (e) { /* ignore */ }
}
overlay.remove();
isDownloading = false;
if (window.LauncherUI) window.LauncherUI.hideProgress();
@@ -779,9 +839,21 @@ async function performRepair() {
function resetPlayButton() {
isDownloading = false;
if (playBtn) {
playBtn.disabled = false;
playText.textContent = window.i18n ? window.i18n.t('play.play') : 'PLAY';
const btn = homePlayBtn || playBtn;
if (btn) {
btn.disabled = false;
const textEl = btn.querySelector('span');
if (textEl) textEl.textContent = window.i18n ? window.i18n.t('play.playButton') : 'PLAY HYTALE';
}
}
function lockPlayButton(text) {
isDownloading = true;
const btn = homePlayBtn || playBtn;
if (btn) {
btn.disabled = true;
const textEl = btn.querySelector('span');
if (textEl) textEl.textContent = text || 'LAUNCHING...';
}
}

View File

@@ -515,8 +515,9 @@ async function savePlayerName() {
// Also refresh the UUID list to update which entry is marked as current
await loadAllUuids();
// Refresh header identity dropdown
// Refresh header identity dropdown + shield icon
if (window.loadIdentities) window.loadIdentities();
updatePasswordShieldIcon();
} catch (error) {
console.error('Error saving player name:', error);
@@ -746,6 +747,7 @@ async function performRegenerateUuid() {
await loadAllUuids();
}
if (window.loadIdentities) window.loadIdentities();
updatePasswordShieldIcon();
} else {
throw new Error(result.error || 'Failed to generate new UUID');
}
@@ -912,18 +914,61 @@ async function confirmAddIdentity() {
return;
}
if (window.electronAPI && window.electronAPI.setUuidForUser) {
const result = await window.electronAPI.setUuidForUser(username, uuid);
if (result.success) {
const msg = window.i18n ? window.i18n.t('notifications.identityAdded') : 'Identity added successfully!';
showNotification(msg, 'success');
hideAddIdentityForm();
await loadAllUuids();
if (window.loadIdentities) window.loadIdentities();
} else {
throw new Error(result.error || 'Failed to add identity');
// Check if name already exists locally
if (window.electronAPI && window.electronAPI.getAllUuidMappings) {
const mappings = await window.electronAPI.getAllUuidMappings();
const existing = mappings.find(m => m.username.toLowerCase() === username.toLowerCase());
if (existing) {
showNotification(`Identity "${existing.username}" already exists (UUID: ${existing.uuid.substring(0, 8)}...). Use the identity list to manage it.`, 'error');
return;
}
// Check if UUID already used by another identity
const uuidMatch = mappings.find(m => m.uuid.toLowerCase() === uuid.toLowerCase());
if (uuidMatch) {
showNotification(`This UUID is already used by identity "${uuidMatch.username}". Each identity must have a unique UUID.`, 'error');
return;
}
}
// Check username reservation on auth server
try {
const cfg = await window.electronAPI.loadConfig();
const authDomain = cfg.authDomain || 'auth.sanasol.ws';
const checkResp = await fetch(`https://${authDomain}/player/username/status/${encodeURIComponent(username)}`);
if (checkResp.ok) {
const status = await checkResp.json();
if (status.reserved) {
showNotification(`Username "${username}" is reserved by another player who set a password. Choose a different name.`, 'error');
return;
}
}
} catch (e) {
// Server check failed — allow creation (fail-open)
console.log('[Identity] Server username check skipped:', e.message);
}
// Check if UUID is password-protected on server (restore access flow)
let uuidIsProtected = false;
let registeredName = null;
try {
if (window.electronAPI.checkPasswordStatus) {
const pwStatus = await window.electronAPI.checkPasswordStatus(uuid);
if (pwStatus && pwStatus.hasPassword) {
uuidIsProtected = true;
registeredName = pwStatus.registeredName || null;
}
}
} catch (e) {
console.log('[Identity] UUID password check skipped:', e.message);
}
if (uuidIsProtected) {
// UUID is password-protected — need password to restore it
showRestoreProtectedIdentityDialog(username, uuid, registeredName);
return;
}
await saveNewIdentity(username, uuid);
} catch (error) {
console.error('Error adding identity:', error);
const msg = window.i18n ? window.i18n.t('notifications.identityAddFailed') : 'Failed to add identity';
@@ -931,6 +976,174 @@ async function confirmAddIdentity() {
}
}
async function saveNewIdentity(username, uuid) {
if (window.electronAPI && window.electronAPI.setUuidForUser) {
const result = await window.electronAPI.setUuidForUser(username, uuid);
if (result.success) {
showNotification('Identity added successfully!', 'success');
hideAddIdentityForm();
await loadAllUuids();
if (window.loadIdentities) window.loadIdentities();
updatePasswordShieldIcon();
} else if (result.error === 'duplicate') {
showNotification(`Identity "${username}" already exists (UUID: ${result.existingUuid.substring(0, 8)}...). Use the identity list to manage it.`, 'error');
} else if (result.error === 'uuid_in_use') {
showNotification(`This UUID is already used by identity "${result.existingUsername}". Each identity must have a unique UUID.`, 'error');
} else {
throw new Error(result.error || 'Failed to add identity');
}
}
}
function showRestoreProtectedIdentityDialog(username, uuid, registeredName) {
const existing = document.querySelector('.custom-confirm-modal');
if (existing) existing.remove();
const nameWarning = registeredName && registeredName.toLowerCase() !== username.toLowerCase()
? `<p style="color: #f59e0b; margin: 0 0 12px; font-size: 0.9rem;">
<i class="fas fa-exclamation-triangle"></i> This UUID is locked to name "<strong>${escapeHtml(registeredName)}</strong>".
Your entered name "${escapeHtml(username)}" will be replaced.
</p>`
: '';
const overlay = document.createElement('div');
overlay.className = 'custom-confirm-modal';
overlay.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.8); backdrop-filter: blur(4px);
z-index: 20000; display: flex; align-items: center; justify-content: center;
opacity: 0; transition: opacity 0.3s ease;
`;
const dialog = document.createElement('div');
dialog.style.cssText = `
background: #1f2937; border-radius: 12px; padding: 0;
min-width: 420px; max-width: 520px;
box-shadow: 0 20px 40px rgba(0,0,0,0.6);
border: 1px solid rgba(147, 51, 234, 0.4);
transform: scale(0.9); transition: transform 0.3s ease;
`;
dialog.innerHTML = `
<div style="padding: 24px; border-bottom: 1px solid rgba(255,255,255,0.1);">
<div style="display: flex; align-items: center; gap: 12px; color: #9333ea;">
<i class="fas fa-shield-alt" style="font-size: 24px;"></i>
<h3 style="margin: 0; font-size: 1.2rem; font-weight: 600;">Restore Protected Identity</h3>
</div>
</div>
<div style="padding: 24px;">
<p style="color: #e5e7eb; margin: 0 0 16px; line-height: 1.6;">
This UUID is <strong style="color: #22c55e;">password-protected</strong>. Enter the password to restore access.
</p>
${nameWarning}
<div id="restoreError" style="display: none; color: #f87171; background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 8px; padding: 8px 12px; margin-bottom: 12px; font-size: 0.85rem;"></div>
<input type="password" id="restorePasswordInput" style="
width: 100%; box-sizing: border-box; padding: 10px 14px;
background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.2);
border-radius: 8px; color: #fff; font-size: 0.95rem; outline: none;
" placeholder="Password" autofocus />
</div>
<div style="padding: 16px 24px; display: flex; gap: 10px; justify-content: flex-end; border-top: 1px solid rgba(255,255,255,0.1);">
<button id="restoreCancelBtn" style="
padding: 8px 20px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.2);
background: transparent; color: #9ca3af; cursor: pointer; font-size: 0.9rem;
">Cancel</button>
<button id="restoreConfirmBtn" style="
padding: 8px 20px; border-radius: 8px; border: none;
background: linear-gradient(135deg, #9333ea, #3b82f6); color: white;
cursor: pointer; font-weight: 600; font-size: 0.9rem;
">Verify & Restore</button>
</div>
`;
overlay.appendChild(dialog);
document.body.appendChild(overlay);
requestAnimationFrame(() => {
overlay.style.opacity = '1';
dialog.style.transform = 'scale(1)';
});
const input = overlay.querySelector('#restorePasswordInput');
const errorMsg = overlay.querySelector('#restoreError');
const confirmBtn = overlay.querySelector('#restoreConfirmBtn');
const cancelBtn = overlay.querySelector('#restoreCancelBtn');
let busy = false;
const close = () => { overlay.remove(); };
cancelBtn.onclick = close;
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
const doRestore = async () => {
if (busy) return;
const password = input.value.trim();
if (!password) {
errorMsg.textContent = 'Password is required';
errorMsg.style.display = 'block';
input.focus();
return;
}
busy = true;
confirmBtn.disabled = true;
confirmBtn.textContent = 'Verifying...';
errorMsg.style.display = 'none';
try {
// Use the registered name if UUID is name-locked
const finalName = registeredName || username;
// Verify password by attempting to get tokens
const cfg = await window.electronAPI.loadConfig();
const authDomain = cfg.authDomain || 'auth.sanasol.ws';
const resp = await fetch(`https://${authDomain}/game-session/new`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid, name: finalName, password, scopes: 'hytale:server hytale:client' })
});
if (resp.status === 401 || resp.status === 429) {
const err = await resp.json();
errorMsg.textContent = err.error || 'Incorrect password';
errorMsg.style.display = 'block';
input.value = '';
input.focus();
busy = false;
confirmBtn.disabled = false;
confirmBtn.textContent = 'Verify & Restore';
return;
}
if (!resp.ok) {
throw new Error(`Server returned ${resp.status}`);
}
// Password verified — save identity locally (force to allow the name)
close();
if (window.electronAPI && window.electronAPI.setUuidForUser) {
const result = await window.electronAPI.setUuidForUser(finalName, uuid, true);
if (result.success || (result && result.uuid)) {
showNotification(`Identity "${finalName}" restored successfully!`, 'success');
hideAddIdentityForm();
await loadAllUuids();
if (window.loadIdentities) window.loadIdentities();
updatePasswordShieldIcon();
} else {
showNotification(result.error || 'Failed to save identity', 'error');
}
}
} catch (e) {
errorMsg.textContent = 'Error: ' + e.message;
errorMsg.style.display = 'block';
busy = false;
confirmBtn.disabled = false;
confirmBtn.textContent = 'Verify & Restore';
}
};
confirmBtn.onclick = doRestore;
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') doRestore(); });
}
function toggleAdvancedSection() {
if (!uuidAdvancedContent || !uuidAdvancedToggle) return;
const isOpen = uuidAdvancedContent.style.display !== 'none';
@@ -981,10 +1194,12 @@ async function refreshPasswordStatus() {
window.handleSetPassword = async function () {
const newPw = document.getElementById('newPasswordInput');
const currentPw = document.getElementById('currentPasswordInput');
const setBtn = document.getElementById('setPasswordBtn');
if (!newPw || !newPw.value || newPw.value.length < 6) {
showNotification('Password must be at least 6 characters', 'error');
return;
}
if (setBtn) { setBtn.disabled = true; setBtn.textContent = 'Setting...'; }
try {
const uuid = await window.electronAPI.getCurrentUuid();
const result = await window.electronAPI.setPlayerPassword(uuid, newPw.value, currentPw?.value || null);
@@ -994,20 +1209,25 @@ window.handleSetPassword = async function () {
if (currentPw) currentPw.value = '';
refreshPasswordStatus();
updatePasswordShieldIcon();
if (window.loadIdentities) window.loadIdentities();
} else {
showNotification(result.error || 'Failed to set password', 'error');
}
} catch (e) {
showNotification('Error: ' + e.message, 'error');
} finally {
if (setBtn) { setBtn.disabled = false; setBtn.textContent = 'Set Password'; }
}
};
window.handleRemovePassword = async function () {
const currentPw = document.getElementById('currentPasswordInput');
const removeBtn = document.getElementById('removePasswordBtn');
if (!currentPw || !currentPw.value) {
showNotification('Enter your current password to remove it', 'error');
return;
}
if (removeBtn) { removeBtn.disabled = true; removeBtn.textContent = 'Removing...'; }
try {
const uuid = await window.electronAPI.getCurrentUuid();
const result = await window.electronAPI.removePlayerPassword(uuid, currentPw.value);
@@ -1016,11 +1236,14 @@ window.handleRemovePassword = async function () {
currentPw.value = '';
refreshPasswordStatus();
updatePasswordShieldIcon();
if (window.loadIdentities) window.loadIdentities();
} else {
showNotification(result.error || 'Failed to remove password', 'error');
}
} catch (e) {
showNotification('Error: ' + e.message, 'error');
} finally {
if (removeBtn) { removeBtn.disabled = false; removeBtn.textContent = 'Remove Password'; }
}
};
@@ -1108,10 +1331,12 @@ window.closePasswordModal = function () {
window.handlePasswordModalSet = async function () {
const newPw = document.getElementById('pwModalNewPassword');
const curPw = document.getElementById('pwModalCurrentPassword');
const setBtn = document.getElementById('pwModalSetBtn');
if (!newPw || !newPw.value || newPw.value.length < 6) {
showNotification('Password must be at least 6 characters', 'error');
return;
}
if (setBtn) { setBtn.disabled = true; const s = setBtn.querySelector('span'); if (s) s.textContent = 'Saving...'; }
try {
const uuid = await window.electronAPI.getCurrentUuid();
const result = await window.electronAPI.setPlayerPassword(uuid, newPw.value, curPw?.value || null);
@@ -1121,21 +1346,26 @@ window.handlePasswordModalSet = async function () {
newPw.value = '';
if (curPw) curPw.value = '';
updatePasswordShieldIcon();
if (window.loadIdentities) window.loadIdentities();
openPasswordModal(); // refresh modal state
} else {
showNotification(result.error || 'Failed to set password', 'error');
}
} catch (e) {
showNotification('Error: ' + e.message, 'error');
} finally {
if (setBtn) { setBtn.disabled = false; const s = setBtn.querySelector('span'); if (s) s.textContent = 'Set Password'; }
}
};
window.handlePasswordModalRemove = async function () {
const curPw = document.getElementById('pwModalCurrentPassword');
const removeBtn = document.getElementById('pwModalRemoveBtn');
if (!curPw || !curPw.value) {
showNotification('Enter your current password to remove it', 'error');
return;
}
if (removeBtn) { removeBtn.disabled = true; removeBtn.textContent = 'Removing...'; }
try {
const uuid = await window.electronAPI.getCurrentUuid();
const result = await window.electronAPI.removePlayerPassword(uuid, curPw.value);
@@ -1143,12 +1373,15 @@ window.handlePasswordModalRemove = async function () {
showNotification('Password removed', 'success');
curPw.value = '';
updatePasswordShieldIcon();
if (window.loadIdentities) window.loadIdentities();
openPasswordModal(); // refresh modal state
} else {
showNotification(result.error || 'Failed to remove password', 'error');
}
} catch (e) {
showNotification('Error: ' + e.message, 'error');
} finally {
if (removeBtn) { removeBtn.disabled = false; removeBtn.textContent = 'Remove Password'; }
}
};
@@ -1240,7 +1473,7 @@ async function performSetCustomUuid(uuid) {
showNotification(msg, 'error');
return;
}
const result = await window.electronAPI.setUuidForUser(username, uuid);
const result = await window.electronAPI.setUuidForUser(username, uuid, true); // force: true — explicit UUID change
if (result.success) {
if (currentUuidDisplay) currentUuidDisplay.value = uuid;
@@ -1251,6 +1484,7 @@ async function performSetCustomUuid(uuid) {
await loadAllUuids();
if (window.loadIdentities) window.loadIdentities();
updatePasswordShieldIcon();
} else {
throw new Error(result.error || 'Failed to set custom UUID');
}
@@ -1329,8 +1563,9 @@ async function performSwitchToUsername(username) {
// Refresh the UUID list to show new "Current" badge
await loadAllUuids();
// Refresh header identity dropdown
// Refresh header identity dropdown + shield icon
if (window.loadIdentities) window.loadIdentities();
updatePasswordShieldIcon();
const msg = window.i18n
? window.i18n.t('notifications.switchUsernameSuccess').replace('{username}', username)
@@ -1348,46 +1583,195 @@ async function performSwitchToUsername(username) {
window.deleteUuid = async function (username) {
try {
const message = window.i18n ? window.i18n.t('confirm.deleteUuidMessage').replace('{username}', username) : `Are you sure you want to delete the UUID for "${username}"? This action cannot be undone.`;
const title = window.i18n ? window.i18n.t('confirm.deleteUuidTitle') : 'Delete UUID';
const confirmBtn = window.i18n ? window.i18n.t('confirm.deleteUuidButton') : 'Delete';
const cancelBtn = window.i18n ? window.i18n.t('common.cancel') : 'Cancel';
// Look up UUID for this username
let uuid = null;
if (window.electronAPI && window.electronAPI.getAllUuidMappings) {
const mappings = await window.electronAPI.getAllUuidMappings();
const entry = mappings.find(m => m.username.toLowerCase() === username.toLowerCase());
if (entry) uuid = entry.uuid;
}
// Check if password-protected
let isProtected = false;
if (uuid && window.electronAPI && window.electronAPI.checkPasswordStatus) {
try {
const pwStatus = await window.electronAPI.checkPasswordStatus(uuid);
isProtected = pwStatus && pwStatus.hasPassword;
} catch (e) {
console.log('[Identity] Password status check failed:', e.message);
}
}
if (isProtected) {
// Password-protected identity — show warning with password input
showPasswordProtectedDeleteDialog(username, uuid);
} else {
// Normal identity — simple confirm
const message = `Are you sure you want to delete the identity "${username}"? This action cannot be undone.`;
showCustomConfirm(
message,
title,
async () => {
await performDeleteUuid(username);
},
'Delete Identity',
async () => { await performDeleteUuid(username); },
null,
confirmBtn,
cancelBtn
'Delete',
'Cancel'
);
}
} catch (error) {
console.error('Error in deleteUuid:', error);
const msg = window.i18n ? window.i18n.t('notifications.uuidDeleteFailed') : 'Failed to delete UUID';
showNotification(msg, 'error');
showNotification('Failed to delete identity', 'error');
}
};
function showPasswordProtectedDeleteDialog(username, uuid) {
const existing = document.querySelector('.custom-confirm-modal');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.className = 'custom-confirm-modal';
overlay.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.8); backdrop-filter: blur(4px);
z-index: 20000; display: flex; align-items: center; justify-content: center;
opacity: 0; transition: opacity 0.3s ease;
`;
const dialog = document.createElement('div');
dialog.style.cssText = `
background: #1f2937; border-radius: 12px; padding: 0;
min-width: 420px; max-width: 520px;
box-shadow: 0 20px 40px rgba(0,0,0,0.6);
border: 1px solid rgba(239, 68, 68, 0.4);
transform: scale(0.9); transition: transform 0.3s ease;
`;
dialog.innerHTML = `
<div style="padding: 24px; border-bottom: 1px solid rgba(255,255,255,0.1);">
<div style="display: flex; align-items: center; gap: 12px; color: #ef4444;">
<i class="fas fa-shield-alt" style="font-size: 24px;"></i>
<h3 style="margin: 0; font-size: 1.2rem; font-weight: 600;">Delete Protected Identity</h3>
</div>
</div>
<div style="padding: 24px;">
<p style="color: #e5e7eb; margin: 0 0 16px; line-height: 1.6;">
<strong>"${escapeHtml(username)}"</strong> is password-protected. Deleting it will:
</p>
<ul style="color: #f87171; margin: 0 0 16px; padding-left: 20px; line-height: 1.8;">
<li>Remove the password protection from this UUID</li>
<li>Release the reserved username "${escapeHtml(username)}"</li>
<li>Allow anyone to use this UUID and name</li>
</ul>
<p style="color: #9ca3af; margin: 0 0 16px; font-size: 0.9rem;">
Enter your current password to confirm deletion:
</p>
<div id="pwDeleteError" style="display: none; color: #f87171; background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 8px; padding: 8px 12px; margin-bottom: 12px; font-size: 0.85rem;"></div>
<input type="password" id="pwDeleteInput" style="
width: 100%; box-sizing: border-box; padding: 10px 14px;
background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.2);
border-radius: 8px; color: #fff; font-size: 0.95rem; outline: none;
" placeholder="Current password" autofocus />
</div>
<div style="padding: 16px 24px; display: flex; gap: 10px; justify-content: flex-end; border-top: 1px solid rgba(255,255,255,0.1);">
<button id="pwDeleteCancelBtn" style="
padding: 8px 20px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.2);
background: transparent; color: #9ca3af; cursor: pointer; font-size: 0.9rem;
">Cancel</button>
<button id="pwDeleteConfirmBtn" style="
padding: 8px 20px; border-radius: 8px; border: none;
background: #ef4444; color: white; cursor: pointer; font-weight: 600; font-size: 0.9rem;
">Delete & Remove Password</button>
</div>
`;
overlay.appendChild(dialog);
document.body.appendChild(overlay);
requestAnimationFrame(() => {
overlay.style.opacity = '1';
dialog.style.transform = 'scale(1)';
});
const input = overlay.querySelector('#pwDeleteInput');
const errorMsg = overlay.querySelector('#pwDeleteError');
const confirmBtn = overlay.querySelector('#pwDeleteConfirmBtn');
const cancelBtn = overlay.querySelector('#pwDeleteCancelBtn');
let busy = false;
const close = () => { overlay.remove(); };
cancelBtn.onclick = close;
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
const doDelete = async () => {
if (busy) return;
const password = input.value.trim();
if (!password) {
errorMsg.textContent = 'Password is required';
errorMsg.style.display = 'block';
input.focus();
return;
}
busy = true;
confirmBtn.disabled = true;
confirmBtn.textContent = 'Removing...';
errorMsg.style.display = 'none';
try {
// Step 1: Remove password on server (validates current password)
const removeResult = await window.electronAPI.removePlayerPassword(uuid, password);
if (!removeResult.success) {
errorMsg.textContent = removeResult.error || 'Incorrect password';
errorMsg.style.display = 'block';
input.value = '';
input.focus();
busy = false;
confirmBtn.disabled = false;
confirmBtn.textContent = 'Delete & Remove Password';
return;
}
// Step 2: Also clear saved password if any
try {
const cfg = await window.electronAPI.loadConfig();
if (cfg.savedPasswords && cfg.savedPasswords[uuid]) {
delete cfg.savedPasswords[uuid];
await window.electronAPI.saveConfig({ savedPasswords: cfg.savedPasswords });
}
} catch (e) { /* ignore */ }
// Step 3: Delete identity locally
close();
await performDeleteUuid(username);
} catch (e) {
errorMsg.textContent = 'Error: ' + e.message;
errorMsg.style.display = 'block';
busy = false;
confirmBtn.disabled = false;
confirmBtn.textContent = 'Delete & Remove Password';
}
};
confirmBtn.onclick = doDelete;
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') doDelete(); });
}
async function performDeleteUuid(username) {
try {
if (window.electronAPI && window.electronAPI.deleteUuidForUser) {
const result = await window.electronAPI.deleteUuidForUser(username);
if (result.success) {
const msg = window.i18n ? window.i18n.t('notifications.uuidDeleteSuccess') : 'UUID deleted successfully!';
showNotification(msg, 'success');
showNotification('Identity deleted successfully!', 'success');
await loadAllUuids();
if (window.loadIdentities) window.loadIdentities();
updatePasswordShieldIcon();
} else {
throw new Error(result.error || 'Failed to delete UUID');
throw new Error(result.error || 'Failed to delete identity');
}
}
} catch (error) {
console.error('Error deleting UUID:', error);
const msg = window.i18n ? window.i18n.t('notifications.uuidDeleteFailed').replace('{error}', error.message) : `Failed to delete UUID: ${error.message}`;
showNotification(msg, 'error');
showNotification(`Failed to delete identity: ${error.message}`, 'error');
}
}

View File

@@ -529,7 +529,7 @@ function getAllUuidMappingsArray() {
* Validates UUID format before saving
* Preserves original case of username
*/
function setUuidForUser(username, uuid) {
function setUuidForUser(username, uuid, { force = false } = {}) {
const { validate: validateUuid } = require('uuid');
if (!username || typeof username !== 'string' || !username.trim()) {
@@ -543,15 +543,29 @@ function setUuidForUser(username, uuid) {
const displayName = username.trim();
const normalizedLookup = displayName.toLowerCase();
// 1. Update UUID store (source of truth)
// 1. Check for existing entries — reject overwrite unless forced
migrateUuidStoreIfNeeded();
const uuidStore = loadUuidStore();
const storeKey = Object.keys(uuidStore).find(k => k.toLowerCase() === normalizedLookup);
if (storeKey && uuidStore[storeKey] !== uuid && !force) {
console.log(`[Config] Rejected UUID overwrite for "${displayName}": existing ${uuidStore[storeKey]}, attempted ${uuid}`);
return { success: false, error: 'duplicate', existingUuid: uuidStore[storeKey] };
}
// Check if UUID already used by a different name
if (!force) {
const existingByUuid = Object.entries(uuidStore).find(([k, v]) => v.toLowerCase() === uuid.toLowerCase() && k.toLowerCase() !== normalizedLookup);
if (existingByUuid) {
console.log(`[Config] Rejected duplicate UUID for "${displayName}": UUID ${uuid} already used by "${existingByUuid[0]}"`);
return { success: false, error: 'uuid_in_use', existingUsername: existingByUuid[0] };
}
}
// 2. Update UUID store (source of truth)
if (storeKey) delete uuidStore[storeKey];
uuidStore[displayName] = uuid;
saveUuidStore(uuidStore);
// 2. Update config.json (backward compat)
// 3. Update config.json (backward compat)
const config = loadConfig();
const userUuids = config.userUuids || {};
const existingKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup);
@@ -560,7 +574,7 @@ function setUuidForUser(username, uuid) {
saveConfig({ userUuids });
console.log(`[Config] UUID set for "${displayName}": ${uuid}`);
return uuid;
return { success: true, uuid };
}
/**
@@ -619,7 +633,7 @@ function resetCurrentUserUuid() {
const { v4: uuidv4 } = require('uuid');
const newUuid = uuidv4();
return setUuidForUser(username, newUuid);
return setUuidForUser(username, newUuid, { force: true });
}
// =============================================================================

View File

@@ -82,6 +82,12 @@ async function fetchAuthTokens(uuid, name, password) {
err.usernameTaken = true;
throw err;
}
if (response.status === 403 && errBody.name_locked) {
const err = new Error(`This UUID is locked to username "${errBody.registeredName}". Change your identity name to match.`);
err.nameLocked = true;
err.registeredName = errBody.registeredName;
throw err;
}
throw new Error(`Auth server returned ${response.status}`);
}
@@ -123,7 +129,7 @@ async function fetchAuthTokens(uuid, name, password) {
return { identityToken, sessionToken };
} catch (error) {
// Re-throw authentication errors — must not fall back to local tokens
if (error.passwordRequired || error.lockedOut || error.usernameTaken) {
if (error.passwordRequired || error.lockedOut || error.usernameTaken || error.nameLocked) {
throw error;
}
console.error('Failed to fetch auth tokens:', error.message);
@@ -694,7 +700,7 @@ async function launchGameWithVersionCheck(playerNameOverride = null, progressCal
progressCallback(`Error: ${error.message}`, -1, null, null, null);
}
// Re-throw authentication errors so IPC handler can return proper flags
if (error.passwordRequired || error.lockedOut || error.usernameTaken) {
if (error.passwordRequired || error.lockedOut || error.usernameTaken || error.nameLocked) {
throw error;
}
// Always return an error response instead of throwing

21
main.js
View File

@@ -532,10 +532,10 @@ ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath, g
// Check if UUID has password before launching
let launchOptions = {};
const { getAuthServerUrl, getUuidForUser } = require('./backend/core/config');
const launchUuid = getUuidForUser(playerName);
try {
const { getAuthServerUrl } = require('./backend/core/config');
const { getUuidForUser } = require('./backend/core/config');
const uuid = getUuidForUser(playerName);
const uuid = launchUuid;
const authServerUrl = getAuthServerUrl();
const statusResp = await fetch(`${authServerUrl}/player/password/status/${uuid}`);
if (statusResp.ok) {
@@ -574,7 +574,7 @@ ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath, g
}
if (error.passwordRequired) {
return { success: false, passwordRequired: true, error: 'Password required' };
return { success: false, passwordRequired: true, uuid: launchUuid, error: 'Password required' };
}
if (error.lockedOut) {
return { success: false, error: 'Too many failed attempts. Try again in ' + Math.ceil((error.lockoutSeconds || 900) / 60) + ' minutes.' };
@@ -582,6 +582,9 @@ ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath, g
if (error.usernameTaken) {
return { success: false, usernameTaken: true, error: errorMessage };
}
if (error.nameLocked) {
return { success: false, nameLocked: true, registeredName: error.registeredName, error: error.message };
}
return { success: false, error: errorMessage };
}
@@ -625,6 +628,9 @@ ipcMain.handle('launch-game-with-password', async (event, playerName, javaPath,
if (error.usernameTaken) {
return { success: false, usernameTaken: true, error: error.message || error.toString() };
}
if (error.nameLocked) {
return { success: false, nameLocked: true, registeredName: error.registeredName, error: error.message };
}
return { success: false, error: error.message || error.toString() };
}
});
@@ -1423,9 +1429,12 @@ ipcMain.handle('get-all-uuid-mappings', async () => {
}
});
ipcMain.handle('set-uuid-for-user', async (event, username, uuid) => {
ipcMain.handle('set-uuid-for-user', async (event, username, uuid, force) => {
try {
await setUuidForUser(username, uuid);
const result = setUuidForUser(username, uuid, { force: !!force });
if (result && result.success === false) {
return result; // { success: false, error: 'duplicate', existingUuid }
}
return { success: true };
} catch (error) {
console.error('Error setting UUID for user:', error);

View File

@@ -1,6 +1,6 @@
{
"name": "hytale-f2p-launcher",
"version": "2.4.5",
"version": "2.4.6",
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support",
"homepage": "https://git.sanhost.net/sanasol/hytale-f2p",
"main": "main.js",

View File

@@ -103,7 +103,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
// UUID Management methods
getCurrentUuid: () => ipcRenderer.invoke('get-current-uuid'),
getAllUuidMappings: () => ipcRenderer.invoke('get-all-uuid-mappings'),
setUuidForUser: (username, uuid) => ipcRenderer.invoke('set-uuid-for-user', username, uuid),
setUuidForUser: (username, uuid, force) => ipcRenderer.invoke('set-uuid-for-user', username, uuid, force),
generateNewUuid: () => ipcRenderer.invoke('generate-new-uuid'),
deleteUuidForUser: (username) => ipcRenderer.invoke('delete-uuid-for-user', username),
resetCurrentUserUuid: () => ipcRenderer.invoke('reset-current-user-uuid'),