From daff7b054e0fd9ca4599a5742a8ceb6e742126d5 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:55:13 -0400 Subject: [PATCH 01/12] feat: save Bitbucket credentials from Source Control settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bitbucket could only be configured through T3CODE_BITBUCKET_* environment variables and a server restart. Settings → Source Control now has fields for an access token or an Atlassian email + API token, saved on the environment's server. Tokens follow the existing write-only secret path: the server keeps them in its secret store, and settings.json and clients only see a redaction marker. BitbucketApi resolves credentials per request (saved settings first, then the environment variables), so changes apply without a restart. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/serverSettings.test.ts | 64 ++++++++ apps/server/src/serverSettings.ts | 59 +++++-- .../src/sourceControl/BitbucketApi.test.ts | 57 ++++++- apps/server/src/sourceControl/BitbucketApi.ts | 90 ++++++++--- .../BitbucketSourceControlProvider.ts | 2 +- .../SourceControlDiscovery.test.ts | 2 +- .../settings/BitbucketCredentialsSettings.tsx | 147 ++++++++++++++++++ .../settings/SourceControlSettings.tsx | 18 ++- .../src/components/settings/settingsSearch.ts | 8 + docs/user/source-control.md | 19 +-- packages/contracts/src/pullRequest.ts | 4 +- packages/contracts/src/settings.ts | 22 +++ 12 files changed, 448 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/components/settings/BitbucketCredentialsSettings.tsx diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index b2caba4a0347..6060595c8bdf 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1278,6 +1278,70 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect( + "keeps Bitbucket tokens in the secret store and tells clients only that one is set", + () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + + const saved = yield* serverSettings.updateSettings({ + bitbucket: { email: "me@example.com", accessToken: "bb-access", apiToken: "bb-api" }, + }); + assert.deepEqual(saved.bitbucket, { + email: "me@example.com", + accessToken: "bb-access", + apiToken: "bb-api", + }); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.notInclude(raw, "bb-access"); + assert.notInclude(raw, "bb-api"); + assert.include(raw, "me@example.com"); + + const forClient = ServerSettingsModule.redactServerSettingsForClient(saved).bitbucket; + assert.equal(forClient.email, "me@example.com"); + assert.notInclude(forClient.accessToken, "bb-access"); + assert.notInclude(forClient.apiToken, "bb-api"); + assert.isAbove(forClient.accessToken.length, 0); + assert.isAbove(forClient.apiToken.length, 0); + + // A client echoing the redacted values back, or omitting them, keeps the saved tokens. + yield* serverSettings.updateSettings({ bitbucket: forClient }); + yield* serverSettings.updateSettings({ bitbucket: { email: "other@example.com" } }); + assert.deepEqual((yield* serverSettings.getSettings).bitbucket, { + email: "other@example.com", + accessToken: "bb-access", + apiToken: "bb-api", + }); + + const cleared = yield* serverSettings.updateSettings({ bitbucket: { accessToken: "" } }); + assert.equal(cleared.bitbucket.accessToken, ""); + assert.equal(cleared.bitbucket.apiToken, "bb-api"); + assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token"))); + assert.equal( + ServerSettingsModule.redactServerSettingsForClient(cleared).bitbucket.accessToken, + "", + ); + }).pipe( + Effect.provide( + ServerSettingsModule.layer.pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-server-settings-test-", + }), + ), + ), + ), + ), + ), + ); + it.effect("materializes provider secrets for terminal environment resolution", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index ed52282c0237..9f7a5d6e74d7 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -140,16 +140,24 @@ function providerEnvironmentSecretName(input: { } /** - * On disk the hub key is replaced by this marker and the real value lives in - * the secret store, mirroring provider environment secrets. A client that - * sends the marker back means "keep what you have". + * On disk a hub key or Bitbucket token is replaced by this marker and the + * real value lives in the secret store, mirroring provider environment + * secrets. A client that sends the marker back means "keep what you have". */ -const USAGE_LIMIT_SOURCE_KEY_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; +const SECRET_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; function usageLimitSourceSecretName(sourceId: string): string { return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`; } +const BITBUCKET_SECRET_NAMES = { + accessToken: "bitbucket-access-token", + apiToken: "bitbucket-api-token", +} as const; +const BITBUCKET_SECRET_FIELDS = ["accessToken", "apiToken"] as const; + +const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : ""); + function redactProviderEnvironmentVariable( variable: ProviderInstanceEnvironmentVariable, ): ProviderInstanceEnvironmentVariable { @@ -182,11 +190,16 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS id, { ...source, - managementKey: source.managementKey.length > 0 ? USAGE_LIMIT_SOURCE_KEY_REDACTED : "", + managementKey: redactSecret(source.managementKey), }, ]), ); - return { ...settings, providerInstances, usageLimitSources }; + const bitbucket = { + ...settings.bitbucket, + accessToken: redactSecret(settings.bitbucket.accessToken), + apiToken: redactSecret(settings.bitbucket.apiToken), + }; + return { ...settings, providerInstances, usageLimitSources, bitbucket }; } export class ServerSettingsService extends Context.Service< @@ -693,7 +706,7 @@ const make = Effect.gen(function* () { } const usageLimitSources: Record = {}; for (const [sourceId, source] of Object.entries(settings.usageLimitSources)) { - if (source.managementKey !== USAGE_LIMIT_SOURCE_KEY_REDACTED) { + if (source.managementKey !== SECRET_REDACTED) { usageLimitSources[sourceId] = source; continue; } @@ -709,10 +722,23 @@ const make = Effect.gen(function* () { managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", }; } + const bitbucket = { ...settings.bitbucket }; + for (const field of BITBUCKET_SECRET_FIELDS) { + if (bitbucket[field] !== SECRET_REDACTED) continue; + const secret = yield* secretStore + .get(BITBUCKET_SECRET_NAMES[field]) + .pipe( + Effect.mapError( + (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }), + ), + ); + bitbucket[field] = Option.isSome(secret) ? textDecoder.decode(secret.value) : ""; + } return { ...settings, providerInstances: providerInstances as ServerSettings["providerInstances"], usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], + bitbucket, }; }); @@ -829,7 +855,7 @@ const make = Effect.gen(function* () { const usageLimitSources: Record = {}; for (const [sourceId, source] of Object.entries(next.usageLimitSources)) { const secretName = usageLimitSourceSecretName(sourceId); - if (source.managementKey === USAGE_LIMIT_SOURCE_KEY_REDACTED) { + if (source.managementKey === SECRET_REDACTED) { usageLimitSources[sourceId] = source; continue; } @@ -843,7 +869,7 @@ const make = Effect.gen(function* () { secretName, value: textEncoder.encode(source.managementKey), }); - usageLimitSources[sourceId] = { ...source, managementKey: USAGE_LIMIT_SOURCE_KEY_REDACTED }; + usageLimitSources[sourceId] = { ...source, managementKey: SECRET_REDACTED }; } for (const sourceId of Object.keys(current.usageLimitSources)) { if (sourceId in next.usageLimitSources) continue; @@ -854,11 +880,26 @@ const make = Effect.gen(function* () { }); } + const bitbucket = { ...next.bitbucket }; + for (const field of BITBUCKET_SECRET_FIELDS) { + const value = bitbucket[field]; + if (value === SECRET_REDACTED) continue; + if (value.length === 0 && current.bitbucket[field].length === 0) continue; + const secretName = BITBUCKET_SECRET_NAMES[field]; + if (value.length === 0) { + changes.push({ kind: "remove", secretName, operation: "remove-secret" }); + continue; + } + changes.push({ kind: "write", secretName, value: textEncoder.encode(value) }); + bitbucket[field] = SECRET_REDACTED; + } + return { settings: { ...next, providerInstances: providerInstances as ServerSettings["providerInstances"], usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], + bitbucket, }, changes, }; diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index b27d56bef399..f7f2b2f068bf 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -16,6 +16,7 @@ import { import { GitCommandError } from "@t3tools/contracts"; import * as BitbucketApi from "./BitbucketApi.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import type * as VcsDriver from "../vcs/VcsDriver.ts"; @@ -64,6 +65,7 @@ function makeLayer(input: { request: HttpClientRequest.HttpClientRequest, ) => HttpClientError.HttpClientError; readonly git?: Partial; + readonly env?: Record; }) { const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => input.requestFailure @@ -150,7 +152,7 @@ function makeLayer(input: { Layer.provide( ConfigProvider.layer( ConfigProvider.fromEnv({ - env: { + env: input.env ?? { T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0", T3CODE_BITBUCKET_EMAIL: "user@example.com", T3CODE_BITBUCKET_API_TOKEN: "token", @@ -158,6 +160,7 @@ function makeLayer(input: { }), ), ), + Layer.provideMerge(ServerSettings.layerTest()), Layer.provideMerge(NodeServices.layer), ); @@ -509,6 +512,58 @@ it.effect("reports auth status through the Bitbucket REST /user endpoint", () => }).pipe(Effect.provide(layer)); }); +it.effect("prefers credentials saved in settings over the environment, without a restart", () => { + const { execute, layer } = makeLayer({ + response: () => Response.json({ username: "bitbucket-user" }), + }); + const lastAuthorization = () => execute.mock.calls.at(-1)?.[0].headers.authorization; + const basic = (user: string, password: string) => `Basic ${btoa(`${user}:${password}`)}`; + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const settings = yield* ServerSettings.ServerSettingsService; + + yield* bitbucket.probeAuth; + assert.strictEqual(lastAuthorization(), basic("user@example.com", "token")); + + yield* settings.updateSettings({ + bitbucket: { email: "saved@example.com", apiToken: "saved-api-token" }, + }); + yield* bitbucket.probeAuth; + assert.strictEqual(lastAuthorization(), basic("saved@example.com", "saved-api-token")); + + yield* settings.updateSettings({ bitbucket: { accessToken: "saved-access-token" } }); + yield* bitbucket.probeAuth; + assert.strictEqual(lastAuthorization(), "Bearer saved-access-token"); + + yield* settings.updateSettings({ bitbucket: { accessToken: "", apiToken: "" } }); + yield* bitbucket.probeAuth; + assert.strictEqual(lastAuthorization(), basic("user@example.com", "token")); + }).pipe(Effect.provide(layer)); +}); + +it.effect("reports saved credentials as configured when Bitbucket cannot confirm them", () => { + const { layer } = makeLayer({ + response: () => new Response(null, { status: 401 }), + env: { T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0" }, + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const settings = yield* ServerSettings.ServerSettingsService; + + assert.strictEqual((yield* bitbucket.probeAuth).status, "unauthenticated"); + + yield* settings.updateSettings({ bitbucket: { accessToken: "saved-access-token" } }); + assert.deepStrictEqual(yield* bitbucket.probeAuth, { + status: "unknown", + account: Option.none(), + host: Option.some("bitbucket.org"), + detail: Option.some("Bitbucket access token is configured."), + }); + }).pipe(Effect.provide(layer)); +}); + it.effect("preserves the HTTP client failure without deriving the domain message from it", () => { const transportCause = new Error("socket reset by peer"); let requestFailure: HttpClientError.HttpClientError | undefined; diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 202740680be8..8903d60badaa 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -7,8 +7,10 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { + DEFAULT_SERVER_SETTINGS, NonNegativeInt, TrimmedNonEmptyString, + type BitbucketSettings, type SourceControlProviderAuth, type SourceControlRepositoryCloneUrls, type SourceControlRepositoryVisibility, @@ -28,6 +30,7 @@ import { } from "./bitbucketPullRequests.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import { retryAtFromHeader } from "./SourceControlRateLimit.ts"; @@ -537,10 +540,44 @@ function repositoryOwnerName(repositoryName: string): string { return repositoryName.split("/")[0]?.trim() || "bitbucket"; } -function authFromConfig( - config: Config.Success, -): SourceControlProviderAuth { - if (Option.isSome(config.accessToken)) { +type BitbucketCredential = + | { readonly kind: "access-token"; readonly accessToken: string } + | { readonly kind: "api-token"; readonly email: string; readonly apiToken: string }; + +function credentialFrom(input: { + readonly accessToken: string; + readonly email: string; + readonly apiToken: string; +}): BitbucketCredential | null { + if (input.accessToken.length > 0) { + return { kind: "access-token", accessToken: input.accessToken }; + } + if (input.email.length > 0 && input.apiToken.length > 0) { + return { kind: "api-token", email: input.email, apiToken: input.apiToken }; + } + return null; +} + +/** + * Credentials saved in settings win over the `T3CODE_BITBUCKET_*` environment variables, which + * stay as a fallback. Within each source the access token wins. + */ +function resolveCredential( + settings: BitbucketSettings, + env: Config.Success, +): BitbucketCredential | null { + return ( + credentialFrom(settings) ?? + credentialFrom({ + accessToken: Option.getOrElse(env.accessToken, () => ""), + email: Option.getOrElse(env.email, () => ""), + apiToken: Option.getOrElse(env.apiToken, () => ""), + }) + ); +} + +function authFromCredential(credential: BitbucketCredential | null): SourceControlProviderAuth { + if (credential?.kind === "access-token") { return { status: "unknown", account: Option.none(), @@ -549,10 +586,10 @@ function authFromConfig( }; } - if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { + if (credential?.kind === "api-token") { return { status: "unknown", - account: config.email, + account: Option.some(credential.email), host: Option.some("bitbucket.org"), detail: Option.some("Bitbucket API token is configured."), }; @@ -563,7 +600,7 @@ function authFromConfig( account: Option.none(), host: Option.some("bitbucket.org"), detail: Option.some( - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + "Add Bitbucket credentials in Settings → Source Control, or set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", ), }; } @@ -612,6 +649,7 @@ function responseError( /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const config = yield* BitbucketApiEnvConfig; + const serverSettings = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; const fileSystem = yield* FileSystem.FileSystem; const git = yield* GitVcsDriver.GitVcsDriver; @@ -619,15 +657,27 @@ export const make = Effect.gen(function* () { const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; - const withAuth = (request: HttpClientRequest.HttpClientRequest) => { - if (Option.isSome(config.accessToken)) { - return request.pipe(HttpClientRequest.bearerToken(config.accessToken.value)); - } - if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { - return request.pipe(HttpClientRequest.basicAuth(config.email.value, config.apiToken.value)); - } - return request; - }; + // Read on every request so credentials saved in settings apply without a restart. + const currentCredential = serverSettings.getSettings.pipe( + Effect.map((settings) => resolveCredential(settings.bitbucket, config)), + Effect.catch((error) => + Effect.logWarning("failed to read Bitbucket credentials from settings", { + operation: error.operation, + cause: error.cause, + }).pipe(Effect.as(resolveCredential(DEFAULT_SERVER_SETTINGS.bitbucket, config))), + ), + ); + + const withAuth = (request: HttpClientRequest.HttpClientRequest) => + currentCredential.pipe( + Effect.map((credential) => + credential === null + ? request + : credential.kind === "access-token" + ? request.pipe(HttpClientRequest.bearerToken(credential.accessToken)) + : request.pipe(HttpClientRequest.basicAuth(credential.email, credential.apiToken)), + ), + ); const decodeResponse = ( operation: BitbucketApiOperation, @@ -654,7 +704,8 @@ export const make = Effect.gen(function* () { request: HttpClientRequest.HttpClientRequest, schema: S, ): Effect.Effect => - httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe( + withAuth(request.pipe(HttpClientRequest.acceptJson)).pipe( + Effect.flatMap(httpClient.execute), Effect.mapError( (cause) => new BitbucketRequestError({ @@ -847,7 +898,8 @@ export const make = Effect.gen(function* () { input.body === undefined ? base : base.pipe(HttpClientRequest.bodyText(input.body, "application/json")); - return httpClient.execute(withAuth(withBody)).pipe( + return withAuth(withBody).pipe( + Effect.flatMap(httpClient.execute), Effect.mapError( (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }), ), @@ -913,7 +965,7 @@ export const make = Effect.gen(function* () { host: Option.some("bitbucket.org"), detail: Option.none(), })), - Effect.orElseSucceed(() => authFromConfig(config)), + Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))), ), listPullRequests: (input) => resolveRepository(input).pipe( diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index e27aa4c7dc88..07db4e317648 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -193,7 +193,7 @@ export const makeDiscovery = Effect.gen(function* () { kind: "bitbucket", label: "Bitbucket", installHint: - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN on the server (use a Bitbucket API token with pull request, repository, and user read scopes).", + "Add Bitbucket credentials in Settings → Source Control. Tokens need pull request, repository, and user read scopes.", probeAuth: bitbucket.probeAuth, } satisfies SourceControlApiDiscoverySpec; }); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 30aa22995b95..be2096d11f4a 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -423,7 +423,7 @@ it.effect("reports implemented tools separately from locally available executabl account: Option.none(), host: Option.some("bitbucket.org"), detail: Option.some( - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + "Add Bitbucket credentials in Settings → Source Control, or set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", ), }), }, diff --git a/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx b/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx new file mode 100644 index 000000000000..2d763eb69ca1 --- /dev/null +++ b/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx @@ -0,0 +1,147 @@ +import type { BitbucketSettings, EnvironmentId } from "@t3tools/contracts"; +import { useState } from "react"; + +import { useEnvironmentSettings } from "../../hooks/useSettings"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; + +type BitbucketSettingsPatch = { -readonly [K in keyof BitbucketSettings]?: string }; + +function savedCredentialLabel(saved: BitbucketSettings): string { + if (saved.accessToken.length > 0) return "Using the saved access token."; + if (saved.email.length > 0 && saved.apiToken.length > 0) { + return `Using the saved API token for ${saved.email}.`; + } + return "No credentials saved. The server's T3CODE_BITBUCKET_* variables are used if set."; +} + +/** + * Bitbucket credentials for one environment. Tokens are write-only: the server + * keeps them in its secret store and only reports whether each one is set. + */ +export function BitbucketCredentialsSettings({ + environmentId, + onSaved, +}: { + readonly environmentId: EnvironmentId; + readonly onSaved: () => void; +}) { + const saved = useEnvironmentSettings(environmentId, (settings) => settings.bitbucket); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "save Bitbucket credentials", + }); + const [accessToken, setAccessToken] = useState(""); + const [emailDraft, setEmailDraft] = useState(null); + const [apiToken, setApiToken] = useState(""); + const [saving, setSaving] = useState(false); + const email = emailDraft ?? saved.email; + const hasAccessToken = saved.accessToken.length > 0; + const hasApiToken = saved.apiToken.length > 0; + + const draft: BitbucketSettingsPatch = {}; + if (accessToken.trim()) draft.accessToken = accessToken.trim(); + if (apiToken.trim()) draft.apiToken = apiToken.trim(); + if (email.trim() !== saved.email) draft.email = email.trim(); + const canSave = Object.keys(draft).length > 0; + + const save = async (patch: BitbucketSettingsPatch) => { + setSaving(true); + try { + const result = await updateSettings({ + environmentId, + input: { patch: { bitbucket: patch } }, + }); + if (result._tag === "Success") { + setAccessToken(""); + setApiToken(""); + setEmailDraft(null); + onSaved(); + } + } finally { + setSaving(false); + } + }; + + return ( +
{ + event.preventDefault(); + if (canSave) void save(draft); + }} + > +

+ {savedCredentialLabel(saved)} Tokens are stored on this server and can't be viewed after + saving. The access token is used when both are set. +

+
+ +
+ setAccessToken(event.target.value)} + /> + {hasAccessToken ? ( + + ) : null} +
+
+
+ + setEmailDraft(event.target.value)} + /> +
+
+ +
+ setApiToken(event.target.value)} + /> + {hasApiToken || saved.email.length > 0 ? ( + + ) : null} +
+
+
+ +
+
+ ); +} diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 10f86084ea20..1d2734924588 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -56,6 +56,7 @@ import { JujutsuIcon, type Icon, } from "../Icons"; +import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; import { @@ -277,7 +278,11 @@ function DiscoveryItemRow({ const searchTargetId = useSettingsSearchTargetId(); useEffect(() => { - if (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) { + if ( + (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) || + (item.kind === "bitbucket" && + searchTargetId === searchableSetting("bitbucket-credentials").id) + ) { setIsExpanded(true); } }, [item.kind, searchTargetId]); @@ -586,7 +591,16 @@ export function SourceControlSettingsPanel() { headerAction={hasVersionControlSystems ? null : scanButton} > {result.sourceControlProviders.map((item) => ( - + + {item.kind === "bitbucket" ? ( + + + + ) : undefined} + ))} ) : null} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index c9cb76401bd6..4edce393883c 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -695,6 +695,14 @@ export const SETTINGS_SEARCH_ITEMS = [ environmentOnly: true, scope: "environment-defaults", }, + { + id: "bitbucket-credentials", + title: "Bitbucket credentials", + to: "/settings/source-control", + searchTerms: ["bitbucket atlassian access token api token email credentials sign in"], + environmentOnly: true, + scope: "environment-defaults", + }, { id: "source-control-writing-style", title: "Source control writing style", diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 408810571632..448ffabb1c67 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -47,23 +47,24 @@ glab auth login ### Bitbucket -Set an access token in the server's environment: +Open **Settings → Source Control** and expand **Bitbucket**. Enter either an access token, or an +Atlassian account email and API token with read/write access to repositories and pull requests, +plus user read access (`read:user:bitbucket`). Choose **Save**; the change applies right away. -```bash -export T3CODE_BITBUCKET_ACCESS_TOKEN="your-access-token" -``` +The access token takes precedence if both are saved. Credentials are saved on the environment's +server, so select a remote environment to configure it. Saved tokens can't be viewed again; enter a +new one to replace it, or choose **Clear** to remove it. -Or use an Atlassian account email and API token with read/write access to repositories and pull -requests, plus user read access (`read:user:bitbucket`): +If no credentials are saved, T3 Code falls back to these variables in the server's environment. +Restart the server after changing them: ```bash +export T3CODE_BITBUCKET_ACCESS_TOKEN="your-access-token" +# or export T3CODE_BITBUCKET_EMAIL="you@example.com" export T3CODE_BITBUCKET_API_TOKEN="your-token" ``` -The access token takes precedence if both are configured. Restart the server after changing these -variables. - ### Azure DevOps Install [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/), add the DevOps extension, and sign in: diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 1a3c2d50b209..7a249cefb373 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -1277,9 +1277,9 @@ const PROVIDER_REQUIREMENT: Partial< }, bitbucket: { missing: - "Bitbucket needs API credentials on the server. Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + "Bitbucket needs API credentials on the server. Add them in Settings → Source Control.", unauthenticated: - "Bitbucket rejected the configured credentials. Check T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN.", + "Bitbucket rejected the configured credentials. Check them in Settings → Source Control.", }, }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e8dc37bfc839..3fd27b7f7379 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -901,6 +901,19 @@ export const UsageLimitSourceConfig = Schema.Struct({ }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; +/** + * Bitbucket API credentials for this environment, used before the + * `T3CODE_BITBUCKET_*` environment variables. The tokens live in the server's + * secret store; settings and clients only see a redaction marker when one is + * set. The access token wins when both kinds are configured. + */ +export const BitbucketSettings = Schema.Struct({ + email: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + accessToken: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + apiToken: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), +}); +export type BitbucketSettings = typeof BitbucketSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -1277,6 +1290,7 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed({})), ), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + bitbucket: BitbucketSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), // Keyed by a user-chosen id so a source keeps its rows across edits. Entries // this build cannot decode round-trip untouched, as provider instances do. usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( @@ -1538,6 +1552,14 @@ export const ServerSettingsPatch = Schema.Struct({ otlpLogsUrl: Schema.optionalKey(TrimmedString), }), ), + /** An empty token clears it; an omitted one keeps what the server has. */ + bitbucket: Schema.optionalKey( + Schema.Struct({ + email: Schema.optionalKey(TrimmedString), + accessToken: Schema.optionalKey(TrimmedString), + apiToken: Schema.optionalKey(TrimmedString), + }), + ), providers: Schema.optionalKey( Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), From 9a897b5e135cc1694f7aaf467e384ed2164f4a74 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:06:38 -0400 Subject: [PATCH 02/12] fix(web): show unverified saved Bitbucket credentials as configured Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/settings/SourceControlSettings.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 1d2734924588..9c585fe3a4f7 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -235,7 +235,9 @@ function itemSummary({ ); } - if (!item.executable) { + // API integrations have no CLI to sign in with; an unverified saved credential falls + // through to the "could not verify" detail instead of repeating the setup hint. + if (!item.executable && auth.status === "unauthenticated") { return Available. {item.installHint}; } From 2e00bfd3a808490d25e9d947338cc66a10779253 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:10:34 -0400 Subject: [PATCH 03/12] feat(web): choose one Bitbucket sign-in method, with guidance for each Co-Authored-By: Claude Opus 5.5 --- .../settings/BitbucketCredentialsSettings.tsx | 213 ++++++++++++------ docs/user/source-control.md | 19 +- 2 files changed, 155 insertions(+), 77 deletions(-) diff --git a/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx b/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx index 2d763eb69ca1..ea8bcfea098f 100644 --- a/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx +++ b/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx @@ -1,4 +1,5 @@ import type { BitbucketSettings, EnvironmentId } from "@t3tools/contracts"; +import { ExternalLinkIcon } from "lucide-react"; import { useState } from "react"; import { useEnvironmentSettings } from "../../hooks/useSettings"; @@ -7,19 +8,51 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Label } from "../ui/label"; +import { Toggle, ToggleGroup } from "../ui/toggle-group"; -type BitbucketSettingsPatch = { -readonly [K in keyof BitbucketSettings]?: string }; +type CredentialMethod = "access-token" | "api-token"; -function savedCredentialLabel(saved: BitbucketSettings): string { - if (saved.accessToken.length > 0) return "Using the saved access token."; - if (saved.email.length > 0 && saved.apiToken.length > 0) { - return `Using the saved API token for ${saved.email}.`; +const METHODS: Record< + CredentialMethod, + { + readonly label: string; + readonly description: string; + readonly link: string; + readonly linkLabel: string; } +> = { + "access-token": { + label: "Access token", + description: + "A token created for one repository, project, or workspace in its Bitbucket settings. It can only reach what it was created for.", + link: "https://support.atlassian.com/bitbucket-cloud/docs/access-tokens/", + linkLabel: "About access tokens", + }, + "api-token": { + label: "Atlassian API token", + description: + "A token for your Atlassian account, used with your account email. It can reach every repository you can. Give it read and write access to repositories and pull requests, plus read:user:bitbucket.", + link: "https://id.atlassian.com/manage-profile/security/api-tokens", + linkLabel: "Create an API token", + }, +}; + +function savedMethod(saved: BitbucketSettings): CredentialMethod | null { + if (saved.accessToken.length > 0) return "access-token"; + if (saved.email.length > 0 && saved.apiToken.length > 0) return "api-token"; + return null; +} + +function savedCredentialLabel(saved: BitbucketSettings): string { + const method = savedMethod(saved); + if (method === "access-token") return "Using the saved access token."; + if (method === "api-token") return `Using the saved API token for ${saved.email}.`; return "No credentials saved. The server's T3CODE_BITBUCKET_* variables are used if set."; } /** - * Bitbucket credentials for one environment. Tokens are write-only: the server + * Bitbucket credentials for one environment: an access token or an Atlassian + * account email + API token, never both. Tokens are write-only: the server * keeps them in its secret store and only reports whether each one is set. */ export function BitbucketCredentialsSettings({ @@ -33,26 +66,44 @@ export function BitbucketCredentialsSettings({ const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { label: "save Bitbucket credentials", }); + const [methodChoice, setMethodChoice] = useState(null); const [accessToken, setAccessToken] = useState(""); const [emailDraft, setEmailDraft] = useState(null); const [apiToken, setApiToken] = useState(""); const [saving, setSaving] = useState(false); + const current = savedMethod(saved); + const method = methodChoice ?? current ?? "access-token"; + const methodIsSaved = current === method; const email = emailDraft ?? saved.email; - const hasAccessToken = saved.accessToken.length > 0; - const hasApiToken = saved.apiToken.length > 0; + const info = METHODS[method]; - const draft: BitbucketSettingsPatch = {}; - if (accessToken.trim()) draft.accessToken = accessToken.trim(); - if (apiToken.trim()) draft.apiToken = apiToken.trim(); - if (email.trim() !== saved.email) draft.email = email.trim(); - const canSave = Object.keys(draft).length > 0; + // Saving one method clears the other, so a hidden credential never wins over the visible one. + const patch: BitbucketSettings | null = + method === "access-token" + ? accessToken.trim() + ? { accessToken: accessToken.trim(), email: "", apiToken: "" } + : null + : email.trim() && (apiToken.trim() || saved.apiToken.length > 0) + ? { + accessToken: "", + email: email.trim(), + // Omitting a token would clear it; resend the saved marker to keep it. + apiToken: apiToken.trim() || saved.apiToken, + } + : null; + const canSave = + patch !== null && + (method === "access-token" || + !methodIsSaved || + apiToken.trim() !== "" || + email.trim() !== saved.email); - const save = async (patch: BitbucketSettingsPatch) => { + const save = async (next: Partial) => { setSaving(true); try { const result = await updateSettings({ environmentId, - input: { patch: { bitbucket: patch } }, + input: { patch: { bitbucket: next } }, }); if (result._tag === "Success") { setAccessToken(""); @@ -70,74 +121,96 @@ export function BitbucketCredentialsSettings({ className="grid gap-4" onSubmit={(event) => { event.preventDefault(); - if (canSave) void save(draft); + if (canSave && patch) void save(patch); }} > -

+

{savedCredentialLabel(saved)} Tokens are stored on this server and can't be viewed after - saving. The access token is used when both are set. + saving. +

+ { + const value = next[0]; + if (value === "access-token" || value === "api-token") setMethodChoice(value); + }} + > + {METHODS["access-token"].label} + {METHODS["api-token"].label} + +

+ {info.description}{" "} + + {info.linkLabel} + +

-
- -
+ {method === "access-token" ? ( +
+ setAccessToken(event.target.value)} /> - {hasAccessToken ? ( - - ) : null}
-
-
- - setEmailDraft(event.target.value)} - /> -
-
- -
- setApiToken(event.target.value)} - /> - {hasApiToken || saved.email.length > 0 ? ( - - ) : null} -
-
-
+ ) : ( + <> +
+ + setEmailDraft(event.target.value)} + /> +
+
+ + 0 ? "Saved. Enter a new token to replace it" : "Not set" + } + value={apiToken} + onChange={(event) => setApiToken(event.target.value)} + /> +
+ + )} + {current !== null && !methodIsSaved ? ( +

+ Saving replaces the saved {METHODS[current].label.toLowerCase()}. +

+ ) : null} +
+ {current !== null ? ( + + ) : null} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 448ffabb1c67..ad0fa4e44279 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -47,13 +47,18 @@ glab auth login ### Bitbucket -Open **Settings → Source Control** and expand **Bitbucket**. Enter either an access token, or an -Atlassian account email and API token with read/write access to repositories and pull requests, -plus user read access (`read:user:bitbucket`). Choose **Save**; the change applies right away. - -The access token takes precedence if both are saved. Credentials are saved on the environment's -server, so select a remote environment to configure it. Saved tokens can't be viewed again; enter a -new one to replace it, or choose **Clear** to remove it. +Open **Settings → Source Control**, expand **Bitbucket**, and choose how to sign in: + +- **Access token**: a token created for one repository, project, or workspace. It can only reach + what it was created for. +- **Atlassian API token**: a token for your Atlassian account, used with your account email. It can + reach every repository you can. Give it read/write access to repositories and pull requests, plus + user read access (`read:user:bitbucket`). + +Choose **Save**; the change applies right away, and replaces any credential saved with the other +method. Credentials are saved on the environment's server, so select a remote environment to +configure it. Saved tokens can't be viewed again; enter a new one to replace it, or choose +**Remove credentials**. If no credentials are saved, T3 Code falls back to these variables in the server's environment. Restart the server after changing them: From f51a598fbefb6991cd671c13c28f75e2e5b084f1 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:11:22 -0400 Subject: [PATCH 04/12] docs: point Bitbucket troubleshooting at saved credentials Co-Authored-By: Claude Opus 5.5 --- docs/user/source-control.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/user/source-control.md b/docs/user/source-control.md index ad0fa4e44279..1e1f45ac09c4 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -144,7 +144,8 @@ does not show its diff, so marks are made and read on web and desktop. ## Troubleshooting - **Not authenticated:** run the provider's login command on the server, then rescan. For Bitbucket, - confirm the running server received the environment variables. + check the credentials saved in Settings → Source Control, or confirm the running server received + the environment variables. - **GitHub sign-in cannot be verified:** update GitHub CLI to at least 2.81.0. - **Push fails despite a connected account:** check the Git remote's credentials. SSH and HTTPS remotes can require separate setup from the hosting provider's API access. From b145016a2a0626fca227a5ed4b4a808af86f1e69 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:22:30 -0400 Subject: [PATCH 05/12] feat(web): show saved Bitbucket tokens as filled password fields Saved tokens now render as a masked password input instead of an empty field with a placeholder note. Typing replaces the token; leaving the field empty keeps the saved one. Also tightens the form and row copy. Co-Authored-By: Claude Opus 5.5 --- .../src/sourceControl/BitbucketApi.test.ts | 2 +- apps/server/src/sourceControl/BitbucketApi.ts | 6 +- .../BitbucketSourceControlProvider.ts | 3 +- .../SourceControlDiscovery.test.ts | 2 +- .../settings/BitbucketCredentialsSettings.tsx | 155 ++++++++++-------- docs/user/source-control.md | 4 +- 6 files changed, 98 insertions(+), 74 deletions(-) diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index f7f2b2f068bf..b089ff8de1c3 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -559,7 +559,7 @@ it.effect("reports saved credentials as configured when Bitbucket cannot confirm status: "unknown", account: Option.none(), host: Option.some("bitbucket.org"), - detail: Option.some("Bitbucket access token is configured."), + detail: Option.some("An access token is configured."), }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 8903d60badaa..99167a972c91 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -582,7 +582,7 @@ function authFromCredential(credential: BitbucketCredential | null): SourceContr status: "unknown", account: Option.none(), host: Option.some("bitbucket.org"), - detail: Option.some("Bitbucket access token is configured."), + detail: Option.some("An access token is configured."), }; } @@ -591,7 +591,7 @@ function authFromCredential(credential: BitbucketCredential | null): SourceContr status: "unknown", account: Option.some(credential.email), host: Option.some("bitbucket.org"), - detail: Option.some("Bitbucket API token is configured."), + detail: Option.some("An API token is configured."), }; } @@ -600,7 +600,7 @@ function authFromCredential(credential: BitbucketCredential | null): SourceContr account: Option.none(), host: Option.some("bitbucket.org"), detail: Option.some( - "Add Bitbucket credentials in Settings → Source Control, or set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.", ), }; } diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index 07db4e317648..f1a01056fbd0 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -192,8 +192,7 @@ export const makeDiscovery = Effect.gen(function* () { type: "api", kind: "bitbucket", label: "Bitbucket", - installHint: - "Add Bitbucket credentials in Settings → Source Control. Tokens need pull request, repository, and user read scopes.", + installHint: "Add a Bitbucket token in Settings → Source Control.", probeAuth: bitbucket.probeAuth, } satisfies SourceControlApiDiscoverySpec; }); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index be2096d11f4a..820b9a76c9a2 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -423,7 +423,7 @@ it.effect("reports implemented tools separately from locally available executabl account: Option.none(), host: Option.some("bitbucket.org"), detail: Option.some( - "Add Bitbucket credentials in Settings → Source Control, or set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.", ), }), }, diff --git a/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx b/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx index ea8bcfea098f..2cb098af20db 100644 --- a/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx +++ b/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx @@ -24,30 +24,68 @@ const METHODS: Record< "access-token": { label: "Access token", description: - "A token created for one repository, project, or workspace in its Bitbucket settings. It can only reach what it was created for.", + "Scoped to one repository, project, or workspace. Create it in that item's Bitbucket settings.", link: "https://support.atlassian.com/bitbucket-cloud/docs/access-tokens/", - linkLabel: "About access tokens", + linkLabel: "Learn more", }, "api-token": { - label: "Atlassian API token", + label: "API token", description: - "A token for your Atlassian account, used with your account email. It can reach every repository you can. Give it read and write access to repositories and pull requests, plus read:user:bitbucket.", + "Uses your Atlassian account, so it reaches every repository you can. Give it read and write access to repositories and pull requests, and read:user:bitbucket.", link: "https://id.atlassian.com/manage-profile/security/api-tokens", linkLabel: "Create an API token", }, }; +/** Filler for a saved token's field. A password input renders it as dots; it is never sent. */ +const SAVED_TOKEN_MASK = "saved-token-mask"; + function savedMethod(saved: BitbucketSettings): CredentialMethod | null { if (saved.accessToken.length > 0) return "access-token"; if (saved.email.length > 0 && saved.apiToken.length > 0) return "api-token"; return null; } -function savedCredentialLabel(saved: BitbucketSettings): string { - const method = savedMethod(saved); - if (method === "access-token") return "Using the saved access token."; - if (method === "api-token") return `Using the saved API token for ${saved.email}.`; - return "No credentials saved. The server's T3CODE_BITBUCKET_* variables are used if set."; +/** + * A write-only token field. A saved token shows as a filled password field; typing replaces + * it, and leaving the field empty restores the saved one. `draft` is null until edited. + */ +function TokenInput({ + id, + isSaved, + draft, + onDraftChange, +}: { + readonly id: string; + readonly isSaved: boolean; + readonly draft: string | null; + readonly onDraftChange: (draft: string | null) => void; +}) { + const masked = isSaved && draft === null; + return ( + { + if (masked) event.target.select(); + }} + onChange={(event) => { + const value = event.target.value; + if (!masked) return onDraftChange(value); + // Typing at the end of the mask, or deleting into it, starts a fresh token. + if (value.startsWith(SAVED_TOKEN_MASK)) + return onDraftChange(value.slice(SAVED_TOKEN_MASK.length)); + onDraftChange(SAVED_TOKEN_MASK.startsWith(value) ? "" : value); + }} + onBlur={() => { + if (isSaved && draft === "") onDraftChange(null); + }} + /> + ); } /** @@ -67,38 +105,33 @@ export function BitbucketCredentialsSettings({ label: "save Bitbucket credentials", }); const [methodChoice, setMethodChoice] = useState(null); - const [accessToken, setAccessToken] = useState(""); + const [accessToken, setAccessToken] = useState(null); const [emailDraft, setEmailDraft] = useState(null); - const [apiToken, setApiToken] = useState(""); + const [apiToken, setApiToken] = useState(null); const [saving, setSaving] = useState(false); const current = savedMethod(saved); const method = methodChoice ?? current ?? "access-token"; const methodIsSaved = current === method; - const email = emailDraft ?? saved.email; + const email = (emailDraft ?? saved.email).trim(); + const newAccessToken = accessToken?.trim() ?? ""; + const newApiToken = apiToken?.trim() ?? ""; const info = METHODS[method]; // Saving one method clears the other, so a hidden credential never wins over the visible one. const patch: BitbucketSettings | null = method === "access-token" - ? accessToken.trim() - ? { accessToken: accessToken.trim(), email: "", apiToken: "" } + ? newAccessToken + ? { accessToken: newAccessToken, email: "", apiToken: "" } : null - : email.trim() && (apiToken.trim() || saved.apiToken.length > 0) - ? { - accessToken: "", - email: email.trim(), - // Omitting a token would clear it; resend the saved marker to keep it. - apiToken: apiToken.trim() || saved.apiToken, - } + : email && (newApiToken || saved.apiToken) + ? // Resending the saved token's redacted value keeps it. + { accessToken: "", email, apiToken: newApiToken || saved.apiToken } : null; const canSave = patch !== null && - (method === "access-token" || - !methodIsSaved || - apiToken.trim() !== "" || - email.trim() !== saved.email); + (method === "access-token" || !methodIsSaved || newApiToken !== "" || email !== saved.email); - const save = async (next: Partial) => { + const save = async (next: BitbucketSettings) => { setSaving(true); try { const result = await updateSettings({ @@ -106,8 +139,8 @@ export function BitbucketCredentialsSettings({ input: { patch: { bitbucket: next } }, }); if (result._tag === "Success") { - setAccessToken(""); - setApiToken(""); + setAccessToken(null); + setApiToken(null); setEmailDraft(null); onSaved(); } @@ -124,10 +157,6 @@ export function BitbucketCredentialsSettings({ if (canSave && patch) void save(patch); }} > -

- {savedCredentialLabel(saved)} Tokens are stored on this server and can't be viewed after - saving. -

- setAccessToken(event.target.value)} + isSaved={methodIsSaved} + draft={accessToken} + onDraftChange={setAccessToken} />
) : ( @@ -175,45 +201,44 @@ export function BitbucketCredentialsSettings({ autoComplete="off" size="sm" placeholder="you@example.com" - value={email} + value={emailDraft ?? saved.email} onChange={(event) => setEmailDraft(event.target.value)} />
- 0 ? "Saved. Enter a new token to replace it" : "Not set" - } - value={apiToken} - onChange={(event) => setApiToken(event.target.value)} + isSaved={methodIsSaved} + draft={apiToken} + onDraftChange={setApiToken} />
)} - {current !== null && !methodIsSaved ? ( +

- Saving replaces the saved {METHODS[current].label.toLowerCase()}. + {current === null + ? "Without a saved token, the server falls back to its T3CODE_BITBUCKET_* environment variables." + : methodIsSaved + ? null + : `Saving replaces your ${METHODS[current].label.toLowerCase()}.`}

- ) : null} -
- {current !== null ? ( - + ) : null} + - ) : null} - +
); diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 1e1f45ac09c4..0427f143c7d5 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -51,14 +51,14 @@ Open **Settings → Source Control**, expand **Bitbucket**, and choose how to si - **Access token**: a token created for one repository, project, or workspace. It can only reach what it was created for. -- **Atlassian API token**: a token for your Atlassian account, used with your account email. It can +- **API token**: an Atlassian API token for your account, used with your account email. It can reach every repository you can. Give it read/write access to repositories and pull requests, plus user read access (`read:user:bitbucket`). Choose **Save**; the change applies right away, and replaces any credential saved with the other method. Credentials are saved on the environment's server, so select a remote environment to configure it. Saved tokens can't be viewed again; enter a new one to replace it, or choose -**Remove credentials**. +**Remove**. If no credentials are saved, T3 Code falls back to these variables in the server's environment. Restart the server after changing them: From 96a3b20e12b851961142e80fb0eb4e9bdf436fd8 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:51:08 -0400 Subject: [PATCH 06/12] fix: address review feedback on Bitbucket credentials - Key the credentials form by environment so drafts never carry over and get saved to a different environment. - Clear a saved token field on focus instead of stripping the mask prefix from typed input, which could truncate a real token. - Provide settings to BitbucketApi in the title evaluation script. Co-Authored-By: Claude Opus 5.5 --- apps/server/scripts/evaluate-thread-titles.ts | 4 +++- .../settings/BitbucketCredentialsSettings.tsx | 17 +++++------------ .../settings/SourceControlSettings.tsx | 2 ++ 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index 78273a9ef790..4c6e04bb510e 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -28,6 +28,7 @@ import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts"; +import * as ServerSettings from "../src/serverSettings.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "../src/vcs/VcsProjectConfig.ts"; @@ -155,7 +156,8 @@ await Effect.runPromise( GitLabCli.layer, ForgejoCli.layer, AzureDevOpsCli.layer, - BitbucketApi.layer, + // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables. + BitbucketApi.layer.pipe(Layer.provide(ServerSettings.layerTest())), ), ), Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))), diff --git a/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx b/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx index 2cb098af20db..f67574fadc30 100644 --- a/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx +++ b/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx @@ -47,8 +47,8 @@ function savedMethod(saved: BitbucketSettings): CredentialMethod | null { } /** - * A write-only token field. A saved token shows as a filled password field; typing replaces - * it, and leaving the field empty restores the saved one. `draft` is null until edited. + * A write-only token field. A saved token shows as a filled password field; focusing it starts + * a fresh token, and leaving that empty restores the saved one. `draft` is null until edited. */ function TokenInput({ id, @@ -70,17 +70,10 @@ function TokenInput({ size="sm" placeholder={isSaved ? undefined : "Not set"} value={masked ? SAVED_TOKEN_MASK : (draft ?? "")} - onFocus={(event) => { - if (masked) event.target.select(); - }} - onChange={(event) => { - const value = event.target.value; - if (!masked) return onDraftChange(value); - // Typing at the end of the mask, or deleting into it, starts a fresh token. - if (value.startsWith(SAVED_TOKEN_MASK)) - return onDraftChange(value.slice(SAVED_TOKEN_MASK.length)); - onDraftChange(SAVED_TOKEN_MASK.startsWith(value) ? "" : value); + onFocus={() => { + if (masked) onDraftChange(""); }} + onChange={(event) => onDraftChange(event.target.value)} onBlur={() => { if (isSaved && draft === "") onDraftChange(null); }} diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 9c585fe3a4f7..9fba0edc7d44 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -597,6 +597,8 @@ export function SourceControlSettingsPanel() { {item.kind === "bitbucket" ? ( From 96d5fbd8f962bf8a53c062204b09030f51f48b18 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:30:08 -0400 Subject: [PATCH 07/12] fix(server): harden Bitbucket credential handling - Ignore saved or env tokens that are not safe for an HTTP header. The HTTP stack rejects them with an error quoting the header, and that error reaches clients as a cause. - When a client echoes the redaction marker, move a plaintext token hand-edited into settings.json into the secret store instead of dropping it. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/serverSettings.test.ts | 29 +++++++++++++++++++ apps/server/src/serverSettings.ts | 10 +++++-- .../src/sourceControl/BitbucketApi.test.ts | 20 +++++++++++++ apps/server/src/sourceControl/BitbucketApi.ts | 10 +++++-- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 6060595c8bdf..c47a951632e8 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1342,6 +1342,35 @@ it.layer(NodeServices.layer)("server settings", (it) => { ), ); + it.effect( + "moves a hand-edited Bitbucket token into the secret store when a client echoes the marker", + () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"bitbucket":{"email":"me@example.com","apiToken":"hand-edited-token"}}', + ); + + // The form resends the redacted token when only the email changes. + const forClient = ServerSettingsModule.redactServerSettingsForClient( + yield* serverSettings.getSettings, + ).bitbucket; + const updated = yield* serverSettings.updateSettings({ + bitbucket: { email: "new@example.com", apiToken: forClient.apiToken }, + }); + + assert.equal(updated.bitbucket.apiToken, "hand-edited-token"); + assert.equal((yield* serverSettings.getSettings).bitbucket.apiToken, "hand-edited-token"); + assert.notInclude( + yield* fileSystem.readFileString(serverConfig.settingsPath), + "hand-edited-token", + ); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("materializes provider secrets for terminal environment resolution", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 9f7a5d6e74d7..4c9eea2deea6 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -882,8 +882,14 @@ const make = Effect.gen(function* () { const bitbucket = { ...next.bitbucket }; for (const field of BITBUCKET_SECRET_FIELDS) { - const value = bitbucket[field]; - if (value === SECRET_REDACTED) continue; + let value = bitbucket[field]; + if (value === SECRET_REDACTED) { + // The marker keeps what is saved. A plaintext value hand-edited into settings.json + // is not in the secret store yet, so move it there instead of dropping it. + const inline = current.bitbucket[field]; + if (inline === SECRET_REDACTED || inline.length === 0) continue; + value = inline; + } if (value.length === 0 && current.bitbucket[field].length === 0) continue; const secretName = BITBUCKET_SECRET_NAMES[field]; if (value.length === 0) { diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index b089ff8de1c3..83965a38b21c 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -542,6 +542,26 @@ it.effect("prefers credentials saved in settings over the environment, without a }).pipe(Effect.provide(layer)); }); +it.effect("never puts a saved token that is unsafe for an HTTP header on the wire", () => { + const { execute, layer } = makeLayer({ + response: () => Response.json({ username: "bitbucket-user" }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const settings = yield* ServerSettings.ServerSettingsService; + + // Fetch would reject this header with an error quoting the token, and that error reaches + // clients. The unusable token is ignored, so the environment credential is used instead. + yield* settings.updateSettings({ bitbucket: { accessToken: "saved\ntoken" } }); + yield* bitbucket.probeAuth; + assert.strictEqual( + execute.mock.calls.at(-1)?.[0].headers.authorization, + `Basic ${btoa("user@example.com:token")}`, + ); + }).pipe(Effect.provide(layer)); +}); + it.effect("reports saved credentials as configured when Bitbucket cannot confirm them", () => { const { layer } = makeLayer({ response: () => new Response(null, { status: 401 }), diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 99167a972c91..e8ad76254200 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -544,15 +544,21 @@ type BitbucketCredential = | { readonly kind: "access-token"; readonly accessToken: string } | { readonly kind: "api-token"; readonly email: string; readonly apiToken: string }; +/** + * Visible ASCII only. A value the HTTP stack rejects makes it throw an error quoting the whole + * header, and that error travels to clients as a cause, so an unusable token is treated as unset. + */ +const HEADER_SAFE = /^[\x21-\x7e]+$/u; + function credentialFrom(input: { readonly accessToken: string; readonly email: string; readonly apiToken: string; }): BitbucketCredential | null { - if (input.accessToken.length > 0) { + if (HEADER_SAFE.test(input.accessToken)) { return { kind: "access-token", accessToken: input.accessToken }; } - if (input.email.length > 0 && input.apiToken.length > 0) { + if (HEADER_SAFE.test(input.email) && HEADER_SAFE.test(input.apiToken)) { return { kind: "api-token", email: input.email, apiToken: input.apiToken }; } return null; From 6ffd7bf11b1ac51f7a1e13076dbfb30e5a401167 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:39:04 -0400 Subject: [PATCH 08/12] fix(server): don't log settings read causes; clear stale Bitbucket secrets - The credential fallback no longer logs the settings error's cause, which can quote a hand-edited token from a malformed settings.json. - An empty Bitbucket token now always removes its secret, so one cleared by editing settings.json by hand doesn't linger in the secret store. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/serverSettings.test.ts | 46 +++++++++++++------ apps/server/src/serverSettings.ts | 1 - apps/server/src/sourceControl/BitbucketApi.ts | 2 +- 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index c47a951632e8..58ef893b3bb3 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -44,6 +44,20 @@ const makeServerSettingsLayer = () => ), ); +/** Like `makeServerSettingsLayer`, but also exposes the secret store for assertions. */ +const makeServerSettingsLayerWithSecrets = () => + ServerSettingsModule.layer.pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-server-settings-test-", + }), + ), + ), + ); + const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) => Layer.succeed( ServerSecretStore.ServerSecretStore, @@ -1325,21 +1339,23 @@ it.layer(NodeServices.layer)("server settings", (it) => { ServerSettingsModule.redactServerSettingsForClient(cleared).bitbucket.accessToken, "", ); - }).pipe( - Effect.provide( - ServerSettingsModule.layer.pipe( - Layer.provideMerge(ServerSecretStore.layer), - Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), - Layer.provideMerge( - Layer.fresh( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-server-settings-test-", - }), - ), - ), - ), - ), - ), + }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())), + ); + + it.effect("removes a Bitbucket secret once its token is cleared by hand in settings.json", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + // A token was saved, then the user deleted it from settings.json directly. + yield* secrets.set("bitbucket-access-token", new TextEncoder().encode("stale-token")); + yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}"); + + yield* serverSettings.updateSettings({ cursorKeychainUsageEnabled: true }); + + assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token"))); + }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())), ); it.effect( diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 4c9eea2deea6..517146967a93 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -890,7 +890,6 @@ const make = Effect.gen(function* () { if (inline === SECRET_REDACTED || inline.length === 0) continue; value = inline; } - if (value.length === 0 && current.bitbucket[field].length === 0) continue; const secretName = BITBUCKET_SECRET_NAMES[field]; if (value.length === 0) { changes.push({ kind: "remove", secretName, operation: "remove-secret" }); diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index e8ad76254200..2404e6b497e3 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -667,9 +667,9 @@ export const make = Effect.gen(function* () { const currentCredential = serverSettings.getSettings.pipe( Effect.map((settings) => resolveCredential(settings.bitbucket, config)), Effect.catch((error) => + // No cause: a settings decode error can quote a hand-edited token. Effect.logWarning("failed to read Bitbucket credentials from settings", { operation: error.operation, - cause: error.cause, }).pipe(Effect.as(resolveCredential(DEFAULT_SERVER_SETTINGS.bitbucket, config))), ), ); From f79218e509544155ded3b664d954425666b0a654 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:38:30 -0700 Subject: [PATCH 09/12] fix(web): show saved Bitbucket tokens with the stored-secret placeholder Co-Authored-By: Claude Opus 5.5 (1M context) --- .../settings/BitbucketCredentialsSettings.tsx | 35 ++++++------------- 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx b/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx index f67574fadc30..5792bd52e5be 100644 --- a/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx +++ b/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx @@ -37,19 +37,13 @@ const METHODS: Record< }, }; -/** Filler for a saved token's field. A password input renders it as dots; it is never sent. */ -const SAVED_TOKEN_MASK = "saved-token-mask"; - function savedMethod(saved: BitbucketSettings): CredentialMethod | null { if (saved.accessToken.length > 0) return "access-token"; if (saved.email.length > 0 && saved.apiToken.length > 0) return "api-token"; return null; } -/** - * A write-only token field. A saved token shows as a filled password field; focusing it starts - * a fresh token, and leaving that empty restores the saved one. `draft` is null until edited. - */ +/** A write-only token field. It never shows the saved token; typing a new one replaces it. */ function TokenInput({ id, isSaved, @@ -58,25 +52,18 @@ function TokenInput({ }: { readonly id: string; readonly isSaved: boolean; - readonly draft: string | null; - readonly onDraftChange: (draft: string | null) => void; + readonly draft: string; + readonly onDraftChange: (draft: string) => void; }) { - const masked = isSaved && draft === null; return ( { - if (masked) onDraftChange(""); - }} + placeholder={isSaved ? "Stored secret, enter a new value to replace" : "Not set"} + value={draft} onChange={(event) => onDraftChange(event.target.value)} - onBlur={() => { - if (isSaved && draft === "") onDraftChange(null); - }} /> ); } @@ -98,16 +85,16 @@ export function BitbucketCredentialsSettings({ label: "save Bitbucket credentials", }); const [methodChoice, setMethodChoice] = useState(null); - const [accessToken, setAccessToken] = useState(null); + const [accessToken, setAccessToken] = useState(""); const [emailDraft, setEmailDraft] = useState(null); - const [apiToken, setApiToken] = useState(null); + const [apiToken, setApiToken] = useState(""); const [saving, setSaving] = useState(false); const current = savedMethod(saved); const method = methodChoice ?? current ?? "access-token"; const methodIsSaved = current === method; const email = (emailDraft ?? saved.email).trim(); - const newAccessToken = accessToken?.trim() ?? ""; - const newApiToken = apiToken?.trim() ?? ""; + const newAccessToken = accessToken.trim(); + const newApiToken = apiToken.trim(); const info = METHODS[method]; // Saving one method clears the other, so a hidden credential never wins over the visible one. @@ -132,8 +119,8 @@ export function BitbucketCredentialsSettings({ input: { patch: { bitbucket: next } }, }); if (result._tag === "Success") { - setAccessToken(null); - setApiToken(null); + setAccessToken(""); + setApiToken(""); setEmailDraft(null); onSaved(); } From b27b9135d1cec1212a2d5abed8c3afa8fc459d71 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:02:22 -0400 Subject: [PATCH 10/12] fix(web): lock the Bitbucket credentials form while saving A successful save clears the drafts, so edits typed while the request was in flight were silently dropped. The fields and method toggle are now disabled until the save completes. Co-Authored-By: Claude Opus 5.5 --- .../settings/BitbucketCredentialsSettings.tsx | 153 +++++++++--------- 1 file changed, 78 insertions(+), 75 deletions(-) diff --git a/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx b/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx index 5792bd52e5be..bab12320052a 100644 --- a/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx +++ b/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx @@ -137,89 +137,92 @@ export function BitbucketCredentialsSettings({ if (canSave && patch) void save(patch); }} > - { - const value = next[0]; - if (value === "access-token" || value === "api-token") setMethodChoice(value); - }} - > - {METHODS["access-token"].label} - {METHODS["api-token"].label} - -

- {info.description}{" "} - + { + const value = next[0]; + if (value === "access-token" || value === "api-token") setMethodChoice(value); + }} > - {info.linkLabel} - - -

- {method === "access-token" ? ( -
- - -
- ) : ( - <> -
- - setEmailDraft(event.target.value)} - /> -
+ {METHODS["access-token"].label} + {METHODS["api-token"].label} + +

+ {info.description}{" "} + + {info.linkLabel} + + +

+ {method === "access-token" ? (
- +
- - )} -
-

- {current === null - ? "Without a saved token, the server falls back to its T3CODE_BITBUCKET_* environment variables." - : methodIsSaved - ? null - : `Saving replaces your ${METHODS[current].label.toLowerCase()}.`} -

-
- {current !== null ? ( - + ) : null} + - ) : null} - +
-
+ ); } From 6c6f42f164452be368dc65a31fc81e33432c5e1a Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:13:45 -0400 Subject: [PATCH 11/12] fix(web): render the Bitbucket docs link through InlineButton Co-Authored-By: Claude Opus 5.5 --- .../settings/BitbucketCredentialsSettings.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx b/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx index bab12320052a..a423334d1c7f 100644 --- a/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx +++ b/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; import { useEnvironmentSettings } from "../../hooks/useSettings"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; -import { Button } from "../ui/button"; +import { Button, InlineButton } from "../ui/button"; import { Input } from "../ui/input"; import { Label } from "../ui/label"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; @@ -153,15 +153,10 @@ export function BitbucketCredentialsSettings({

{info.description}{" "} - + }> {info.linkLabel} - +

{method === "access-token" ? (
From 746262980a246790027965f6630a18cb6548f34c Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:42:44 -0400 Subject: [PATCH 12/12] fix(server): move hand-edited Bitbucket tokens to the secret store on load A plaintext Bitbucket token typed into settings.json only moved into the secret store on a later settings update, so it could stay on disk indefinitely. Loading settings (startup or a watched-file reload) now moves it and rewrites the file with the redaction marker. Untrusted files are left alone, and if the store fails the token keeps working and the move is retried on the next load. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/serverSettings.test.ts | 27 +++++++++++++++++++ apps/server/src/serverSettings.ts | 37 +++++++++++++++++++++++--- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 58ef893b3bb3..502ae3430e27 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1358,6 +1358,33 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())), ); + it.effect("moves a hand-edited Bitbucket token into the secret store when settings load", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"bitbucket":{"accessToken":"hand-edited-token"}}', + ); + + // Loading alone moves it: no settings update is needed. + const loaded = yield* serverSettings.getSettings; + + assert.equal(loaded.bitbucket.accessToken, "hand-edited-token"); + assert.notInclude( + yield* fileSystem.readFileString(serverConfig.settingsPath), + "hand-edited-token", + ); + const stored = yield* secrets.get("bitbucket-access-token"); + assert.equal( + Option.isSome(stored) ? new TextDecoder().decode(stored.value) : null, + "hand-edited-token", + ); + }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())), + ); + it.effect( "moves a hand-edited Bitbucket token into the secret store when a client echoes the marker", () => diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 517146967a93..6949a66d981b 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -568,6 +568,35 @@ const make = Effect.gen(function* () { ), ); + /** + * Moves Bitbucket tokens hand-edited into settings.json into the secret store as they load, + * so plaintext does not stay on disk. If the store is unavailable, the token keeps working + * from the file and the move is retried on the next load. + */ + const moveInlineBitbucketTokens = (settings: ServerSettings) => + Effect.gen(function* () { + const bitbucket = { ...settings.bitbucket }; + let moved = false; + for (const field of BITBUCKET_SECRET_FIELDS) { + const value = bitbucket[field]; + if (value.length === 0 || value === SECRET_REDACTED) continue; + const stored = yield* secretStore + .set(BITBUCKET_SECRET_NAMES[field], textEncoder.encode(value)) + .pipe( + Effect.as(true), + Effect.catch(() => + Effect.logWarning("failed to move a Bitbucket token into the secret store", { + field, + }).pipe(Effect.as(false)), + ), + ); + if (!stored) continue; + bitbucket[field] = SECRET_REDACTED; + moved = true; + } + return moved ? { ...settings, bitbucket } : settings; + }); + const loadSettingsFromDisk = Effect.gen(function* () { let settings = DEFAULT_SERVER_SETTINGS; let persisted: typeof PersistedOptionalProviderSettings.Type = {}; @@ -652,10 +681,12 @@ const make = Effect.gen(function* () { const folded = settingsFileTrusted ? foldLegacyProjectSettings(loaded, legacyProjectRows) : loaded; - if (folded !== loaded) { - yield* writeSettingsAtomically(folded); + // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair. + const migrated = settingsFileTrusted ? yield* moveInlineBitbucketTokens(folded) : folded; + if (migrated !== loaded) { + yield* writeSettingsAtomically(migrated); } - return folded; + return migrated; }); const settingsCache = yield* Cache.make({