feat(git-provider): enhance sharing and permissions management

- Added functionality to toggle sharing of Git providers with the organization.
- Introduced a new column "sharedWithOrganization" in the git_provider table to track sharing status.
- Updated user permissions to include accessedGitProviders, allowing for more granular access control.
- Enhanced API routes to support fetching accessible Git providers based on user roles and permissions.
- Implemented UI components for managing Git provider sharing and permissions in the dashboard.
This commit is contained in:
Mauricio Siu
2026-04-03 14:29:48 -06:00
parent 86ba597d67
commit 06b18aca08
16 changed files with 8664 additions and 69 deletions
@@ -5,6 +5,7 @@ import {
ImportIcon,
Loader2,
Trash2,
Users,
} from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
@@ -24,6 +25,13 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
import { AddBitbucketProvider } from "./bitbucket/add-bitbucket-provider";
@@ -39,6 +47,8 @@ export const ShowGitProviders = () => {
const { data, isPending, refetch } = api.gitProvider.getAll.useQuery();
const { mutateAsync, isPending: isRemoving } =
api.gitProvider.remove.useMutation();
const { mutateAsync: toggleShare } =
api.gitProvider.toggleShare.useMutation();
const url = useUrl();
const getGitlabUrl = (
@@ -154,10 +164,62 @@ export const ShowGitProviders = () => {
)}
</span>
</div>
{!gitProvider.isOwner && (
<Badge
variant="secondary"
className="text-xs"
>
<Users className="size-3 mr-1" />
Shared
</Badge>
)}
</div>
</div>
<div className="flex flex-row gap-1 items-center">
{gitProvider.isOwner && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 mr-2">
<Users className="size-4 text-muted-foreground" />
<Switch
checked={
gitProvider.sharedWithOrganization
}
onCheckedChange={async (
checked,
) => {
await toggleShare({
gitProviderId:
gitProvider.gitProviderId,
sharedWithOrganization:
checked,
})
.then(() => {
toast.success(
checked
? "Provider shared with organization"
: "Provider unshared",
);
refetch();
})
.catch(() => {
toast.error(
"Error updating sharing",
);
});
}}
/>
</div>
</TooltipTrigger>
<TooltipContent>
Share with entire organization
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isBitbucket &&
gitProvider.bitbucket?.appPassword &&
!gitProvider.bitbucket?.apiToken ? (
@@ -222,62 +284,75 @@ export const ShowGitProviders = () => {
</div>
)}
{isGithub && haveGithubRequirements && (
<EditGithubProvider
githubId={gitProvider.github?.githubId}
/>
)}
{gitProvider.isOwner && (
<>
{isGithub && haveGithubRequirements && (
<EditGithubProvider
githubId={
gitProvider.github?.githubId
}
/>
)}
{isGitlab && (
<EditGitlabProvider
gitlabId={gitProvider.gitlab?.gitlabId}
/>
)}
{isGitlab && (
<EditGitlabProvider
gitlabId={
gitProvider.gitlab?.gitlabId
}
/>
)}
{isBitbucket && (
<EditBitbucketProvider
bitbucketId={
gitProvider.bitbucket?.bitbucketId
}
/>
)}
{isBitbucket && (
<EditBitbucketProvider
bitbucketId={
gitProvider.bitbucket?.bitbucketId
}
/>
)}
{isGitea && (
<EditGiteaProvider
giteaId={gitProvider.gitea?.giteaId}
/>
)}
{isGitea && (
<EditGiteaProvider
giteaId={gitProvider.gitea?.giteaId}
/>
)}
<DialogAction
title="Delete Git Provider"
description="Are you sure you want to delete this Git Provider?"
type="destructive"
onClick={async () => {
await mutateAsync({
gitProviderId: gitProvider.gitProviderId,
})
.then(() => {
toast.success(
"Git Provider deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting Git Provider",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
<DialogAction
title="Delete Git Provider"
description={
gitProvider.sharedWithOrganization
? "This provider is shared with the organization. Deleting it will remove access for all members. Are you sure?"
: "Are you sure you want to delete this Git Provider?"
}
type="destructive"
onClick={async () => {
await mutateAsync({
gitProviderId:
gitProvider.gitProviderId,
})
.then(() => {
toast.success(
"Git Provider deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting Git Provider",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</>
)}
</div>
</div>
</div>
@@ -26,6 +26,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { EnterpriseFeatureLocked } from "@/components/proprietary/enterprise-feature-gate";
import { api, type RouterOutputs } from "@/utils/api";
/** Shape returned by project.allForPermissions (admin only). Used for the permissions UI. */
@@ -170,6 +171,7 @@ const addPermissions = z.object({
accessedProjects: z.array(z.string()).optional(),
accessedEnvironments: z.array(z.string()).optional(),
accessedServices: z.array(z.string()).optional(),
accessedGitProviders: z.array(z.string()).optional(),
canCreateProjects: z.boolean().optional().default(false),
canCreateServices: z.boolean().optional().default(false),
canDeleteProjects: z.boolean().optional().default(false),
@@ -196,6 +198,15 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
const { data: projects } = api.project.allForPermissions.useQuery(undefined, {
enabled: isOpen,
});
const { data: haveValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
const { data: gitProviders } = api.gitProvider.allForPermissions.useQuery(
undefined,
{
enabled: isOpen && !!haveValidLicense,
},
);
const { data, refetch } = api.user.one.useQuery(
{
@@ -214,6 +225,7 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
accessedProjects: [],
accessedEnvironments: [],
accessedServices: [],
accessedGitProviders: [],
canDeleteEnvironments: false,
canCreateProjects: false,
canCreateServices: false,
@@ -235,6 +247,7 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
accessedProjects: data.accessedProjects || [],
accessedEnvironments: data.accessedEnvironments || [],
accessedServices: data.accessedServices || [],
accessedGitProviders: data.accessedGitProviders || [],
canCreateProjects: data.canCreateProjects,
canCreateServices: data.canCreateServices,
canDeleteProjects: data.canDeleteProjects,
@@ -262,6 +275,7 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
accessedProjects: data.accessedProjects || [],
accessedEnvironments: data.accessedEnvironments || [],
accessedServices: data.accessedServices || [],
accessedGitProviders: data.accessedGitProviders || [],
canAccessToDocker: data.canAccessToDocker,
canAccessToAPI: data.canAccessToAPI,
canAccessToSSHKeys: data.canAccessToSSHKeys,
@@ -870,6 +884,81 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
</FormItem>
)}
/>
{haveValidLicense ? (
<FormField
control={form.control}
name="accessedGitProviders"
render={() => (
<FormItem className="md:col-span-2">
<div className="mb-4">
<FormLabel className="text-base">
Git Providers
</FormLabel>
<FormDescription>
Select the Git Providers that the user can access
</FormDescription>
</div>
{gitProviders?.length === 0 && (
<p className="text-sm text-muted-foreground">
No git providers found
</p>
)}
<div className="grid md:grid-cols-1 gap-2">
{gitProviders?.map((provider) => (
<FormField
key={provider.gitProviderId}
control={form.control}
name="accessedGitProviders"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0 rounded-lg border p-3">
<FormControl>
<Checkbox
checked={field.value?.includes(
provider.gitProviderId,
)}
onCheckedChange={(checked) => {
if (checked) {
field.onChange([
...(field.value || []),
provider.gitProviderId,
]);
} else {
field.onChange(
field.value?.filter(
(v) =>
v !== provider.gitProviderId,
),
);
}
}}
/>
</FormControl>
<div className="flex items-center gap-2">
<FormLabel className="text-sm cursor-pointer">
{provider.name}
</FormLabel>
<span className="text-xs text-muted-foreground capitalize">
({provider.providerType})
</span>
</div>
</FormItem>
)}
/>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
) : (
<div className="md:col-span-2">
<EnterpriseFeatureLocked
compact
title="Git Provider Assignment"
description="Assign specific Git Providers to users with an Enterprise license."
/>
</div>
)}
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2">
<Button
isLoading={isPending}
@@ -0,0 +1,2 @@
ALTER TABLE "member" ADD COLUMN "accessedGitProviders" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
ALTER TABLE "git_provider" ADD COLUMN "sharedWithOrganization" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1100,6 +1100,13 @@
"when": 1774910955774,
"tag": "0156_fair_vargas",
"breakpoints": true
},
{
"idx": 157,
"version": "7",
"when": 1775246622387,
"tag": "0157_stiff_misty_knight",
"breakpoints": true
}
]
}
+4 -1
View File
@@ -1,6 +1,7 @@
import {
createBitbucket,
findBitbucketById,
getAccessibleGitProviderIds,
getBitbucketBranches,
getBitbucketRepositories,
testBitbucketConnection,
@@ -54,6 +55,8 @@ export const bitbucketRouter = createTRPCRouter({
return await findBitbucketById(input.bitbucketId);
}),
bitbucketProviders: protectedProcedure.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
let result = await db.query.bitbucket.findMany({
with: {
gitProvider: true,
@@ -67,7 +70,7 @@ export const bitbucketRouter = createTRPCRouter({
return (
provider.gitProvider.organizationId ===
ctx.session.activeOrganizationId &&
provider.gitProvider.userId === ctx.session.userId
accessibleIds.has(provider.gitProvider.gitProviderId)
);
});
return result;
@@ -1,18 +1,34 @@
import { findGitProviderById, removeGitProvider } from "@dokploy/server";
import {
findGitProviderById,
getAccessibleGitProviderIds,
removeGitProvider,
updateGitProvider,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import { and, desc, eq } from "drizzle-orm";
import { desc, eq, inArray } from "drizzle-orm";
import { audit } from "@/server/api/utils/audit";
import {
createTRPCRouter,
protectedProcedure,
withPermission,
} from "@/server/api/trpc";
import { apiRemoveGitProvider, gitProvider } from "@/server/db/schema";
import {
apiRemoveGitProvider,
apiToggleShareGitProvider,
gitProvider,
} from "@/server/db/schema";
export const gitProviderRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => {
return await db.query.gitProvider.findMany({
const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
if (accessibleIds.size === 0) {
return [];
}
const results = await db.query.gitProvider.findMany({
with: {
gitlab: true,
bitbucket: true,
@@ -20,12 +36,67 @@ export const gitProviderRouter = createTRPCRouter({
gitea: true,
},
orderBy: desc(gitProvider.createdAt),
where: and(
eq(gitProvider.userId, ctx.session.userId),
eq(gitProvider.organizationId, ctx.session.activeOrganizationId),
),
where: inArray(gitProvider.gitProviderId, [...accessibleIds]),
});
return results.map((r) => ({
...r,
isOwner: r.userId === ctx.session.userId,
}));
}),
toggleShare: protectedProcedure
.input(apiToggleShareGitProvider)
.mutation(async ({ input, ctx }) => {
const provider = await findGitProviderById(input.gitProviderId);
if (provider.userId !== ctx.session.userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Only the owner can share this provider",
});
}
await audit(ctx, {
action: "update",
resourceType: "gitProvider",
resourceId: provider.gitProviderId,
resourceName: provider.name ?? provider.gitProviderId,
});
return await updateGitProvider(input.gitProviderId, {
sharedWithOrganization: input.sharedWithOrganization,
});
}),
allForPermissions: withPermission("member", "update")
.use(async ({ ctx, next }) => {
const licensed = await hasValidLicense(
ctx.session.activeOrganizationId,
);
if (!licensed) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Valid enterprise license required",
});
}
return next();
})
.query(async ({ ctx }) => {
return await db.query.gitProvider.findMany({
columns: {
gitProviderId: true,
name: true,
providerType: true,
},
orderBy: desc(gitProvider.createdAt),
where: eq(
gitProvider.organizationId,
ctx.session.activeOrganizationId,
),
});
}),
remove: withPermission("gitProviders", "delete")
.input(apiRemoveGitProvider)
.mutation(async ({ input, ctx }) => {
+4 -1
View File
@@ -1,6 +1,7 @@
import {
createGitea,
findGiteaById,
getAccessibleGitProviderIds,
getGiteaBranches,
getGiteaRepositories,
haveGiteaRequirements,
@@ -57,6 +58,8 @@ export const giteaRouter = createTRPCRouter({
}),
giteaProviders: protectedProcedure.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
let result = await db.query.gitea.findMany({
with: {
gitProvider: true,
@@ -67,7 +70,7 @@ export const giteaRouter = createTRPCRouter({
(provider) =>
provider.gitProvider.organizationId ===
ctx.session.activeOrganizationId &&
provider.gitProvider.userId === ctx.session.userId,
accessibleIds.has(provider.gitProvider.gitProviderId),
);
const filtered = result
+4 -1
View File
@@ -1,5 +1,6 @@
import {
findGithubById,
getAccessibleGitProviderIds,
getGithubBranches,
getGithubRepositories,
haveGithubRequirements,
@@ -35,6 +36,8 @@ export const githubRouter = createTRPCRouter({
return await getGithubBranches(input);
}),
githubProviders: protectedProcedure.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
let result = await db.query.github.findMany({
with: {
gitProvider: true,
@@ -45,7 +48,7 @@ export const githubRouter = createTRPCRouter({
(provider) =>
provider.gitProvider.organizationId ===
ctx.session.activeOrganizationId &&
provider.gitProvider.userId === ctx.session.userId,
accessibleIds.has(provider.gitProvider.gitProviderId),
);
const filtered = result
+4 -1
View File
@@ -1,6 +1,7 @@
import {
createGitlab,
findGitlabById,
getAccessibleGitProviderIds,
getGitlabBranches,
getGitlabRepositories,
haveGitlabRequirements,
@@ -54,6 +55,8 @@ export const gitlabRouter = createTRPCRouter({
return await findGitlabById(input.gitlabId);
}),
gitlabProviders: protectedProcedure.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
let result = await db.query.gitlab.findMany({
with: {
gitProvider: true,
@@ -64,7 +67,7 @@ export const gitlabRouter = createTRPCRouter({
return (
provider.gitProvider.organizationId ===
ctx.session.activeOrganizationId &&
provider.gitProvider.userId === ctx.session.userId
accessibleIds.has(provider.gitProvider.gitProviderId)
);
});
const filtered = result
@@ -143,7 +143,12 @@ export const licenseKeyRouter = createTRPCRouter({
});
}
await deactivateLicenseKey(currentUser.licenseKey);
try {
await deactivateLicenseKey(currentUser.licenseKey);
} catch (_) {
// Always clean up locally even if the license server is unreachable
}
await db
.update(user)
.set({
+9 -1
View File
@@ -13,6 +13,7 @@ import {
updateUser,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import {
account,
apiAssignPermissions,
@@ -344,12 +345,19 @@ export const userRouter = createTRPCRouter({
});
}
const { id, ...rest } = input;
const { id, accessedGitProviders, ...rest } = input;
const licensed = await hasValidLicense(
ctx.session?.activeOrganizationId || "",
);
await db
.update(member)
.set({
...rest,
...(licensed && accessedGitProviders !== undefined
? { accessedGitProviders }
: {}),
})
.where(
and(
+4
View File
@@ -163,6 +163,10 @@ export const member = pgTable("member", {
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
accessedGitProviders: text("accessedGitProviders")
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
});
export const memberRelations = relations(member, ({ one }) => ({
@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm";
import { pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import { boolean, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
@@ -32,6 +32,9 @@ export const gitProvider = pgTable("git_provider", {
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
sharedWithOrganization: boolean("sharedWithOrganization")
.notNull()
.default(false),
});
export const gitProviderRelations = relations(gitProvider, ({ one }) => ({
@@ -64,3 +67,8 @@ export const gitProviderRelations = relations(gitProvider, ({ one }) => ({
export const apiRemoveGitProvider = z.object({
gitProviderId: z.string().min(1),
});
export const apiToggleShareGitProvider = z.object({
gitProviderId: z.string().min(1),
sharedWithOrganization: z.boolean(),
});
+1
View File
@@ -126,6 +126,7 @@ export const apiAssignPermissions = createSchema
accessedProjects: z.array(z.string()).optional(),
accessedEnvironments: z.array(z.string()).optional(),
accessedServices: z.array(z.string()).optional(),
accessedGitProviders: z.array(z.string()).optional(),
canCreateProjects: z.boolean().optional(),
canCreateServices: z.boolean().optional(),
canDeleteProjects: z.boolean().optional(),
+51 -2
View File
@@ -1,7 +1,8 @@
import { db } from "@dokploy/server/db";
import { gitProvider } from "@dokploy/server/db/schema";
import { gitProvider, member } from "@dokploy/server/db/schema";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
export type GitProvider = typeof gitProvider.$inferSelect;
@@ -41,3 +42,51 @@ export const updateGitProvider = async (
.returning()
.then((response) => response[0]);
};
export const getAccessibleGitProviderIds = async (session: {
userId: string;
activeOrganizationId: string;
}): Promise<Set<string>> => {
const { userId, activeOrganizationId } = session;
const allOrgProviders = await db.query.gitProvider.findMany({
where: eq(gitProvider.organizationId, activeOrganizationId),
columns: {
gitProviderId: true,
userId: true,
sharedWithOrganization: true,
},
});
const memberRecord = await db.query.member.findFirst({
where: and(
eq(member.userId, userId),
eq(member.organizationId, activeOrganizationId),
),
columns: { accessedGitProviders: true, role: true },
});
if (
memberRecord?.role === "owner" ||
memberRecord?.role === "admin"
) {
return new Set(allOrgProviders.map((p) => p.gitProviderId));
}
const licensed = await hasValidLicense(activeOrganizationId);
const assignedSet = licensed
? new Set(memberRecord?.accessedGitProviders ?? [])
: new Set<string>();
const result = new Set<string>();
for (const p of allOrgProviders) {
if (
p.userId === userId ||
p.sharedWithOrganization ||
assignedSet.has(p.gitProviderId)
) {
result.add(p.gitProviderId);
}
}
return result;
};