Skip to content

Add comprehensive login page customization options #7374

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cd99956
feat: add comprehensive login page customization options
strickvl Jun 12, 2025
bac8b42
Merge branch 'main' into main
strickvl Jun 17, 2025
2553f45
feat: replace individual UI flags with unified --custom-strings flag
strickvl Jun 17, 2025
c2eb61d
docs: simplify migration guide to only cover released flags
strickvl Jun 17, 2025
c0189ed
docs: update Docker customization examples to use --custom-strings
strickvl Jun 17, 2025
cacf3cc
Merge branch 'main' into main
strickvl Jun 21, 2025
2b2819e
docs: remove niche customization sections from FAQ and install guide
strickvl Jun 24, 2025
b275d52
refactor: remove redundant custom-strings validation and global variable
strickvl Jun 24, 2025
af2f599
refactor: simplify loadCustomStrings function signature and error han…
strickvl Jun 24, 2025
e89a9af
test: remove outdated tests for deprecated custom UI flags
strickvl Jun 24, 2025
a8b7fbe
refactor: use addResourceBundle instead of re-initializing i18next
strickvl Jun 24, 2025
27ee714
docs: improve custom-strings flag description
strickvl Jun 24, 2025
d5c0c88
refactor: keep --app-name flag as non-deprecated for {{app}} placeholder
strickvl Jun 24, 2025
fd6ca51
refactor: rename --custom-strings flag to --i18n
strickvl Jun 24, 2025
ca12a25
docs: consolidate i18n documentation in guide.md
strickvl Jun 24, 2025
dceb0a6
refactor: simplify --i18n flag to file-only approach
strickvl Jun 24, 2025
9bf53dc
CI fixes
strickvl Jun 27, 2025
354677c
Merge branch 'main' into main
strickvl Jun 27, 2025
0d4bd15
Address test coverage
strickvl Jun 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
- [Proxying to a Svelte app](#proxying-to-a-svelte-app)
- [Prefixing `/absproxy/<port>` with a path](#prefixing-absproxyport-with-a-path)
- [Preflight requests](#preflight-requests)
- [Internationalization and customization](#internationalization-and-customization)
- [Available keys and placeholders](#available-keys-and-placeholders)
- [Legacy flag](#legacy-flag)

<!-- END doctoc generated TOC please keep comment here to allow auto update -->
<!-- prettier-ignore-end -->
Expand Down Expand Up @@ -458,3 +461,45 @@ By default, if you have auth enabled, code-server will authenticate all proxied
requests including preflight requests. This can cause issues because preflight
requests do not typically include credentials. To allow all preflight requests
through the proxy without authentication, use `--skip-auth-preflight`.

## Internationalization and customization

code-server allows you to provide a JSON file to configure certain strings. This can be used for both internationalization and customization.

Create a JSON file with your custom strings:

```json
{
"WELCOME": "Welcome to {{app}}",
"LOGIN_TITLE": "{{app}} Access Portal",
"LOGIN_BELOW": "Please log in to continue",
"PASSWORD_PLACEHOLDER": "Enter Password"
}
```

Then reference the file:

```shell
code-server --i18n /path/to/custom-strings.json
```

Or this can be done in the config file:

```yaml
i18n: /path/to/custom-strings.json
```

You can combine this with the `--locale` flag to configure language support for both code-server and VS Code in cases where code-server has no support but VS Code does. If you are using this for internationalization, please consider sending us a pull request to contribute it to `src/node/i18n/locales`.

### Available keys and placeholders

Refer to [../src/node/i18n/locales/en.json](../src/node/i18n/locales/en.json) for a full list of the available keys for translations. Note that the only placeholders supported for each key are the ones used in the default string.

The `--app-name` flag controls the `{{app}}` placeholder in templates. If you want to change the name, you can either:

1. Set `--app-name` (potentially alongside `--i18n`)
2. Use `--i18n` and hardcode the name in your strings

### Legacy flag

The `--welcome-text` flag is now deprecated. Use the `WELCOME` key instead.
10 changes: 9 additions & 1 deletion src/node/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export interface UserProvidedArgs extends UserProvidedCodeArgs {
"app-name"?: string
"welcome-text"?: string
"abs-proxy-base-path"?: string
i18n?: string
/* Positional arguments. */
_?: string[]
}
Expand Down Expand Up @@ -284,17 +285,24 @@ export const options: Options<Required<UserProvidedArgs>> = {
"app-name": {
type: "string",
short: "an",
description: "The name to use in branding. Will be shown in titlebar and welcome message",
description:
"Will replace the {{app}} placeholder in any strings, which by default includes the title bar and welcome message",
},
"welcome-text": {
type: "string",
short: "w",
description: "Text to show on login page",
deprecated: true,
},
"abs-proxy-base-path": {
type: "string",
description: "The base path to prefix to all absproxy requests",
},
i18n: {
type: "string",
path: true,
description: "Path to JSON file with custom translations. Merges with default strings and supports all i18n keys.",
},
}

export const optionDescriptions = (opts: Partial<Options<Required<UserProvidedArgs>>> = options): string[] => {
Expand Down
60 changes: 43 additions & 17 deletions src/node/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,59 @@
import { promises as fs } from "fs"
import i18next, { init } from "i18next"
import * as en from "./locales/en.json"
import * as ja from "./locales/ja.json"
import * as th from "./locales/th.json"
import * as ur from "./locales/ur.json"
import * as zhCn from "./locales/zh-cn.json"

const defaultResources = {
en: {
translation: en,
},
"zh-cn": {
translation: zhCn,
},
th: {
translation: th,
},
ja: {
translation: ja,
},
ur: {
translation: ur,
},
}

export async function loadCustomStrings(filePath: string): Promise<void> {
try {
// Read custom strings from file path only
const fileContent = await fs.readFile(filePath, "utf8")
const customStringsData = JSON.parse(fileContent)

// User-provided strings override all languages.
Object.keys(defaultResources).forEach((locale) => {
i18next.addResourceBundle(locale, "translation", customStringsData)
})
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
throw new Error(`Custom strings file not found: ${filePath}\nPlease ensure the file exists and is readable.`)
} else if (error instanceof SyntaxError) {
throw new Error(`Invalid JSON in custom strings file: ${filePath}\n${error.message}`)
} else {
throw new Error(
`Failed to load custom strings from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
}

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,
},
th: {
translation: th,
},
ja: {
translation: ja,
},
ur: {
translation: ur,
},
},
resources: defaultResources,
})

export default i18next
7 changes: 7 additions & 0 deletions src/node/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { plural } from "../common/util"
import { createApp, ensureAddress } from "./app"
import { AuthType, DefaultedArgs, Feature, toCodeArgs, UserProvidedArgs } from "./cli"
import { commit, version, vsRootPath } from "./constants"
import { loadCustomStrings } from "./i18n"
import { register } from "./routes"
import { VSCodeModule } from "./routes/vscode"
import { isDirectory, open } from "./util"
Expand Down Expand Up @@ -122,6 +123,12 @@ export const runCodeServer = async (
): Promise<{ dispose: Disposable["dispose"]; server: http.Server }> => {
logger.info(`code-server ${version} ${commit}`)

// Load custom strings if provided
if (args.i18n) {
await loadCustomStrings(args.i18n)
logger.info("Loaded custom strings")
}

logger.info(`Using user-data-dir ${args["user-data-dir"]}`)
logger.debug(`Using extensions-dir ${args["extensions-dir"]}`)

Expand Down
19 changes: 14 additions & 5 deletions src/node/routes/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,23 +31,32 @@ const getRoot = async (req: Request, error?: Error): Promise<string> => {
const locale = req.args["locale"] || "en"
i18n.changeLanguage(locale)
const appName = req.args["app-name"] || "code-server"
const welcomeText = req.args["welcome-text"] || (i18n.t("WELCOME", { app: appName }) as string)
const welcomeText = escapeHtml(req.args["welcome-text"] || (i18n.t("WELCOME", { app: appName }) as string))

// Determine password message using i18n
let passwordMsg = i18n.t("LOGIN_PASSWORD", { configFile: req.args.config })
if (req.args.usingEnvPassword) {
passwordMsg = i18n.t("LOGIN_USING_ENV_PASSWORD")
} else if (req.args.usingEnvHashedPassword) {
passwordMsg = i18n.t("LOGIN_USING_HASHED_PASSWORD")
}
passwordMsg = escapeHtml(passwordMsg)

// Get messages from i18n (with HTML escaping for security)
const loginTitle = escapeHtml(i18n.t("LOGIN_TITLE", { app: appName }))
const loginBelow = escapeHtml(i18n.t("LOGIN_BELOW"))
const passwordPlaceholder = escapeHtml(i18n.t("PASSWORD_PLACEHOLDER"))
const submitText = escapeHtml(i18n.t("SUBMIT"))

return replaceTemplates(
req,
content
.replace(/{{I18N_LOGIN_TITLE}}/g, i18n.t("LOGIN_TITLE", { app: appName }))
.replace(/{{I18N_LOGIN_TITLE}}/g, loginTitle)
.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(/{{I18N_LOGIN_BELOW}}/g, loginBelow)
.replace(/{{I18N_PASSWORD_PLACEHOLDER}}/g, passwordPlaceholder)
.replace(/{{I18N_SUBMIT}}/g, submitText)
.replace(/{{ERROR}}/, error ? `<div class="error">${escapeHtml(error.message)}</div>` : ""),
)
}
Expand Down
24 changes: 24 additions & 0 deletions test/unit/node/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ describe("parser", () => {
"--verbose",
["--app-name", "custom instance name"],
["--welcome-text", "welcome to code"],
["--i18n", "path/to/custom-strings.json"],
"2",

["--locale", "ja"],
Expand Down Expand Up @@ -145,6 +146,7 @@ describe("parser", () => {
verbose: true,
"app-name": "custom instance name",
"welcome-text": "welcome to code",
i18n: path.resolve("path/to/custom-strings.json"),
version: true,
"bind-addr": "192.169.0.1:8080",
"session-socket": "/tmp/override-code-server-ipc-socket",
Expand Down Expand Up @@ -347,6 +349,28 @@ describe("parser", () => {
})
})

it("should parse i18n flag with file path", async () => {
// Test with file path (no validation at CLI parsing level)
const args = parse(["--i18n", "/path/to/custom-strings.json"])
expect(args).toEqual({
i18n: "/path/to/custom-strings.json",
})
})

it("should parse i18n flag with relative file path", async () => {
// Test with relative file path
expect(() => parse(["--i18n", "./custom-strings.json"])).not.toThrow()
expect(() => parse(["--i18n", "strings.json"])).not.toThrow()
})

it("should support app-name and deprecated welcome-text flags", async () => {
const args = parse(["--app-name", "My App", "--welcome-text", "Welcome!"])
expect(args).toEqual({
"app-name": "My App",
"welcome-text": "Welcome!",
})
})

it("should use env var github token", async () => {
process.env.GITHUB_TOKEN = "ga-foo"
const args = parse([])
Expand Down
Loading
Loading