feat: add i18n in login page (#5947)
* feat: add i18n in login page * fix: add word space and put the app name into the title * fix: remove duplicate replace title * fix: prettier format code * fix: fix typescript check warning * fix: add zh-cn locale file code owner * fix: use existing flag locale to the login page Co-authored-by: Joe Previte <jjprevite@gmail.com>
This commit is contained in:
parent
d40a9742c0
commit
7c2aa8c417
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@ -3,3 +3,5 @@
|
|||||||
ci/helm-chart/ @Matthew-Beckett @alexgorbatchev
|
ci/helm-chart/ @Matthew-Beckett @alexgorbatchev
|
||||||
|
|
||||||
docs/install.md @GNUxeava
|
docs/install.md @GNUxeava
|
||||||
|
|
||||||
|
src/node/i18n/locales/zh-cn.json @zhaozhiming
|
||||||
|
@ -95,6 +95,7 @@
|
|||||||
"express": "5.0.0-alpha.8",
|
"express": "5.0.0-alpha.8",
|
||||||
"http-proxy": "^1.18.0",
|
"http-proxy": "^1.18.0",
|
||||||
"httpolyglot": "^0.1.2",
|
"httpolyglot": "^0.1.2",
|
||||||
|
"i18next": "^22.4.6",
|
||||||
"js-yaml": "^4.0.0",
|
"js-yaml": "^4.0.0",
|
||||||
"limiter": "^1.1.5",
|
"limiter": "^1.1.5",
|
||||||
"pem": "^1.14.2",
|
"pem": "^1.14.2",
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
http-equiv="Content-Security-Policy"
|
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:;"
|
content="style-src 'self'; script-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:; font-src 'self' data:;"
|
||||||
/>
|
/>
|
||||||
<title>{{APP_NAME}} login</title>
|
<title>{{I18N_LOGIN_TITLE}}</title>
|
||||||
<link rel="icon" href="{{CS_STATIC_BASE}}/src/browser/media/favicon-dark-support.svg" />
|
<link rel="icon" href="{{CS_STATIC_BASE}}/src/browser/media/favicon-dark-support.svg" />
|
||||||
<link rel="alternate icon" href="{{CS_STATIC_BASE}}/src/browser/media/favicon.ico" />
|
<link rel="alternate icon" href="{{CS_STATIC_BASE}}/src/browser/media/favicon.ico" />
|
||||||
<link rel="manifest" href="{{BASE}}/manifest.json" crossorigin="use-credentials" />
|
<link rel="manifest" href="{{BASE}}/manifest.json" crossorigin="use-credentials" />
|
||||||
@ -25,7 +25,7 @@
|
|||||||
<div class="card-box">
|
<div class="card-box">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1 class="main">{{WELCOME_TEXT}}</h1>
|
<h1 class="main">{{WELCOME_TEXT}}</h1>
|
||||||
<div class="sub">Please log in below. {{PASSWORD_MSG}}</div>
|
<div class="sub">{{I18N_LOGIN_BELOW}} {{PASSWORD_MSG}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<form class="login-form" method="post">
|
<form class="login-form" method="post">
|
||||||
@ -38,11 +38,11 @@
|
|||||||
autofocus
|
autofocus
|
||||||
class="password"
|
class="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="PASSWORD"
|
placeholder="{{I18N_PASSWORD_PLACEHOLDER}}"
|
||||||
name="password"
|
name="password"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
/>
|
/>
|
||||||
<input class="submit -button" value="SUBMIT" type="submit" />
|
<input class="submit -button" value="{{I18N_SUBMIT}}" type="submit" />
|
||||||
</div>
|
</div>
|
||||||
{{ERROR}}
|
{{ERROR}}
|
||||||
</form>
|
</form>
|
||||||
|
@ -180,7 +180,14 @@ export const options: Options<Required<UserProvidedArgs>> = {
|
|||||||
enable: { type: "string[]" },
|
enable: { type: "string[]" },
|
||||||
help: { type: "boolean", short: "h", description: "Show this output." },
|
help: { type: "boolean", short: "h", description: "Show this output." },
|
||||||
json: { type: "boolean" },
|
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." },
|
open: { type: "boolean", description: "Open in browser on startup. Does not work remotely." },
|
||||||
|
|
||||||
"bind-addr": {
|
"bind-addr": {
|
||||||
|
21
src/node/i18n/index.ts
Normal file
21
src/node/i18n/index.ts
Normal file
@ -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
|
13
src/node/i18n/locales/en.json
Normal file
13
src/node/i18n/locales/en.json
Normal file
@ -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"
|
||||||
|
}
|
13
src/node/i18n/locales/zh-cn.json
Normal file
13
src/node/i18n/locales/zh-cn.json
Normal file
@ -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": "密码不正确"
|
||||||
|
}
|
@ -7,6 +7,7 @@ import { CookieKeys } from "../../common/http"
|
|||||||
import { rootPath } from "../constants"
|
import { rootPath } from "../constants"
|
||||||
import { authenticated, getCookieOptions, redirect, replaceTemplates } from "../http"
|
import { authenticated, getCookieOptions, redirect, replaceTemplates } from "../http"
|
||||||
import { getPasswordMethod, handlePasswordValidation, humanPath, sanitizeString, escapeHtml } from "../util"
|
import { getPasswordMethod, handlePasswordValidation, humanPath, sanitizeString, escapeHtml } from "../util"
|
||||||
|
import i18n from "../i18n"
|
||||||
|
|
||||||
// RateLimiter wraps around the limiter library for logins.
|
// RateLimiter wraps around the limiter library for logins.
|
||||||
// It allows 2 logins every minute plus 12 logins every hour.
|
// 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<string> => {
|
const getRoot = async (req: Request, error?: Error): Promise<string> => {
|
||||||
const content = await fs.readFile(path.join(rootPath, "src/browser/pages/login.html"), "utf8")
|
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 appName = req.args["app-name"] || "code-server"
|
||||||
const welcomeText = req.args["welcome-text"] || `Welcome to ${appName}`
|
const welcomeText = req.args["welcome-text"] || (i18n.t("WELCOME", { app: appName }) as string)
|
||||||
let passwordMsg = `Check the config file at ${humanPath(os.homedir(), req.args.config)} for the password.`
|
let passwordMsg = i18n.t("LOGIN_PASSWORD", { configFile: humanPath(os.homedir(), req.args.config) })
|
||||||
if (req.args.usingEnvPassword) {
|
if (req.args.usingEnvPassword) {
|
||||||
passwordMsg = "Password was set from $PASSWORD."
|
passwordMsg = i18n.t("LOGIN_USING_ENV_PASSWORD")
|
||||||
} else if (req.args.usingEnvHashedPassword) {
|
} else if (req.args.usingEnvHashedPassword) {
|
||||||
passwordMsg = "Password was set from $HASHED_PASSWORD."
|
passwordMsg = i18n.t("LOGIN_USING_HASHED_PASSWORD")
|
||||||
}
|
}
|
||||||
|
|
||||||
return replaceTemplates(
|
return replaceTemplates(
|
||||||
req,
|
req,
|
||||||
content
|
content
|
||||||
.replace(/{{APP_NAME}}/g, appName)
|
.replace(/{{I18N_LOGIN_TITLE}}/g, i18n.t("LOGIN_TITLE", { app: appName }))
|
||||||
.replace(/{{WELCOME_TEXT}}/g, welcomeText)
|
.replace(/{{WELCOME_TEXT}}/g, welcomeText)
|
||||||
.replace(/{{PASSWORD_MSG}}/g, passwordMsg)
|
.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 ? `<div class="error">${escapeHtml(error.message)}</div>` : ""),
|
.replace(/{{ERROR}}/, error ? `<div class="error">${escapeHtml(error.message)}</div>` : ""),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -70,11 +76,11 @@ router.post<{}, string, { password: string; base?: string }, { to?: string }>("/
|
|||||||
try {
|
try {
|
||||||
// Check to see if they exceeded their login attempts
|
// Check to see if they exceeded their login attempts
|
||||||
if (!limiter.canTry()) {
|
if (!limiter.canTry()) {
|
||||||
throw new Error("Login rate limited!")
|
throw new Error(i18n.t("LOGIN_RATE_LIMIT") as string)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!password) {
|
if (!password) {
|
||||||
throw new Error("Missing password")
|
throw new Error(i18n.t("MISS_PASSWORD") as string)
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordMethod = getPasswordMethod(hashedPasswordFromArgs)
|
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) {
|
} catch (error: any) {
|
||||||
const renderedHtml = await getRoot(req, error)
|
const renderedHtml = await getRoot(req, error)
|
||||||
res.send(renderedHtml)
|
res.send(renderedHtml)
|
||||||
|
@ -138,5 +138,16 @@ describe("login", () => {
|
|||||||
expect(resp.status).toBe(200)
|
expect(resp.status).toBe(200)
|
||||||
expect(htmlContent).toContain(`Welcome to ${appName}`)
|
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`)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -22,7 +22,8 @@
|
|||||||
"./test/node_modules/@types",
|
"./test/node_modules/@types",
|
||||||
"./lib/vscode/src/vs/server/@types"
|
"./lib/vscode/src/vs/server/@types"
|
||||||
],
|
],
|
||||||
"downlevelIteration": true
|
"downlevelIteration": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": ["./src/**/*"],
|
"include": ["./src/**/*"],
|
||||||
"exclude": ["/test", "/lib", "/ci", "/doc"]
|
"exclude": ["/test", "/lib", "/ci", "/doc"]
|
||||||
|
19
yarn.lock
19
yarn.lock
@ -2,6 +2,13 @@
|
|||||||
# yarn lockfile v1
|
# 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":
|
"@coder/logger@^3.0.0":
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@coder/logger/-/logger-3.0.0.tgz#fd4d2332ca375412c75cb5ba7767d3290b106dec"
|
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"
|
agent-base "6"
|
||||||
debug "4"
|
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:
|
iconv-lite@0.4.24:
|
||||||
version "0.4.24"
|
version "0.4.24"
|
||||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
|
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"
|
resolved "https://registry.yarnpkg.com/readline-transform/-/readline-transform-1.0.0.tgz#3157f97428acaec0f05a5c1ff2c3120f4e6d904b"
|
||||||
integrity sha512-7KA6+N9IGat52d83dvxnApAWN+MtVb1MiVuMR/cf1O4kYsJG+g/Aav0AHcHKsb6StinayfPLne0+fMX2sOzAKg==
|
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:
|
regexp.prototype.flags@^1.4.3:
|
||||||
version "1.4.3"
|
version "1.4.3"
|
||||||
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac"
|
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac"
|
||||||
|
Reference in New Issue
Block a user