Compare commits

...

11 Commits

Author SHA1 Message Date
Mauricio Siu
fdc524d79d fix(ui): adjust layout in UpdateServer component
- Removed unnecessary padding from DialogContent for a cleaner appearance.
- Added margin-top to the button container for improved spacing.
2025-07-13 23:37:05 -06:00
Mauricio Siu
93d6662466 docs(preview): update collaborator permission description in preview settings 2025-07-13 23:26:41 -06:00
Mauricio Siu
1977235d31 Merge pull request #2192 from Dokploy/fix/preview-deployments-public-repos
feat(preview): add collaborator permission requirement for preview de…
2025-07-13 23:20:51 -06:00
Mauricio Siu
1dd713a1d1 fix(deploy): change preview deployment limit check to be exclusive 2025-07-13 23:20:23 -06:00
Mauricio Siu
18b65f28f2 chore(package): bump version to v0.24.3 and comment out unused trustedOrigins function in auth.ts 2025-07-13 23:19:31 -06:00
Mauricio Siu
666db23b8e test: add previewRequireCollaboratorPermissions field to drop and traefik test cases 2025-07-13 23:17:32 -06:00
Mauricio Siu
2ca5321fdc feat(preview): add collaborator permission requirement for preview deployments
- Introduced a new boolean field `previewRequireCollaboratorPermissions` in the application schema to enforce permission checks for preview deployments.
- Updated the UI to include a toggle for this setting in the preview deployment settings.
- Enhanced GitHub deployment handler to validate PR authors against the required permissions, blocking unauthorized deployments and providing security notifications.
- Added SQL migration to update the database schema accordingly.
2025-07-13 23:12:09 -06:00
Mauricio Siu
3f3ff9670b chore(package): bump version to v0.24.2 2025-07-13 20:45:33 -06:00
Mauricio Siu
7fb902551e Merge pull request #2189 from jhon2c/fix/logs-overflow
fix(logs): Restore overflow classnames in logs components
2025-07-13 20:44:34 -06:00
Jhon
a201b3f979 fix(ui): regression of overflow-y-auto class in non dialog related componentes 2025-07-13 21:28:50 -03:00
Jhon
01d78e50fc fix(logs): adds back overflow classnames 2025-07-13 21:09:12 -03:00
23 changed files with 6475 additions and 16 deletions

View File

@@ -29,6 +29,7 @@ const baseApp: ApplicationNested = {
herokuVersion: "", herokuVersion: "",
giteaBranch: "", giteaBranch: "",
giteaBuildPath: "", giteaBuildPath: "",
previewRequireCollaboratorPermissions: false,
giteaId: "", giteaId: "",
giteaOwner: "", giteaOwner: "",
giteaRepository: "", giteaRepository: "",

View File

@@ -18,6 +18,7 @@ const baseApp: ApplicationNested = {
appName: "", appName: "",
autoDeploy: true, autoDeploy: true,
enableSubmodules: false, enableSubmodules: false,
previewRequireCollaboratorPermissions: false,
serverId: "", serverId: "",
branch: null, branch: null,
dockerBuildStage: "", dockerBuildStage: "",

View File

@@ -48,7 +48,7 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
</div> </div>
) : ( ) : (
<div className="flex flex-col pt-2 relative"> <div className="flex flex-col pt-2 relative">
<div className="flex flex-col gap-6 max-h-[35rem] min-h-[10rem]"> <div className="flex flex-col gap-6 max-h-[35rem] min-h-[10rem] overflow-y-auto">
<CodeEditor <CodeEditor
lineWrapping lineWrapping
value={data || "Empty"} value={data || "Empty"}

View File

@@ -158,7 +158,7 @@ export const ShowDeployment = ({
<div <div
ref={scrollRef} ref={scrollRef}
onScroll={handleScroll} onScroll={handleScroll}
className="h-[720px] space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar" className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
> >
{" "} {" "}
{filteredLogs.length > 0 ? ( {filteredLogs.length > 0 ? (

View File

@@ -46,6 +46,7 @@ const schema = z
previewPath: z.string(), previewPath: z.string(),
previewCertificateType: z.enum(["letsencrypt", "none", "custom"]), previewCertificateType: z.enum(["letsencrypt", "none", "custom"]),
previewCustomCertResolver: z.string().optional(), previewCustomCertResolver: z.string().optional(),
previewRequireCollaboratorPermissions: z.boolean(),
}) })
.superRefine((input, ctx) => { .superRefine((input, ctx) => {
if ( if (
@@ -83,6 +84,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewHttps: false, previewHttps: false,
previewPath: "/", previewPath: "/",
previewCertificateType: "none", previewCertificateType: "none",
previewRequireCollaboratorPermissions: true,
}, },
resolver: zodResolver(schema), resolver: zodResolver(schema),
}); });
@@ -105,6 +107,8 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewPath: data.previewPath || "/", previewPath: data.previewPath || "/",
previewCertificateType: data.previewCertificateType || "none", previewCertificateType: data.previewCertificateType || "none",
previewCustomCertResolver: data.previewCustomCertResolver || "", previewCustomCertResolver: data.previewCustomCertResolver || "",
previewRequireCollaboratorPermissions:
data.previewRequireCollaboratorPermissions || true,
}); });
} }
}, [data]); }, [data]);
@@ -121,6 +125,8 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewPath: formData.previewPath, previewPath: formData.previewPath,
previewCertificateType: formData.previewCertificateType, previewCertificateType: formData.previewCertificateType,
previewCustomCertResolver: formData.previewCustomCertResolver, previewCustomCertResolver: formData.previewCustomCertResolver,
previewRequireCollaboratorPermissions:
formData.previewRequireCollaboratorPermissions,
}) })
.then(() => { .then(() => {
toast.success("Preview Deployments settings updated"); toast.success("Preview Deployments settings updated");
@@ -312,6 +318,37 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
</div> </div>
</div> </div>
<div className="grid gap-4 lg:grid-cols-2">
<FormField
control={form.control}
name="previewRequireCollaboratorPermissions"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm col-span-2">
<div className="space-y-0.5">
<FormLabel>
Require Collaborator Permissions
</FormLabel>
<FormDescription>
Require collaborator permissions to preview
deployments, valid roles are:
<ul>
<li>Admin</li>
<li>Maintain</li>
<li>Write</li>
</ul>
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormField <FormField
control={form.control} control={form.control}
name="env" name="env"

View File

@@ -42,7 +42,7 @@ export const ShowContainerConfig = ({ containerId, serverId }: Props) => {
See in detail the config of this container See in detail the config of this container
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="text-wrap rounded-lg border p-4 text-sm bg-card max-h-[80vh]"> <div className="text-wrap rounded-lg border p-4 overflow-y-auto text-sm bg-card max-h-[80vh]">
<code> <code>
<pre className="whitespace-pre-wrap break-words"> <pre className="whitespace-pre-wrap break-words">
<CodeEditor <CodeEditor

View File

@@ -274,7 +274,7 @@ export const DockerLogsId: React.FC<Props> = ({
<div <div
ref={scrollRef} ref={scrollRef}
onScroll={handleScroll} onScroll={handleScroll}
className="h-[720px] space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar" className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
> >
{filteredLogs.length > 0 ? ( {filteredLogs.length > 0 ? (
filteredLogs.map((filteredLog: LogLine, index: number) => ( filteredLogs.map((filteredLog: LogLine, index: number) => (

View File

@@ -138,7 +138,7 @@ export function LineCountFilter({
}} }}
/> />
</div> </div>
<CommandPrimitive.List className="max-h-[300px] overflow-x-hidden"> <CommandPrimitive.List className="max-h-[300px] overflow-y-auto overflow-x-hidden">
<CommandPrimitive.Group className="px-2 py-1.5"> <CommandPrimitive.Group className="px-2 py-1.5">
{lineCountOptions.map((option) => { {lineCountOptions.map((option) => {
const isSelected = value === option.value; const isSelected = value === option.value;

View File

@@ -167,7 +167,7 @@ export const DuplicateProject = ({
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Selected services to duplicate</Label> <Label>Selected services to duplicate</Label>
<div className="space-y-2 max-h-[200px] border rounded-md p-4"> <div className="space-y-2 max-h-[200px] overflow-y-auto border rounded-md p-4">
{selectedServices.map((service) => ( {selectedServices.map((service) => (
<div key={service.id} className="flex items-center space-x-2"> <div key={service.id} className="flex items-center space-x-2">
<span className="text-sm"> <span className="text-sm">

View File

@@ -157,7 +157,7 @@ export const ShowProjects = () => {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className="w-[200px] space-y-2 max-h-[400px]" className="w-[200px] space-y-2 overflow-y-auto max-h-[400px]"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{project.applications.length > 0 && ( {project.applications.length > 0 && (
@@ -265,7 +265,7 @@ export const ShowProjects = () => {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className="w-[200px] space-y-2 max-h-[280px]" className="w-[200px] space-y-2 overflow-y-auto max-h-[280px]"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<DropdownMenuLabel className="font-normal"> <DropdownMenuLabel className="font-normal">

View File

@@ -147,7 +147,7 @@ export const SetupServer = ({ serverId }: Props) => {
<li>2. Add The SSH Key to Server Manually</li> <li>2. Add The SSH Key to Server Manually</li>
</ul> </ul>
<div className="flex flex-col gap-4 w-full overflow-auto"> <div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex relative flex-col gap-2"> <div className="flex relative flex-col gap-2 overflow-y-auto">
<div className="text-sm text-primary flex flex-row gap-2 items-center"> <div className="text-sm text-primary flex flex-row gap-2 items-center">
Copy Public Key ({server?.sshKey?.name}) Copy Public Key ({server?.sshKey?.name})
<button <button

View File

@@ -117,7 +117,7 @@ export const CreateSSHKey = () => {
Option 2 Option 2
</span> </span>
<div className="flex flex-col gap-4 w-full overflow-auto"> <div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex relative flex-col gap-2"> <div className="flex relative flex-col gap-2 overflow-y-auto">
<div className="text-sm text-primary flex flex-row gap-2 items-center"> <div className="text-sm text-primary flex flex-row gap-2 items-center">
Copy Public Key Copy Public Key
<button <button

View File

@@ -126,7 +126,7 @@ export const UpdateServer = ({
</TooltipProvider> </TooltipProvider>
)} )}
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-lg p-6"> <DialogContent className="max-w-lg">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<DialogTitle className="text-2xl font-semibold"> <DialogTitle className="text-2xl font-semibold">
Web Server Update Web Server Update
@@ -253,7 +253,7 @@ export const UpdateServer = ({
<ToggleAutoCheckUpdates disabled={isLoading} /> <ToggleAutoCheckUpdates disabled={isLoading} />
</div> </div>
<div className="space-y-4 flex items-center justify-end"> <div className="space-y-4 flex items-center justify-end mt-4 ">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" onClick={() => onOpenChange?.(false)}> <Button variant="outline" onClick={() => onOpenChange?.(false)}>
Cancel Cancel

View File

@@ -128,7 +128,7 @@ export default function SwarmMonitorCard({ serverId }: Props) {
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<div className="max-h-48"> <div className="max-h-48 overflow-y-auto">
{activeNodes.map((node) => ( {activeNodes.map((node) => (
<div key={node.ID} className="flex items-center gap-2"> <div key={node.ID} className="flex items-center gap-2">
{node.Hostname} {node.Hostname}
@@ -162,7 +162,7 @@ export default function SwarmMonitorCard({ serverId }: Props) {
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<div className="max-h-48"> <div className="max-h-48 overflow-y-auto">
{managerNodes.map((node) => ( {managerNodes.map((node) => (
<div key={node.ID} className="flex items-center gap-2"> <div key={node.ID} className="flex items-center gap-2">
{node.Hostname} {node.Hostname}

View File

@@ -0,0 +1 @@
ALTER TABLE "application" ADD COLUMN "previewRequireCollaboratorPermissions" boolean DEFAULT true;

File diff suppressed because it is too large Load Diff

View File

@@ -722,6 +722,13 @@
"when": 1751848685503, "when": 1751848685503,
"tag": "0102_opposite_grandmaster", "tag": "0102_opposite_grandmaster",
"breakpoints": true "breakpoints": true
},
{
"idx": 103,
"version": "7",
"when": 1752465764072,
"tag": "0103_cultured_pestilence",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "dokploy", "name": "dokploy",
"version": "v0.24.1", "version": "v0.24.3",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",

View File

@@ -10,6 +10,9 @@ import {
findPreviewDeploymentsByPullRequestId, findPreviewDeploymentsByPullRequestId,
removePreviewDeployment, removePreviewDeployment,
shouldDeploy, shouldDeploy,
findGithubById,
checkUserRepositoryPermissions,
createSecurityBlockedComment,
} from "@dokploy/server"; } from "@dokploy/server";
import { Webhooks } from "@octokit/webhooks"; import { Webhooks } from "@octokit/webhooks";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
@@ -346,6 +349,18 @@ export default async function handler(
const deploymentHash = githubBody?.pull_request?.head?.sha; const deploymentHash = githubBody?.pull_request?.head?.sha;
const branch = githubBody?.pull_request?.base?.ref; const branch = githubBody?.pull_request?.base?.ref;
const owner = githubBody?.repository?.owner?.login; const owner = githubBody?.repository?.owner?.login;
const prAuthor = githubBody?.pull_request?.user?.login;
// Validate PR author information is present
if (!prAuthor) {
console.warn(
"⚠️ SECURITY: PR author information missing in webhook payload",
);
res.status(400).json({
message: "PR author information missing",
});
return;
}
const apps = await db.query.applications.findMany({ const apps = await db.query.applications.findMany({
where: and( where: and(
@@ -361,13 +376,72 @@ export default async function handler(
}, },
}); });
// SECURITY: Check collaborator permissions per application setting
const secureApps: typeof apps = [];
const blockedApps: string[] = [];
let userPermission: string | null = null;
for (const app of apps) {
// If the app requires collaborator permissions, verify them
if (app.previewRequireCollaboratorPermissions !== false) {
try {
const githubProvider = await findGithubById(githubResult.githubId);
const { hasWriteAccess, permission } =
await checkUserRepositoryPermissions(
githubProvider,
owner,
repository,
prAuthor,
);
userPermission = permission; // Store permission for comment
if (!hasWriteAccess) {
console.warn(
`🚨 SECURITY: Blocked preview deployment for ${app.name} from unauthorized user ${prAuthor} on ${owner}/${repository}. Permission: ${permission || "none"}`,
);
blockedApps.push(app.name);
continue;
}
console.log(
`✅ SECURITY: Preview deployment authorized for ${app.name} from user ${prAuthor} on ${owner}/${repository}. Permission: ${permission}`,
);
} catch (error) {
console.error(
`Error validating PR author permissions for ${app.name}:`,
error,
);
blockedApps.push(app.name);
continue; // Skip this app on error
}
} else {
console.warn(
`⚠️ SECURITY: Preview deployment for ${app.name} allows deployment from any PR author (security check disabled)`,
);
}
secureApps.push(app);
}
const prBranch = githubBody?.pull_request?.head?.ref; const prBranch = githubBody?.pull_request?.head?.ref;
const prNumber = githubBody?.pull_request?.number; const prNumber = githubBody?.pull_request?.number;
const prTitle = githubBody?.pull_request?.title; const prTitle = githubBody?.pull_request?.title;
const prURL = githubBody?.pull_request?.html_url; const prURL = githubBody?.pull_request?.html_url;
for (const app of apps) { // Create security notification comment if any apps were blocked
if (blockedApps.length > 0) {
await createSecurityBlockedComment({
owner,
repository,
prNumber: Number.parseInt(prNumber),
prAuthor,
permission: userPermission,
githubId: githubResult.githubId,
});
}
for (const app of secureApps) {
const previewLimit = app?.previewLimit || 0; const previewLimit = app?.previewLimit || 0;
if (app?.previewDeployments?.length > previewLimit) { if (app?.previewDeployments?.length > previewLimit) {
continue; continue;

View File

@@ -131,6 +131,10 @@ export const applications = pgTable("application", {
isPreviewDeploymentsActive: boolean("isPreviewDeploymentsActive").default( isPreviewDeploymentsActive: boolean("isPreviewDeploymentsActive").default(
false, false,
), ),
// Security: Require collaborator permissions for preview deployments
previewRequireCollaboratorPermissions: boolean(
"previewRequireCollaboratorPermissions",
).default(true),
rollbackActive: boolean("rollbackActive").default(false), rollbackActive: boolean("rollbackActive").default(false),
buildArgs: text("buildArgs"), buildArgs: text("buildArgs"),
memoryReservation: text("memoryReservation"), memoryReservation: text("memoryReservation"),
@@ -428,6 +432,7 @@ const createSchema = createInsertSchema(applications, {
previewHttps: z.boolean().optional(), previewHttps: z.boolean().optional(),
previewPath: z.string().optional(), previewPath: z.string().optional(),
previewCertificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), previewCertificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
previewRequireCollaboratorPermissions: z.boolean().optional(),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional(),
cleanCache: z.boolean().optional(), cleanCache: z.boolean().optional(),
}); });

View File

@@ -45,6 +45,7 @@ export * from "./setup/traefik-setup";
export * from "./setup/server-validate"; export * from "./setup/server-validate";
export * from "./setup/server-audit"; export * from "./setup/server-audit";
export * from "./utils/watch-paths/should-deploy"; export * from "./utils/watch-paths/should-deploy";
export * from "./utils/providers/github";
export * from "./utils/backups/index"; export * from "./utils/backups/index";
export * from "./utils/backups/mariadb"; export * from "./utils/backups/mariadb";
export * from "./utils/backups/mongo"; export * from "./utils/backups/mongo";

View File

@@ -192,3 +192,156 @@ export const createPreviewDeploymentComment = async ({
pullRequestCommentId: `${issue.data.id}`, pullRequestCommentId: `${issue.data.id}`,
}).then((response) => response[0]); }).then((response) => response[0]);
}; };
/**
* Generate security notification message for blocked PR deployments
*/
export const getSecurityBlockedMessage = (
prAuthor: string,
repositoryName: string,
permission: string | null,
) => {
return `### 🚨 Preview Deployment Blocked - Security Protection
**Your pull request was blocked from triggering preview deployments**
#### Why was this blocked?
- **User**: \`${prAuthor}\`
- **Repository**: \`${repositoryName}\`
- **Permission Level**: \`${permission || "none"}\`
- **Required Level**: \`write\`, \`maintain\`, or \`admin\`
#### How to resolve this:
**Option 1: Get Collaborator Access (Recommended)**
Ask a repository maintainer to invite you as a collaborator with **write permissions** or higher.
**Option 2: Request Permission Override**
Ask a repository administrator to disable security validation for this specific application if appropriate.
#### For Repository Administrators:
To disable this security check (⚠️ **not recommended for public repositories**):
Enter to preview settings and disable the security check.
---
*This security measure protects against malicious code execution in preview deployments. Only trusted collaborators should have the ability to trigger deployments.*
<details>
<summary>🛡️ Learn more about this security feature</summary>
This protection prevents unauthorized users from:
- Executing malicious code on the deployment server
- Accessing environment variables and secrets
- Potentially compromising the infrastructure
Preview deployments are powerful but require trust. Only users with repository write access can trigger them.
</details>`;
};
/**
* Check if a security notification comment already exists on a GitHub PR
* This prevents creating duplicate security comments on subsequent pushes
*/
export const hasExistingSecurityComment = async ({
owner,
repository,
prNumber,
githubId,
}: {
owner: string;
repository: string;
prNumber: number;
githubId: string;
}): Promise<boolean> => {
try {
const github = await findGithubById(githubId);
const octokit = authGithub(github);
// Get all comments for this PR
const { data: comments } = await octokit.rest.issues.listComments({
owner,
repo: repository,
issue_number: prNumber,
});
// Check if any comment contains our security notification marker
const securityCommentExists = comments.some((comment) =>
comment.body?.includes(
"🚨 Preview Deployment Blocked - Security Protection",
),
);
return securityCommentExists;
} catch (error) {
console.error(
`❌ Failed to check existing comments on PR #${prNumber}:`,
error,
);
// If we can't check, assume no comment exists to avoid blocking functionality
return false;
}
};
/**
* Create a security notification comment on a GitHub PR
*/
export const createSecurityBlockedComment = async ({
owner,
repository,
prNumber,
prAuthor,
permission,
githubId,
}: {
owner: string;
repository: string;
prNumber: number;
prAuthor: string;
permission: string | null;
githubId: string;
}) => {
try {
// Check if a security comment already exists to prevent duplicates
const commentExists = await hasExistingSecurityComment({
owner,
repository,
prNumber,
githubId,
});
if (commentExists) {
console.log(
` Security notification comment already exists on PR #${prNumber}, skipping duplicate`,
);
return null;
}
const github = await findGithubById(githubId);
const octokit = authGithub(github);
const securityMessage = getSecurityBlockedMessage(
prAuthor,
repository,
permission,
);
const issue = await octokit.rest.issues.createComment({
owner,
repo: repository,
issue_number: prNumber,
body: securityMessage,
});
console.log(
`✅ Security notification comment created on PR #${prNumber}: ${issue.data.html_url}`,
);
return issue.data;
} catch (error) {
console.error(
`❌ Failed to create security comment on PR #${prNumber}:`,
error,
);
// Don't throw error - security comment is nice-to-have, not critical
return null;
}
};

View File

@@ -45,6 +45,49 @@ export const getGithubToken = async (
return installation.token; return installation.token;
}; };
/**
* Check if a GitHub user has write/admin permissions on a repository
* This is used to validate PR authors before allowing preview deployments
*/
export const checkUserRepositoryPermissions = async (
githubProvider: Github,
owner: string,
repo: string,
username: string,
): Promise<{ hasWriteAccess: boolean; permission: string | null }> => {
try {
const octokit = authGithub(githubProvider);
// Check if user is a collaborator with write permissions
const { data: permission } =
await octokit.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username,
});
// Allow only users with 'write', 'admin', or 'maintain' permissions
// Currently exists Read, Triage, Write, Maintain, Admin
const allowedPermissions = ["write", "admin", "maintain"];
const hasWriteAccess = allowedPermissions.includes(permission.permission);
return {
hasWriteAccess,
permission: permission.permission,
};
} catch (error) {
// If user is not a collaborator, GitHub API returns 404
console.warn(
`User ${username} is not a collaborator of ${owner}/${repo}:`,
error,
);
return {
hasWriteAccess: false,
permission: null,
};
}
};
export const haveGithubRequirements = (githubProvider: Github) => { export const haveGithubRequirements = (githubProvider: Github) => {
return !!( return !!(
githubProvider?.githubAppId && githubProvider?.githubAppId &&