diff --git a/docs/FAQ.md b/docs/FAQ.md index c46c003b8800..1d97d8c2e21d 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -81,6 +81,23 @@ You can change the config file's location using the `--config` flag or The default location respects `$XDG_CONFIG_HOME`. +### Login page customization + +You can customize the login page using the `--custom-strings` flag: + +```bash +code-server --custom-strings '{"LOGIN_TITLE": "My Code Server", "WELCOME": "Welcome to my portal"}' +``` + +Or use a JSON file: +```bash +code-server --custom-strings /path/to/custom-strings.json +``` + +Legacy individual flags (`--app-name`, `--welcome-text`) are still supported but deprecated. + +For detailed customization options and examples, see the [customization guide](./customization.md). + ## How do I make my keyboard shortcuts work? Many shortcuts will not work by default, since they'll be "caught" by the browser. diff --git a/docs/README.md b/docs/README.md index 5724c804c087..e80b7bfb23be 100644 --- a/docs/README.md +++ b/docs/README.md @@ -61,6 +61,8 @@ code-server. We also have an in-depth [setup and configuration](https://coder.com/docs/code-server/latest/guide) guide. +You can also customize the login page appearance - see our [customization guide](./customization.md). + ## Questions? See answers to [frequently asked diff --git a/docs/customization.md b/docs/customization.md new file mode 100644 index 000000000000..4a9f7998de4b --- /dev/null +++ b/docs/customization.md @@ -0,0 +1,170 @@ +# Login Page Customization + +code-server allows you to customize the login page appearance and messages through a unified `--custom-strings` flag or legacy CLI arguments. + +## Recommended Approach: Custom Strings + +The `--custom-strings` flag provides a scalable way to customize any UI text by leveraging the built-in internationalization system. + +### Using JSON File + +Create a JSON file with your customizations: + +```json +{ + "WELCOME": "Welcome to {{app}} Development Portal", + "LOGIN_TITLE": "{{app}} Secure Access", + "LOGIN_BELOW": "Please authenticate to continue", + "PASSWORD_PLACEHOLDER": "Enter your access code", + "SUBMIT": "AUTHENTICATE", + "LOGIN_PASSWORD": "Check the config file at {{configFile}} for the password.", + "LOGIN_USING_ENV_PASSWORD": "Access code provided via environment variable", + "LOGIN_USING_HASHED_PASSWORD": "Access code configured securely", + "LOGIN_RATE_LIMIT": "Too many attempts. Please wait before trying again.", + "MISS_PASSWORD": "Access code is required", + "INCORRECT_PASSWORD": "Invalid access code" +} +``` + +```bash +code-server --custom-strings /path/to/custom-strings.json +``` + +### Using Inline JSON + +```bash +code-server --custom-strings '{"WELCOME": "Welcome to My Dev Portal", "LOGIN_TITLE": "Development Access", "SUBMIT": "SIGN IN"}' +``` + +### Configuration File + +Add to your `~/.config/code-server/config.yaml`: + +```yaml +bind-addr: 127.0.0.1:8080 +auth: password +password: your-password +custom-strings: | + { + "WELCOME": "Welcome to {{app}} Development Portal", + "LOGIN_TITLE": "{{app}} Secure Access", + "PASSWORD_PLACEHOLDER": "Enter your access code", + "SUBMIT": "AUTHENTICATE" + } +``` + +## Available Customization Keys + +| Key | Description | Default | Supports {{app}} placeholder | +|-----|-------------|---------|------------------------------| +| `WELCOME` | Welcome message on login page | "Welcome to {{app}}" | ✅ | +| `LOGIN_TITLE` | Main title on login page | "{{app}} login" | ✅ | +| `LOGIN_BELOW` | Text below the login title | "Please log in below." | ❌ | +| `PASSWORD_PLACEHOLDER` | Password field placeholder text | "PASSWORD" | ❌ | +| `SUBMIT` | Login button text | "SUBMIT" | ❌ | +| `LOGIN_PASSWORD` | Message for config file password | "Check the config file at {{configFile}} for the password." | ❌ | +| `LOGIN_USING_ENV_PASSWORD` | Message when using `$PASSWORD` env var | "Password was set from $PASSWORD." | ❌ | +| `LOGIN_USING_HASHED_PASSWORD` | Message when using `$HASHED_PASSWORD` env var | "Password was set from $HASHED_PASSWORD." | ❌ | +| `LOGIN_RATE_LIMIT` | Rate limiting error message | "Login rate limited!" | ❌ | +| `MISS_PASSWORD` | Empty password error message | "Missing password" | ❌ | +| `INCORRECT_PASSWORD` | Wrong password error message | "Incorrect password" | ❌ | + +## Docker Examples + +### Basic Docker Deployment + +```bash +docker run -it --name code-server -p 127.0.0.1:8080:8080 \ + -v "$PWD:/home/coder/project" \ + -v "$PWD/custom-strings.json:/custom-strings.json" \ + codercom/code-server:latest --custom-strings /custom-strings.json +``` + +### Corporate Branding with Inline JSON + +```bash +docker run -it --name code-server -p 127.0.0.1:8080:8080 \ + -v "$PWD:/home/coder/project" \ + codercom/code-server:latest --custom-strings '{ + "WELCOME": "Welcome to ACME Corporation Development Portal", + "LOGIN_TITLE": "ACME Dev Portal Access", + "LOGIN_BELOW": "Enter your corporate credentials", + "PASSWORD_PLACEHOLDER": "Corporate Password", + "SUBMIT": "SIGN IN", + "LOGIN_USING_ENV_PASSWORD": "Password managed by IT department" + }' +``` + +## Legacy Support (Deprecated) + +The following flags are still supported but deprecated. Use `--custom-strings` for new deployments: + +```bash +# Deprecated - use --custom-strings instead +code-server \ + --app-name "My Development Server" \ + --welcome-text "Welcome to the development environment" +``` + +These legacy flags will show deprecation warnings and may be removed in future versions. + +## Migration Guide + +### From Legacy Flags to Custom Strings + +**Old approach:** +```bash +code-server \ + --app-name "Dev Portal" \ + --welcome-text "Welcome to development" +``` + +**New approach:** +```bash +code-server --custom-strings '{ + "WELCOME": "Welcome to development" +}' +``` + +**Note:** The `--app-name` flag controls the `{{app}}` placeholder in templates. You can either: +1. Keep using `--app-name` alongside `--custom-strings` +2. Customize the full text without placeholders in your JSON + +## Benefits of Custom Strings + +- ✅ **Scalable**: Add any new UI strings without new CLI flags +- ✅ **Flexible**: Supports both files and inline JSON +- ✅ **Future-proof**: Automatically supports new UI strings as they're added +- ✅ **Organized**: All customizations in one place +- ✅ **Version-controlled**: JSON files can be tracked in your repository + +## Advanced Usage + +### Multi-language Support + +Create different JSON files for different languages: + +```bash +# English +code-server --custom-strings /config/strings-en.json + +# Spanish +code-server --custom-strings /config/strings-es.json --locale es +``` + +### Dynamic Customization + +Generate JSON dynamically in scripts: + +```bash +#!/bin/bash +COMPANY_NAME="ACME Corp" +cat > /tmp/strings.json << EOF +{ + "WELCOME": "Welcome to ${COMPANY_NAME} Development Portal", + "LOGIN_TITLE": "${COMPANY_NAME} Access Portal" +} +EOF + +code-server --custom-strings /tmp/strings.json +``` \ No newline at end of file diff --git a/docs/install.md b/docs/install.md index e2dd905f9401..2cda3583605f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -287,6 +287,34 @@ docker run -it --name code-server -p 127.0.0.1:8080:8080 \ codercom/code-server:latest ``` +### Customizing the login page + +You can customize the login page using the `--custom-strings` flag: + +```bash +# Example with inline JSON customization +docker run -it --name code-server -p 127.0.0.1:8080:8080 \ + -v "$PWD:/home/coder/project" \ + codercom/code-server:latest --custom-strings '{ + "LOGIN_TITLE": "My Development Environment", + "WELCOME": "Welcome to your coding workspace", + "PASSWORD_PLACEHOLDER": "Enter your secure password", + "SUBMIT": "ACCESS" + }' +``` + +Or mount a JSON file: + +```bash +# Example with JSON file +docker run -it --name code-server -p 127.0.0.1:8080:8080 \ + -v "$PWD:/home/coder/project" \ + -v "$PWD/custom-strings.json:/config/strings.json" \ + codercom/code-server:latest --custom-strings /config/strings.json +``` + +For detailed customization options, see the [customization guide](./customization.md). + Our official image supports `amd64` and `arm64`. For `arm32` support, you can use a [community-maintained code-server alternative](https://hub.docker.com/r/linuxserver/code-server). diff --git a/src/node/cli.ts b/src/node/cli.ts index a29ec591e0a4..132f29dc7a2e 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -93,6 +93,7 @@ export interface UserProvidedArgs extends UserProvidedCodeArgs { "app-name"?: string "welcome-text"?: string "abs-proxy-base-path"?: string + "custom-strings"?: string /* Positional arguments. */ _?: string[] } @@ -285,16 +286,22 @@ export const options: Options> = { type: "string", short: "an", description: "The name to use in branding. Will be shown in titlebar and welcome message", + deprecated: true, }, "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", }, + "custom-strings": { + type: "string", + description: "Path to JSON file or raw JSON string with custom UI strings. Merges with default strings and supports all i18n keys.", + }, } export const optionDescriptions = (opts: Partial>> = options): string[] => { @@ -459,6 +466,21 @@ export const parse = ( throw new Error("--cert-key is missing") } + // Validate custom-strings flag + if (args["custom-strings"]) { + try { + // First try to parse as JSON directly + JSON.parse(args["custom-strings"]) + } catch (jsonError) { + // If JSON parsing fails, check if it's a valid file path + if (!args["custom-strings"].startsWith("{") && !args["custom-strings"].startsWith("[")) { + // Assume it's a file path - validation will happen later when the file is read + } else { + throw error(`--custom-strings contains invalid JSON: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}`) + } + } + } + logger.debug(() => [`parsed ${opts?.configFile ? "config" : "command line"}`, field("args", redactArgs(args))]) return args @@ -593,6 +615,7 @@ export async function setDefaults(cliArgs: UserProvidedArgs, configArgs?: Config args["disable-proxy"] = true } + const usingEnvHashedPassword = !!process.env.HASHED_PASSWORD if (process.env.HASHED_PASSWORD) { args["hashed-password"] = process.env.HASHED_PASSWORD diff --git a/src/node/i18n/index.ts b/src/node/i18n/index.ts index 4ee718e13aa2..c78fcbbf4188 100644 --- a/src/node/i18n/index.ts +++ b/src/node/i18n/index.ts @@ -1,33 +1,82 @@ import i18next, { init } from "i18next" +import { promises as fs } from "fs" 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, + }, +} + +let customStrings: Record = {} + +export async function loadCustomStrings(customStringsArg?: string): Promise { + if (!customStringsArg) { + return + } + + try { + let customStringsData: Record + + // Try to parse as JSON first + try { + customStringsData = JSON.parse(customStringsArg) + } catch { + // If JSON parsing fails, treat as file path + const fileContent = await fs.readFile(customStringsArg, "utf8") + customStringsData = JSON.parse(fileContent) + } + + customStrings = customStringsData + + // Re-initialize i18next with merged resources + const mergedResources = Object.keys(defaultResources).reduce((acc, lang) => { + const langKey = lang as keyof typeof defaultResources + acc[langKey] = { + translation: { + ...defaultResources[langKey].translation, + ...customStrings, + }, + } + return acc + }, {} as typeof defaultResources) + + await i18next.init({ + lng: "en", + fallbackLng: "en", + returnNull: false, + lowerCaseLng: true, + debug: process.env.NODE_ENV === "development", + resources: mergedResources, + }) + } catch (error) { + throw new Error(`Failed to load custom strings: ${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 diff --git a/src/node/main.ts b/src/node/main.ts index 470ddeb25cc7..2969476fb6d8 100644 --- a/src/node/main.ts +++ b/src/node/main.ts @@ -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" @@ -122,6 +123,17 @@ export const runCodeServer = async ( ): Promise<{ dispose: Disposable["dispose"]; server: http.Server }> => { logger.info(`code-server ${version} ${commit}`) + // Load custom strings if provided + if (args["custom-strings"]) { + try { + await loadCustomStrings(args["custom-strings"]) + logger.info("Loaded custom strings") + } catch (error) { + logger.error("Failed to load custom strings", field("error", error)) + throw error + } + } + logger.info(`Using user-data-dir ${args["user-data-dir"]}`) logger.debug(`Using extensions-dir ${args["extensions-dir"]}`) diff --git a/src/node/routes/login.ts b/src/node/routes/login.ts index 29d51a59d13b..511d4817455e 100644 --- a/src/node/routes/login.ts +++ b/src/node/routes/login.ts @@ -31,23 +31,32 @@ const getRoot = async (req: Request, error?: Error): Promise => { 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 ? `
${escapeHtml(error.message)}
` : ""), ) } diff --git a/test/unit/node/cli.test.ts b/test/unit/node/cli.test.ts index d62edb840464..c62ec563a8fc 100644 --- a/test/unit/node/cli.test.ts +++ b/test/unit/node/cli.test.ts @@ -49,6 +49,10 @@ describe("parser", () => { delete process.env.CS_DISABLE_GETTING_STARTED_OVERRIDE delete process.env.VSCODE_PROXY_URI delete process.env.CS_DISABLE_PROXY + delete process.env.CS_LOGIN_TITLE + delete process.env.CS_LOGIN_ENV_PASSWORD_MSG + delete process.env.CS_PASSWORD_PLACEHOLDER + delete process.env.CS_SUBMIT_TEXT console.log = jest.fn() }) @@ -75,6 +79,7 @@ describe("parser", () => { "--verbose", ["--app-name", "custom instance name"], ["--welcome-text", "welcome to code"], + ["--custom-strings", '{"LOGIN_TITLE": "Custom Portal"}'], "2", ["--locale", "ja"], @@ -145,6 +150,7 @@ describe("parser", () => { verbose: true, "app-name": "custom instance name", "welcome-text": "welcome to code", + "custom-strings": '{"LOGIN_TITLE": "Custom Portal"}', version: true, "bind-addr": "192.169.0.1:8080", "session-socket": "/tmp/override-code-server-ipc-socket", @@ -347,6 +353,31 @@ describe("parser", () => { }) }) + it("should parse custom-strings flag", async () => { + // Test with JSON string + const jsonString = '{"WELCOME": "Custom Welcome", "LOGIN_TITLE": "My App"}' + const args = parse(["--custom-strings", jsonString]) + expect(args).toEqual({ + "custom-strings": jsonString, + }) + }) + + it("should validate custom-strings JSON", async () => { + // Test with invalid JSON + expect(() => parse(["--custom-strings", '{"invalid": json}'])).toThrowError(/contains invalid JSON/) + + // Test with valid JSON that looks like a file path + expect(() => parse(["--custom-strings", "/path/to/file.json"])).not.toThrow() + }) + + it("should support deprecated app-name and 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([]) diff --git a/test/unit/node/routes/login.test.ts b/test/unit/node/routes/login.test.ts index 2835bad82354..06c8e1b22ceb 100644 --- a/test/unit/node/routes/login.test.ts +++ b/test/unit/node/routes/login.test.ts @@ -146,5 +146,70 @@ describe("login", () => { expect(resp.status).toBe(200) expect(htmlContent).toContain(`欢迎来到 code-server`) }) + + it("should return custom login title", async () => { + process.env.PASSWORD = previousEnvPassword + const loginTitle = "Custom Access Portal" + const codeServer = await integration.setup([`--login-title=${loginTitle}`], "") + const resp = await codeServer.fetch("/login", { method: "GET" }) + + const htmlContent = await resp.text() + expect(resp.status).toBe(200) + expect(htmlContent).toContain(`${loginTitle}`) + }) + + it("should return custom password placeholder", async () => { + process.env.PASSWORD = previousEnvPassword + const placeholder = "Enter access code" + const codeServer = await integration.setup([`--password-placeholder=${placeholder}`], "") + const resp = await codeServer.fetch("/login", { method: "GET" }) + + const htmlContent = await resp.text() + expect(resp.status).toBe(200) + expect(htmlContent).toContain(`placeholder="${placeholder}"`) + }) + + it("should return custom submit button text", async () => { + process.env.PASSWORD = previousEnvPassword + const submitText = "ACCESS PORTAL" + const codeServer = await integration.setup([`--submit-text=${submitText}`], "") + const resp = await codeServer.fetch("/login", { method: "GET" }) + + const htmlContent = await resp.text() + expect(resp.status).toBe(200) + expect(htmlContent).toContain(`value="${submitText}"`) + }) + + it("should return custom env password message", async () => { + const envMessage = "Password configured via container environment" + const codeServer = await integration.setup([`--login-env-password-msg=${envMessage}`, `--password=test123`], "") + const resp = await codeServer.fetch("/login", { method: "GET" }) + + const htmlContent = await resp.text() + expect(resp.status).toBe(200) + expect(htmlContent).toContain(envMessage) + }) + + it("should escape HTML in custom messages", async () => { + process.env.PASSWORD = previousEnvPassword + const maliciousTitle = "" + const codeServer = await integration.setup([`--login-title=${maliciousTitle}`, `--password=test123`], "") + const resp = await codeServer.fetch("/login", { method: "GET" }) + + const htmlContent = await resp.text() + expect(resp.status).toBe(200) + expect(htmlContent).toContain("<script>alert('xss')</script>") + expect(htmlContent).not.toContain("") + }) + + it("should return custom error messages", async () => { + const customMissingMsg = "Access code required" + const codeServer = await integration.setup([`--missing-password-msg=${customMissingMsg}`, `--password=test123`], "") + const resp = await codeServer.fetch("/login", { method: "POST" }) + + const htmlContent = await resp.text() + expect(resp.status).toBe(200) + expect(htmlContent).toContain(customMissingMsg) + }) }) })