diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 75b2db38856..0ad5f02e03b 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -80,8 +80,8 @@ metadata from the authenticated profile. Both `ultra` and `claude_max_20x` displ (the existing `ultra` and `max_20x` tier aliases are retained). Unfamiliar validated tiers retain their original name without failing the usage read. Claude account rows prefer your alias, then a masked email, and fall back to a numbered label when no safe identity is available. The provider Overview shows the -same label as a separate current-account field when its account list is verified. Email labels stay -masked even when `privacy.maskEmails` is false. +same label as a separate current-account field when its account list is verified. Set +`privacy.maskEmails` to `false` to show the full email in these rows instead. You can log in multiple Claude accounts via the Providers dashboard (`ocx login anthropic` / add-account). By default every request uses the **active** account only. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 362ce770bcd..615f514a2a9 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -471,8 +471,10 @@ show `p***n@example.com` rather than the address on file. | --- | --- | --- | --- | | `privacy.maskEmails?` | `boolean` | `true` | Set to `false` to reveal stored account emails in projected email fields. Absent, `true`, and any malformed value keep masking, so only a deliberate `false` reveals an address. | -Claude dashboard account labels remain masked even when this flag is `false`; an account alias -takes precedence over the masked label. The active account uses the same label in Overview. +Dashboard labels for OAuth accounts (Claude, Grok, Kiro, and the other OAuth providers) follow this +flag: masked by default, the stored email when it is `false`. An account alias takes precedence over +the email label. The active account uses the same label in Overview, the reset and coupon dialogs, +and the remove confirmation. The desktop tray surfaces keep showing the masked form either way. Turn it off when you run many accounts on a machine you control and cannot tell them apart from masked forms. Read the flag as a disclosure decision rather than a display preference: management diff --git a/gui/src/components/provider-workspace/types.ts b/gui/src/components/provider-workspace/types.ts index fa69fc3f96d..9baa5c08f06 100644 --- a/gui/src/components/provider-workspace/types.ts +++ b/gui/src/components/provider-workspace/types.ts @@ -59,6 +59,7 @@ export type OAuthAccountRow = AccountQuotaReading & { id: string; alias?: string; email?: string; + accountLabel?: string; maskedLabel?: string; active: boolean; expiresAt?: number; diff --git a/gui/src/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts index 1a594d4daf0..49f55d7e4a5 100644 --- a/gui/src/hooks/useProviderAccountPools.ts +++ b/gui/src/hooks/useProviderAccountPools.ts @@ -19,6 +19,7 @@ export interface OAuthAccount extends AccountQuotaReading { id: string; alias?: string; email?: string; + accountLabel?: string; maskedLabel?: string; active: boolean; needsReauth?: boolean; diff --git a/gui/src/provider-workspace/auth.ts b/gui/src/provider-workspace/auth.ts index 9b8c267bb5a..1686ec901b8 100644 --- a/gui/src/provider-workspace/auth.ts +++ b/gui/src/provider-workspace/auth.ts @@ -8,6 +8,9 @@ export type ProviderAuthSurface = "codex-accounts" | "oauth-accounts" | "api-key export interface OAuthAccountIdentity { id: string; alias?: string; + /** Server label that follows privacy.maskEmails: masked by default, the raw email when unmasked. */ + accountLabel?: string; + /** Always-masked label; fallback for servers that do not send accountLabel. */ maskedLabel?: string; } @@ -40,7 +43,7 @@ export function oauthAccountDisplayLabel( ): string { const alias = account.alias?.trim(); if (alias) return alias; - const label = account.maskedLabel?.trim(); + const label = account.accountLabel?.trim() || account.maskedLabel?.trim(); if (label) return label; const index = accounts.findIndex(candidate => candidate.id === account.id); return t("pws.accountOrdinal", { count: String(index >= 0 ? index + 1 : 1) }); diff --git a/src/lib/privacy.ts b/src/lib/privacy.ts index a26bff18ef9..ef5826c43e7 100644 --- a/src/lib/privacy.ts +++ b/src/lib/privacy.ts @@ -16,6 +16,21 @@ export function maskedAccountLabel(value: string | null | undefined): string | u return maskEmail(value) ?? undefined; } +/** + * Dashboard account label that follows `privacy.maskEmails` (#8286). + * + * Masked mode is exactly {@link maskedAccountLabel}. Unmasked mode is the operator's explicit + * disclosure choice, so it returns the stored address as-is (trimmed) — the same value the + * unmasked `email` field already carries — and omits only empty, oversized, or control- or + * format-character values (bidi overrides, zero-width marks) that could not render as one honest label. + */ +export function accountDisplayLabel(value: string | null | undefined, mask: boolean): string | undefined { + if (mask) return maskedAccountLabel(value); + const label = value?.trim(); + if (!label || label.length > 254 || /[\p{Cc}\p{Cf}]/u.test(label)) return undefined; + return label; +} + /** * Whether stored account emails are masked in management and CLI projections (#3859). * diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 3242ec47119..1b411e81348 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -40,7 +40,7 @@ import { OAuthMutationBusyError, removeCredential } from "../../oauth/store"; import { cancelKiroDeviceLogin, kiroDeviceConfigBaseline, selectKiroDeviceProfile, startKiroDeviceLogin, statusKiroDeviceLogin, type KiroDeviceMethod } from "../../oauth/kiro-device-login"; import { parseKiroIamOptions } from "../../oauth/kiro-iam-identity-center"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; -import { emailMaskingEnabled, maskedAccountLabel } from "../../lib/privacy"; +import { accountDisplayLabel, emailMaskingEnabled, maskedAccountLabel } from "../../lib/privacy"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets } from "../../providers/derive"; @@ -497,7 +497,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< } // Multiauth account management: list a provider's logged-in accounts, switch the active - // one, or remove one. Emails are masked; tokens never leave the store. + // one, or remove one. Emails and accountLabel follow privacy.maskEmails, maskedLabel is always + // masked, and tokens never leave the store. if (url.pathname === "/api/oauth/accounts" && req.method === "GET") { const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); @@ -516,7 +517,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< } = await import("../../oauth/health"); const projectAccounts = () => { const set = getAccountSet(provider); - const current = getLoginStatus(provider, emailMaskingEnabled(config)); + const maskEmails = emailMaskingEnabled(config); + const current = getLoginStatus(provider, maskEmails); return { ...(provider === "anthropic2" ? { provider } : {}), activeAccountId: current.activeAccountId ?? null, accounts: (current.accounts ?? []).map(summary => { @@ -528,7 +530,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined, }); const maskedLabel = maskedAccountLabel(full?.credential.email); - return { ...summary, ...(maskedLabel ? { maskedLabel } : {}), ...oauthAccountHealthFields(provider, summary.id, health), quotaMode, + // `maskedLabel` stays masked for tray surfaces; `accountLabel` follows privacy.maskEmails. + const accountLabel = accountDisplayLabel(full?.credential.email, maskEmails); + return { ...summary, ...(maskedLabel ? { maskedLabel } : {}), ...(accountLabel ? { accountLabel } : {}), + ...oauthAccountHealthFields(provider, summary.id, health), quotaMode, ...(supportsPause ? { paused: full?.paused === true, ...(full?.pauseDetails ? { pauseDetails: full.pauseDetails } : {}) } : {}), ...(isAnthropicOAuthInstance(provider) && supportsPause ? { autoSwitchThresholdOverride: full?.autoSwitchThresholdOverride ?? null, effectiveAutoSwitchThreshold: effectiveAnthropicAccountThresholdForInstance(provider as AnthropicInstanceId, config, full), diff --git a/structure/providers/anthropic-account-pool.md b/structure/providers/anthropic-account-pool.md index d743b045813..708da2454db 100644 --- a/structure/providers/anthropic-account-pool.md +++ b/structure/providers/anthropic-account-pool.md @@ -48,10 +48,13 @@ a missed read leaves the stored label in place. An account replacement cannot inherit another account's label. `src/providers/anthropic-subscription.ts` projects optional management `planLabel`: Pro, Max 5x, Ultra (both `ultra` and `max_20x`), or the original unfamiliar validated tier. Dashboard OAuth rows prefer a user alias, then the -management roster's independently masked identity label, then a localized ordinal. The same +management roster's `accountLabel`, then its always-masked `maskedLabel`, then a localized +ordinal. `accountLabel` follows `privacy.maskEmails` (#8286): the validated masked identity by +default, the trimmed stored email when the operator sets the flag to `false`. The same policy in `gui/src/provider-workspace/auth.ts` covers reset-grant dialogs and account mutation confirmations/notifications through `gui/src/hooks/useProviderAccountPools.ts`. The roster label -never follows the configurable unmasked email projection; malformed identities are omitted. +`maskedLabel`, which tray surfaces read, never follows the unmasked email projection; malformed +identities are omitted from both labels in masked mode. `gui/src/pages/providers-page-utils.ts` also strips Claude email metadata from the catalog/Add Provider account-status projection without mutating the source OAuth status; generic/custom account-status labels remain intact. The current quota section keeps diff --git a/tests/gui/provider-workspace-auth.test.ts b/tests/gui/provider-workspace-auth.test.ts index 332d6e9bf6e..149d8134db3 100644 --- a/tests/gui/provider-workspace-auth.test.ts +++ b/tests/gui/provider-workspace-auth.test.ts @@ -52,6 +52,18 @@ describe("safe OAuth account labels", () => { expect(oauthAccountDisplayLabel(rows, identity, t)).toBe("s***d@example.com"); expect(oauthAccountDisplayLabel(rows, legacy, t)).toBe("Account 3"); }); + + test("the server account label wins over the masked label and follows the privacy setting (#8286)", () => { + const unmasked = { id: "opaque-a", accountLabel: "first@example.com", maskedLabel: "f***t@example.com" }; + const masked = { id: "opaque-b", accountLabel: "s***d@example.com", maskedLabel: "s***d@example.com" }; + const aliased = { id: "opaque-c", alias: "Work", accountLabel: "third@example.com", maskedLabel: "t***d@example.com" }; + const blank = { id: "opaque-d", accountLabel: " ", maskedLabel: "f***h@example.com" }; + const rows = [unmasked, masked, aliased, blank]; + expect(oauthAccountDisplayLabel(rows, unmasked, t)).toBe("first@example.com"); + expect(oauthAccountDisplayLabel(rows, masked, t)).toBe("s***d@example.com"); + expect(oauthAccountDisplayLabel(rows, aliased, t)).toBe("Work"); + expect(oauthAccountDisplayLabel(rows, blank, t)).toBe("f***h@example.com"); + }); const accounts = [ { id: "opaque-first", maskedLabel: "f***@example.com" }, { id: "opaque-second" }, diff --git a/tests/lib/privacy-mask-account.test.ts b/tests/lib/privacy-mask-account.test.ts index 005be8d381b..f40f14a0153 100644 --- a/tests/lib/privacy-mask-account.test.ts +++ b/tests/lib/privacy-mask-account.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { emailMaskingEnabled, maskAccountId, maskEmail, maskedAccountLabel, projectEmail } from "../../src/lib/privacy"; +import { accountDisplayLabel, emailMaskingEnabled, maskAccountId, maskEmail, maskedAccountLabel, projectEmail } from "../../src/lib/privacy"; test("account display labels omit malformed identities and mask valid addresses", () => { expect(maskedAccountLabel("person@example.test")).toBe("p***n@example.test"); @@ -9,6 +9,16 @@ test("account display labels omit malformed identities and mask valid addresses" } }); +test("dashboard account labels follow the masking decision (#8286)", () => { + expect(accountDisplayLabel("person@example.test", true)).toBe("p***n@example.test"); + expect(accountDisplayLabel("not-an-email", true)).toBeUndefined(); + expect(accountDisplayLabel(" person@example.test ", false)).toBe("person@example.test"); + expect(accountDisplayLabel("workspace-login", false)).toBe("workspace-login"); + for (const value of [undefined, "", " ", "x".repeat(255), "a@example.test\nextra", "a\u202E@example.test", "a\u200B@example.test"]) { + expect(accountDisplayLabel(value, false)).toBeUndefined(); + } +}); + function fastestLabelMs(input: string, runs = 5): number { let best = Infinity; for (let i = 0; i < runs; i++) { diff --git a/tests/oauth/oauth-accounts-api.test.ts b/tests/oauth/oauth-accounts-api.test.ts index f40594b5f40..03fc5ffc4bd 100644 --- a/tests/oauth/oauth-accounts-api.test.ts +++ b/tests/oauth/oauth-accounts-api.test.ts @@ -299,15 +299,31 @@ describe("multiauth accounts API", () => { } }); - test("GET provides a masked identity label even when email masking is disabled", async () => { + test("GET keeps the account label masked by default and keeps maskedLabel masked", async () => { + saveConfig({ ...baseConfig(), privacy: { maskEmails: "false" } } as unknown as OcxConfig); + const server = startServer(0); + try { + const res = await fetch(new URL("/api/oauth/accounts?provider=anthropic", server.url)); + expect(res.status).toBe(200); + const body = await res.json() as { accounts: Array<{ id: string; accountLabel?: string; maskedLabel?: string }> }; + const first = body.accounts.find(account => account.id === "aaaa1111"); + expect(first?.accountLabel).toBe("f***t@example.com"); + expect(first?.maskedLabel).toBe("f***t@example.com"); + expect(JSON.stringify(body).includes("first@example.com")).toBe(false); + } finally { await server.stop(true); } + }); + + test("GET shows the raw account label when email masking is disabled (#8286)", async () => { saveConfig({ ...baseConfig(), privacy: { maskEmails: false } }); const server = startServer(0); try { const res = await fetch(new URL("/api/oauth/accounts?provider=anthropic", server.url)); expect(res.status).toBe(200); - const body = await res.json() as { accounts: Array<{ id: string; email?: string; maskedLabel?: string }> }; + const body = await res.json() as { accounts: Array<{ id: string; email?: string; accountLabel?: string; maskedLabel?: string }> }; const first = body.accounts.find(account => account.id === "aaaa1111"); expect(first?.email).toBe("first@example.com"); + expect(first?.accountLabel).toBe("first@example.com"); + // The tray-facing masked label keeps its contract regardless of the flag. expect(first?.maskedLabel).toBe("f***t@example.com"); expect(first?.maskedLabel).not.toBe(first?.email); } finally { await server.stop(true); } @@ -322,8 +338,9 @@ describe("multiauth accounts API", () => { const server = startServer(0); try { const res = await fetch(new URL("/api/oauth/accounts?provider=anthropic", server.url)); - const body = await res.json() as { accounts: Array<{ maskedLabel?: string }> }; + const body = await res.json() as { accounts: Array<{ accountLabel?: string; maskedLabel?: string }> }; expect(body.accounts.map(account => account.maskedLabel)).toEqual([undefined, undefined]); + expect(body.accounts.map(account => account.accountLabel)).toEqual([undefined, undefined]); } finally { await server.stop(true); } });