feat: add password protection UI and fix launch flow

- Password management UI in settings (set/change/remove password)
- Shield icon on play button for protected identities
- Interactive password popup on launch with inline error display
- Fix: re-throw password errors instead of falling to local tokens
- Fix: password popup properly cleans up on success/cancel
- Fix: expose updatePasswordShieldIcon for cross-module access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
sanasol
2026-02-28 16:45:46 +01:00
parent ee53911a06
commit 0b861904ba
7 changed files with 809 additions and 17 deletions

View File

@@ -43,24 +43,45 @@ try {
const execAsync = promisify(exec);
// Fetch tokens from the auth server (properly signed with server's Ed25519 key)
async function fetchAuthTokens(uuid, name) {
async function fetchAuthTokens(uuid, name, password) {
const authServerUrl = getAuthServerUrl();
try {
console.log(`Fetching auth tokens from ${authServerUrl}/game-session/child`);
const bodyData = {
uuid: uuid,
name: name,
scopes: ['hytale:server', 'hytale:client']
};
if (password) bodyData.password = password;
const response = await fetch(`${authServerUrl}/game-session/child`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
uuid: uuid,
name: name,
scopes: ['hytale:server', 'hytale:client']
})
body: JSON.stringify(bodyData)
});
if (!response.ok) {
const errBody = await response.json().catch(() => ({}));
if (response.status === 401 && errBody.password_required) {
const err = new Error('Password required');
err.passwordRequired = true;
err.attemptsRemaining = errBody.attemptsRemaining;
throw err;
}
if (response.status === 429) {
const err = new Error('Too many failed attempts. Try again later.');
err.lockedOut = true;
err.lockoutSeconds = errBody.lockoutSeconds;
throw err;
}
if (response.status === 403 && errBody.username_taken) {
const err = new Error('This username is reserved by another player who has set a password. Please use a different name.');
err.usernameTaken = true;
throw err;
}
throw new Error(`Auth server returned ${response.status}`);
}
@@ -77,10 +98,12 @@ async function fetchAuthTokens(uuid, name) {
if (payload.username && payload.username !== name && name !== 'Player') {
console.warn(`[Auth] Token username mismatch: token has "${payload.username}", expected "${name}". Retrying...`);
// Retry once with explicit name
const retryBody = { uuid: uuid, name: name, scopes: ['hytale:server', 'hytale:client'] };
if (password) retryBody.password = password;
const retryResponse = await fetch(`${authServerUrl}/game-session/child`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid: uuid, name: name, scopes: ['hytale:server', 'hytale:client'] })
body: JSON.stringify(retryBody)
});
if (retryResponse.ok) {
const retryData = await retryResponse.json();
@@ -99,6 +122,10 @@ async function fetchAuthTokens(uuid, name) {
console.log('Auth tokens received from server');
return { identityToken, sessionToken };
} catch (error) {
// Re-throw authentication errors — must not fall back to local tokens
if (error.passwordRequired || error.lockedOut || error.usernameTaken) {
throw error;
}
console.error('Failed to fetch auth tokens:', error.message);
// Fallback to local generation if server unavailable
return generateLocalTokens(uuid, name);
@@ -147,7 +174,7 @@ function generateLocalTokens(uuid, name) {
};
}
async function launchGame(playerNameOverride = null, progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null) {
async function launchGame(playerNameOverride = null, progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null, options = {}) {
// ==========================================================================
// CACHE INVALIDATION: Clear proxyClient module cache to force fresh .env load
// This prevents stale cached values from affecting multiple launch attempts
@@ -256,11 +283,12 @@ async function launchGame(playerNameOverride = null, progressCallback, javaPathO
const uuid = getUuidForUser(playerName);
console.log(`[Launcher] UUID for "${playerName}": ${uuid} (verify this stays constant across launches)`);
// Fetch tokens from auth server
// Fetch tokens from auth server (with password if provided)
if (progressCallback) {
progressCallback('Fetching authentication tokens...', null, null, null, null);
}
const { identityToken, sessionToken } = await fetchAuthTokens(uuid, playerName);
const launchPassword = options?.password || null;
const { identityToken, sessionToken } = await fetchAuthTokens(uuid, playerName, launchPassword);
// Patch client and server binaries to use custom auth server (BEFORE signing on macOS)
// FORCE patch on every launch to ensure consistency
@@ -578,7 +606,7 @@ async function launchGame(playerNameOverride = null, progressCallback, javaPathO
}
}
async function launchGameWithVersionCheck(playerNameOverride = null, progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null) {
async function launchGameWithVersionCheck(playerNameOverride = null, progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null, options = {}) {
try {
// ==========================================================================
// PRE-LAUNCH VALIDATION: Check username is configured
@@ -651,7 +679,7 @@ async function launchGameWithVersionCheck(playerNameOverride = null, progressCal
progressCallback('Launching game...', 80, null, null, null);
}
const launchResult = await launchGame(playerNameOverride, progressCallback, javaPathOverride, installPathOverride, gpuPreference, branch);
const launchResult = await launchGame(playerNameOverride, progressCallback, javaPathOverride, installPathOverride, gpuPreference, branch, options);
// Ensure we always return a result
if (!launchResult) {
@@ -665,6 +693,10 @@ async function launchGameWithVersionCheck(playerNameOverride = null, progressCal
if (progressCallback) {
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) {
throw error;
}
// Always return an error response instead of throwing
return { success: false, error: error.message || 'Unknown launch error' };
}