From d21ae5499ac7d5f99d888a908f0cff91a070bd8d Mon Sep 17 00:00:00 2001 From: hstyi Date: Thu, 23 Oct 2025 17:23:01 +0800 Subject: [PATCH] feat: HTTP server for authentication --- build.gradle.kts | 1 + .../app/termora/account/AccountOption.kt | 199 +++++++++++++++++- .../app/termora/account/LoginServerDialog.kt | 141 +------------ .../app/termora/account/ServerManager.kt | 40 ++-- src/main/resources/i18n/messages.properties | 4 +- .../resources/i18n/messages_ru_RU.properties | 21 +- .../resources/i18n/messages_zh_CN.properties | 4 +- .../resources/i18n/messages_zh_TW.properties | 4 +- 8 files changed, 235 insertions(+), 179 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 608d36d..fc68187 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -339,6 +339,7 @@ tasks.register("jlink") { "java.security.jgss", "jdk.crypto.ec", "jdk.unsupported", + "jdk.httpserver", ) commandLine( diff --git a/src/main/kotlin/app/termora/account/AccountOption.kt b/src/main/kotlin/app/termora/account/AccountOption.kt index baa7bbf..5e2b34e 100644 --- a/src/main/kotlin/app/termora/account/AccountOption.kt +++ b/src/main/kotlin/app/termora/account/AccountOption.kt @@ -1,6 +1,7 @@ package app.termora.account import app.termora.* +import app.termora.Application.ohMyJson import app.termora.OptionsPane.Companion.FORM_MARGIN import app.termora.actions.AnAction import app.termora.actions.AnActionEvent @@ -8,21 +9,36 @@ import app.termora.database.DatabaseManager import app.termora.plugin.internal.extension.DynamicExtensionHandler import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.layout.FormLayout +import com.sun.net.httpserver.HttpServer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import org.apache.commons.codec.binary.Hex +import org.apache.commons.io.IOUtils +import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.time.DateFormatUtils +import org.jdesktop.swingx.JXBusyLabel import org.jdesktop.swingx.JXHyperlink +import org.slf4j.LoggerFactory import java.awt.BorderLayout +import java.awt.CardLayout +import java.net.InetSocketAddress import java.net.URI +import java.net.URLEncoder import java.util.* +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit import javax.swing.* import kotlin.time.Duration.Companion.milliseconds class AccountOption : JPanel(BorderLayout()), OptionsPane.Option, Disposable { + companion object { + private val log = LoggerFactory.getLogger(AccountOption::class.java) + } private val owner get() = SwingUtilities.getWindowAncestor(this) private val databaseManager get() = DatabaseManager.getInstance() @@ -30,18 +46,31 @@ class AccountOption : JPanel(BorderLayout()), OptionsPane.Option, Disposable { private val accountProperties get() = AccountProperties.getInstance() private val userInfoPanel = JPanel(BorderLayout()) private val lastSynchronizationOnLabel = JLabel() + private val serverManager get() = ServerManager.getInstance() + private val cardLayout = CardLayout() + private val contentPanel = JPanel(cardLayout) + private val loginPanel = JPanel(BorderLayout()) + private val busyLabel = JXBusyLabel() + private var httpServer: HttpServer? = null init { initView() initEvents() } - private fun initView() { refreshUserInfoPanel() - add(userInfoPanel, BorderLayout.CENTER) - } + refreshLoginPanel() + contentPanel.add(userInfoPanel, "UserInfo") + contentPanel.add(loginPanel, "Login") + + cardLayout.show(contentPanel, "UserInfo") + + add(contentPanel, BorderLayout.CENTER) + + + } private fun initEvents() { // 服务器签名发生变更 @@ -99,11 +128,7 @@ class AccountOption : JPanel(BorderLayout()), OptionsPane.Option, Disposable { planBox.add(Box.createHorizontalStrut(16)) val upgrade = JXHyperlink(object : AnAction(I18n.getString("termora.settings.account.upgrade")) { override fun actionPerformed(evt: AnActionEvent) { - if (I18n.isChinaMainland()) { - Application.browse(URI.create("https://www.termora.cn/pricing?version=${Application.getVersion()}")) - } else { - Application.browse(URI.create("https://www.termora.app/pricing?version=${Application.getVersion()}")) - } + Application.browse(URI.create("${accountManager.getServer()}/v1/client/redirect?to=upgrade&version=${Application.getVersion()}")) } }) upgrade.isFocusable = false @@ -145,6 +170,29 @@ class AccountOption : JPanel(BorderLayout()), OptionsPane.Option, Disposable { .build() } + private fun getLoginComponent(): JComponent { + val layout = FormLayout( + "default:grow", + "pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref" + ) + + val cancelBtn = JXHyperlink(object : AnAction(I18n.getString("termora.cancel")) { + override fun actionPerformed(evt: AnActionEvent) { + httpServer?.stop(0) + cardLayout.show(contentPanel, "UserInfo") + } + }) + + val tipLabel = JLabel(I18n.getString("termora.settings.account.wait-login")) + tipLabel.foreground = UIManager.getColor("TextField.placeholderForeground") + + return FormBuilder.create().layout(layout).debug(false).padding("10dlu,0,0,0") + .add(busyLabel).xy(1, 1, "center, fill") + .add(tipLabel).xy(1, 3, "center, fill") + .add(cancelBtn).xy(1, 5, "center, fill") + .build() + } + private fun createActionPanel(isFreePlan: Boolean): JComponent { val actionBox = Box.createHorizontalBox() actionBox.add(Box.createHorizontalGlue()) @@ -219,11 +267,139 @@ class AccountOption : JPanel(BorderLayout()), OptionsPane.Option, Disposable { return actionBox } + private fun showLoginPanel() { + refreshLoginPanel() + busyLabel.isBusy = true + cardLayout.show(contentPanel, "Login") + } + private fun onLogin() { + httpServer?.stop(0) + val dialog = LoginServerDialog(owner) dialog.isVisible = true + val server = dialog.server ?: return + + showLoginPanel() + + onLogin(server) } + + private fun onLogin(server: Server) { + + val httpServer = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + .apply { httpServer = this } + val future = processLogin(server, httpServer) + + val loginJob = swingCoroutineScope.launch(Dispatchers.IO) { + try { + val loginResult = future.get(5, TimeUnit.MINUTES) + serverManager.login(server, loginResult.refreshToken, loginResult.password) + } catch (e: Exception) { + if (log.isErrorEnabled) log.error(e.message, e) + withContext(Dispatchers.Swing) { + OptionPane.showMessageDialog( + owner, + StringUtils.defaultIfBlank( + e.message ?: StringUtils.EMPTY, + I18n.getString("termora.settings.account.login-failed") + ), + messageType = JOptionPane.ERROR_MESSAGE, + ) + } + } finally { + withContext(Dispatchers.Swing) { cardLayout.show(contentPanel, "UserInfo") } + httpServer.stop(0) + } + } + + Disposer.register(this, object : Disposable { + override fun dispose() { + loginJob.cancel() + httpServer.stop(0) + } + }) + } + + override fun dispose() { + busyLabel.isBusy = false + super.dispose() + } + + private fun processLogin(server: Server, httpServer: HttpServer): CompletableFuture { + val keypair = RSA.generateKeyPair(2048) + val future = CompletableFuture() + + httpServer.createContext("/callback") { exchange -> + val method = exchange.requestMethod + if (method.equals("OPTIONS", ignoreCase = true)) { + exchange.responseHeaders.add("Access-Control-Allow-Origin", "*") + exchange.responseHeaders.add("Access-Control-Allow-Methods", "POST, OPTIONS") + exchange.responseHeaders.add("Access-Control-Allow-Headers", "Content-Type") + exchange.sendResponseHeaders(204, -1) + } else { + var loginResult: LoginResult? = null + + if (method.equals("POST", ignoreCase = true)) { + try { + val text = String(exchange.requestBody.readAllBytes()) + loginResult = ohMyJson.decodeFromString(text) + + val secretKey = RSA.decrypt(keypair.private, Hex.decodeHex(loginResult.secretKey)) + val secretIv = RSA.decrypt(keypair.private, Hex.decodeHex(loginResult.secretIv)) + val password = AES.CBC.decrypt(secretKey, secretIv, Hex.decodeHex(loginResult.password)) + val refreshToken = AES.CBC.decrypt( + secretKey, secretIv, Hex.decodeHex(loginResult.refreshToken) + ) + loginResult = loginResult.copy( + password = String(password), + refreshToken = String(refreshToken) + ) + } catch (e: Exception) { + if (log.isErrorEnabled) { + log.error(e.message, e) + } + } + } + + val response = "OK".toByteArray() + exchange.responseHeaders.add("Access-Control-Allow-Origin", "*") + exchange.sendResponseHeaders(200, response.size.toLong()) + exchange.responseBody.use { it.write(response) } + + if (loginResult != null) { + future.complete(loginResult) + } + } + IOUtils.closeQuietly { exchange.close() } + } + httpServer.start() + + val sb = StringBuilder() + val redirect = StringBuilder() + redirect.append("/device?callback=").append("http://127.0.0.1:${httpServer.address.port}/callback") + redirect.append("&from=device&publicKey=").append(keypair.public.encoded.toHexString()) + redirect.append("&format=hex&device=termora&device-version=").append(Application.getVersion()) + + sb.append(server.server) + sb.append("/v1/client/redirect?to=login&from=device") + sb.append("&redirect=").append(URLEncoder.encode(redirect.toString(), Charsets.UTF_8)) + + Application.browse(URI.create(sb.toString())) + + return future + } + + @Serializable + private data class LoginResult( + val password: String, + val refreshToken: String, + val secretKey: String, + val secretIv: String, + ) + + private fun refreshUserInfoPanel() { userInfoPanel.removeAll() userInfoPanel.add(getCenterComponent(), BorderLayout.CENTER) @@ -231,6 +407,13 @@ class AccountOption : JPanel(BorderLayout()), OptionsPane.Option, Disposable { userInfoPanel.repaint() } + private fun refreshLoginPanel() { + loginPanel.removeAll() + loginPanel.add(getLoginComponent(), BorderLayout.CENTER) + loginPanel.revalidate() + loginPanel.repaint() + } + override fun getIcon(isSelected: Boolean): Icon { return Icons.user } diff --git a/src/main/kotlin/app/termora/account/LoginServerDialog.kt b/src/main/kotlin/app/termora/account/LoginServerDialog.kt index e28d7e7..9bf936a 100644 --- a/src/main/kotlin/app/termora/account/LoginServerDialog.kt +++ b/src/main/kotlin/app/termora/account/LoginServerDialog.kt @@ -9,12 +9,6 @@ import app.termora.database.DatabaseManager import com.formdev.flatlaf.FlatClientProperties import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.layout.FormLayout -import kotlinx.coroutines.* -import kotlinx.coroutines.swing.Swing -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.boolean -import kotlinx.serialization.json.jsonPrimitive -import okhttp3.Request import org.apache.commons.lang3.StringUtils import org.jdesktop.swingx.JXHyperlink import org.slf4j.LoggerFactory @@ -24,12 +18,10 @@ import java.awt.Window import java.awt.event.WindowAdapter import java.awt.event.WindowEvent import java.net.URI -import java.util.concurrent.atomic.AtomicBoolean import javax.swing.* import javax.swing.event.ListDataEvent import javax.swing.event.ListDataListener import kotlin.math.max -import kotlin.time.Duration.Companion.milliseconds class LoginServerDialog(owner: Window) : DialogWrapper(owner) { companion object { @@ -37,18 +29,14 @@ class LoginServerDialog(owner: Window) : DialogWrapper(owner) { } private val serverComboBox = OutlineComboBox() - private val usernameTextField = OutlineTextField(128) - private val passwordField = OutlinePasswordField() - private val mfaTextField = OutlineTextField(128) private val okAction = OkAction(I18n.getString("termora.settings.account.login")) private val cancelAction = super.createCancelAction() private val cancelButton = super.createJButtonForAction(cancelAction) - private val isLoggingIn = AtomicBoolean(false) private val singaporeServer = Server(I18n.getString("termora.settings.account.server-singapore"), "https://account.termora.app") private val chinaServer = Server(I18n.getString("termora.settings.account.server-china"), "https://account.termora.cn") - private val serverManager get() = ServerManager.getInstance() + var server: Server? = null init { isModal = true @@ -60,12 +48,10 @@ class LoginServerDialog(owner: Window) : DialogWrapper(owner) { size = Dimension(max(preferredSize.width, UIManager.getInt("Dialog.width") - 250), preferredSize.height) setLocationRelativeTo(owner) - passwordField.putClientProperty(FlatClientProperties.STYLE, mapOf("showCapsLock" to true)) addWindowListener(object : WindowAdapter() { override fun windowOpened(e: WindowEvent) { removeWindowListener(this) - usernameTextField.requestFocus() } }) } @@ -73,7 +59,7 @@ class LoginServerDialog(owner: Window) : DialogWrapper(owner) { override fun createCenterPanel(): JComponent { val layout = FormLayout( "left:pref, $FORM_MARGIN, default:grow, $FORM_MARGIN, pref", - "pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN" + "pref, $FORM_MARGIN" ) var rows = 1 @@ -90,7 +76,6 @@ class LoginServerDialog(owner: Window) : DialogWrapper(owner) { serverComboBox.addItem(Server(server.name, server.server)) } - mfaTextField.placeholderText = I18n.getString("termora.settings.account.mfa") serverComboBox.renderer = object : DefaultListCellRenderer() { override fun getListCellRendererComponent( @@ -153,40 +138,6 @@ class LoginServerDialog(owner: Window) : DialogWrapper(owner) { } } - val registerAction = object : AnAction(I18n.getString("termora.settings.account.register")) { - override fun actionPerformed(evt: AnActionEvent) { - val server = serverComboBox.selectedItem as Server? - if (server == null) { - serverComboBox.outline = FlatClientProperties.OUTLINE_ERROR - serverComboBox.requestFocusInWindow() - return - } - - try { - val text = AccountHttp.execute( - AccountHttp.client, Request.Builder() - .get().url("${server.server}/v1/client/system").build() - ) - val json = runCatching { ohMyJson.decodeFromString(text) }.getOrNull() - val allowRegister = json?.get("register")?.jsonPrimitive?.boolean ?: false - if (allowRegister.not()) { - throw IllegalStateException(I18n.getString("termora.settings.account.not-support-register")) - } - Application.browse(URI.create("${server.server}/v1/client/redirect?to=register&from=${Application.getName()}")) - } catch (e: Exception) { - if (log.isErrorEnabled) { - log.error(e.message, e) - } - OptionPane.showMessageDialog( - dialog, - e.message ?: I18n.getString("termora.settings.account.not-support-register"), - messageType = JOptionPane.ERROR_MESSAGE - ) - } - } - } - - fun refreshButton() { if (serverComboBox.selectedItem == singaporeServer || serverComboBox.selectedItem == chinaServer || serverComboBox.itemCount < 1) { newAction.name = I18n.getString("termora.welcome.contextmenu.new") @@ -214,21 +165,11 @@ class LoginServerDialog(owner: Window) : DialogWrapper(owner) { }) - val registerLink = JXHyperlink(registerAction) - registerLink.isFocusable = false - return FormBuilder.create().layout(layout).debug(false).padding("0dlu, $FORM_MARGIN, 0dlu, $FORM_MARGIN") .add("${I18n.getString("termora.settings.account.server")}:").xy(1, rows) .add(serverComboBox).xy(3, rows) .add(newServer).xy(5, rows).apply { rows += step } - .add("${I18n.getString("termora.settings.account")}:").xy(1, rows) - .add(usernameTextField).xy(3, rows) - .add(registerLink).xy(5, rows).apply { rows += step } - .add("${I18n.getString("termora.new-host.general.password")}:").xy(1, rows) - .add(passwordField).xy(3, rows).apply { rows += step } - .add("MFA:").xy(1, rows) - .add(mfaTextField).xy(3, rows).apply { rows += step } .build() } @@ -315,95 +256,21 @@ class LoginServerDialog(owner: Window) : DialogWrapper(owner) { } override fun doOKAction() { - if (isLoggingIn.get()) return - val server = serverComboBox.selectedItem as? Server + server = serverComboBox.selectedItem as? Server if (server == null) { serverComboBox.outline = FlatClientProperties.OUTLINE_ERROR serverComboBox.requestFocusInWindow() return } - if (usernameTextField.text.isBlank()) { - usernameTextField.outline = FlatClientProperties.OUTLINE_ERROR - usernameTextField.requestFocusInWindow() - return - } else if (passwordField.password.isEmpty()) { - passwordField.outline = FlatClientProperties.OUTLINE_ERROR - passwordField.requestFocusInWindow() - return - } - - if (isLoggingIn.compareAndSet(false, true)) { - okAction.isEnabled = false - usernameTextField.isEnabled = false - passwordField.isEnabled = false - mfaTextField.isEnabled = false - serverComboBox.isEnabled = false - cancelButton.isVisible = false - onLogin(server) - return - } super.doOKAction() } - private fun onLogin(server: Server) { - val job = swingCoroutineScope.launch(Dispatchers.IO) { - var c = 0 - while (isActive) { - if (++c > 3) c = 0 - okAction.name = I18n.getString("termora.settings.account.login") + ".".repeat(c) - delay(350.milliseconds) - } - } - - val loginJob = swingCoroutineScope.launch(Dispatchers.IO) { - try { - serverManager.login( - server, usernameTextField.text, - String(passwordField.password), mfaTextField.text.trim() - ) - withContext(Dispatchers.Swing) { - super.doOKAction() - } - } catch (e: Exception) { - if (log.isErrorEnabled) log.error(e.message, e) - withContext(Dispatchers.Swing) { - OptionPane.showMessageDialog( - this@LoginServerDialog, - StringUtils.defaultIfBlank( - e.message ?: StringUtils.EMPTY, - I18n.getString("termora.settings.account.login-failed") - ), - messageType = JOptionPane.ERROR_MESSAGE, - ) - } - } finally { - job.cancel() - withContext(Dispatchers.Swing) { - okAction.name = I18n.getString("termora.settings.account.login") - okAction.isEnabled = true - usernameTextField.isEnabled = true - passwordField.isEnabled = true - serverComboBox.isEnabled = true - cancelButton.isVisible = true - mfaTextField.isEnabled = true - } - isLoggingIn.compareAndSet(true, false) - } - } - - Disposer.register(disposable, object : Disposable { - override fun dispose() { - if (loginJob.isActive) - loginJob.cancel() - } - }) - } override fun doCancelAction() { - if (isLoggingIn.get()) return + server = null super.doCancelAction() } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/account/ServerManager.kt b/src/main/kotlin/app/termora/account/ServerManager.kt index 32a36f6..04eed7b 100644 --- a/src/main/kotlin/app/termora/account/ServerManager.kt +++ b/src/main/kotlin/app/termora/account/ServerManager.kt @@ -1,7 +1,10 @@ package app.termora.account -import app.termora.* +import app.termora.AES import app.termora.Application.ohMyJson +import app.termora.ApplicationScope +import app.termora.PBKDF2 +import app.termora.RSA import kotlinx.serialization.Serializable import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -9,7 +12,6 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.apache.commons.codec.binary.Base64 -import org.apache.commons.codec.digest.DigestUtils import java.util.concurrent.atomic.AtomicBoolean class ServerManager private constructor() { @@ -28,7 +30,7 @@ class ServerManager private constructor() { /** * 登录,不报错就是登录成功 */ - fun login(server: Server, username: String, password: String, mfa: String) { + fun login(server: Server, refreshToken: String, password: String) { if (accountManager.isLocally().not()) { throw IllegalStateException("Already logged in") @@ -39,25 +41,25 @@ class ServerManager private constructor() { } try { - doLogin(server, username, password, mfa) + doLogin(server, refreshToken, password) } finally { isLoggingIn.compareAndSet(true, false) } } - private fun doLogin(server: Server, username: String, password: String, mfa: String) { + private fun doLogin(server: Server, refreshToken: String, password: String) { // 服务器信息 val serverInfo = getServerInfo(server) // call login - val loginResponse = callLogin(serverInfo, server, username, password, mfa) + val loginResponse = callToken(server, refreshToken) // call me val meResponse = callMe(server.server, loginResponse.accessToken) // 解密 - val salt = "${serverInfo.salt}:${username}".toByteArray() + val salt = "${serverInfo.salt}:${meResponse.email}".toByteArray() val privateKeySecureKey = PBKDF2.hash(salt, password.toCharArray(), 1024, 256) val privateKeySecureIv = PBKDF2.hash(salt, password.toCharArray(), 1024, 128) val privateKeyEncoded = AES.CBC.decrypt( @@ -106,29 +108,19 @@ class ServerManager private constructor() { return ohMyJson.decodeFromString(AccountHttp.execute(request = request)) } - private fun callLogin( - serverInfo: ServerInfo, + private fun callToken( server: Server, - username: String, - password: String, - mfa: String + refreshToken: String, ): LoginResponse { - - val passwordHex = DigestUtils.sha256Hex("${serverInfo.salt}:${username}:${password}") - val requestBody = ohMyJson.encodeToString(mapOf("email" to username, "password" to passwordHex, "mfa" to mfa)) + val body = ohMyJson.encodeToString(mapOf("refreshToken" to refreshToken)) .toRequestBody("application/json".toMediaType()) - - val request = Request.Builder() - .url("${server.server}/v1/login") - .post(requestBody) + val request = Request.Builder().url("${server.server}/v1/token") + .header("Authorization", "Bearer $refreshToken") + .post(body) .build() val response = AccountHttp.client.newCall(request).execute() - val text = response.use { response.body.use { it?.string() } } - - if (text == null) { - throw ResponseException(response.code, response) - } + val text = response.use { response.body.use { it.string() } } if (response.isSuccessful.not()) { val message = ohMyJson.parseToJsonElement(text).jsonObject["message"]?.jsonPrimitive?.content diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 627596e..d9415d9 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -89,11 +89,9 @@ termora.settings.plugin.install-from-disk-warning={0} plugin will have ac termora.settings.plugin.not-compatible=The plugin {0} is incompatible with the current version. Please reinstall {0} termora.settings.account=Account -termora.settings.account.register=Register -termora.settings.account.not-support-register=This server does not support account registration termora.settings.account.login=Log in termora.settings.account.server=Server -termora.settings.account.mfa=MFA is optional +termora.settings.account.wait-login=Waiting for login in the default browser... termora.settings.account.locally=locally termora.settings.account.lifetime=Lifetime termora.settings.account.upgrade=Upgrade diff --git a/src/main/resources/i18n/messages_ru_RU.properties b/src/main/resources/i18n/messages_ru_RU.properties index ec26946..f4c7e47 100644 --- a/src/main/resources/i18n/messages_ru_RU.properties +++ b/src/main/resources/i18n/messages_ru_RU.properties @@ -49,7 +49,26 @@ termora.setting.security.enter-password-again=Повторите пароль termora.setting.security.password-is-different=Пароли отличаются termora.setting.security.mnemonic-note=Сохраните мнемоническую фразу в надежном месте, она может помочь восстановить данные, если вы забудете пароль - +termora.settings.account=Учётная запись +termora.settings.account.login=Войти +termora.settings.account.server=Сервер +termora.settings.account.wait-login=Ожидание входа в браузере по умолчанию... +termora.settings.account.locally=локально +termora.settings.account.lifetime=Время действия +termora.settings.account.upgrade=Обновить +termora.settings.account.verify=Подтвердить +termora.settings.account.subscription=Подписка +termora.settings.account.valid-to=Действительна до +termora.settings.account.synchronization-on=Синхронизация вкл +termora.settings.account.sync-now = Синхронизировать сейчас +termora.settings.account.logout = Выйти +termora.settings.account.logout-confirm = Вы уверены, что хотите выйти? +termora.settings.account.unsynced-logout-confirm = Несинхронизировано Вы уверены, что хотите выйти? +termora.settings.account.server-singapore = Сингапур +termora.settings.account.server-china = Материковый Китай +termora.settings.account.new-server = Новый сервер +termora.settings.account.deploy-server = Развернуть +termora.settings.account.login-failed = Не удалось войти, повторите попытку позже termora.settings.terminal=Терминал diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index ae20869..c30fb32 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -103,10 +103,8 @@ termora.settings.plugin.not-compatible=插件 {0} 与当前版本不兼 termora.settings.account=账号 termora.settings.account.login=登录 -termora.settings.account.register=注册 -termora.settings.account.not-support-register=该服务器不支持注册账号 termora.settings.account.server=服务器 -termora.settings.account.mfa=多因素验证是可选的 +termora.settings.account.wait-login=正在等待默认浏览器中登录... termora.settings.account.locally=本地的 termora.settings.account.lifetime=长期 termora.settings.account.verify=验证 diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index 6a8f361..8d02883 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -114,10 +114,8 @@ termora.settings.plugin.not-compatible=插件 {0} 與目前版本不相 termora.settings.account=帳號 termora.settings.account.login=登入 -termora.settings.account.register=註冊 -termora.settings.account.not-support-register=此伺服器不支援註冊帳號 termora.settings.account.server=伺服器 -termora.settings.account.mfa=多因素驗證是可選的 +termora.settings.account.wait-login=正在等待預設瀏覽器登入... termora.settings.account.locally=本地的 termora.settings.account.lifetime=長期 termora.settings.account.verify=驗證