Skip to content

Commit 9c120c6

Browse files
Organization switching & active org management (#173)
1 parent 738bbaa commit 9c120c6

File tree

21 files changed

+1115
-6
lines changed

21 files changed

+1115
-6
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- RedefineTables
2+
PRAGMA defer_foreign_keys=ON;
3+
PRAGMA foreign_keys=OFF;
4+
CREATE TABLE "new_UserToOrg" (
5+
"joinedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
6+
"orgId" INTEGER NOT NULL,
7+
"userId" TEXT NOT NULL,
8+
"role" TEXT NOT NULL DEFAULT 'MEMBER',
9+
10+
PRIMARY KEY ("orgId", "userId"),
11+
CONSTRAINT "UserToOrg_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
12+
CONSTRAINT "UserToOrg_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
13+
);
14+
INSERT INTO "new_UserToOrg" ("joinedAt", "orgId", "userId") SELECT "joinedAt", "orgId", "userId" FROM "UserToOrg";
15+
DROP TABLE "UserToOrg";
16+
ALTER TABLE "new_UserToOrg" RENAME TO "UserToOrg";
17+
PRAGMA foreign_keys=ON;
18+
PRAGMA defer_foreign_keys=OFF;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "User" ADD COLUMN "activeOrgId" INTEGER;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
-- RedefineTables
2+
PRAGMA defer_foreign_keys=ON;
3+
PRAGMA foreign_keys=OFF;
4+
CREATE TABLE "new_Repo" (
5+
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
6+
"name" TEXT NOT NULL,
7+
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
8+
"updatedAt" DATETIME NOT NULL,
9+
"indexedAt" DATETIME,
10+
"isFork" BOOLEAN NOT NULL,
11+
"isArchived" BOOLEAN NOT NULL,
12+
"metadata" JSONB NOT NULL,
13+
"cloneUrl" TEXT NOT NULL,
14+
"tenantId" INTEGER NOT NULL,
15+
"repoIndexingStatus" TEXT NOT NULL DEFAULT 'NEW',
16+
"external_id" TEXT NOT NULL,
17+
"external_codeHostType" TEXT NOT NULL,
18+
"external_codeHostUrl" TEXT NOT NULL,
19+
"orgId" INTEGER,
20+
CONSTRAINT "Repo_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org" ("id") ON DELETE CASCADE ON UPDATE CASCADE
21+
);
22+
INSERT INTO "new_Repo" ("cloneUrl", "createdAt", "external_codeHostType", "external_codeHostUrl", "external_id", "id", "indexedAt", "isArchived", "isFork", "metadata", "name", "repoIndexingStatus", "tenantId", "updatedAt") SELECT "cloneUrl", "createdAt", "external_codeHostType", "external_codeHostUrl", "external_id", "id", "indexedAt", "isArchived", "isFork", "metadata", "name", "repoIndexingStatus", "tenantId", "updatedAt" FROM "Repo";
23+
DROP TABLE "Repo";
24+
ALTER TABLE "new_Repo" RENAME TO "Repo";
25+
CREATE UNIQUE INDEX "Repo_external_id_external_codeHostUrl_key" ON "Repo"("external_id", "external_codeHostUrl");
26+
PRAGMA foreign_keys=ON;
27+
PRAGMA defer_foreign_keys=OFF;

packages/db/prisma/schema.prisma

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ model Repo {
4747
// The base url of the external service (e.g., https://github.com)
4848
external_codeHostUrl String
4949
50+
org Org? @relation(fields: [orgId], references: [id], onDelete: Cascade)
51+
orgId Int?
52+
5053
@@unique([external_id, external_codeHostUrl])
5154
}
5255

@@ -71,6 +74,12 @@ model Org {
7174
updatedAt DateTime @updatedAt
7275
members UserToOrg[]
7376
configs Config[]
77+
repos Repo[]
78+
}
79+
80+
enum OrgRole {
81+
OWNER
82+
MEMBER
7483
}
7584

7685
model UserToOrg {
@@ -84,18 +93,21 @@ model UserToOrg {
8493
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
8594
userId String
8695
96+
role OrgRole @default(MEMBER)
97+
8798
@@id([orgId, userId])
8899
}
89100

90101
// @see : https://authjs.dev/concepts/database-models#user
91102
model User {
92-
id String @id @default(cuid())
103+
id String @id @default(cuid())
93104
name String?
94-
email String? @unique
105+
email String? @unique
95106
emailVerified DateTime?
96107
image String?
97108
accounts Account[]
98109
orgs UserToOrg[]
110+
activeOrgId Int?
99111
100112
createdAt DateTime @default(now())
101113
updatedAt DateTime @updatedAt

packages/web/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@iconify/react": "^5.1.0",
4242
"@iizukak/codemirror-lang-wgsl": "^0.3.0",
4343
"@radix-ui/react-avatar": "^1.1.2",
44+
"@radix-ui/react-dialog": "^1.1.4",
4445
"@radix-ui/react-dropdown-menu": "^2.1.1",
4546
"@radix-ui/react-icons": "^1.3.0",
4647
"@radix-ui/react-label": "^2.1.0",
@@ -69,6 +70,7 @@
6970
"client-only": "^0.0.1",
7071
"clsx": "^2.1.1",
7172
"cm6-graphql": "^0.2.0",
73+
"cmdk": "1.0.0",
7274
"codemirror": "^5.65.3",
7375
"codemirror-lang-brainfuck": "^0.1.0",
7476
"codemirror-lang-elixir": "^4.0.0",
@@ -110,6 +112,7 @@
110112
"zod": "^3.23.8"
111113
},
112114
"devDependencies": {
115+
"@sourcebot/db": "^0.1.0",
113116
"@types/node": "^20",
114117
"@types/react": "^18",
115118
"@types/react-dom": "^18",
@@ -122,10 +125,9 @@
122125
"jsdom": "^25.0.1",
123126
"npm-run-all": "^4.1.5",
124127
"postcss": "^8",
125-
"@sourcebot/db": "^0.1.0",
126128
"tailwindcss": "^3.4.1",
127129
"typescript": "^5",
128130
"vite-tsconfig-paths": "^5.1.3",
129131
"vitest": "^2.1.5"
130132
}
131-
}
133+
}
1.81 KB
Loading

packages/web/src/actions.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use server';
2+
3+
import { auth } from "./auth";
4+
import { notAuthenticated, notFound } from "./lib/serviceError";
5+
import { prisma } from "@/prisma";
6+
7+
8+
export const createOrg = async (name: string) => {
9+
const session = await auth();
10+
if (!session) {
11+
return notAuthenticated();
12+
}
13+
14+
// Create the org
15+
const org = await prisma.org.create({
16+
data: {
17+
name,
18+
members: {
19+
create: {
20+
userId: session.user.id,
21+
role: "OWNER",
22+
},
23+
},
24+
}
25+
});
26+
27+
return {
28+
id: org.id,
29+
}
30+
}
31+
32+
export const switchActiveOrg = async (orgId: number) => {
33+
const session = await auth();
34+
if (!session) {
35+
return notAuthenticated();
36+
}
37+
38+
// Check to see if the user is a member of the org
39+
const membership = await prisma.userToOrg.findUnique({
40+
where: {
41+
orgId_userId: {
42+
userId: session.user.id,
43+
orgId,
44+
}
45+
},
46+
});
47+
if (!membership) {
48+
return notFound();
49+
}
50+
51+
// Update the user's active org
52+
await prisma.user.update({
53+
where: {
54+
id: session.user.id,
55+
},
56+
data: {
57+
activeOrgId: orgId,
58+
}
59+
});
60+
61+
return {
62+
id: orgId,
63+
}
64+
}

packages/web/src/app/components/navigationMenu.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import logoLight from "../../../public/sb_logo_light_small.png";
88
import { SettingsDropdown } from "./settingsDropdown";
99
import { GitHubLogoIcon, DiscordLogoIcon } from "@radix-ui/react-icons";
1010
import { redirect } from "next/navigation";
11+
import { OrgSelector } from "./orgSelector";
1112

1213
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
1314
const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
@@ -36,6 +37,9 @@ export const NavigationMenu = async () => {
3637
/>
3738
</Link>
3839

40+
<OrgSelector />
41+
<Separator orientation="vertical" className="h-6 mx-2" />
42+
3943
<NavigationMenuBase>
4044
<NavigationMenuList>
4145
<NavigationMenuItem>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { auth } from "@/auth";
2+
import { getUser, getUserOrgs } from "../../data/user";
3+
import { OrgSelectorDropdown } from "./orgSelectorDropdown";
4+
5+
export const OrgSelector = async () => {
6+
const session = await auth();
7+
if (!session) {
8+
return null;
9+
}
10+
11+
const user = await getUser(session.user.id);
12+
if (!user) {
13+
return null;
14+
}
15+
16+
const orgs = await getUserOrgs(session.user.id);
17+
const activeOrg = orgs.find((org) => org.id === user.activeOrgId);
18+
if (!activeOrg) {
19+
return null;
20+
}
21+
22+
return (
23+
<OrgSelectorDropdown
24+
orgs={orgs.map((org) => ({
25+
name: org.name,
26+
id: org.id,
27+
}))}
28+
activeOrgId={activeOrg.id}
29+
/>
30+
)
31+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
'use client';
2+
import { Button } from "@/components/ui/button";
3+
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
4+
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
5+
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
6+
import { Input } from "@/components/ui/input";
7+
import { zodResolver } from "@hookform/resolvers/zod";
8+
import { PlusCircledIcon } from "@radix-ui/react-icons";
9+
import { useForm } from "react-hook-form";
10+
import { z } from "zod";
11+
12+
const formSchema = z.object({
13+
name: z.string().min(2).max(40),
14+
});
15+
16+
17+
interface OrgCreationDialogProps {
18+
onSubmit: (data: z.infer<typeof formSchema>) => void;
19+
isOpen: boolean;
20+
onOpenChange: (isOpen: boolean) => void;
21+
}
22+
23+
export const OrgCreationDialog = ({
24+
onSubmit,
25+
isOpen,
26+
onOpenChange,
27+
}: OrgCreationDialogProps) => {
28+
const form = useForm<z.infer<typeof formSchema>>({
29+
resolver: zodResolver(formSchema),
30+
defaultValues: {
31+
name: "",
32+
},
33+
});
34+
35+
return (
36+
<Dialog
37+
open={isOpen}
38+
onOpenChange={onOpenChange}
39+
>
40+
<DialogTrigger asChild>
41+
<DropdownMenuItem
42+
onSelect={(e) => e.preventDefault()}
43+
>
44+
<Button
45+
variant="ghost"
46+
size="default"
47+
className="w-full justify-start gap-1.5 p-0"
48+
>
49+
<PlusCircledIcon className="h-5 w-5 text-muted-foreground" />
50+
Create organization
51+
</Button>
52+
</DropdownMenuItem>
53+
</DialogTrigger>
54+
<DialogContent>
55+
<DialogHeader>
56+
<DialogTitle>Create an organization</DialogTitle>
57+
<DialogDescription>Organizations allow you to collaborate with team members.</DialogDescription>
58+
</DialogHeader>
59+
<Form {...form}>
60+
<form onSubmit={form.handleSubmit(onSubmit)}>
61+
<FormField
62+
control={form.control}
63+
name="name"
64+
render={({ field }) => (
65+
<FormItem>
66+
<FormLabel>Organization name</FormLabel>
67+
<FormControl>
68+
<Input {...field} />
69+
</FormControl>
70+
<FormMessage />
71+
</FormItem>
72+
)}
73+
/>
74+
<Button className="mt-5" type="submit">Submit</Button>
75+
</form>
76+
</Form>
77+
</DialogContent>
78+
</Dialog>
79+
)
80+
}

0 commit comments

Comments
 (0)