diff --git a/app/__init__.py b/app/__init__.py index 1088065..9ef0423 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -74,6 +74,8 @@ def create_app( static_dir = os.path.join(base_dir, 'static') app = Flask(__name__, template_folder=template_dir, static_folder=static_dir) app.config.from_object(config) + from .static_delivery import init_static_delivery + init_static_delivery(app) app.extensions['runtime_lifecycle'] = RuntimeLifecycle( max_workers=config.BACKGROUND_WORKERS ) @@ -167,6 +169,10 @@ def enforce_restore_maintenance_and_session_epoch(): 'error': 'WebSSH is in restore maintenance mode', 'code': 'maintenance', }), 503 + # Static files are public and user-independent. Avoid opening the + # session for them so shared caches do not receive ``Vary: Cookie``. + if request.endpoint == 'static': + return None if ( current_user.is_authenticated and current_user.is_ldap_managed @@ -387,7 +393,11 @@ def add_security_headers(response): if not config.DEBUG: response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' - if initialize_storage and session.get('_user_id') is not None: + if ( + request.endpoint != 'static' + and initialize_storage + and session.get('_user_id') is not None + ): from .session_epoch import current_epoch session['_auth_epoch'] = current_epoch() return response diff --git a/app/static_delivery.py b/app/static_delivery.py new file mode 100644 index 0000000..f49a0fd --- /dev/null +++ b/app/static_delivery.py @@ -0,0 +1,241 @@ +"""Safe delivery policy for public, application-owned static assets.""" + +import gzip +import hashlib +import re +from functools import lru_cache, wraps +from pathlib import Path + +from flask import request, url_for +from flask.sessions import SecureCookieSessionInterface + + +_VERSION_PATTERN = re.compile(r"[0-9a-f]{16}") +_COMPRESSIBLE_MIMETYPES = ( + "application/javascript", + "application/x-javascript", + "image/svg+xml", + "text/css", + "text/javascript", +) +_ASSET_INDEX_EXTENSION = "static_delivery_asset_paths" +_DEFERRED_CONDITIONAL_ENV = "webssh.static_delivery.deferred_conditional" +_CONDITIONAL_ENV_KEYS = ( + "HTTP_IF_MATCH", + "HTTP_IF_NONE_MATCH", + "HTTP_IF_MODIFIED_SINCE", + "HTTP_IF_UNMODIFIED_SINCE", +) + + +def _remove_cookie_variance(response) -> None: + response.vary = [ + value for value in response.vary + if value.casefold() != "cookie" + ] + + +def _add_accept_encoding_variance(response) -> None: + if all(value.casefold() != "accept-encoding" for value in response.vary): + response.vary = [*response.vary, "Accept-Encoding"] + + +def _build_static_asset_index(app) -> dict[str, Path]: + """Index trusted files without joining request-controlled path segments.""" + if not app.static_folder: + raise RuntimeError("static delivery requires an application static folder") + try: + static_root = Path(app.static_folder).resolve(strict=True) + except OSError as exc: + raise RuntimeError("application static folder does not exist") from exc + + asset_paths = {} + for candidate in static_root.rglob("*"): + try: + resolved = candidate.resolve(strict=True) + resolved.relative_to(static_root) + except (OSError, ValueError): + continue + if resolved.is_file(): + asset_paths[candidate.relative_to(static_root).as_posix()] = resolved + return asset_paths + + +@lru_cache(maxsize=512) +def _content_version( + path: str, + modified_ns: int, + changed_ns: int, + size: int, +) -> str: + del modified_ns, changed_ns, size + digest = hashlib.sha256() + with open(path, "rb") as asset: + for chunk in iter(lambda: asset.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest()[:16] + + +def static_asset_version(app, filename: str) -> str | None: + """Return a content-derived cache key for one local static asset.""" + asset_paths = app.extensions.get(_ASSET_INDEX_EXTENSION, {}) + asset_path = asset_paths.get(filename) + if asset_path is None: + return None + try: + stat = asset_path.stat() + return _content_version( + str(asset_path), + stat.st_mtime_ns, + stat.st_ctime_ns, + stat.st_size, + ) + except OSError: + return None + + +def _compress_static_response(response): + """Apply deterministic gzip with an encoding-specific validator.""" + _add_accept_encoding_variance(response) + if ( + request.method not in {"GET", "HEAD"} + or response.status_code != 200 + or "Content-Range" in response.headers + or "Content-Encoding" in response.headers + or request.accept_encodings.best_match(("gzip", "identity")) != "gzip" + or ( + response.content_length is not None + and response.content_length < 1024 + ) + ): + return response + + response.direct_passthrough = False + plain = response.get_data() + compressed = gzip.compress(plain, compresslevel=6, mtime=0) + if len(compressed) >= len(plain): + return response + + etag, weak = response.get_etag() + if etag: + response.set_etag(f"{etag}:gzip", weak=weak) + response.set_data(compressed) + response.headers["Content-Encoding"] = "gzip" + response.headers["Content-Length"] = str(len(compressed)) + return response.make_conditional(request) + + +def _apply_deferred_static_condition(response): + deferred = request.environ.pop(_DEFERRED_CONDITIONAL_ENV, False) + if not deferred or response.status_code != 200: + return response + return response.make_conditional(request) + + +class _StaticAwareSessionInterface(SecureCookieSessionInterface): + """Remove artificial cookie variance after Flask saves the session.""" + + def save_session(self, app, session, response) -> None: + super().save_session(app, session, response) + if request.endpoint != "static": + return + if response.headers.getlist("Set-Cookie"): + response.headers["Cache-Control"] = "private, no-store" + else: + _remove_cookie_variance(response) + + +def _has_current_asset_version(app) -> bool: + """Return whether the only query key matches the current asset content.""" + versions = request.args.getlist("v") + if ( + len(request.args) != 1 + or len(versions) != 1 + or _VERSION_PATTERN.fullmatch(versions[0]) is None + ): + return False + filename = (request.view_args or {}).get("filename") + if not isinstance(filename, str): + return False + return versions[0] == static_asset_version(app, filename) + + +def init_static_delivery(app) -> None: + """Enable compression and explicit caching only for Flask's static route. + + Dynamic HTML and API responses are deliberately excluded so secrets, CSRF + tokens, and user-specific data never share a compression context. + """ + if not isinstance(app.session_interface, SecureCookieSessionInterface): + raise RuntimeError( + "static delivery requires Flask's secure-cookie session interface" + ) + app.session_interface = _StaticAwareSessionInterface() + app.extensions[_ASSET_INDEX_EXTENSION] = _build_static_asset_index(app) + + static_view = app.view_functions.get("static") + if static_view is None: + raise RuntimeError("static delivery requires Flask's static endpoint") + + @wraps(static_view) + def serve_static_with_negotiated_conditionals(**values): + deferred_headers = {} + if ( + request.method in {"GET", "HEAD"} + and "Range" not in request.headers + and request.accept_encodings.best_match(("gzip", "identity")) == "gzip" + ): + for key in _CONDITIONAL_ENV_KEYS: + value = request.environ.pop(key, None) + if value is not None: + deferred_headers[key] = value + if deferred_headers: + request.environ[_DEFERRED_CONDITIONAL_ENV] = True + try: + return static_view(**values) + finally: + request.environ.update(deferred_headers) + + app.view_functions["static"] = serve_static_with_negotiated_conditionals + + def static_asset_url(filename: str) -> str: + version = static_asset_version(app, filename) + if version is None: + raise FileNotFoundError(f"static asset does not exist: {filename}") + return url_for("static", filename=filename, v=version) + + app.jinja_env.globals["static_asset_url"] = static_asset_url + + @app.after_request + def optimize_static_delivery(response): + if request.endpoint != "static": + return response + + # Flask-Login checks its remember-cookie marker after every response, + # which marks the session as accessed even though static content does + # not depend on it. Remove that artificial variance for shared caches. + _remove_cookie_variance(response) + + if response.mimetype in _COMPRESSIBLE_MIMETYPES: + response = _compress_static_response(response) + response = _apply_deferred_static_condition(response) + + if response.headers.getlist("Set-Cookie"): + response.headers["Cache-Control"] = "private, no-store" + elif ( + request.method in {"GET", "HEAD"} + and response.status_code in {200, 206, 304} + and _has_current_asset_version(app) + ): + response.headers["Cache-Control"] = ( + "public, max-age=31536000, immutable" + ) + elif request.method not in {"GET", "HEAD"} or request.args: + response.headers["Cache-Control"] = "no-store" + else: + response.headers["Cache-Control"] = ( + "public, max-age=0, must-revalidate" + ) + response.headers.pop("Pragma", None) + response.headers.pop("Expires", None) + return response diff --git a/package.json b/package.json index c11a3b6..de017ca 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,10 @@ "scripts": { "vendor": "node scripts/vendor.js", "vendor:check": "node scripts/vendor.js --check", + "i18n:auth": "node scripts/build-auth-i18n.js", + "i18n:auth:check": "node scripts/build-auth-i18n.js --check", "lint:js": "eslint static/js", - "test:js": "node --test tests/js/*.test.js", + "test:js": "npm run i18n:auth:check && node --test tests/js/*.test.js", "test:e2e": "playwright test --forbid-only" }, "dependencies": { diff --git a/scripts/build-auth-i18n.js b/scripts/build-auth-i18n.js new file mode 100644 index 0000000..84a7b00 --- /dev/null +++ b/scripts/build-auth-i18n.js @@ -0,0 +1,97 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const projectRoot = path.resolve(__dirname, '..'); +const sourcePath = path.join(projectRoot, 'static/js/i18n.js'); +const outputPath = path.join(projectRoot, 'static/js/i18n-auth.js'); +const surfacePaths = [ + 'templates/login.html', + 'templates/register.html', + 'templates/change_password.html', + 'static/js/auth.js', + 'static/js/webauthn.js', + 'static/js/security-ui.js', +]; + +function read(relativePath) { + return fs.readFileSync(path.join(projectRoot, relativePath), 'utf8'); +} + +function parseTranslations(source) { + const declaration = 'const translations = '; + const runtimeMarker = 'const BrowserPreferences = '; + const objectStart = source.indexOf(declaration); + const runtimeStart = source.indexOf(runtimeMarker); + if (objectStart < 0 || runtimeStart < 0 || runtimeStart <= objectStart) { + throw new Error('Unable to locate the translation table and runtime.'); + } + const objectLiteral = source + .slice(objectStart + declaration.length, runtimeStart) + .trim() + .replace(/;$/, ''); + const translations = vm.runInNewContext(`(${objectLiteral})`, Object.create(null)); + return { + translations, + runtime: source.slice(runtimeStart).trimStart(), + }; +} + +function referencedTranslationKeys(translations) { + const availableKeys = new Set(Object.keys(translations.en || {})); + const keys = new Set(); + const keyPattern = /["']([a-z][a-zA-Z0-9_-]*\.[a-zA-Z0-9_.-]+)["']/g; + + for (const relativePath of surfacePaths) { + const source = read(relativePath); + let match; + while ((match = keyPattern.exec(source)) !== null) { + if (availableKeys.has(match[1])) { + keys.add(match[1]); + } + } + } + return [...keys].sort(); +} + +function build() { + const fullSource = fs.readFileSync(sourcePath, 'utf8'); + const { translations, runtime } = parseTranslations(fullSource); + const locales = Object.keys(translations); + const keys = referencedTranslationKeys(translations); + if (locales.length === 0 || keys.length === 0) { + throw new Error('Refusing to generate an empty authentication bundle.'); + } + + const selected = Object.fromEntries(locales.map(locale => { + const localeTranslations = translations[locale]; + const missing = keys.filter(key => !(key in localeTranslations)); + if (missing.length > 0) { + throw new Error(`${locale} is missing authentication keys: ${missing.join(', ')}`); + } + return [locale, Object.fromEntries(keys.map(key => [key, localeTranslations[key]]))]; + })); + + return [ + '// Generated by scripts/build-auth-i18n.js. Do not edit directly.', + `const translations = ${JSON.stringify(selected, null, 4)};`, + '', + runtime.trimEnd(), + '', + ].join('\n'); +} + +const generated = build(); +if (process.argv.includes('--check')) { + const committed = fs.existsSync(outputPath) + ? fs.readFileSync(outputPath, 'utf8') + : ''; + if (committed !== generated) { + console.error('static/js/i18n-auth.js is stale; run npm run i18n:auth.'); + process.exitCode = 1; + } +} else { + fs.writeFileSync(outputPath, generated, 'utf8'); +} diff --git a/static/css/style.css b/static/css/style.css index 997fc55..18d6faa 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -97,6 +97,11 @@ body { max-width: 100vw; position: relative; isolation: isolate; + --theme-background-image: var(--theme-background-image-source); +} + +body[data-defer-theme-background]:not([data-theme-background-ready]) { + --theme-background-image: none; } body::before { @@ -138,7 +143,7 @@ html { } body[data-theme="glass"] { - --theme-background-image: url("../images/theme-backgrounds/carbon-glass.png"); + --theme-background-image-source: url("../images/theme-backgrounds/carbon-glass.png?v=95ff9eb547df4609"); --theme-background-opacity: 0.07; } @@ -171,7 +176,7 @@ body[data-theme="retro"] { --term-foreground: #eee4d6; --term-yellow: #dca144; --term-bright-yellow: #efbd65; - --theme-background-image: url("../images/theme-backgrounds/retro-amber.png"); + --theme-background-image-source: url("../images/theme-backgrounds/retro-amber.png?v=6dfcfa4b2da2ed89"); --theme-background-opacity: 0.16; } @@ -202,7 +207,7 @@ body[data-theme="solar"] { --term-foreground: #e8f0f8; --term-blue: #65a0ed; --term-bright-blue: #8ab9f3; - --theme-background-image: url("../images/theme-backgrounds/navy-topography.png"); + --theme-background-image-source: url("../images/theme-backgrounds/navy-topography.png?v=2fc2ee91cd3a367a"); --theme-background-opacity: 0.065; } @@ -251,7 +256,7 @@ body[data-theme="paper"] { --term-bright-magenta: #9165ab; --term-bright-cyan: #328b94; --term-bright-white: #15283a; - --theme-background-image: url("../images/theme-backgrounds/paper-blueprint.png"); + --theme-background-image-source: url("../images/theme-backgrounds/paper-blueprint.png?v=6446debbede0159c"); --theme-background-opacity: 0.18; --theme-surface-shell: color-mix(in srgb, var(--bg-secondary) 62%, transparent); --theme-surface-modal: color-mix(in srgb, var(--bg-modal) 68%, transparent); @@ -300,7 +305,7 @@ body[data-theme="noir"] { --term-bright-magenta: #deaeff; --term-bright-cyan: #a0f0ff; --term-bright-white: #ffffff; - --theme-background-image: url("../images/theme-backgrounds/noir-architecture.png"); + --theme-background-image-source: url("../images/theme-backgrounds/noir-architecture.png?v=19e5587f9a64e767"); --theme-background-opacity: 0.06; } @@ -347,7 +352,7 @@ body[data-theme="arctic-ice"] { --term-bright-magenta: #dac8ff; --term-bright-cyan: #56d8f0; --term-bright-white: #ffffff; - --theme-background-image: url("../images/theme-backgrounds/arctic-frost.png"); + --theme-background-image-source: url("../images/theme-backgrounds/arctic-frost.png?v=9ce8ec48bcfb1cec"); --theme-background-opacity: 0.07; } @@ -393,7 +398,7 @@ body[data-theme="rose-gold"] { --term-bright-magenta: #ffb8d4; --term-bright-cyan: #a0f0dc; --term-bright-white: #ffffff; - --theme-background-image: url("../images/theme-backgrounds/rose-brushed-metal.png"); + --theme-background-image-source: url("../images/theme-backgrounds/rose-brushed-metal.png?v=b52519a406770ed3"); --theme-background-opacity: 0.06; } @@ -439,7 +444,7 @@ body[data-theme="cyberpunk-neon"] { --term-bright-magenta: #f070ff; --term-bright-cyan: #33f5ff; --term-bright-white: #ffffff; - --theme-background-image: url("../images/theme-backgrounds/neon-circuit.png"); + --theme-background-image-source: url("../images/theme-backgrounds/neon-circuit.png?v=8b50e5a15d57eedb"); --theme-background-opacity: 0.14; } @@ -486,7 +491,7 @@ body[data-theme="emerald-matrix"] { --term-bright-magenta: #70f0d4; --term-bright-cyan: #6ee7b7; --term-bright-white: #ffffff; - --theme-background-image: url("../images/theme-backgrounds/matrix-signal.png"); + --theme-background-image-source: url("../images/theme-backgrounds/matrix-signal.png?v=6c7915bd78ae16d6"); --theme-background-opacity: 0.18; } @@ -533,7 +538,7 @@ body[data-theme="obsidian"] { --term-bright-magenta: #f070ff; --term-bright-cyan: #56d8f0; --term-bright-white: #ffffff; - --theme-background-image: url("../images/theme-backgrounds/obsidian-glass.png"); + --theme-background-image-source: url("../images/theme-backgrounds/obsidian-glass.png?v=4a998eeddd245ec7"); --theme-background-opacity: 0.055; } diff --git a/static/js/i18n-auth.js b/static/js/i18n-auth.js new file mode 100644 index 0000000..339d273 --- /dev/null +++ b/static/js/i18n-auth.js @@ -0,0 +1,1066 @@ +// Generated by scripts/build-auth-i18n.js. Do not edit directly. +const translations = { + "en": { + "admin.auditExportFailed": "Audit export failed (HTTP {status}).", + "admin.disableFeatureWarning": "Disable {feature}? If this is your current sign-in method, you may not be able to sign in again. Existing browser, SSH, and tmux sessions remain available until their normal timeout; this change does not terminate them. New logins and factor setup use the new rule immediately.", + "admin.featureActive": "{feature} is active.", + "admin.featureAdminDisabled": "{feature} is available but not activated in the admin panel.", + "admin.featureDeploymentDisabled": "{feature} is disabled by deployment configuration.", + "admin.featureNotReady": "{feature} is configured but not ready.", + "auth.accountSecurity": "Account security", + "auth.authenticationSource": "Authentication source", + "auth.availableVerificationMethods": "Available verification methods", + "auth.backToApp": "Back to App", + "auth.backToSignIn": "Back to sign in", + "auth.changePassword": "Change Password", + "auth.changePasswordPageTitle": "Change password · WebSSH", + "auth.changePasswordPrompt": "Update your account password", + "auth.chooseSignInMethod": "Choose how to sign in", + "auth.codeVerificationFailed": "The code could not be verified.", + "auth.confirmNewPassword": "Confirm New Password", + "auth.confirmPassword": "Confirm Password", + "auth.continueWithRecoveryCode": "Continue with recovery code", + "auth.createAccount": "Create Account", + "auth.currentPassword": "Current Password", + "auth.currentPasswordRequired": "Current password is required.", + "auth.deviceAuthentication": "Device authentication", + "auth.directoryUsername": "Directory username", + "auth.enterAuthenticatorCode": "Enter the six-digit code from your authenticator app.", + "auth.githubPrompt": "Continue with GitHub", + "auth.githubPromptHint": "WebSSH uses GitHub only to verify your identity and configured organization membership.", + "auth.githubSignInHint": "Use your linked GitHub account", + "auth.haveAccount": "Already have an account?", + "auth.identityProviderPrompt": "Continue with your identity provider", + "auth.identityProviderPromptHint": "WebSSH redirects you to the configured provider. Provider credentials are never entered here.", + "auth.instanceStatus": "WebSSH instance", + "auth.localAccessHint": "Account managed by this WebSSH instance", + "auth.localAccount": "Local account", + "auth.localCredentials": "Local credentials", + "auth.localOrDirectory": "Local / Directory", + "auth.localRegistration": "Local registration", + "auth.login": "Sign In", + "auth.loginHere": "Sign in here", + "auth.loginPageTitle": "Sign in · WebSSH", + "auth.newPassword": "New Password", + "auth.noAccount": "Don't have an account?", + "auth.openConfiguredAddress": "Open configured WebSSH address", + "auth.organizationSso": "SSO with your organization", + "auth.passkeyAccessHint": "Use a Passkey registered to your account", + "auth.passkeyFailed": "Passkey sign-in could not be completed. Try again or use another sign-in method.", + "auth.passkeyNotAllowed": "Passkey sign-in was cancelled or no matching Passkey was available.", + "auth.passkeyOriginMismatch": "Passkeys are configured for {origin}. Open that address and try again.", + "auth.passkeyPrompt": "Use a Passkey", + "auth.passkeyPromptHint": "Authenticate with a Passkey saved on this device or another nearby device.", + "auth.passkeySecurityError": "Passkey sign-in is unavailable at this address. Open the configured WebSSH address and try again.", + "auth.passkeyUnsupported": "This browser cannot use Passkeys for this WebSSH instance.", + "auth.password": "Password", + "auth.passwordHint": "Minimum 8 characters", + "auth.passwordOverviewHint": "If you sign in through LDAP or OIDC, change that password with your identity provider instead of on this page.", + "auth.passwordOverviewTitle": "This password belongs only to your local WebSSH account.", + "auth.passwordStrongEnough": "Strong enough", + "auth.passwordsMatch": "Passwords match", + "auth.passwordsNoMatch": "Passwords do not match", + "auth.productAreas": "WebSSH product areas", + "auth.recoveryAccessHint": "One-time access followed by factor repair", + "auth.recoveryAuthenticationFailed": "Recovery authentication failed.", + "auth.recoveryCode": "Recovery code", + "auth.recoveryCodeRequired": "Enter a recovery code.", + "auth.recoverySessionHint": "This creates a restricted session where you must add a replacement factor or explicitly disable MFA.", + "auth.registerHere": "Register here", + "auth.registerPageTitle": "Create account · WebSSH", + "auth.registerPrompt": "Create a local WebSSH account", + "auth.registration": "Registration", + "auth.registrationAvailable": "Available on this instance", + "auth.registrationHint": "Choose credentials for this instance. You can add stronger factors after sign-in.", + "auth.rememberMe": "Remember me", + "auth.serverControlCenter": "The control center for your servers.", + "auth.signInAvailable": "Sign-in available", + "auth.signInPrompt": "Access your SSH workspace", + "auth.signInSourceHint": "Use the identity source configured for this WebSSH instance.", + "auth.signInWithGithub": "Sign in with GitHub", + "auth.signInWithIdentityProvider": "Sign in with identity provider", + "auth.signInWithPasskey": "Sign in with passkey", + "auth.togglePasswordVisibility": "Toggle password visibility", + "auth.twoFactorAuthentication": "Two-factor authentication", + "auth.updatePassword": "Update Password", + "auth.useRecoveryCode": "Use recovery code", + "auth.username": "Username", + "auth.usernameAndPassword": "Username and password", + "auth.usernameRequired": "Username required", + "auth.usernameRules": "3-32 characters: letters, numbers, and underscore only", + "auth.usernameRulesShort": "3-32 chars, letters/numbers/_", + "auth.validUsername": "Valid username", + "auth.validationLooksGood": "Looks good", + "auth.verificationOverviewEyebrow": "Confirm sign-in", + "auth.verificationOverviewHint": "Use one of your available factors for this second step. A recovery code opens a restricted session where you renew your account protection.", + "auth.verificationOverviewTitle": "Confirm that it is really you.", + "auth.verifyCode": "Verify code", + "auth.websshSignIn": "WebSSH sign in", + "auth.welcomeToWebssh": "Welcome to WebSSH", + "brand.tagline": "Your shell. Your rules.", + "commands.workspace": "Commands", + "common.delete": "Delete", + "common.requestFailed": "Request failed (HTTP {status}).", + "connectionAssets.hosts": "Hosts", + "files.fileManager": "File Manager", + "navigation.sshWorkspaces": "SSH Workspaces", + "navigation.websshWorkspaces": "WebSSH workspaces", + "security.accountIdentityUnavailable": "Account identity is unavailable", + "security.authenticatorCode": "Authenticator code", + "security.authenticatorDefaultName": "Authenticator app", + "security.authenticatorDeleted": "Authenticator app deleted", + "security.authenticatorName": "Authenticator name", + "security.certificateAuthority": "Certificate authority", + "security.chooseConfirmationMethod": "Choose how you want to confirm this security change.", + "security.confirmAccountName": "Type your account name to confirm", + "security.confirmDeleteAuthenticator": "Delete this authenticator app? Make sure another MFA method remains available.", + "security.confirmDeleteAuthority": "Really delete the certificate authority for {host}?", + "security.confirmDeletePasskey": "Delete this passkey? Make sure another sign-in method remains available.", + "security.confirmDeleteTrust": "Really delete trust for {host}?", + "security.confirmDisableMfa": "Disable every MFA factor?", + "security.confirmDisableMfaAndRemoveTotp": "Disable MFA and remove all authenticator apps? This cannot be undone.", + "security.confirmEnablePasskeyMfa": "Require a Passkey, authenticator app, or recovery code after every password or directory sign-in?", + "security.confirmFactorChange": "Confirm this account security change.", + "security.confirmRemoveRevocation": "Really remove the revocation for {host}?", + "security.confirmWithDirectory": "Confirm with the password you use for directory sign-in.", + "security.confirmWithTotp": "Enter a current code from your authenticator app.", + "security.connectGithub": "Connect GitHub", + "security.deleteAuthority": "Delete authority", + "security.deleteTrust": "Delete trust", + "security.directoryPassword": "Directory password", + "security.disconnectGithub": "Disconnect GitHub", + "security.disconnectGithubConfirm": "Disconnect this GitHub identity from your WebSSH account?", + "security.extraProtectionEnabled": "A registered strong factor is required for protected changes.", + "security.extraProtectionOptional": "Passkeys and authenticator apps are optional for this account.", + "security.githubConnectedAs": "Connected as {login}", + "security.githubDisconnected": "GitHub disconnected", + "security.githubNotConnected": "Not connected", + "security.hostKeyRevoked": "Revoked key", + "security.hostKeyTrusted": "Trusted key", + "security.invalidTotpCode": "Enter a valid six-digit authenticator code.", + "security.legacyPasskeyConfirm": "Create a replacement passkey? Test it before deleting the old passkey.", + "security.methodGithub": "GitHub", + "security.methodLdap": "Directory password", + "security.methodOidc": "Identity provider", + "security.methodPasskey": "Passkey", + "security.methodPassword": "WebSSH password", + "security.methodRecoveryCode": "Recovery code", + "security.methodTotp": "Authenticator app", + "security.mfaDisabled": "MFA disabled", + "security.mfaEnabled": "MFA enabled", + "security.mfaOptional": "MFA optional", + "security.noPasskeys": "No passkey is registered.", + "security.noTotpAuthenticators": "No authenticator app is enrolled.", + "security.passkeyAdded": "Passkey added", + "security.passkeyDefaultName": "My passkey", + "security.passkeyName": "Passkey name", + "security.passkeysUnsupported": "Passkeys are not supported by this browser.", + "security.removeRevocation": "Remove revocation", + "security.replacementPasskey": "Replacement passkey", + "security.storeRecoveryCodes": "Store these recovery codes securely. They will not be shown again." + }, + "vi": { + "admin.auditExportFailed": "Xuất nhật ký kiểm tra thất bại (HTTP {status}).", + "admin.disableFeatureWarning": "Tắt {feature}? Nếu đây là phương thức đăng nhập hiện tại, bạn có thể không đăng nhập lại được. Các phiên trình duyệt, SSH và tmux hiện có vẫn hoạt động đến thời hạn bình thường; thay đổi này không kết thúc chúng. Đăng nhập mới và thiết lập yếu tố sẽ áp dụng quy tắc mới ngay lập tức.", + "admin.featureActive": "{feature} đang hoạt động.", + "admin.featureAdminDisabled": "{feature} khả dụng nhưng chưa được bật trong bảng quản trị.", + "admin.featureDeploymentDisabled": "{feature} bị tắt trong cấu hình triển khai.", + "admin.featureNotReady": "{feature} đã được cấu hình nhưng chưa sẵn sàng.", + "auth.accountSecurity": "Bảo mật tài khoản", + "auth.authenticationSource": "Nguồn xác thực", + "auth.availableVerificationMethods": "Các phương thức xác minh khả dụng", + "auth.backToApp": "Quay lại ứng dụng", + "auth.backToSignIn": "Quay lại đăng nhập", + "auth.changePassword": "Đổi mật khẩu", + "auth.changePasswordPageTitle": "Đổi mật khẩu · WebSSH", + "auth.changePasswordPrompt": "Cập nhật mật khẩu tài khoản", + "auth.chooseSignInMethod": "Chọn cách đăng nhập", + "auth.codeVerificationFailed": "Không thể xác minh mã.", + "auth.confirmNewPassword": "Xác nhận mật khẩu mới", + "auth.confirmPassword": "Xác nhận mật khẩu", + "auth.continueWithRecoveryCode": "Tiếp tục bằng mã khôi phục", + "auth.createAccount": "Tạo tài khoản", + "auth.currentPassword": "Mật khẩu hiện tại", + "auth.currentPasswordRequired": "Cần nhập mật khẩu hiện tại.", + "auth.deviceAuthentication": "Xác thực bằng thiết bị", + "auth.directoryUsername": "Tên người dùng thư mục", + "auth.enterAuthenticatorCode": "Nhập mã sáu chữ số từ ứng dụng xác thực.", + "auth.githubPrompt": "Tiếp tục với GitHub", + "auth.githubPromptHint": "WebSSH chỉ dùng GitHub để xác minh danh tính và tư cách thành viên tổ chức đã cấu hình.", + "auth.githubSignInHint": "Sử dụng tài khoản GitHub đã liên kết", + "auth.haveAccount": "Đã có tài khoản?", + "auth.identityProviderPrompt": "Tiếp tục với nhà cung cấp danh tính", + "auth.identityProviderPromptHint": "WebSSH chuyển hướng bạn đến nhà cung cấp đã cấu hình. Thông tin đăng nhập của nhà cung cấp không bao giờ được nhập tại đây.", + "auth.instanceStatus": "Phiên bản WebSSH", + "auth.localAccessHint": "Tài khoản do phiên bản WebSSH này quản lý", + "auth.localAccount": "Tài khoản cục bộ", + "auth.localCredentials": "Thông tin đăng nhập cục bộ", + "auth.localOrDirectory": "Cục bộ / Thư mục", + "auth.localRegistration": "Đăng ký cục bộ", + "auth.login": "Đăng nhập", + "auth.loginHere": "Đăng nhập tại đây", + "auth.loginPageTitle": "Đăng nhập · WebSSH", + "auth.newPassword": "Mật khẩu mới", + "auth.noAccount": "Chưa có tài khoản?", + "auth.openConfiguredAddress": "Mở địa chỉ WebSSH đã cấu hình", + "auth.organizationSso": "SSO với tổ chức của bạn", + "auth.passkeyAccessHint": "Dùng Passkey đã đăng ký cho tài khoản", + "auth.passkeyFailed": "Không thể hoàn tất đăng nhập bằng passkey. Hãy thử lại hoặc dùng phương thức đăng nhập khác.", + "auth.passkeyNotAllowed": "Đăng nhập bằng passkey đã bị hủy hoặc không có passkey phù hợp.", + "auth.passkeyOriginMismatch": "Passkey được cấu hình cho {origin}. Hãy mở địa chỉ đó và thử lại.", + "auth.passkeyPrompt": "Sử dụng passkey", + "auth.passkeyPromptHint": "Xác thực bằng passkey được lưu trên thiết bị này hoặc một thiết bị ở gần.", + "auth.passkeySecurityError": "Không thể đăng nhập bằng passkey tại địa chỉ này. Hãy mở địa chỉ WebSSH đã cấu hình và thử lại.", + "auth.passkeyUnsupported": "Trình duyệt này không thể dùng passkey cho phiên bản WebSSH này.", + "auth.password": "Mật khẩu", + "auth.passwordHint": "Tối thiểu 8 ký tự", + "auth.passwordOverviewHint": "Nếu bạn đăng nhập qua LDAP hoặc OIDC, hãy đổi mật khẩu đó tại nhà cung cấp danh tính thay vì trên trang này.", + "auth.passwordOverviewTitle": "Mật khẩu này chỉ thuộc về tài khoản WebSSH cục bộ của bạn.", + "auth.passwordStrongEnough": "Đủ mạnh", + "auth.passwordsMatch": "Mật khẩu trùng khớp", + "auth.passwordsNoMatch": "Mật khẩu không trùng khớp", + "auth.productAreas": "Khu vực sản phẩm WebSSH", + "auth.recoveryAccessHint": "Truy cập một lần, sau đó thiết lập lại yếu tố", + "auth.recoveryAuthenticationFailed": "Xác thực khôi phục thất bại.", + "auth.recoveryCode": "Mã khôi phục", + "auth.recoveryCodeRequired": "Nhập mã khôi phục.", + "auth.recoverySessionHint": "Thao tác này tạo một phiên bị giới hạn, trong đó bạn phải thêm yếu tố thay thế hoặc chủ động tắt MFA.", + "auth.registerHere": "Đăng ký tại đây", + "auth.registerPageTitle": "Tạo tài khoản · WebSSH", + "auth.registerPrompt": "Tạo tài khoản WebSSH cục bộ", + "auth.registration": "Đăng ký", + "auth.registrationAvailable": "Khả dụng trên phiên bản này", + "auth.registrationHint": "Chọn thông tin đăng nhập cho phiên bản này. Bạn có thể thêm các yếu tố mạnh hơn sau khi đăng nhập.", + "auth.rememberMe": "Ghi nhớ đăng nhập", + "auth.serverControlCenter": "Trung tâm điều khiển máy chủ của bạn.", + "auth.signInAvailable": "Có thể đăng nhập", + "auth.signInPrompt": "Truy cập không gian làm việc SSH của bạn", + "auth.signInSourceHint": "Sử dụng nguồn danh tính được cấu hình cho phiên bản WebSSH này.", + "auth.signInWithGithub": "Đăng nhập bằng GitHub", + "auth.signInWithIdentityProvider": "Đăng nhập bằng nhà cung cấp danh tính", + "auth.signInWithPasskey": "Đăng nhập bằng passkey", + "auth.togglePasswordVisibility": "Ẩn hoặc hiện mật khẩu", + "auth.twoFactorAuthentication": "Xác thực hai yếu tố", + "auth.updatePassword": "Cập nhật mật khẩu", + "auth.useRecoveryCode": "Sử dụng mã khôi phục", + "auth.username": "Tên đăng nhập", + "auth.usernameAndPassword": "Tên người dùng và mật khẩu", + "auth.usernameRequired": "Cần nhập tên người dùng", + "auth.usernameRules": "3-32 ký tự: chỉ chữ cái, chữ số và dấu gạch dưới", + "auth.usernameRulesShort": "3-32 ký tự, chữ/số/_", + "auth.validUsername": "Tên người dùng hợp lệ", + "auth.validationLooksGood": "Hợp lệ", + "auth.verificationOverviewEyebrow": "Xác nhận đăng nhập", + "auth.verificationOverviewHint": "Dùng một yếu tố hiện có cho bước thứ hai. Mã khôi phục sẽ mở một phiên bị giới hạn để bạn thiết lập lại biện pháp bảo vệ tài khoản.", + "auth.verificationOverviewTitle": "Xác nhận đúng là bạn.", + "auth.verifyCode": "Xác minh mã", + "auth.websshSignIn": "Đăng nhập WebSSH", + "auth.welcomeToWebssh": "Chào mừng đến với WebSSH", + "brand.tagline": "Shell của bạn. Quy tắc của bạn.", + "commands.workspace": "Lệnh", + "common.delete": "Xóa", + "common.requestFailed": "Yêu cầu thất bại (HTTP {status}).", + "connectionAssets.hosts": "Máy chủ", + "files.fileManager": "Trình quản lý tệp", + "navigation.sshWorkspaces": "Không gian làm việc SSH", + "navigation.websshWorkspaces": "Không gian làm việc WebSSH", + "security.accountIdentityUnavailable": "Không có danh tính tài khoản", + "security.authenticatorCode": "Mã xác thực", + "security.authenticatorDefaultName": "Ứng dụng xác thực", + "security.authenticatorDeleted": "Đã xóa ứng dụng xác thực", + "security.authenticatorName": "Tên ứng dụng xác thực", + "security.certificateAuthority": "Tổ chức chứng thực", + "security.chooseConfirmationMethod": "Chọn cách bạn muốn xác nhận thay đổi bảo mật này.", + "security.confirmAccountName": "Nhập tên tài khoản để xác nhận", + "security.confirmDeleteAuthenticator": "Xóa ứng dụng xác thực này? Hãy đảm bảo vẫn còn một phương thức MFA khác.", + "security.confirmDeleteAuthority": "Bạn có thực sự muốn xóa tổ chức chứng thực cho {host} không?", + "security.confirmDeletePasskey": "Xóa passkey này? Hãy đảm bảo vẫn còn một phương thức đăng nhập khác.", + "security.confirmDeleteTrust": "Bạn có thực sự muốn xóa tin cậy cho {host} không?", + "security.confirmDisableMfa": "Tắt tất cả các yếu tố MFA?", + "security.confirmDisableMfaAndRemoveTotp": "Tắt MFA và xóa tất cả ứng dụng xác thực? Không thể hoàn tác thao tác này.", + "security.confirmEnablePasskeyMfa": "Yêu cầu Passkey, ứng dụng xác thực hoặc mã khôi phục sau mỗi lần đăng nhập bằng mật khẩu hoặc thư mục?", + "security.confirmFactorChange": "Xác nhận thay đổi bảo mật cho tài khoản này.", + "security.confirmRemoveRevocation": "Bạn có thực sự muốn gỡ trạng thái thu hồi cho {host} không?", + "security.confirmWithDirectory": "Xác nhận bằng mật khẩu bạn dùng để đăng nhập thư mục.", + "security.confirmWithTotp": "Nhập mã hiện tại từ ứng dụng xác thực.", + "security.connectGithub": "Kết nối GitHub", + "security.deleteAuthority": "Xóa tổ chức chứng thực", + "security.deleteTrust": "Xóa tin cậy", + "security.directoryPassword": "Mật khẩu thư mục", + "security.disconnectGithub": "Ngắt kết nối GitHub", + "security.disconnectGithubConfirm": "Ngắt liên kết danh tính GitHub này khỏi tài khoản WebSSH?", + "security.extraProtectionEnabled": "Cần một yếu tố mạnh đã đăng ký cho các thay đổi được bảo vệ.", + "security.extraProtectionOptional": "Passkey và ứng dụng xác thực là tùy chọn cho tài khoản này.", + "security.githubConnectedAs": "Đã kết nối dưới tên {login}", + "security.githubDisconnected": "Đã ngắt kết nối GitHub", + "security.githubNotConnected": "Chưa kết nối", + "security.hostKeyRevoked": "Khóa đã bị thu hồi", + "security.hostKeyTrusted": "Khóa đáng tin cậy", + "security.invalidTotpCode": "Nhập mã xác thực gồm sáu chữ số hợp lệ.", + "security.legacyPasskeyConfirm": "Tạo passkey thay thế? Hãy kiểm tra nó trước khi xóa passkey cũ.", + "security.methodGithub": "GitHub", + "security.methodLdap": "Mật khẩu thư mục", + "security.methodOidc": "Nhà cung cấp danh tính", + "security.methodPasskey": "Passkey", + "security.methodPassword": "Mật khẩu WebSSH", + "security.methodRecoveryCode": "Mã khôi phục", + "security.methodTotp": "Ứng dụng xác thực", + "security.mfaDisabled": "Đã tắt MFA", + "security.mfaEnabled": "Đã bật MFA", + "security.mfaOptional": "MFA tùy chọn", + "security.noPasskeys": "Chưa đăng ký passkey nào.", + "security.noTotpAuthenticators": "Chưa đăng ký ứng dụng xác thực.", + "security.passkeyAdded": "Đã thêm passkey", + "security.passkeyDefaultName": "Passkey của tôi", + "security.passkeyName": "Tên passkey", + "security.passkeysUnsupported": "Trình duyệt này không hỗ trợ passkey.", + "security.removeRevocation": "Gỡ thu hồi", + "security.replacementPasskey": "Passkey thay thế", + "security.storeRecoveryCodes": "Lưu các mã khôi phục này ở nơi an toàn. Chúng sẽ không được hiển thị lại." + }, + "de": { + "admin.auditExportFailed": "Audit-Export fehlgeschlagen (HTTP {status}).", + "admin.disableFeatureWarning": "{feature} deaktivieren? Wenn dies Ihre aktuelle Anmeldemethode ist, können Sie sich möglicherweise nicht erneut anmelden. Bestehende Browser-, SSH- und tmux-Sitzungen bleiben bis zu ihrem regulären Ablauf verfügbar; diese Änderung beendet sie nicht. Neue Anmeldungen und Faktoreinrichtungen verwenden die neue Regel sofort.", + "admin.featureActive": "{feature} ist aktiv.", + "admin.featureAdminDisabled": "{feature} ist verfügbar, aber im Adminbereich nicht aktiviert.", + "admin.featureDeploymentDisabled": "{feature} ist durch die Deployment-Konfiguration deaktiviert.", + "admin.featureNotReady": "{feature} ist konfiguriert, aber nicht bereit.", + "auth.accountSecurity": "Kontosicherheit", + "auth.authenticationSource": "Authentifizierungsquelle", + "auth.availableVerificationMethods": "Verfügbare Bestätigungsverfahren", + "auth.backToApp": "Zurück zur App", + "auth.backToSignIn": "Zurück zur Anmeldung", + "auth.changePassword": "Passwort ändern", + "auth.changePasswordPageTitle": "Passwort ändern · WebSSH", + "auth.changePasswordPrompt": "Aktualisieren Sie Ihr Kontopasswort", + "auth.chooseSignInMethod": "Anmeldemethode auswählen", + "auth.codeVerificationFailed": "Der Code konnte nicht überprüft werden.", + "auth.confirmNewPassword": "Neues Passwort bestätigen", + "auth.confirmPassword": "Passwort bestätigen", + "auth.continueWithRecoveryCode": "Mit Wiederherstellungscode fortfahren", + "auth.createAccount": "Konto erstellen", + "auth.currentPassword": "Aktuelles Passwort", + "auth.currentPasswordRequired": "Das aktuelle Passwort ist erforderlich.", + "auth.deviceAuthentication": "Geräteauthentifizierung", + "auth.directoryUsername": "Verzeichnisbenutzername", + "auth.enterAuthenticatorCode": "Gib den sechsstelligen Code aus deiner Authenticator-App ein.", + "auth.githubPrompt": "Mit GitHub fortfahren", + "auth.githubPromptHint": "WebSSH verwendet GitHub nur zur Prüfung deiner Identität und der konfigurierten Organisationsmitgliedschaft.", + "auth.githubSignInHint": "Verknüpftes GitHub-Konto verwenden", + "auth.haveAccount": "Bereits ein Konto?", + "auth.identityProviderPrompt": "Mit deinem Identitätsanbieter fortfahren", + "auth.identityProviderPromptHint": "WebSSH leitet dich zum konfigurierten Anbieter weiter. Zugangsdaten des Anbieters werden hier nie eingegeben.", + "auth.instanceStatus": "WebSSH-Instanz", + "auth.localAccessHint": "Konto dieser WebSSH-Instanz", + "auth.localAccount": "Lokales Konto", + "auth.localCredentials": "Lokale Zugangsdaten", + "auth.localOrDirectory": "Lokal / Verzeichnis", + "auth.localRegistration": "Lokale Registrierung", + "auth.login": "Anmelden", + "auth.loginHere": "Hier anmelden", + "auth.loginPageTitle": "Anmelden · WebSSH", + "auth.newPassword": "Neues Passwort", + "auth.noAccount": "Noch kein Konto?", + "auth.openConfiguredAddress": "Konfigurierte WebSSH-Adresse öffnen", + "auth.organizationSso": "SSO mit deiner Organisation", + "auth.passkeyAccessHint": "Einen für dein Konto registrierten Passkey verwenden", + "auth.passkeyFailed": "Die Passkey-Anmeldung konnte nicht abgeschlossen werden. Versuche es erneut oder verwende eine andere Anmeldemethode.", + "auth.passkeyNotAllowed": "Die Passkey-Anmeldung wurde abgebrochen oder es war kein passender Passkey verfügbar.", + "auth.passkeyOriginMismatch": "Passkeys sind für {origin} konfiguriert. Öffne diese Adresse und versuche es erneut.", + "auth.passkeyPrompt": "Passkey verwenden", + "auth.passkeyPromptHint": "Authentifiziere dich mit einem Passkey auf diesem oder einem anderen Gerät in der Nähe.", + "auth.passkeySecurityError": "Die Passkey-Anmeldung ist unter dieser Adresse nicht verfügbar. Öffne die konfigurierte WebSSH-Adresse und versuche es erneut.", + "auth.passkeyUnsupported": "Dieser Browser kann für diese WebSSH-Instanz keine Passkeys verwenden.", + "auth.password": "Passwort", + "auth.passwordHint": "Mindestens 8 Zeichen", + "auth.passwordOverviewHint": "Wenn du dich über LDAP oder OIDC anmeldest, änderst du das Kennwort beim jeweiligen Identitätsanbieter und nicht auf dieser Seite.", + "auth.passwordOverviewTitle": "Dieses Kennwort gehört nur zu deinem lokalen WebSSH-Konto.", + "auth.passwordStrongEnough": "Ausreichend sicher", + "auth.passwordsMatch": "Passwörter stimmen überein", + "auth.passwordsNoMatch": "Passwörter stimmen nicht überein", + "auth.productAreas": "WebSSH-Produktbereiche", + "auth.recoveryAccessHint": "Einmaliger Zugriff mit anschließender Faktor-Reparatur", + "auth.recoveryAuthenticationFailed": "Die Wiederherstellungsanmeldung ist fehlgeschlagen.", + "auth.recoveryCode": "Wiederherstellungscode", + "auth.recoveryCodeRequired": "Gib einen Wiederherstellungscode ein.", + "auth.recoverySessionHint": "Dadurch wird eine eingeschränkte Sitzung erstellt, in der du einen Ersatzfaktor hinzufügen oder MFA ausdrücklich deaktivieren musst.", + "auth.registerHere": "Hier registrieren", + "auth.registerPageTitle": "Konto erstellen · WebSSH", + "auth.registerPrompt": "Lokales WebSSH-Konto erstellen", + "auth.registration": "Registrierung", + "auth.registrationAvailable": "Auf dieser Instanz verfügbar", + "auth.registrationHint": "Wähle Zugangsdaten für diese Instanz. Nach der Anmeldung kannst du stärkere Faktoren hinzufügen.", + "auth.rememberMe": "Angemeldet bleiben", + "auth.serverControlCenter": "Die Schaltzentrale für deine Server.", + "auth.signInAvailable": "Anmeldung verfügbar", + "auth.signInPrompt": "Auf Ihren SSH-Arbeitsbereich zugreifen", + "auth.signInSourceHint": "Verwende die für diese WebSSH-Instanz konfigurierte Identitätsquelle.", + "auth.signInWithGithub": "Mit GitHub anmelden", + "auth.signInWithIdentityProvider": "Mit Identitätsanbieter anmelden", + "auth.signInWithPasskey": "Mit Passkey anmelden", + "auth.togglePasswordVisibility": "Passwortsichtbarkeit umschalten", + "auth.twoFactorAuthentication": "Zwei-Faktor-Authentifizierung", + "auth.updatePassword": "Passwort aktualisieren", + "auth.useRecoveryCode": "Wiederherstellungscode verwenden", + "auth.username": "Benutzername", + "auth.usernameAndPassword": "Benutzername und Passwort", + "auth.usernameRequired": "Benutzername erforderlich", + "auth.usernameRules": "3–32 Zeichen: nur Buchstaben, Zahlen und Unterstrich", + "auth.usernameRulesShort": "3–32 Zeichen, Buchstaben/Zahlen/_", + "auth.validUsername": "Gültiger Benutzername", + "auth.validationLooksGood": "Sieht gut aus", + "auth.verificationOverviewEyebrow": "Anmeldung bestätigen", + "auth.verificationOverviewHint": "Nutze für den zweiten Schritt einen verfügbaren Faktor. Ein Wiederherstellungscode öffnet eine eingeschränkte Sitzung, in der du deinen Kontoschutz erneuerst.", + "auth.verificationOverviewTitle": "Bestätige, dass du es wirklich bist.", + "auth.verifyCode": "Code überprüfen", + "auth.websshSignIn": "Bei WebSSH anmelden", + "auth.welcomeToWebssh": "Willkommen bei WebSSH", + "brand.tagline": "Deine Shell. Deine Regeln.", + "commands.workspace": "Befehle", + "common.delete": "Löschen", + "common.requestFailed": "Anfrage fehlgeschlagen (HTTP {status}).", + "connectionAssets.hosts": "Hosts", + "files.fileManager": "Dateimanager", + "navigation.sshWorkspaces": "SSH-Workspaces", + "navigation.websshWorkspaces": "WebSSH-Arbeitsbereiche", + "security.accountIdentityUnavailable": "Kontoidentität ist nicht verfügbar", + "security.authenticatorCode": "Authenticator-Code", + "security.authenticatorDefaultName": "Authenticator-App", + "security.authenticatorDeleted": "Authenticator-App gelöscht", + "security.authenticatorName": "Name des Authenticators", + "security.certificateAuthority": "Zertifizierungsstelle", + "security.chooseConfirmationMethod": "Wähle aus, wie du diese Sicherheitsänderung bestätigen möchtest.", + "security.confirmAccountName": "Kontonamen zur Bestätigung eingeben", + "security.confirmDeleteAuthenticator": "Diese Authenticator-App löschen? Stelle sicher, dass eine weitere MFA-Methode verfügbar bleibt.", + "security.confirmDeleteAuthority": "Die Zertifizierungsstelle für {host} wirklich löschen?", + "security.confirmDeletePasskey": "Diesen Passkey löschen? Stelle sicher, dass eine weitere Anmeldemethode verfügbar bleibt.", + "security.confirmDeleteTrust": "Das Vertrauen für {host} wirklich löschen?", + "security.confirmDisableMfa": "Alle MFA-Faktoren deaktivieren?", + "security.confirmDisableMfaAndRemoveTotp": "MFA deaktivieren und alle Authenticator-Apps entfernen? Dies kann nicht rückgängig gemacht werden.", + "security.confirmEnablePasskeyMfa": "Nach jeder Passwort- oder Verzeichnis-Anmeldung einen Passkey, eine Authenticator-App oder einen Wiederherstellungscode verlangen?", + "security.confirmFactorChange": "Bestätige diese Sicherheitsänderung für das Konto.", + "security.confirmRemoveRevocation": "Die Sperre für {host} wirklich entfernen?", + "security.confirmWithDirectory": "Bestätige mit dem Kennwort, das du für die Verzeichnisanmeldung verwendest.", + "security.confirmWithTotp": "Gib einen aktuellen Code aus deiner Authenticator-App ein.", + "security.connectGithub": "GitHub verbinden", + "security.deleteAuthority": "Zertifizierungsstelle löschen", + "security.deleteTrust": "Vertrauen löschen", + "security.directoryPassword": "Verzeichniskennwort", + "security.disconnectGithub": "GitHub trennen", + "security.disconnectGithubConfirm": "Diese GitHub-Identität vom WebSSH-Konto trennen?", + "security.extraProtectionEnabled": "Für geschützte Änderungen ist ein registrierter starker Faktor erforderlich.", + "security.extraProtectionOptional": "Passkeys und Authenticator-Apps sind für dieses Konto optional.", + "security.githubConnectedAs": "Verbunden als {login}", + "security.githubDisconnected": "GitHub getrennt", + "security.githubNotConnected": "Nicht verbunden", + "security.hostKeyRevoked": "Gesperrter Schlüssel", + "security.hostKeyTrusted": "Vertrauenswürdiger Schlüssel", + "security.invalidTotpCode": "Gib einen gültigen sechsstelligen Authenticator-Code ein.", + "security.legacyPasskeyConfirm": "Einen Ersatz-Passkey erstellen? Teste ihn, bevor du den alten Passkey löschst.", + "security.methodGithub": "GitHub", + "security.methodLdap": "Verzeichniskennwort", + "security.methodOidc": "Identitätsanbieter", + "security.methodPasskey": "Passkey", + "security.methodPassword": "WebSSH-Kennwort", + "security.methodRecoveryCode": "Wiederherstellungscode", + "security.methodTotp": "Authenticator-App", + "security.mfaDisabled": "MFA deaktiviert", + "security.mfaEnabled": "MFA aktiviert", + "security.mfaOptional": "MFA optional", + "security.noPasskeys": "Kein Passkey registriert.", + "security.noTotpAuthenticators": "Keine Authenticator-App eingerichtet.", + "security.passkeyAdded": "Passkey hinzugefügt", + "security.passkeyDefaultName": "Mein Passkey", + "security.passkeyName": "Passkey-Name", + "security.passkeysUnsupported": "Dieser Browser unterstützt keine Passkeys.", + "security.removeRevocation": "Sperre entfernen", + "security.replacementPasskey": "Ersatz-Passkey", + "security.storeRecoveryCodes": "Bewahre diese Wiederherstellungscodes sicher auf. Sie werden nicht erneut angezeigt." + }, + "fr": { + "admin.auditExportFailed": "Échec de l’export de l’audit (HTTP {status}).", + "admin.disableFeatureWarning": "Désactiver {feature} ? S’il s’agit de votre méthode de connexion actuelle, vous pourriez ne plus pouvoir vous reconnecter. Les sessions navigateur, SSH et tmux existantes restent disponibles jusqu’à leur expiration normale ; ce changement ne les termine pas. Les nouvelles connexions et configurations de facteurs appliquent immédiatement la nouvelle règle.", + "admin.featureActive": "{feature} est actif.", + "admin.featureAdminDisabled": "{feature} est disponible mais non activé dans l’administration.", + "admin.featureDeploymentDisabled": "{feature} est désactivé par la configuration du déploiement.", + "admin.featureNotReady": "{feature} est configuré mais pas prêt.", + "auth.accountSecurity": "Sécurité du compte", + "auth.authenticationSource": "Source d'authentification", + "auth.availableVerificationMethods": "Méthodes de vérification disponibles", + "auth.backToApp": "Retour à l'application", + "auth.backToSignIn": "Retour à la connexion", + "auth.changePassword": "Changer le mot de passe", + "auth.changePasswordPageTitle": "Modifier le mot de passe · WebSSH", + "auth.changePasswordPrompt": "Mettez à jour le mot de passe de votre compte", + "auth.chooseSignInMethod": "Choisir le mode de connexion", + "auth.codeVerificationFailed": "Le code n'a pas pu être vérifié.", + "auth.confirmNewPassword": "Confirmer le nouveau mot de passe", + "auth.confirmPassword": "Confirmer le mot de passe", + "auth.continueWithRecoveryCode": "Continuer avec un code de récupération", + "auth.createAccount": "Créer un compte", + "auth.currentPassword": "Mot de passe actuel", + "auth.currentPasswordRequired": "Le mot de passe actuel est requis.", + "auth.deviceAuthentication": "Authentification de l'appareil", + "auth.directoryUsername": "Nom d'utilisateur de l'annuaire", + "auth.enterAuthenticatorCode": "Saisissez le code à six chiffres de votre application d'authentification.", + "auth.githubPrompt": "Continuer avec GitHub", + "auth.githubPromptHint": "WebSSH utilise GitHub uniquement pour vérifier votre identité et l’appartenance aux organisations configurées.", + "auth.githubSignInHint": "Utiliser votre compte GitHub lié", + "auth.haveAccount": "Vous avez déjà un compte?", + "auth.identityProviderPrompt": "Continuer avec votre fournisseur d'identité", + "auth.identityProviderPromptHint": "WebSSH vous redirige vers le fournisseur configuré. Les identifiants du fournisseur ne sont jamais saisis ici.", + "auth.instanceStatus": "Instance WebSSH", + "auth.localAccessHint": "Compte géré par cette instance WebSSH", + "auth.localAccount": "Compte local", + "auth.localCredentials": "Identifiants locaux", + "auth.localOrDirectory": "Local / Annuaire", + "auth.localRegistration": "Inscription locale", + "auth.login": "Se connecter", + "auth.loginHere": "Connectez-vous ici", + "auth.loginPageTitle": "Se connecter · WebSSH", + "auth.newPassword": "Nouveau mot de passe", + "auth.noAccount": "Vous n'avez pas de compte?", + "auth.openConfiguredAddress": "Ouvrir l'adresse WebSSH configurée", + "auth.organizationSso": "SSO avec votre organisation", + "auth.passkeyAccessHint": "Utiliser une clé d’accès enregistrée pour votre compte", + "auth.passkeyFailed": "La connexion par clé d'accès n'a pas pu être effectuée. Réessayez ou utilisez un autre mode de connexion.", + "auth.passkeyNotAllowed": "La connexion par clé d'accès a été annulée ou aucune clé d'accès correspondante n'était disponible.", + "auth.passkeyOriginMismatch": "Les clés d'accès sont configurées pour {origin}. Ouvrez cette adresse et réessayez.", + "auth.passkeyPrompt": "Utiliser une clé d'accès", + "auth.passkeyPromptHint": "Authentifiez-vous avec une clé d'accès enregistrée sur cet appareil ou sur un autre appareil à proximité.", + "auth.passkeySecurityError": "La connexion par clé d'accès n'est pas disponible à cette adresse. Ouvrez l'adresse WebSSH configurée et réessayez.", + "auth.passkeyUnsupported": "Ce navigateur ne peut pas utiliser de clés d'accès pour cette instance WebSSH.", + "auth.password": "Mot de passe", + "auth.passwordHint": "Minimum 8 caractères", + "auth.passwordOverviewHint": "Si vous vous connectez via LDAP ou OIDC, modifiez ce mot de passe auprès de votre fournisseur d’identité et non sur cette page.", + "auth.passwordOverviewTitle": "Ce mot de passe appartient uniquement à votre compte WebSSH local.", + "auth.passwordStrongEnough": "Assez robuste", + "auth.passwordsMatch": "Les mots de passe correspondent", + "auth.passwordsNoMatch": "Les mots de passe ne correspondent pas", + "auth.productAreas": "Espaces produit WebSSH", + "auth.recoveryAccessHint": "Accès unique suivi du remplacement du facteur", + "auth.recoveryAuthenticationFailed": "L'authentification de récupération a échoué.", + "auth.recoveryCode": "Code de récupération", + "auth.recoveryCodeRequired": "Saisissez un code de récupération.", + "auth.recoverySessionHint": "Une session restreinte est créée. Vous devez y ajouter un facteur de remplacement ou désactiver explicitement l'AMF.", + "auth.registerHere": "Inscrivez-vous ici", + "auth.registerPageTitle": "Créer un compte · WebSSH", + "auth.registerPrompt": "Créez un compte WebSSH local", + "auth.registration": "Inscription", + "auth.registrationAvailable": "Disponible sur cette instance", + "auth.registrationHint": "Choisissez les identifiants de cette instance. Vous pourrez ajouter des facteurs plus robustes après la connexion.", + "auth.rememberMe": "Se souvenir de moi", + "auth.serverControlCenter": "Le centre de contrôle de vos serveurs.", + "auth.signInAvailable": "Connexion disponible", + "auth.signInPrompt": "Accédez à votre espace de travail SSH", + "auth.signInSourceHint": "Utilisez la source d'identité configurée pour cette instance WebSSH.", + "auth.signInWithGithub": "Se connecter avec GitHub", + "auth.signInWithIdentityProvider": "Se connecter avec le fournisseur d’identité", + "auth.signInWithPasskey": "Se connecter avec une clé d’accès", + "auth.togglePasswordVisibility": "Afficher ou masquer le mot de passe", + "auth.twoFactorAuthentication": "Authentification à deux facteurs", + "auth.updatePassword": "Mettre à jour le mot de passe", + "auth.useRecoveryCode": "Utiliser le code de récupération", + "auth.username": "Nom d'utilisateur", + "auth.usernameAndPassword": "Nom d'utilisateur et mot de passe", + "auth.usernameRequired": "Nom d'utilisateur requis", + "auth.usernameRules": "3 à 32 caractères : lettres, chiffres et trait de soulignement uniquement", + "auth.usernameRulesShort": "3 à 32 caractères, lettres/chiffres/_", + "auth.validUsername": "Nom d'utilisateur valide", + "auth.validationLooksGood": "Correct", + "auth.verificationOverviewEyebrow": "Confirmer la connexion", + "auth.verificationOverviewHint": "Utilisez un facteur disponible pour cette seconde étape. Un code de récupération ouvre une session restreinte dans laquelle vous renouvelez la protection du compte.", + "auth.verificationOverviewTitle": "Confirmez qu’il s’agit bien de vous.", + "auth.verifyCode": "Vérifier le code", + "auth.websshSignIn": "Connexion à WebSSH", + "auth.welcomeToWebssh": "Bienvenue dans WebSSH", + "brand.tagline": "Votre shell. Vos règles.", + "commands.workspace": "Commandes", + "common.delete": "Supprimer", + "common.requestFailed": "Échec de la requête (HTTP {status}).", + "connectionAssets.hosts": "Hôtes", + "files.fileManager": "Gestionnaire de fichiers", + "navigation.sshWorkspaces": "Espaces de travail SSH", + "navigation.websshWorkspaces": "Espaces de travail WebSSH", + "security.accountIdentityUnavailable": "L’identité du compte est indisponible", + "security.authenticatorCode": "Code d’authentification", + "security.authenticatorDefaultName": "Application d’authentification", + "security.authenticatorDeleted": "Application d’authentification supprimée", + "security.authenticatorName": "Nom de l’authentificateur", + "security.certificateAuthority": "Autorité de certification", + "security.chooseConfirmationMethod": "Choisissez comment confirmer cette modification de sécurité.", + "security.confirmAccountName": "Saisissez le nom du compte pour confirmer", + "security.confirmDeleteAuthenticator": "Supprimer cette application d’authentification ? Vérifiez qu’une autre méthode MFA reste disponible.", + "security.confirmDeleteAuthority": "Supprimer l’autorité de certification pour {host} ?", + "security.confirmDeletePasskey": "Supprimer cette clé d’accès ? Vérifiez qu’une autre méthode de connexion reste disponible.", + "security.confirmDeleteTrust": "Supprimer la confiance pour {host} ?", + "security.confirmDisableMfa": "Désactiver tous les facteurs MFA ?", + "security.confirmDisableMfaAndRemoveTotp": "Désactiver l’AMF et supprimer toutes les applications d’authentification ? Cette action est irréversible.", + "security.confirmEnablePasskeyMfa": "Exiger une clé d’accès, une application d’authentification ou un code de récupération après chaque connexion par mot de passe ou annuaire ?", + "security.confirmFactorChange": "Confirmez cette modification de sécurité du compte.", + "security.confirmRemoveRevocation": "Retirer la révocation pour {host} ?", + "security.confirmWithDirectory": "Confirmez avec le mot de passe utilisé pour la connexion à l’annuaire.", + "security.confirmWithTotp": "Saisissez un code actuel de votre application d’authentification.", + "security.connectGithub": "Connecter GitHub", + "security.deleteAuthority": "Supprimer l’autorité", + "security.deleteTrust": "Supprimer la confiance", + "security.directoryPassword": "Mot de passe de l’annuaire", + "security.disconnectGithub": "Déconnecter GitHub", + "security.disconnectGithubConfirm": "Dissocier cette identité GitHub de votre compte WebSSH ?", + "security.extraProtectionEnabled": "Un facteur robuste enregistré est requis pour les modifications protégées.", + "security.extraProtectionOptional": "Les clés d'accès et applications d'authentification sont facultatives pour ce compte.", + "security.githubConnectedAs": "Connecté en tant que {login}", + "security.githubDisconnected": "GitHub déconnecté", + "security.githubNotConnected": "Non connecté", + "security.hostKeyRevoked": "Clé révoquée", + "security.hostKeyTrusted": "Clé approuvée", + "security.invalidTotpCode": "Saisissez un code d’authentification valide à six chiffres.", + "security.legacyPasskeyConfirm": "Créer une clé d’accès de remplacement ? Testez-la avant de supprimer l’ancienne clé.", + "security.methodGithub": "GitHub", + "security.methodLdap": "Mot de passe de l’annuaire", + "security.methodOidc": "Fournisseur d’identité", + "security.methodPasskey": "Clé d’accès", + "security.methodPassword": "Mot de passe WebSSH", + "security.methodRecoveryCode": "Code de récupération", + "security.methodTotp": "Application d’authentification", + "security.mfaDisabled": "MFA désactivée", + "security.mfaEnabled": "MFA activée", + "security.mfaOptional": "AMF facultative", + "security.noPasskeys": "Aucune clé d’accès n’est enregistrée.", + "security.noTotpAuthenticators": "Aucune application d’authentification n’est configurée.", + "security.passkeyAdded": "Clé d’accès ajoutée", + "security.passkeyDefaultName": "Ma clé d’accès", + "security.passkeyName": "Nom de la clé d’accès", + "security.passkeysUnsupported": "Ce navigateur ne prend pas en charge les clés d’accès.", + "security.removeRevocation": "Retirer la révocation", + "security.replacementPasskey": "Clé d’accès de remplacement", + "security.storeRecoveryCodes": "Conservez ces codes de récupération en lieu sûr. Ils ne seront plus affichés." + }, + "es": { + "admin.auditExportFailed": "Error al exportar la auditoría (HTTP {status}).", + "admin.disableFeatureWarning": "¿Desactivar {feature}? Si este es tu método de inicio de sesión actual, es posible que no puedas volver a iniciar sesión. Las sesiones existentes del navegador, SSH y tmux permanecen disponibles hasta su vencimiento normal; este cambio no las finaliza. Los nuevos inicios de sesión y la configuración de factores aplican la nueva regla inmediatamente.", + "admin.featureActive": "{feature} está activo.", + "admin.featureAdminDisabled": "{feature} está disponible, pero no está activado en el panel de administración.", + "admin.featureDeploymentDisabled": "{feature} está desactivado por la configuración del despliegue.", + "admin.featureNotReady": "{feature} está configurado, pero no está listo.", + "auth.accountSecurity": "Seguridad de la cuenta", + "auth.authenticationSource": "Fuente de autenticación", + "auth.availableVerificationMethods": "Métodos de verificación disponibles", + "auth.backToApp": "Volver a la app", + "auth.backToSignIn": "Volver al inicio de sesión", + "auth.changePassword": "Cambiar contraseña", + "auth.changePasswordPageTitle": "Cambiar contraseña · WebSSH", + "auth.changePasswordPrompt": "Actualiza la contraseña de tu cuenta", + "auth.chooseSignInMethod": "Elegir cómo iniciar sesión", + "auth.codeVerificationFailed": "No se pudo verificar el código.", + "auth.confirmNewPassword": "Confirmar nueva contraseña", + "auth.confirmPassword": "Confirmar contraseña", + "auth.continueWithRecoveryCode": "Continuar con código de recuperación", + "auth.createAccount": "Crear cuenta", + "auth.currentPassword": "Contraseña actual", + "auth.currentPasswordRequired": "Se requiere la contraseña actual.", + "auth.deviceAuthentication": "Autenticación del dispositivo", + "auth.directoryUsername": "Nombre de usuario del directorio", + "auth.enterAuthenticatorCode": "Introduce el código de seis dígitos de tu aplicación de autenticación.", + "auth.githubPrompt": "Continuar con GitHub", + "auth.githubPromptHint": "WebSSH solo usa GitHub para verificar tu identidad y la pertenencia a las organizaciones configuradas.", + "auth.githubSignInHint": "Usa tu cuenta de GitHub vinculada", + "auth.haveAccount": "¿Ya tienes una cuenta?", + "auth.identityProviderPrompt": "Continuar con tu proveedor de identidad", + "auth.identityProviderPromptHint": "WebSSH te redirige al proveedor configurado. Las credenciales del proveedor nunca se introducen aquí.", + "auth.instanceStatus": "Instancia de WebSSH", + "auth.localAccessHint": "Cuenta administrada por esta instancia de WebSSH", + "auth.localAccount": "Cuenta local", + "auth.localCredentials": "Credenciales locales", + "auth.localOrDirectory": "Local / Directorio", + "auth.localRegistration": "Registro local", + "auth.login": "Iniciar sesión", + "auth.loginHere": "Inicia sesión aquí", + "auth.loginPageTitle": "Iniciar sesión · WebSSH", + "auth.newPassword": "Nueva contraseña", + "auth.noAccount": "¿No tienes una cuenta?", + "auth.openConfiguredAddress": "Abrir la dirección de WebSSH configurada", + "auth.organizationSso": "SSO con tu organización", + "auth.passkeyAccessHint": "Usa una passkey registrada para tu cuenta", + "auth.passkeyFailed": "No se pudo completar el inicio de sesión con passkey. Inténtalo de nuevo o usa otro método.", + "auth.passkeyNotAllowed": "El inicio de sesión con passkey se canceló o no había ninguna passkey compatible.", + "auth.passkeyOriginMismatch": "Las passkeys están configuradas para {origin}. Abre esa dirección e inténtalo de nuevo.", + "auth.passkeyPrompt": "Usar una passkey", + "auth.passkeyPromptHint": "Autentícate con una passkey guardada en este dispositivo o en otro cercano.", + "auth.passkeySecurityError": "El inicio de sesión con passkey no está disponible en esta dirección. Abre la dirección de WebSSH configurada e inténtalo de nuevo.", + "auth.passkeyUnsupported": "Este navegador no puede usar passkeys para esta instancia de WebSSH.", + "auth.password": "Contraseña", + "auth.passwordHint": "Mínimo 8 caracteres", + "auth.passwordOverviewHint": "Si inicias sesión mediante LDAP u OIDC, cambia esa contraseña con tu proveedor de identidad y no en esta página.", + "auth.passwordOverviewTitle": "Esta contraseña pertenece únicamente a tu cuenta local de WebSSH.", + "auth.passwordStrongEnough": "Suficientemente segura", + "auth.passwordsMatch": "Las contraseñas coinciden", + "auth.passwordsNoMatch": "Las contraseñas no coinciden", + "auth.productAreas": "Áreas de producto de WebSSH", + "auth.recoveryAccessHint": "Acceso de un solo uso seguido de la reparación del factor", + "auth.recoveryAuthenticationFailed": "La autenticación de recuperación ha fallado.", + "auth.recoveryCode": "Código de recuperación", + "auth.recoveryCodeRequired": "Introduce un código de recuperación.", + "auth.recoverySessionHint": "Esto crea una sesión restringida en la que debes añadir un factor de sustitución o desactivar MFA de forma explícita.", + "auth.registerHere": "Regístrate aquí", + "auth.registerPageTitle": "Crear cuenta · WebSSH", + "auth.registerPrompt": "Crea una cuenta local de WebSSH", + "auth.registration": "Registro", + "auth.registrationAvailable": "Disponible en esta instancia", + "auth.registrationHint": "Elige las credenciales para esta instancia. Podrás añadir factores más fuertes después de iniciar sesión.", + "auth.rememberMe": "Recuérdame", + "auth.serverControlCenter": "El centro de control de tus servidores.", + "auth.signInAvailable": "Inicio de sesión disponible", + "auth.signInPrompt": "Accede a tu espacio de trabajo SSH", + "auth.signInSourceHint": "Usa la fuente de identidad configurada para esta instancia de WebSSH.", + "auth.signInWithGithub": "Iniciar sesión con GitHub", + "auth.signInWithIdentityProvider": "Iniciar sesión con el proveedor de identidad", + "auth.signInWithPasskey": "Iniciar sesión con passkey", + "auth.togglePasswordVisibility": "Mostrar u ocultar la contraseña", + "auth.twoFactorAuthentication": "Autenticación de dos factores", + "auth.updatePassword": "Actualizar contraseña", + "auth.useRecoveryCode": "Usar código de recuperación", + "auth.username": "Nombre de usuario", + "auth.usernameAndPassword": "Nombre de usuario y contraseña", + "auth.usernameRequired": "Se requiere nombre de usuario", + "auth.usernameRules": "Entre 3 y 32 caracteres: solo letras, números y guion bajo", + "auth.usernameRulesShort": "3-32 caracteres, letras/números/_", + "auth.validUsername": "Nombre de usuario válido", + "auth.validationLooksGood": "Correcto", + "auth.verificationOverviewEyebrow": "Confirma el inicio de sesión", + "auth.verificationOverviewHint": "Usa uno de tus factores disponibles para este segundo paso. Un código de recuperación abre una sesión restringida en la que renovarás la protección de la cuenta.", + "auth.verificationOverviewTitle": "Confirma que realmente eres tú.", + "auth.verifyCode": "Verificar código", + "auth.websshSignIn": "Iniciar sesión en WebSSH", + "auth.welcomeToWebssh": "Te damos la bienvenida a WebSSH", + "brand.tagline": "Tu shell. Tus reglas.", + "commands.workspace": "Comandos", + "common.delete": "Eliminar", + "common.requestFailed": "Error en la solicitud (HTTP {status}).", + "connectionAssets.hosts": "Hosts", + "files.fileManager": "Gestor de archivos", + "navigation.sshWorkspaces": "Espacios de trabajo SSH", + "navigation.websshWorkspaces": "Espacios de trabajo de WebSSH", + "security.accountIdentityUnavailable": "La identidad de la cuenta no está disponible", + "security.authenticatorCode": "Código de autenticación", + "security.authenticatorDefaultName": "Aplicación de autenticación", + "security.authenticatorDeleted": "Aplicación de autenticación eliminada", + "security.authenticatorName": "Nombre del autenticador", + "security.certificateAuthority": "Autoridad certificadora", + "security.chooseConfirmationMethod": "Elige cómo quieres confirmar este cambio de seguridad.", + "security.confirmAccountName": "Escribe el nombre de la cuenta para confirmar", + "security.confirmDeleteAuthenticator": "¿Eliminar esta aplicación de autenticación? Asegúrate de que quede disponible otro método MFA.", + "security.confirmDeleteAuthority": "¿Eliminar la autoridad certificadora de {host}?", + "security.confirmDeletePasskey": "¿Eliminar esta passkey? Asegúrate de que quede disponible otro método de inicio de sesión.", + "security.confirmDeleteTrust": "¿Eliminar la confianza de {host}?", + "security.confirmDisableMfa": "¿Desactivar todos los factores MFA?", + "security.confirmDisableMfaAndRemoveTotp": "¿Desactivar MFA y eliminar todas las aplicaciones de autenticación? Esta acción no se puede deshacer.", + "security.confirmEnablePasskeyMfa": "¿Exigir una passkey, una aplicación de autenticación o un código de recuperación después de cada inicio con contraseña o directorio?", + "security.confirmFactorChange": "Confirma este cambio de seguridad de la cuenta.", + "security.confirmRemoveRevocation": "¿Quitar la revocación de {host}?", + "security.confirmWithDirectory": "Confirma con la contraseña que utilizas para iniciar sesión en el directorio.", + "security.confirmWithTotp": "Introduce un código actual de tu aplicación de autenticación.", + "security.connectGithub": "Conectar GitHub", + "security.deleteAuthority": "Eliminar autoridad", + "security.deleteTrust": "Eliminar confianza", + "security.directoryPassword": "Contraseña del directorio", + "security.disconnectGithub": "Desconectar GitHub", + "security.disconnectGithubConfirm": "¿Desvincular esta identidad de GitHub de tu cuenta WebSSH?", + "security.extraProtectionEnabled": "Se requiere un factor fuerte registrado para los cambios protegidos.", + "security.extraProtectionOptional": "Las passkeys y las aplicaciones de autenticación son opcionales para esta cuenta.", + "security.githubConnectedAs": "Conectado como {login}", + "security.githubDisconnected": "GitHub desconectado", + "security.githubNotConnected": "No conectado", + "security.hostKeyRevoked": "Clave revocada", + "security.hostKeyTrusted": "Clave de confianza", + "security.invalidTotpCode": "Introduce un código de autenticación válido de seis dígitos.", + "security.legacyPasskeyConfirm": "¿Crear una passkey de reemplazo? Pruébala antes de eliminar la passkey antigua.", + "security.methodGithub": "GitHub", + "security.methodLdap": "Contraseña del directorio", + "security.methodOidc": "Proveedor de identidad", + "security.methodPasskey": "Passkey", + "security.methodPassword": "Contraseña de WebSSH", + "security.methodRecoveryCode": "Código de recuperación", + "security.methodTotp": "Aplicación de autenticación", + "security.mfaDisabled": "MFA desactivado", + "security.mfaEnabled": "MFA activado", + "security.mfaOptional": "MFA opcional", + "security.noPasskeys": "No hay ninguna passkey registrada.", + "security.noTotpAuthenticators": "No hay ninguna aplicación de autenticación configurada.", + "security.passkeyAdded": "Passkey añadida", + "security.passkeyDefaultName": "Mi passkey", + "security.passkeyName": "Nombre de la passkey", + "security.passkeysUnsupported": "Este navegador no admite passkeys.", + "security.removeRevocation": "Quitar revocación", + "security.replacementPasskey": "Passkey de reemplazo", + "security.storeRecoveryCodes": "Guarda estos códigos de recuperación de forma segura. No volverán a mostrarse." + }, + "zh": { + "admin.auditExportFailed": "审计导出失败(HTTP {status})。", + "admin.disableFeatureWarning": "要禁用 {feature} 吗?如果这是您当前的登录方式,您可能无法再次登录。现有浏览器、SSH 和 tmux 会话会保留到正常超时;此更改不会终止它们。新的登录和验证因素设置会立即使用新规则。", + "admin.featureActive": "{feature} 已启用。", + "admin.featureAdminDisabled": "{feature} 可用,但尚未在管理面板中启用。", + "admin.featureDeploymentDisabled": "{feature} 已被部署配置禁用。", + "admin.featureNotReady": "{feature} 已配置但尚未就绪。", + "auth.accountSecurity": "账户安全", + "auth.authenticationSource": "身份验证来源", + "auth.availableVerificationMethods": "可用的验证方式", + "auth.backToApp": "返回应用", + "auth.backToSignIn": "返回登录", + "auth.changePassword": "修改密码", + "auth.changePasswordPageTitle": "更改密码 · WebSSH", + "auth.changePasswordPrompt": "更新你的账号密码", + "auth.chooseSignInMethod": "选择登录方式", + "auth.codeVerificationFailed": "无法验证该代码。", + "auth.confirmNewPassword": "确认新密码", + "auth.confirmPassword": "确认密码", + "auth.continueWithRecoveryCode": "使用恢复代码继续", + "auth.createAccount": "创建账号", + "auth.currentPassword": "当前密码", + "auth.currentPasswordRequired": "请输入当前密码。", + "auth.deviceAuthentication": "设备身份验证", + "auth.directoryUsername": "目录用户名", + "auth.enterAuthenticatorCode": "请输入身份验证器应用中的六位代码。", + "auth.githubPrompt": "使用 GitHub 继续", + "auth.githubPromptHint": "WebSSH 仅使用 GitHub 验证身份及已配置的组织成员资格。", + "auth.githubSignInHint": "使用已关联的 GitHub 账户", + "auth.haveAccount": "已有账号?", + "auth.identityProviderPrompt": "使用身份提供商继续", + "auth.identityProviderPromptHint": "WebSSH 会将你重定向到已配置的提供商。提供商凭据绝不会在此处输入。", + "auth.instanceStatus": "WebSSH 实例", + "auth.localAccessHint": "由此 WebSSH 实例管理的账户", + "auth.localAccount": "本地账户", + "auth.localCredentials": "本地凭据", + "auth.localOrDirectory": "本地 / 目录", + "auth.localRegistration": "本地注册", + "auth.login": "登录", + "auth.loginHere": "立即登录", + "auth.loginPageTitle": "登录 · WebSSH", + "auth.newPassword": "新密码", + "auth.noAccount": "还没有账号?", + "auth.openConfiguredAddress": "打开已配置的 WebSSH 地址", + "auth.organizationSso": "使用组织的 SSO", + "auth.passkeyAccessHint": "使用已为你的账户注册的通行密钥", + "auth.passkeyFailed": "无法完成通行密钥登录。请重试或使用其他登录方式。", + "auth.passkeyNotAllowed": "通行密钥登录已取消,或没有可用的匹配通行密钥。", + "auth.passkeyOriginMismatch": "通行密钥已为 {origin} 配置。请打开该地址后重试。", + "auth.passkeyPrompt": "使用通行密钥", + "auth.passkeyPromptHint": "使用保存在此设备或附近其他设备上的通行密钥进行身份验证。", + "auth.passkeySecurityError": "此地址无法使用通行密钥登录。请打开已配置的 WebSSH 地址后重试。", + "auth.passkeyUnsupported": "此浏览器无法为该 WebSSH 实例使用通行密钥。", + "auth.password": "密码", + "auth.passwordHint": "至少 8 个字符", + "auth.passwordOverviewHint": "如果你通过 LDAP 或 OIDC 登录,请在相应的身份提供商处修改密码,而不是在此页面修改。", + "auth.passwordOverviewTitle": "此密码仅属于你的本地 WebSSH 账户。", + "auth.passwordStrongEnough": "强度足够", + "auth.passwordsMatch": "密码一致", + "auth.passwordsNoMatch": "两次密码不一致", + "auth.productAreas": "WebSSH 产品区域", + "auth.recoveryAccessHint": "一次性访问,随后修复验证因素", + "auth.recoveryAuthenticationFailed": "恢复身份验证失败。", + "auth.recoveryCode": "恢复代码", + "auth.recoveryCodeRequired": "请输入恢复代码。", + "auth.recoverySessionHint": "这会创建一个受限会话,你必须在其中添加替代因素或明确禁用 MFA。", + "auth.registerHere": "立即注册", + "auth.registerPageTitle": "创建账户 · WebSSH", + "auth.registerPrompt": "创建本地 WebSSH 帐户", + "auth.registration": "注册", + "auth.registrationAvailable": "此实例可用", + "auth.registrationHint": "为此实例选择凭据。登录后可以添加更强的身份验证因素。", + "auth.rememberMe": "记住我", + "auth.serverControlCenter": "你的服务器控制中心。", + "auth.signInAvailable": "可以登录", + "auth.signInPrompt": "访问您的 SSH 工作区", + "auth.signInSourceHint": "使用为此 WebSSH 实例配置的身份来源。", + "auth.signInWithGithub": "使用 GitHub 登录", + "auth.signInWithIdentityProvider": "使用身份提供商登录", + "auth.signInWithPasskey": "使用通行密钥登录", + "auth.togglePasswordVisibility": "切换密码可见性", + "auth.twoFactorAuthentication": "双因素身份验证", + "auth.updatePassword": "更新密码", + "auth.useRecoveryCode": "使用恢复代码", + "auth.username": "用户名", + "auth.usernameAndPassword": "用户名和密码", + "auth.usernameRequired": "请输入用户名", + "auth.usernameRules": "3-32 个字符:仅限字母、数字和下划线", + "auth.usernameRulesShort": "3-32 个字符,字母/数字/_", + "auth.validUsername": "用户名有效", + "auth.validationLooksGood": "输入有效", + "auth.verificationOverviewEyebrow": "确认登录", + "auth.verificationOverviewHint": "在第二步中使用一个可用因素。恢复代码会打开受限会话,你需要在其中更新账户保护方式。", + "auth.verificationOverviewTitle": "请确认确实是你本人。", + "auth.verifyCode": "验证代码", + "auth.websshSignIn": "登录 WebSSH", + "auth.welcomeToWebssh": "欢迎使用 WebSSH", + "brand.tagline": "你的 Shell。你的规则。", + "commands.workspace": "命令", + "common.delete": "删除", + "common.requestFailed": "请求失败(HTTP {status})。", + "connectionAssets.hosts": "主机", + "files.fileManager": "文件管理器", + "navigation.sshWorkspaces": "SSH 工作区", + "navigation.websshWorkspaces": "WebSSH 工作区", + "security.accountIdentityUnavailable": "账户身份不可用", + "security.authenticatorCode": "身份验证器代码", + "security.authenticatorDefaultName": "身份验证器应用", + "security.authenticatorDeleted": "身份验证器应用已删除", + "security.authenticatorName": "身份验证器名称", + "security.certificateAuthority": "证书颁发机构", + "security.chooseConfirmationMethod": "选择用于确认此安全更改的方式。", + "security.confirmAccountName": "输入账户名以确认", + "security.confirmDeleteAuthenticator": "删除此身份验证器应用?请确保仍有其他可用的 MFA 方法。", + "security.confirmDeleteAuthority": "确定要删除 {host} 的证书颁发机构吗?", + "security.confirmDeletePasskey": "删除此通行密钥?请确保仍有其他可用的登录方式。", + "security.confirmDeleteTrust": "确定要删除对 {host} 的信任吗?", + "security.confirmDisableMfa": "停用所有 MFA 因素?", + "security.confirmDisableMfaAndRemoveTotp": "禁用 MFA 并移除所有身份验证器应用?此操作无法撤销。", + "security.confirmEnablePasskeyMfa": "每次使用密码或目录登录后,都要求通行密钥、验证器应用或恢复代码吗?", + "security.confirmFactorChange": "确认此账户安全更改。", + "security.confirmRemoveRevocation": "确定要移除 {host} 的吊销记录吗?", + "security.confirmWithDirectory": "使用目录登录密码进行确认。", + "security.confirmWithTotp": "输入身份验证器应用中的当前代码。", + "security.connectGithub": "连接 GitHub", + "security.deleteAuthority": "删除证书颁发机构", + "security.deleteTrust": "删除信任", + "security.directoryPassword": "目录密码", + "security.disconnectGithub": "断开 GitHub", + "security.disconnectGithubConfirm": "要从 WebSSH 账户解除此 GitHub 身份吗?", + "security.extraProtectionEnabled": "受保护的更改需要已注册的强身份验证因素。", + "security.extraProtectionOptional": "此账户可选择使用通行密钥和身份验证器应用。", + "security.githubConnectedAs": "已连接为 {login}", + "security.githubDisconnected": "GitHub 已断开", + "security.githubNotConnected": "未连接", + "security.hostKeyRevoked": "已吊销密钥", + "security.hostKeyTrusted": "受信任密钥", + "security.invalidTotpCode": "请输入有效的六位身份验证器代码。", + "security.legacyPasskeyConfirm": "创建替代通行密钥?删除旧通行密钥前请先测试新密钥。", + "security.methodGithub": "GitHub", + "security.methodLdap": "目录密码", + "security.methodOidc": "身份提供商", + "security.methodPasskey": "通行密钥", + "security.methodPassword": "WebSSH 密码", + "security.methodRecoveryCode": "恢复代码", + "security.methodTotp": "身份验证器应用", + "security.mfaDisabled": "MFA 已停用", + "security.mfaEnabled": "MFA 已启用", + "security.mfaOptional": "MFA 可选", + "security.noPasskeys": "尚未注册通行密钥。", + "security.noTotpAuthenticators": "尚未配置身份验证器应用。", + "security.passkeyAdded": "通行密钥已添加", + "security.passkeyDefaultName": "我的通行密钥", + "security.passkeyName": "通行密钥名称", + "security.passkeysUnsupported": "此浏览器不支持通行密钥。", + "security.removeRevocation": "撤销吊销状态", + "security.replacementPasskey": "替代通行密钥", + "security.storeRecoveryCodes": "请安全保存这些恢复代码。之后不会再次显示。" + } +}; + +const BrowserPreferences = { + get(key, fallback = null) { + try { + const value = window.localStorage?.getItem(key); + return typeof value === 'string' && value ? value : fallback; + } catch { + return fallback; + } + }, + + set(key, value) { + try { + window.localStorage?.setItem(key, String(value)); + return true; + } catch { + return false; + } + } +}; + +const storedLanguage = BrowserPreferences.get('language', 'en'); + +const i18n = { + currentLang: translations[storedLanguage] ? storedLanguage : 'en', + + t(key) { + const activeTranslations = translations[this.currentLang] || translations.en; + return activeTranslations[key] || translations.en[key] || key; + }, + + setLanguage(lang) { + if (translations[lang]) { + this.currentLang = lang; + BrowserPreferences.set('language', lang); + this.updatePageText(); + + window.dispatchEvent(new CustomEvent('languageChanged', { detail: { lang } })); + return true; + } + return false; + }, + + getLanguage() { + return this.currentLang; + }, + + getLanguages() { + return [ + { code: 'en', name: 'English', flag: 'EN' }, + { code: 'vi', name: 'Tiếng Việt', flag: 'VI' }, + { code: 'de', name: 'Deutsch', flag: 'DE' }, + { code: 'fr', name: 'Français', flag: 'FR' }, + { code: 'es', name: 'Español', flag: 'ES' }, + { code: 'zh', name: '中文', flag: 'ZH' } + ]; + }, + + updatePageText() { + document.documentElement.lang = this.currentLang; + + document.querySelectorAll('[data-i18n]').forEach(element => { + const key = element.getAttribute('data-i18n'); + const translation = this.t(key); + + if (element.tagName === 'INPUT' && element.type !== 'submit') { + element.placeholder = translation; + } else { + element.textContent = translation; + } + }); + + document.querySelectorAll('[data-i18n-placeholder]').forEach(element => { + const key = element.getAttribute('data-i18n-placeholder'); + element.placeholder = this.t(key); + }); + + document.querySelectorAll('[data-i18n-title]').forEach(element => { + const key = element.getAttribute('data-i18n-title'); + element.title = this.t(key); + }); + + document.querySelectorAll('[data-i18n-label]').forEach(element => { + const key = element.getAttribute('data-i18n-label'); + element.label = this.t(key); + }); + + document.querySelectorAll('[data-i18n-aria-label]').forEach(element => { + const key = element.getAttribute('data-i18n-aria-label'); + element.setAttribute('aria-label', this.t(key)); + }); + + document.querySelectorAll('[data-i18n-alt]').forEach(element => { + const key = element.getAttribute('data-i18n-alt'); + element.alt = this.t(key); + }); + + if (!document.querySelector('title[data-i18n]') && document.title.includes('SSH Terminal')) { + document.title = this.t('app.title'); + } + } +}; + +window.BrowserPreferences = BrowserPreferences; +window.i18n = i18n; + +document.addEventListener('DOMContentLoaded', () => { + i18n.updatePageText(); +}); diff --git a/static/js/session-diagnostics-charts.js b/static/js/session-diagnostics-charts.js index 28c67ff..bba1409 100644 --- a/static/js/session-diagnostics-charts.js +++ b/static/js/session-diagnostics-charts.js @@ -71,15 +71,17 @@ } function canvasMetrics(canvas, options) { - const ratio = Number(options?.devicePixelRatio ?? root.devicePixelRatio) || 1; - const width = Math.max(1, Number(canvas.clientWidth) || Number(canvas.width) || 1); - const height = Math.max(1, Number(canvas.clientHeight) || Number(canvas.height) || 1); - canvas.width = Math.round(width * ratio); - canvas.height = Math.round(height * ratio); - if (canvas.style) { - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; + const width = Number(canvas.clientWidth); + const height = Number(canvas.clientHeight); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return null; } + const requestedRatio = Number(options?.devicePixelRatio ?? root.devicePixelRatio); + const ratio = Number.isFinite(requestedRatio) && requestedRatio > 0 ? requestedRatio : 1; + const backingWidth = Math.max(1, Math.round(width * ratio)); + const backingHeight = Math.max(1, Math.round(height * ratio)); + if (canvas.width !== backingWidth) canvas.width = backingWidth; + if (canvas.height !== backingHeight) canvas.height = backingHeight; return { width, height, ratio }; } @@ -147,9 +149,11 @@ function drawLineChart(canvas, series, options = {}) { if (!canvas?.getContext) return; + const metrics = canvasMetrics(canvas, options); + if (!metrics) return; const context = canvas.getContext('2d'); if (!context) return; - const { width, height, ratio } = canvasMetrics(canvas, options); + const { width, height, ratio } = metrics; context.setTransform(ratio, 0, 0, ratio, 0, 0); context.clearRect(0, 0, width, height); if (options.grid !== false) { diff --git a/static/js/session-diagnostics.js b/static/js/session-diagnostics.js index e2290af..38a0f23 100644 --- a/static/js/session-diagnostics.js +++ b/static/js/session-diagnostics.js @@ -871,6 +871,9 @@ updateExpandButton(); rerenderLocal(); }; + const handlePrimaryWorkspaceChange = event => { + if (event?.detail?.view === 'workspaces') scheduleRedraw(); + }; const handleSystemdSearch = event => { filters.systemdQuery = event.target.value; rerenderLocal(); }; const handleDockerSearch = event => { filters.dockerQuery = event.target.value; rerenderLocal(); }; const filterHandlers = new Map(); @@ -887,6 +890,7 @@ elements.dockerSearch?.addEventListener('input', handleDockerSearch); windowRef?.addEventListener?.('themeChanged', scheduleRedraw); windowRef?.addEventListener?.('languageChanged', handleLanguageChanged); + windowRef?.addEventListener?.('primary-workspace-change', handlePrimaryWorkspaceChange); windowRef?.addEventListener?.('keydown', handleKeydown); const ResizeObserverCtor = options.ResizeObserver || windowRef?.ResizeObserver; const resizeObserver = ResizeObserverCtor ? new ResizeObserverCtor(scheduleRedraw) : null; @@ -912,6 +916,7 @@ filterHandlers.forEach((handler, button) => button.removeEventListener('click', handler)); windowRef?.removeEventListener?.('themeChanged', scheduleRedraw); windowRef?.removeEventListener?.('languageChanged', handleLanguageChanged); + windowRef?.removeEventListener?.('primary-workspace-change', handlePrimaryWorkspaceChange); windowRef?.removeEventListener?.('keydown', handleKeydown); resizeObserver?.disconnect?.(); if (redrawFrame !== null) { diff --git a/static/js/theme-preference.js b/static/js/theme-preference.js index f0cb15b..8888d9e 100644 --- a/static/js/theme-preference.js +++ b/static/js/theme-preference.js @@ -49,6 +49,28 @@ return themeId; } + function revealDeferredBackground(element) { + if (!element?.hasAttribute('data-defer-theme-background')) { + return false; + } + const reveal = () => { + element.setAttribute('data-theme-background-ready', ''); + }; + const schedule = () => { + if (typeof global.requestIdleCallback === 'function') { + global.requestIdleCallback(reveal, { timeout: 1000 }); + } else { + global.setTimeout(reveal, 0); + } + }; + if (document.readyState === 'complete') { + schedule(); + } else { + global.addEventListener('load', schedule, { once: true }); + } + return true; + } + global.ThemePreference = Object.freeze({ applyStored, isValid, @@ -59,4 +81,5 @@ if (document.body?.hasAttribute('data-use-theme-preference')) { applyStored(document.body); } + revealDeferredBackground(document.body); })(window); diff --git a/templates/admin.html b/templates/admin.html index 11f6b23..db59105 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -11,10 +11,10 @@ Settings · Web SSH Terminal - - - - + + + +
@@ -448,10 +448,10 @@

Final destru {% if not embedded %}
- - - - + + + + {% endif %} diff --git a/templates/change_password.html b/templates/change_password.html index bd607e1..dd1f3a8 100644 --- a/templates/change_password.html +++ b/templates/change_password.html @@ -5,12 +5,14 @@ Change password · WebSSH - - - - + + + + - + + +
@@ -29,7 +31,7 @@
- +
@@ -119,7 +121,7 @@

Change Password

- - + + diff --git a/templates/index.html b/templates/index.html index d30864b..7b702f5 100644 --- a/templates/index.html +++ b/templates/index.html @@ -11,25 +11,25 @@ Web SSH Terminal - + - + - + - - - - + + + + - +
- + Your shell. Your rules.
- - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@@ -39,7 +39,7 @@
- +
@@ -320,9 +320,9 @@

Continue with GitHub

- - - - + + + + diff --git a/templates/register.html b/templates/register.html index 470b0c8..d781926 100644 --- a/templates/register.html +++ b/templates/register.html @@ -5,13 +5,13 @@ Create account · WebSSH - - - - + + + + - - + +
@@ -36,7 +36,7 @@
- +
@@ -136,7 +136,7 @@

Create a local WebSSH account

- - + + diff --git a/templates/security.html b/templates/security.html index 35ae440..0f6894f 100644 --- a/templates/security.html +++ b/templates/security.html @@ -11,16 +11,16 @@ Settings · Web SSH Terminal - - - - + + + +
- + {% if recovery_mode %}
@@ -411,14 +411,14 @@

Co

- - - - - + + + + + {% if admin_panel_enabled and is_admin and not recovery_mode %} - - + + {% endif %} diff --git a/tests/e2e/auth-load-performance.spec.js b/tests/e2e/auth-load-performance.spec.js new file mode 100644 index 0000000..8af9979 --- /dev/null +++ b/tests/e2e/auth-load-performance.spec.js @@ -0,0 +1,34 @@ +const { test, expect } = require('playwright/test'); +const { assertNoExternalRequests } = require('./helpers'); + +test('login load does not wait for the decorative theme background', async ({ page }) => { + assertNoExternalRequests(page); + let releaseBackground; + let backgroundRequested = false; + const backgroundGate = new Promise(resolve => { + releaseBackground = resolve; + }); + await page.route('**/theme-backgrounds/**', async route => { + backgroundRequested = true; + await backgroundGate; + await route.continue(); + }); + + const navigation = page.goto('/login', { waitUntil: 'load' }); + const loadedWithoutBackground = await Promise.race([ + navigation.then(() => true), + new Promise(resolve => setTimeout(() => resolve(false), 1500)), + ]); + + releaseBackground(); + await navigation; + + expect(loadedWithoutBackground).toBe(true); + await expect(page.getByRole('heading', { name: 'Access your SSH workspace' })) + .toBeVisible(); + await expect.poll(() => backgroundRequested).toBe(true); + await expect(page.locator('body')).toHaveAttribute( + 'data-theme-background-ready', + '', + ); +}); diff --git a/tests/e2e/session-workspace.spec.js b/tests/e2e/session-workspace.spec.js index c8c1987..7406f01 100644 --- a/tests/e2e/session-workspace.spec.js +++ b/tests/e2e/session-workspace.spec.js @@ -1227,6 +1227,50 @@ test('diagnostics canvas renders correlated inventory and keeps controls clipboa await assertNoExternalRequests(page); }); +test('diagnostics charts keep stable dimensions across primary workspace navigation', async ({ page }) => { + await login(page); + await seedLinuxSession(page); + await page.locator('#contextDiagnosticsTab').click(); + await expect(page.locator('#sessionDiagnosticsOverlay')).toBeVisible(); + await expect(page.locator('#sessionDiagnosticsNetworkChart')).toBeVisible({ timeout: 6000 }); + + const canvases = page.locator([ + '#sessionDiagnosticsCpuSparkline', + '#sessionDiagnosticsMemorySparkline', + '#sessionDiagnosticsDiskSparkline', + '#sessionDiagnosticsLoadSparkline', + '#sessionDiagnosticsPressureChart', + '#sessionDiagnosticsNetworkChart', + ].join(', ')); + const dimensions = () => canvases.evaluateAll(elements => elements.map(canvas => ({ + clientWidth: canvas.clientWidth, + clientHeight: canvas.clientHeight, + width: canvas.width, + height: canvas.height, + styleWidth: canvas.style.width, + styleHeight: canvas.style.height, + }))); + const initial = await dimensions(); + expect(initial).toHaveLength(6); + expect(initial.every(({ clientWidth, clientHeight }) => clientWidth > 0 && clientHeight > 0)).toBe(true); + expect(initial.every(({ styleWidth, styleHeight }) => !styleWidth && !styleHeight)).toBe(true); + + for (let iteration = 0; iteration < 4; iteration += 1) { + await page.locator('#fileTransferBtn').click(); + await expect(page.locator('#sftpFileManager')).toBeVisible(); + await page.evaluate(() => new Promise(resolve => { + window.dispatchEvent(new Event('themeChanged')); + requestAnimationFrame(() => requestAnimationFrame(resolve)); + })); + await page.locator('#workspaceNavBtn').click(); + await expect(page.locator('#sessionDiagnosticsOverlay')).toBeVisible(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(resolve))); + } + + expect(await dimensions()).toEqual(initial); + await assertNoExternalRequests(page); +}); + test('compact breakpoints close diagnostics until the user reopens session tools', async ({ page }) => { await login(page); await seedLinuxSession(page); diff --git a/tests/js/i18n-auth.test.js b/tests/js/i18n-auth.test.js new file mode 100644 index 0000000..f9d4c60 --- /dev/null +++ b/tests/js/i18n-auth.test.js @@ -0,0 +1,65 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +const source = fs.readFileSync('static/js/i18n-auth.js', 'utf8'); + +function loadAuthI18n(localStorage) { + const document = { + documentElement: {}, + title: '', + addEventListener() {}, + querySelector() { return null; }, + querySelectorAll() { return []; }, + }; + const events = []; + const window = { + localStorage, + dispatchEvent(event) { events.push(event); }, + }; + const context = vm.createContext({ + CustomEvent: class CustomEvent { + constructor(name, options) { + this.name = name; + this.detail = options.detail; + } + }, + document, + window, + }); + vm.runInContext(source, context); + return { events, window }; +} + +test('the auth bundle preserves all locales and representative page translations', () => { + const { events, window } = loadAuthI18n({ + getItem() { return 'en'; }, + setItem() {}, + }); + + assert.deepEqual( + Array.from(window.i18n.getLanguages(), language => language.code), + ['en', 'vi', 'de', 'fr', 'es', 'zh'], + ); + assert.equal(window.i18n.t('auth.login'), 'Sign In'); + assert.equal(window.i18n.t('security.methodPasskey'), 'Passkey'); + assert.equal(window.i18n.t('navigation.sshWorkspaces'), 'SSH Workspaces'); + assert.equal(window.i18n.setLanguage('de'), true); + assert.equal(window.i18n.getLanguage(), 'de'); + assert.equal(window.i18n.t('auth.login'), 'Anmelden'); + assert.equal(events.at(-1).name, 'languageChanged'); + assert.equal(events.at(-1).detail.lang, 'de'); +}); + +test('the auth bundle still works when browser storage is blocked', () => { + const { window } = loadAuthI18n({ + getItem() { throw new Error('storage denied'); }, + setItem() { throw new Error('storage denied'); }, + }); + + assert.equal(window.i18n.getLanguage(), 'en'); + assert.equal(window.i18n.setLanguage('fr'), true); + assert.equal(window.i18n.getLanguage(), 'fr'); + assert.equal(window.BrowserPreferences.set('example', 'value'), false); +}); diff --git a/tests/js/session-diagnostics-charts.test.js b/tests/js/session-diagnostics-charts.test.js index 3fa1674..3eed493 100644 --- a/tests/js/session-diagnostics-charts.test.js +++ b/tests/js/session-diagnostics-charts.test.js @@ -58,6 +58,7 @@ test('draws canvas at device pixel ratio and splits paths at null values', () => }); assert.equal(canvas.width, 400); assert.equal(canvas.height, 160); + assert.deepEqual(canvas.style, {}); assert.equal(canvas.calls.filter(call => call[0] === 'clearRect').length, 1); // Three grid moves plus two separate data-path starts prove the null gap did // not become a connecting data line. @@ -66,6 +67,23 @@ test('draws canvas at device pixel ratio and splits paths at null values', () => assert.equal(canvas.calls.filter(call => call[0] === 'stroke').length >= 2, true); }); +test('does not promote a hidden canvas backing size into its layout size', () => { + const canvas = fakeCanvas(); + const series = [{ key: 'cpu', label: 'CPU', values: [20, 40], max: 100 }]; + const options = { devicePixelRatio: 2 }; + charts.drawLineChart(canvas, series, options); + const visibleCallCount = canvas.calls.length; + + canvas.clientWidth = 0; + canvas.clientHeight = 0; + charts.drawLineChart(canvas, series, options); + + assert.equal(canvas.width, 400); + assert.equal(canvas.height, 160); + assert.equal(canvas.calls.length, visibleCallCount); + assert.deepEqual(canvas.style, {}); +}); + test('does not draw data paths with fewer than two finite values and supports sparklines', () => { const canvas = fakeCanvas(); charts.drawLineChart(canvas, [{ key: 'cpu', label: 'CPU', values: [null, 25], max: 100 }]); diff --git a/tests/js/theme-preference.test.js b/tests/js/theme-preference.test.js new file mode 100644 index 0000000..092d8e7 --- /dev/null +++ b/tests/js/theme-preference.test.js @@ -0,0 +1,76 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +const source = fs.readFileSync('static/js/theme-preference.js', 'utf8'); + +function createBody(attributes) { + const values = new Map(Object.entries(attributes)); + return { + getAttribute(name) { return values.get(name) ?? null; }, + hasAttribute(name) { return values.has(name); }, + setAttribute(name, value) { values.set(name, value); }, + }; +} + +test('a deferred theme background starts only after load and an idle turn', () => { + const body = createBody({ + 'data-theme': 'glass', + 'data-use-theme-preference': '', + 'data-defer-theme-background': '', + }); + const listeners = new Map(); + let idleCallback; + const window = { + addEventListener(name, callback, options) { + listeners.set(name, { callback, options }); + }, + localStorage: { + getItem() { return 'paper'; }, + setItem() {}, + }, + requestIdleCallback(callback, options) { + idleCallback = { callback, options }; + }, + setTimeout() { throw new Error('idle callback should be preferred'); }, + }; + const document = { body, readyState: 'loading' }; + + vm.runInContext(source, vm.createContext({ document, window })); + + assert.equal(body.getAttribute('data-theme'), 'paper'); + assert.equal(body.hasAttribute('data-theme-background-ready'), false); + assert.equal(listeners.get('load').options.once, true); + + listeners.get('load').callback(); + assert.equal(body.hasAttribute('data-theme-background-ready'), false); + assert.equal(idleCallback.options.timeout, 1000); + + idleCallback.callback(); + assert.equal(body.hasAttribute('data-theme-background-ready'), true); +}); + +test('ordinary pages keep their theme background behavior unchanged', () => { + const body = createBody({ + 'data-theme': 'glass', + 'data-use-theme-preference': '', + }); + let loadListenerAdded = false; + const window = { + addEventListener() { loadListenerAdded = true; }, + localStorage: { + getItem() { return 'noir'; }, + setItem() {}, + }, + }; + + vm.runInContext( + source, + vm.createContext({ document: { body, readyState: 'loading' }, window }), + ); + + assert.equal(body.getAttribute('data-theme'), 'noir'); + assert.equal(loadListenerAdded, false); + assert.equal(body.hasAttribute('data-theme-background-ready'), false); +}); diff --git a/tests/test_command_set_ui.py b/tests/test_command_set_ui.py index 72705a9..cb2ab36 100644 --- a/tests/test_command_set_ui.py +++ b/tests/test_command_set_ui.py @@ -137,16 +137,19 @@ def test_command_set_scripts_load_in_dependency_order_before_app(): assert utils < workspace < library < manager < connection < app -def test_connection_command_manager_uses_current_cache_version(): +def test_connection_command_manager_uses_content_addressed_url(): template = read('templates/index.html') - assert "filename='js/connection-command-manager.js') }}?v=2" in template + assert ( + "static_asset_url(filename='js/connection-command-manager.js')" + in template + ) -def test_command_set_manager_uses_current_cache_version(): +def test_command_set_manager_uses_content_addressed_url(): template = read('templates/index.html') - assert "filename='js/command-set-manager.js') }}?v=8" in template + assert "static_asset_url(filename='js/command-set-manager.js')" in template def test_connection_and_profile_payloads_send_only_selected_set_id(): diff --git a/tests/test_key_management_ui.py b/tests/test_key_management_ui.py index 0052b40..1637e20 100644 --- a/tests/test_key_management_ui.py +++ b/tests/test_key_management_ui.py @@ -101,10 +101,10 @@ def test_key_replacement_ui_is_accessible_warns_and_keeps_secrets_out_of_markup( def test_key_replacement_event_updates_ui_and_asset_version(): assert "socket.on('key_replaced'" in APP assert 'ProfileManager.upsertKeySummary(data.key)' in APP - assert "filename='js/profile-manager.js') }}?v=19" in TEMPLATE - assert "filename='js/i18n.js') }}?v=46" in TEMPLATE - assert "filename='js/app.js') }}?v=29" in TEMPLATE - assert "filename='css/style.css') }}?v=24" in TEMPLATE + assert "static_asset_url(filename='js/profile-manager.js')" in TEMPLATE + assert "static_asset_url(filename='js/i18n.js')" in TEMPLATE + assert "static_asset_url(filename='js/app.js')" in TEMPLATE + assert "static_asset_url(filename='css/style.css')" in TEMPLATE def test_socket_events_refresh_key_ui_without_resetting_profile_editor(): diff --git a/tests/test_profile_launcher_ui.py b/tests/test_profile_launcher_ui.py index a442290..c2b3b40 100644 --- a/tests/test_profile_launcher_ui.py +++ b/tests/test_profile_launcher_ui.py @@ -29,34 +29,32 @@ def test_saved_connection_context_precedes_authentication_method(): ) -def test_merged_profile_frontend_assets_have_distinct_cache_versions(): +def test_merged_profile_frontend_assets_use_content_addressed_urls(): template = read('templates/index.html') - expected_versions = { - "filename='css/style.css'": '?v=24', - "filename='css/sftp-file-manager.css'": '?v=22', - "filename='js/i18n.js'": '?v=46', - "filename='js/command-workspace.js'": '?v=4', - "filename='js/command-palette-utils.js'": '?v=1', - "filename='js/profile-launcher-utils.js'": '?v=5', - "filename='js/connection-launcher.js'": '?v=1', - "filename='js/profile-manager.js'": '?v=19', - "filename='js/session-workspace.js'": '?v=9', - "filename='js/session-manager.js'": '?v=12', - "filename='js/terminal-manager.js'": '?v=13', - "filename='js/mobile-app-shell.js'": '?v=7', - "filename='js/smb-source-dialog.js'": '?v=5', - "filename='js/sftp-file-manager.js'": '?v=35', - "filename='js/jump-host-manager.js'": '?v=4', - "filename='js/command-library.js'": '?v=5', - "filename='js/command-set-manager.js'": '?v=8', - "filename='js/session-command-launcher.js'": '?v=5', - "filename='js/connection-history.js'": '?v=2', - "filename='js/app.js'": '?v=29', - } - for asset, version in expected_versions.items(): - asset_start = template.index(asset) - asset_tag = template[asset_start:template.index('>', asset_start)] - assert version in asset_tag + expected_assets = ( + 'css/style.css', + 'css/sftp-file-manager.css', + 'js/i18n.js', + 'js/command-workspace.js', + 'js/command-palette-utils.js', + 'js/profile-launcher-utils.js', + 'js/connection-launcher.js', + 'js/profile-manager.js', + 'js/session-workspace.js', + 'js/session-manager.js', + 'js/terminal-manager.js', + 'js/mobile-app-shell.js', + 'js/smb-source-dialog.js', + 'js/sftp-file-manager.js', + 'js/jump-host-manager.js', + 'js/command-library.js', + 'js/command-set-manager.js', + 'js/session-command-launcher.js', + 'js/connection-history.js', + 'js/app.js', + ) + for asset in expected_assets: + assert f"static_asset_url(filename='{asset}')" in template def test_index_exposes_only_bounded_numeric_transfer_limits(): @@ -175,7 +173,7 @@ def test_mobile_launcher_stacks_status_below_profile_details(): def test_profile_launcher_stylesheet_uses_current_cache_version(): template = read('templates/index.html') - assert "filename='css/style.css') }}?v=24" in template + assert "static_asset_url(filename='css/style.css')" in template def test_active_session_command_launcher_is_loaded_after_command_data_managers(): diff --git a/tests/test_static_asset_references.py b/tests/test_static_asset_references.py new file mode 100644 index 0000000..6ed3118 --- /dev/null +++ b/tests/test_static_asset_references.py @@ -0,0 +1,67 @@ +import hashlib +import re +from pathlib import Path +from urllib.parse import parse_qs + + +def test_direct_template_static_references_use_content_addressed_urls(): + invalid_references = [] + pattern = re.compile(r"static_asset_url\(filename='([^']+)'\)") + legacy_pattern = re.compile(r"url_for\('static'") + reference_count = 0 + + for template_path in sorted(Path('templates').glob('*.html')): + source = template_path.read_text(encoding='utf-8') + if legacy_pattern.search(source): + invalid_references.append(f'{template_path}:legacy-url-for') + for asset in pattern.findall(source): + reference_count += 1 + if not (Path('static') / asset).is_file(): + invalid_references.append(f'{template_path}:{asset}') + + assert reference_count > 0 + assert invalid_references == [] + + +def test_local_css_asset_references_use_current_content_hashes(): + invalid_versions = [] + pattern = re.compile(r'url\(["\']?([^"\')]+)') + + for stylesheet in sorted(Path('static/css').glob('*.css')): + source = stylesheet.read_text(encoding='utf-8') + for target in pattern.findall(source): + if target.startswith(('data:', '#', 'http://', 'https://')): + continue + relative_path, separator, query = target.partition('?') + params = parse_qs(query, keep_blank_values=True) + asset_path = (stylesheet.parent / relative_path).resolve() + if ( + not separator + or set(params) != {'v'} + or len(params['v']) != 1 + or not asset_path.is_file() + ): + invalid_versions.append(f'{stylesheet}:{target}') + continue + expected = hashlib.sha256(asset_path.read_bytes()).hexdigest()[:16] + if params['v'][0] != expected: + invalid_versions.append(f'{stylesheet}:{target}') + + assert invalid_versions == [] + + +def test_auth_pages_use_the_generated_authentication_translation_bundle(): + for template_name in ('login.html', 'register.html', 'change_password.html'): + source = (Path('templates') / template_name).read_text(encoding='utf-8') + assert "static_asset_url(filename='js/i18n-auth.js')" in source + assert "static_asset_url(filename='js/i18n.js')" not in source + assert 'data-defer-theme-background' in source + assert "static_asset_url(filename='js/theme-preference.js')" in source + + +def test_authentication_translation_bundle_stays_within_its_page_load_budget(): + auth_size = Path('static/js/i18n-auth.js').stat().st_size + full_size = Path('static/js/i18n.js').stat().st_size + + assert auth_size < 100_000 + assert auth_size < full_size * 0.2 diff --git a/tests/test_static_delivery.py b/tests/test_static_delivery.py new file mode 100644 index 0000000..260e414 --- /dev/null +++ b/tests/test_static_delivery.py @@ -0,0 +1,210 @@ +import gzip + +from app.static_delivery import static_asset_version + + +def _asset_url(client, filename): + version = static_asset_version(client.application, filename) + assert version is not None + return f'/static/{filename}?v={version}' + + +def test_versioned_static_text_is_publicly_cached_and_compressed(client): + response = client.get( + _asset_url(client, 'js/i18n-auth.js'), + headers={'Accept-Encoding': 'br, gzip'}, + ) + + assert response.status_code == 200 + assert response.headers['Cache-Control'] == ( + 'public, max-age=31536000, immutable' + ) + assert response.headers['Content-Encoding'] == 'gzip' + assert 'Accept-Encoding' in response.headers['Vary'] + assert 'Cookie' not in response.headers['Vary'] + assert b'const translations' in gzip.decompress(response.data) + assert response.headers['Content-Security-Policy'].startswith("default-src 'self'") + + +def test_static_compression_has_encoding_specific_conditional_etags(client): + target = _asset_url(client, 'js/i18n-auth.js') + compressed = client.get( + target, + headers={'Accept-Encoding': 'gzip'}, + ) + plain = client.get(target) + + assert compressed.headers['Content-Encoding'] == 'gzip' + assert compressed.headers['ETag'] != plain.headers['ETag'] + + unchanged = client.get( + target, + headers={ + 'Accept-Encoding': 'gzip', + 'If-None-Match': compressed.headers['ETag'], + }, + ) + assert unchanged.status_code == 304 + assert unchanged.headers['Cache-Control'] == ( + 'public, max-age=31536000, immutable' + ) + + identity_validator = client.get( + target, + headers={ + 'Accept-Encoding': 'gzip, identity;q=0', + 'If-None-Match': plain.headers['ETag'], + }, + ) + assert identity_validator.status_code == 200 + assert identity_validator.headers['Content-Encoding'] == 'gzip' + assert identity_validator.headers['ETag'] == compressed.headers['ETag'] + assert gzip.decompress(identity_validator.data) == plain.data + + failed_precondition = client.get( + target, + headers={ + 'Accept-Encoding': 'gzip', + 'If-Match': '"not-the-current-representation"', + }, + ) + assert failed_precondition.status_code == 412 + assert failed_precondition.headers['Cache-Control'] == 'no-store' + + +def test_unversioned_static_urls_must_revalidate(client): + response = client.get('/static/js/i18n-auth.js') + + assert response.status_code == 200 + assert response.headers['Cache-Control'] == ( + 'public, max-age=0, must-revalidate' + ) + + +def test_untrusted_static_query_strings_are_not_stored(client): + current_version = static_asset_version(client.application, 'js/i18n-auth.js') + assert current_version is not None + wrong_version = '0' * 16 + assert wrong_version != current_version + for target in ( + '/static/js/i18n-auth.js?v=', + f'/static/js/i18n-auth.js?v={current_version}&v={current_version}', + f'/static/js/i18n-auth.js?v={current_version}&download=1', + '/static/js/i18n-auth.js?v=unsafe%20value', + f'/static/js/i18n-auth.js?v={wrong_version}', + ): + response = client.get(target) + assert response.status_code == 200 + assert response.headers['Cache-Control'] == 'no-store' + + +def test_missing_versioned_asset_is_not_immutably_cached(client): + response = client.get('/static/js/does-not-exist.js?v=0000000000000000') + + assert response.status_code == 404 + assert response.headers['Cache-Control'] == 'no-store' + + +def test_asset_versions_never_resolve_paths_outside_the_static_index(client): + assert static_asset_version(client.application, '../app/__init__.py') is None + assert static_asset_version(client.application, '/etc/passwd') is None + + +def test_static_encoding_negotiation_respects_client_quality(client): + target = _asset_url(client, 'js/i18n-auth.js') + brotli_only = client.get( + target, + headers={'Accept-Encoding': 'br'}, + ) + disabled_gzip = client.get( + target, + headers={'Accept-Encoding': 'gzip;q=0, identity;q=1'}, + ) + + assert 'Content-Encoding' not in brotli_only.headers + assert 'Content-Encoding' not in disabled_gzip.headers + assert 'Accept-Encoding' in brotli_only.headers['Vary'] + + +def test_ranges_and_non_text_assets_are_not_compressed(client): + partial = client.get( + _asset_url(client, 'js/i18n-auth.js'), + headers={ + 'Accept-Encoding': 'br, gzip', + 'Range': 'bytes=0-63', + }, + ) + image = client.get( + _asset_url(client, 'images/theme-backgrounds/carbon-glass.png'), + headers={'Accept-Encoding': 'br, gzip'}, + ) + + assert partial.status_code == 206 + assert partial.headers['Content-Range'].startswith('bytes 0-63/') + assert 'Content-Encoding' not in partial.headers + assert image.status_code == 200 + assert 'Content-Encoding' not in image.headers + + +def test_static_head_matches_the_selected_get_representation(client): + target = _asset_url(client, 'js/i18n-auth.js') + static_get = client.get( + target, + headers={'Accept-Encoding': 'br, gzip'}, + ) + static_head = client.head( + target, + headers={'Accept-Encoding': 'br, gzip'}, + ) + + assert static_head.status_code == 200 + assert static_head.data == b'' + for header in ('Content-Encoding', 'Content-Length', 'ETag', 'Vary', 'Cache-Control'): + assert static_head.headers[header] == static_get.headers[header] + + +def test_dynamic_html_is_not_compressed(client): + login = client.get( + '/login', + headers={'Accept-Encoding': 'br, gzip'}, + follow_redirects=True, + ) + + assert login.status_code == 200 + assert login.mimetype == 'text/html' + assert 'Content-Encoding' not in login.headers + + +def test_static_options_response_is_not_immutably_cached(client): + response = client.open( + _asset_url(client, 'js/i18n-auth.js'), + method='OPTIONS', + ) + + assert response.status_code == 200 + assert response.headers['Cache-Control'] == 'no-store' + assert 'Content-Encoding' not in response.headers + + +def test_static_requests_do_not_open_an_existing_login_session(client): + with client.session_transaction() as browser_session: + browser_session['_user_id'] = 'nonexistent-user' + browser_session['_fresh'] = True + + response = client.get(_asset_url(client, 'css/style.css')) + + assert response.status_code == 200 + assert 'Cookie' not in response.headers.get('Vary', '') + assert 'Set-Cookie' not in response.headers + + +def test_static_response_with_an_auth_cookie_mutation_is_never_public(client): + with client.session_transaction() as browser_session: + browser_session['_user_id'] = '1' + browser_session['_remember'] = 'set' + + response = client.get(_asset_url(client, 'css/style.css')) + + assert response.status_code == 200 + assert response.headers['Cache-Control'] == 'private, no-store' + assert response.headers.getlist('Set-Cookie') diff --git a/tests/test_webssh2_shell.py b/tests/test_webssh2_shell.py index a39d5c9..d212b3f 100644 --- a/tests/test_webssh2_shell.py +++ b/tests/test_webssh2_shell.py @@ -2,6 +2,14 @@ from pathlib import Path +from app.static_delivery import static_asset_version + + +def _versioned_asset_marker(app, filename): + version = static_asset_version(app, filename) + assert version is not None + return f'{filename}?v={version}'.encode() + def _create_login(app, client): from app.auth import register_user @@ -133,20 +141,25 @@ def test_every_user_facing_page_loads_the_shared_webssh2_design_layer( def test_every_user_facing_page_uses_current_shared_asset_versions(app, client): _create_login(app, client) - for path in ("/", "/security", "/settings", "/change-password"): + for path, translation_bundle in ( + ("/", 'js/i18n.js'), + ("/security", 'js/i18n.js'), + ("/settings", 'js/i18n.js'), + ("/change-password", 'js/i18n-auth.js'), + ): response = client.get(path) assert response.status_code == 200 - assert b'css/style.css?v=24' in response.data - assert b'css/webssh-2.css?v=32' in response.data - assert b'js/i18n.js?v=46' in response.data + assert _versioned_asset_marker(app, 'css/style.css') in response.data + assert _versioned_asset_marker(app, 'css/webssh-2.css') in response.data + assert _versioned_asset_marker(app, translation_bundle) in response.data client.post("/logout") for path in ("/login", "/register"): response = client.get(path) assert response.status_code == 200 - assert b'css/style.css?v=24' in response.data - assert b'css/webssh-2.css?v=32' in response.data - assert b'js/i18n.js?v=46' in response.data + assert _versioned_asset_marker(app, 'css/style.css') in response.data + assert _versioned_asset_marker(app, 'css/webssh-2.css') in response.data + assert _versioned_asset_marker(app, 'js/i18n-auth.js') in response.data def test_compact_workspace_controls_keep_accessible_names_and_close_command_input( @@ -171,9 +184,9 @@ def test_compact_workspace_controls_keep_accessible_names_and_close_command_inpu b'data-i18n-aria-label="terminal.hideInput"', b'id="mobileSendBtn"', b'data-i18n-aria-label="terminal.sendInput"', - b'js/mobile-app-shell.js?v=7', ): assert marker in response.data + assert _versioned_asset_marker(app, 'js/mobile-app-shell.js') in response.data def test_global_management_navigation_uses_one_primary_workspace_surface( @@ -186,7 +199,10 @@ def test_global_management_navigation_uses_one_primary_workspace_surface( assert response.status_code == 200 assert b'id="primaryWorkspaceSurface"' in response.data - assert b'js/primary-workspace-controller.js?v=1' in response.data + assert ( + _versioned_asset_marker(app, 'js/primary-workspace-controller.js') + in response.data + ) assert b'id="profileManagementModal"' in response.data assert b'id="commandWorkspaceModal"' in response.data assert b'class="session-tabs-row"' in response.data