diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 6b890c501..04718d97b 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -3,3 +3,5 @@
ci/helm-chart/ @Matthew-Beckett @alexgorbatchev
docs/install.md @GNUxeava
+
+src/node/i18n/locales/zh-cn.json @zhaozhiming
diff --git a/package.json b/package.json
index d9d10fc59..8d2c70c0a 100644
--- a/package.json
+++ b/package.json
@@ -95,6 +95,7 @@
"express": "5.0.0-alpha.8",
"http-proxy": "^1.18.0",
"httpolyglot": "^0.1.2",
+ "i18next": "^22.4.6",
"js-yaml": "^4.0.0",
"limiter": "^1.1.5",
"pem": "^1.14.2",
diff --git a/src/browser/pages/login.html b/src/browser/pages/login.html
index c10a599af..e7663cb78 100644
--- a/src/browser/pages/login.html
+++ b/src/browser/pages/login.html
@@ -10,7 +10,7 @@
http-equiv="Content-Security-Policy"
content="style-src 'self'; script-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:; font-src 'self' data:;"
/>
-
{{APP_NAME}} login
+ {{I18N_LOGIN_TITLE}}
@@ -25,7 +25,7 @@
{{ERROR}}
diff --git a/src/node/cli.ts b/src/node/cli.ts
index 754ef6469..520d0a554 100644
--- a/src/node/cli.ts
+++ b/src/node/cli.ts
@@ -180,7 +180,14 @@ export const options: Options
> = {
enable: { type: "string[]" },
help: { type: "boolean", short: "h", description: "Show this output." },
json: { type: "boolean" },
- locale: { type: "string" }, // The preferred way to set the locale is via the UI.
+ locale: {
+ // The preferred way to set the locale is via the UI.
+ type: "string",
+ description: `
+ Set vscode display language and language to show on the login page, more info see
+ https://en.wikipedia.org/wiki/IETF_language_tag
+ `,
+ },
open: { type: "boolean", description: "Open in browser on startup. Does not work remotely." },
"bind-addr": {
diff --git a/src/node/i18n/index.ts b/src/node/i18n/index.ts
new file mode 100644
index 000000000..b3b280b4d
--- /dev/null
+++ b/src/node/i18n/index.ts
@@ -0,0 +1,21 @@
+import i18next, { init } from "i18next"
+import * as en from "./locales/en.json"
+import * as zhCn from "./locales/zh-cn.json"
+
+init({
+ lng: "en",
+ fallbackLng: "en", // language to use if translations in user language are not available.
+ returnNull: false,
+ lowerCaseLng: true,
+ debug: process.env.NODE_ENV === "development",
+ resources: {
+ en: {
+ translation: en,
+ },
+ "zh-cn": {
+ translation: zhCn,
+ },
+ },
+})
+
+export default i18next
diff --git a/src/node/i18n/locales/en.json b/src/node/i18n/locales/en.json
new file mode 100644
index 000000000..14e8d1525
--- /dev/null
+++ b/src/node/i18n/locales/en.json
@@ -0,0 +1,13 @@
+{
+ "LOGIN_TITLE": "{{app}} login",
+ "LOGIN_BELOW": "Please log in below.",
+ "WELCOME": "Welcome to {{app}}",
+ "LOGIN_PASSWORD": "Check the config file at {{configFile}} for the password.",
+ "LOGIN_USING_ENV_PASSWORD": "Password was set from $PASSWORD.",
+ "LOGIN_USING_HASHED_PASSWORD": "Password was set from $HASHED_PASSWORD.",
+ "SUBMIT": "SUBMIT",
+ "PASSWORD_PLACEHOLDER": "PASSWORD",
+ "LOGIN_RATE_LIMIT": "Login rate limited!",
+ "MISS_PASSWORD": "Missing password",
+ "INCORRECT_PASSWORD": "Incorrect password"
+}
diff --git a/src/node/i18n/locales/zh-cn.json b/src/node/i18n/locales/zh-cn.json
new file mode 100644
index 000000000..9f28b6669
--- /dev/null
+++ b/src/node/i18n/locales/zh-cn.json
@@ -0,0 +1,13 @@
+{
+ "LOGIN_TITLE": "{{app}} 登录",
+ "LOGIN_BELOW": "请在下面登录。",
+ "WELCOME": "欢迎来到 {{app}}",
+ "LOGIN_PASSWORD": "查看配置文件 {{configFile}} 中的密码。",
+ "LOGIN_USING_ENV_PASSWORD": "密码在 $PASSWORD 中设置。",
+ "LOGIN_USING_HASHED_PASSWORD": "密码在 $HASHED_PASSWORD 中设置。",
+ "SUBMIT": "提交",
+ "PASSWORD_PLACEHOLDER": "密码",
+ "LOGIN_RATE_LIMIT": "登录速率限制!",
+ "MISS_PASSWORD": "缺少密码",
+ "INCORRECT_PASSWORD": "密码不正确"
+}
diff --git a/src/node/routes/login.ts b/src/node/routes/login.ts
index 633c34ba4..786b89c7f 100644
--- a/src/node/routes/login.ts
+++ b/src/node/routes/login.ts
@@ -7,6 +7,7 @@ import { CookieKeys } from "../../common/http"
import { rootPath } from "../constants"
import { authenticated, getCookieOptions, redirect, replaceTemplates } from "../http"
import { getPasswordMethod, handlePasswordValidation, humanPath, sanitizeString, escapeHtml } from "../util"
+import i18n from "../i18n"
// RateLimiter wraps around the limiter library for logins.
// It allows 2 logins every minute plus 12 logins every hour.
@@ -28,21 +29,26 @@ export class RateLimiter {
const getRoot = async (req: Request, error?: Error): Promise => {
const content = await fs.readFile(path.join(rootPath, "src/browser/pages/login.html"), "utf8")
+ const locale = req.args["locale"] || "en"
+ i18n.changeLanguage(locale)
const appName = req.args["app-name"] || "code-server"
- const welcomeText = req.args["welcome-text"] || `Welcome to ${appName}`
- let passwordMsg = `Check the config file at ${humanPath(os.homedir(), req.args.config)} for the password.`
+ const welcomeText = req.args["welcome-text"] || (i18n.t("WELCOME", { app: appName }) as string)
+ let passwordMsg = i18n.t("LOGIN_PASSWORD", { configFile: humanPath(os.homedir(), req.args.config) })
if (req.args.usingEnvPassword) {
- passwordMsg = "Password was set from $PASSWORD."
+ passwordMsg = i18n.t("LOGIN_USING_ENV_PASSWORD")
} else if (req.args.usingEnvHashedPassword) {
- passwordMsg = "Password was set from $HASHED_PASSWORD."
+ passwordMsg = i18n.t("LOGIN_USING_HASHED_PASSWORD")
}
return replaceTemplates(
req,
content
- .replace(/{{APP_NAME}}/g, appName)
+ .replace(/{{I18N_LOGIN_TITLE}}/g, i18n.t("LOGIN_TITLE", { app: appName }))
.replace(/{{WELCOME_TEXT}}/g, welcomeText)
.replace(/{{PASSWORD_MSG}}/g, passwordMsg)
+ .replace(/{{I18N_LOGIN_BELOW}}/g, i18n.t("LOGIN_BELOW"))
+ .replace(/{{I18N_PASSWORD_PLACEHOLDER}}/g, i18n.t("PASSWORD_PLACEHOLDER"))
+ .replace(/{{I18N_SUBMIT}}/g, i18n.t("SUBMIT"))
.replace(/{{ERROR}}/, error ? `${escapeHtml(error.message)}
` : ""),
)
}
@@ -70,11 +76,11 @@ router.post<{}, string, { password: string; base?: string }, { to?: string }>("/
try {
// Check to see if they exceeded their login attempts
if (!limiter.canTry()) {
- throw new Error("Login rate limited!")
+ throw new Error(i18n.t("LOGIN_RATE_LIMIT") as string)
}
if (!password) {
- throw new Error("Missing password")
+ throw new Error(i18n.t("MISS_PASSWORD") as string)
}
const passwordMethod = getPasswordMethod(hashedPasswordFromArgs)
@@ -108,7 +114,7 @@ router.post<{}, string, { password: string; base?: string }, { to?: string }>("/
}),
)
- throw new Error("Incorrect password")
+ throw new Error(i18n.t("INCORRECT_PASSWORD") as string)
} catch (error: any) {
const renderedHtml = await getRoot(req, error)
res.send(renderedHtml)
diff --git a/test/unit/node/routes/login.test.ts b/test/unit/node/routes/login.test.ts
index b2cf44651..f2f38fedc 100644
--- a/test/unit/node/routes/login.test.ts
+++ b/test/unit/node/routes/login.test.ts
@@ -138,5 +138,16 @@ describe("login", () => {
expect(resp.status).toBe(200)
expect(htmlContent).toContain(`Welcome to ${appName}`)
})
+
+ it("should return correct welcome text when locale is set to non-English", async () => {
+ process.env.PASSWORD = previousEnvPassword
+ const locale = "zh-cn"
+ const codeServer = await integration.setup([`--locale=${locale}`], "")
+ const resp = await codeServer.fetch("/login", { method: "GET" })
+
+ const htmlContent = await resp.text()
+ expect(resp.status).toBe(200)
+ expect(htmlContent).toContain(`欢迎来到 code-server`)
+ })
})
})
diff --git a/tsconfig.json b/tsconfig.json
index 3a591aac2..6ff95ccca 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -22,7 +22,8 @@
"./test/node_modules/@types",
"./lib/vscode/src/vs/server/@types"
],
- "downlevelIteration": true
+ "downlevelIteration": true,
+ "resolveJsonModule": true
},
"include": ["./src/**/*"],
"exclude": ["/test", "/lib", "/ci", "/doc"]
diff --git a/yarn.lock b/yarn.lock
index 76034dc14..99fbd2c66 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2,6 +2,13 @@
# yarn lockfile v1
+"@babel/runtime@^7.20.6":
+ version "7.20.7"
+ resolved "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.20.7.tgz#fcb41a5a70550e04a7b708037c7c32f7f356d8fd"
+ integrity sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==
+ dependencies:
+ regenerator-runtime "^0.13.11"
+
"@coder/logger@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@coder/logger/-/logger-3.0.0.tgz#fd4d2332ca375412c75cb5ba7767d3290b106dec"
@@ -1814,6 +1821,13 @@ https-proxy-agent@5, https-proxy-agent@^5.0.0:
agent-base "6"
debug "4"
+i18next@^22.4.6:
+ version "22.4.6"
+ resolved "https://registry.npmmirror.com/i18next/-/i18next-22.4.6.tgz#876352c3ba81bdfedc38eeda124e2bbd05f46988"
+ integrity sha512-9Tm1ezxWyzV+306CIDMBbYBitC1jedQyYuuLtIv7oxjp2ohh8eyxP9xytIf+2bbQfhH784IQKPSYp+Zq9+YSbw==
+ dependencies:
+ "@babel/runtime" "^7.20.6"
+
iconv-lite@0.4.24:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@@ -2873,6 +2887,11 @@ readline-transform@1.0.0:
resolved "https://registry.yarnpkg.com/readline-transform/-/readline-transform-1.0.0.tgz#3157f97428acaec0f05a5c1ff2c3120f4e6d904b"
integrity sha512-7KA6+N9IGat52d83dvxnApAWN+MtVb1MiVuMR/cf1O4kYsJG+g/Aav0AHcHKsb6StinayfPLne0+fMX2sOzAKg==
+regenerator-runtime@^0.13.11:
+ version "0.13.11"
+ resolved "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
+ integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==
+
regexp.prototype.flags@^1.4.3:
version "1.4.3"
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac"