feat(bitbucket): Deprecate App password and replace it with API token

This commit is contained in:
Vlad Vladov
2025-09-05 02:52:47 +03:00
parent 39872720dd
commit e04e25385d
8 changed files with 126 additions and 66 deletions

View File

@@ -30,15 +30,9 @@ import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
const Schema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
username: z.string().min(1, {
message: "Username is required",
}),
password: z.string().min(1, {
message: "App Password is required",
}),
name: z.string().min(1, { message: "Name is required" }),
username: z.string().min(1, { message: "Username is required" }),
apiToken: z.string().min(1, { message: "API Token is required" }),
workspaceName: z.string().optional(),
});
@@ -47,14 +41,12 @@ type Schema = z.infer<typeof Schema>;
export const AddBitbucketProvider = () => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const _url = useUrl();
const { mutateAsync, error, isError } = api.bitbucket.create.useMutation();
const { data: auth } = api.user.get.useQuery();
const _router = useRouter();
const form = useForm<Schema>({
defaultValues: {
username: "",
password: "",
apiToken: "",
workspaceName: "",
},
resolver: zodResolver(Schema),
@@ -63,7 +55,7 @@ export const AddBitbucketProvider = () => {
useEffect(() => {
form.reset({
username: "",
password: "",
apiToken: "",
workspaceName: "",
});
}, [form, isOpen]);
@@ -71,7 +63,7 @@ export const AddBitbucketProvider = () => {
const onSubmit = async (data: Schema) => {
await mutateAsync({
bitbucketUsername: data.username,
appPassword: data.password,
apiToken: data.apiToken,
bitbucketWorkspaceName: data.workspaceName || "",
authId: auth?.id || "",
name: data.name || "",
@@ -113,24 +105,28 @@ export const AddBitbucketProvider = () => {
>
<CardContent className="p-0">
<div className="flex flex-col gap-4">
<AlertBlock type="warning">
Bitbucket App Passwords are deprecated for new providers. Use
an API Token instead. Existing providers with App Passwords
will continue to work until 9th June 2026.
</AlertBlock>
<div className="mt-1 text-sm">
Manage tokens in
<Link
href="https://bitbucket.org/account/settings/"
target="_blank"
className="inline-flex items-center gap-1 ml-1"
>
<span>Bitbucket settings</span>
<ExternalLink className="w-fit text-primary size-4" />
</Link>
</div>
<p className="text-muted-foreground text-sm">
To integrate your Bitbucket account, you need to create a new
App Password in your Bitbucket settings. Follow these steps:
Make sure to create an API Token with the following permissions:
</p>
<ol className="list-decimal list-inside text-sm text-muted-foreground">
<li className="flex flex-row gap-2 items-center">
Create new App Password{" "}
<Link
href="https://bitbucket.org/account/settings/app-passwords/new"
target="_blank"
>
<ExternalLink className="w-fit text-primary size-4" />
</Link>
</li>
<li>
When creating the App Password, ensure you grant the
following permissions:
<ul className="list-disc list-inside ml-4">
<ul className="list-disc list-inside ml-4 text-sm text-muted-foreground">
<li>Account: Read</li>
<li>Workspace membership: Read</li>
<li>Projects: Read</li>
@@ -138,12 +134,7 @@ export const AddBitbucketProvider = () => {
<li>Pull requests: Read</li>
<li>Webhooks: Read and write</li>
</ul>
</li>
<li>
After creating, you'll receive an App Password. Copy it and
paste it below along with your Bitbucket username.
</li>
</ol>
<FormField
control={form.control}
name="name"
@@ -152,7 +143,7 @@ export const AddBitbucketProvider = () => {
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Random Name eg(my-personal-account)"
placeholder="Your Bitbucket Provider, eg: my-personal-account"
{...field}
/>
</FormControl>
@@ -179,14 +170,13 @@ export const AddBitbucketProvider = () => {
<FormField
control={form.control}
name="password"
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>App Password</FormLabel>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="ATBBPDYUC94nR96Nj7Cqpp4pfwKk03573DD2"
placeholder="Paste your Bitbucket API token"
{...field}
/>
</FormControl>
@@ -200,7 +190,7 @@ export const AddBitbucketProvider = () => {
name="workspaceName"
render={({ field }) => (
<FormItem>
<FormLabel>Workspace Name (Optional)</FormLabel>
<FormLabel>Workspace Name (optional)</FormLabel>
<FormControl>
<Input
placeholder="For organization accounts"

View File

@@ -121,6 +121,11 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
>
<CardContent className="p-0">
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-sm">
For security, credentials (API Token/App Password) cant be
edited. To change them, create a new Bitbucket provider.
</p>
<FormField
control={form.control}
name="name"

View File

@@ -16,6 +16,12 @@ import {
} from "@/components/icons/data-tools-icons";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Card,
@@ -157,7 +163,26 @@ export const ShowGitProviders = () => {
</div>
</div>
<div className="flex flex-row gap-1">
<div className="flex flex-row gap-1 items-center">
{isBitbucket &&
gitProvider.bitbucket?.appPassword &&
!gitProvider.bitbucket?.apiToken ? (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="yellow">
Deprecated
</Badge>
</TooltipTrigger>
<TooltipContent side="left">
App Passwords are deprecated in
Bitbucket. Add an API Token to keep
using this provider.
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
{!haveGithubRequirements && isGithub && (
<div className="flex flex-row gap-1 items-center">
<Badge

View File

@@ -147,9 +147,13 @@ export default async function handler(
const commitedPaths = await extractCommitedPaths(
req.body,
application.bitbucketOwner,
application.bitbucket?.appPassword || "",
application.bitbucket?.apiToken ||
application.bitbucket?.appPassword ||
"",
application.bitbucketRepository || "",
!!application.bitbucket?.apiToken,
);
const shouldDeployPaths = shouldDeploy(
application.watchPaths,
commitedPaths,
@@ -355,8 +359,9 @@ export const getProviderByHeader = (headers: any) => {
export const extractCommitedPaths = async (
body: any,
bitbucketUsername: string | null,
bitbucketAppPassword: string | null,
credential: string | null,
repository: string | null,
useApiToken: boolean,
) => {
const changes = body.push?.changes || [];
@@ -368,15 +373,16 @@ export const extractCommitedPaths = async (
const url = `https://api.bitbucket.org/2.0/repositories/${bitbucketUsername}/${repository}/diffstat/${commit}`;
try {
const response = await fetch(url, {
headers: {
Authorization: `Basic ${Buffer.from(`${bitbucketUsername}:${bitbucketAppPassword}`).toString("base64")}`,
},
});
const headers = useApiToken
? { Authorization: `Bearer ${credential}` }
: {
Authorization: `Basic ${Buffer.from(`${bitbucketUsername}:${credential}`).toString("base64")}`,
};
const response = await fetch(url, { headers });
const data = await response.json();
for (const value of data.values) {
commitedPaths.push(value.new?.path);
if (value?.new?.path) commitedPaths.push(value.new.path);
}
} catch (error) {
console.error(

View File

@@ -100,8 +100,11 @@ export default async function handler(
const commitedPaths = await extractCommitedPaths(
req.body,
composeResult.bitbucketOwner,
composeResult.bitbucket?.appPassword || "",
composeResult.bitbucket?.apiToken ||
composeResult.bitbucket?.appPassword ||
"",
composeResult.bitbucketRepository || "",
!!composeResult.bitbucket?.apiToken,
);
const shouldDeployPaths = shouldDeploy(

View File

@@ -12,6 +12,7 @@ export const bitbucket = pgTable("bitbucket", {
.$defaultFn(() => nanoid()),
bitbucketUsername: text("bitbucketUsername"),
appPassword: text("appPassword"),
apiToken: text("apiToken"),
bitbucketWorkspaceName: text("bitbucketWorkspaceName"),
gitProviderId: text("gitProviderId")
.notNull()
@@ -30,6 +31,7 @@ const createSchema = createInsertSchema(bitbucket);
export const apiCreateBitbucket = createSchema.extend({
bitbucketUsername: z.string().optional(),
appPassword: z.string().optional(),
apiToken: z.string().optional(),
bitbucketWorkspaceName: z.string().optional(),
gitProviderId: z.string().optional(),
authId: z.string().min(1),

View File

@@ -68,10 +68,17 @@ export const updateBitbucket = async (
input: typeof apiUpdateBitbucket._type,
) => {
return await db.transaction(async (tx) => {
// Explicitly omit credentials from updates for safety/back-compat
const {
apiToken: _ignoredApiToken,
appPassword: _ignoredAppPassword,
...safeInput
} = input as any;
const result = await tx
.update(bitbucket)
.set({
...input,
...safeInput,
})
.where(eq(bitbucket.bitbucketId, bitbucketId))
.returning();

View File

@@ -23,6 +23,22 @@ export type ComposeWithBitbucket = InferResultType<
{ bitbucket: true }
>;
export const getBitbucketCloneUrl = (
bitbucketProvider: {
apiToken?: string | null;
bitbucketUsername?: string | null;
appPassword?: string | null;
} | null,
repoClone: string,
) => {
if (!bitbucketProvider) {
throw new Error("Bitbucket provider is required");
}
return bitbucketProvider.apiToken
? `https://x-token-auth:${bitbucketProvider.apiToken}@${repoClone}`
: `https://${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}@${repoClone}`;
};
export const cloneBitbucketRepository = async (
entity: ApplicationWithBitbucket | ComposeWithBitbucket,
logPath: string,
@@ -51,7 +67,7 @@ export const cloneBitbucketRepository = async (
const outputPath = join(basePath, appName, "code");
await recreateDirectory(outputPath);
const repoclone = `bitbucket.org/${bitbucketOwner}/${bitbucketRepository}.git`;
const cloneUrl = `https://${bitbucket?.bitbucketUsername}:${bitbucket?.appPassword}@${repoclone}`;
const cloneUrl = getBitbucketCloneUrl(bitbucket, repoclone);
try {
writeStream.write(`\nCloning Repo ${repoclone} to ${outputPath}: ✅\n`);
const cloneArgs = [
@@ -103,7 +119,7 @@ export const cloneRawBitbucketRepository = async (entity: Compose) => {
const outputPath = join(basePath, appName, "code");
await recreateDirectory(outputPath);
const repoclone = `bitbucket.org/${bitbucketOwner}/${bitbucketRepository}.git`;
const cloneUrl = `https://${bitbucketProvider?.bitbucketUsername}:${bitbucketProvider?.appPassword}@${repoclone}`;
const cloneUrl = getBitbucketCloneUrl(bitbucketProvider, repoclone);
try {
const cloneArgs = [
@@ -153,7 +169,7 @@ export const cloneRawBitbucketRepositoryRemote = async (compose: Compose) => {
const basePath = COMPOSE_PATH;
const outputPath = join(basePath, appName, "code");
const repoclone = `bitbucket.org/${bitbucketOwner}/${bitbucketRepository}.git`;
const cloneUrl = `https://${bitbucketProvider?.bitbucketUsername}:${bitbucketProvider?.appPassword}@${repoclone}`;
const cloneUrl = getBitbucketCloneUrl(bitbucketProvider, repoclone);
try {
const cloneCommand = `
@@ -206,7 +222,7 @@ export const getBitbucketCloneCommand = async (
const outputPath = join(basePath, appName, "code");
await recreateDirectory(outputPath);
const repoclone = `bitbucket.org/${bitbucketOwner}/${bitbucketRepository}.git`;
const cloneUrl = `https://${bitbucketProvider?.bitbucketUsername}:${bitbucketProvider?.appPassword}@${repoclone}`;
const cloneUrl = getBitbucketCloneUrl(bitbucketProvider, repoclone);
const cloneCommand = `
rm -rf ${outputPath};
@@ -241,9 +257,11 @@ export const getBitbucketRepositories = async (bitbucketId?: string) => {
while (url) {
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from(`${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}`).toString("base64")}`,
},
headers: bitbucketProvider.apiToken
? { Authorization: `Bearer ${bitbucketProvider.apiToken}` }
: {
Authorization: `Basic ${Buffer.from(`${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}`).toString("base64")}`,
},
});
if (!response.ok) {
@@ -284,9 +302,11 @@ export const getBitbucketBranches = async (
try {
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from(`${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}`).toString("base64")}`,
},
headers: bitbucketProvider.apiToken
? { Authorization: `Bearer ${bitbucketProvider.apiToken}` }
: {
Authorization: `Basic ${Buffer.from(`${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}`).toString("base64")}`,
},
});
if (!response.ok) {
@@ -335,9 +355,11 @@ export const testBitbucketConnection = async (
try {
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from(`${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}`).toString("base64")}`,
},
headers: bitbucketProvider.apiToken
? { Authorization: `Bearer ${bitbucketProvider.apiToken}` }
: {
Authorization: `Basic ${Buffer.from(`${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}`).toString("base64")}`,
},
});
if (!response.ok) {