Add multi-tenant system

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-02-25 22:52:06 +01:00
parent 29ce55e89c
commit 0c176674a0
35 changed files with 1254 additions and 686 deletions

View File

@@ -30,7 +30,7 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
import type { AppSidebarQuery as AppSidebarQueryType } from "./__generated__/AppSidebarQuery.graphql";
import { TeamSwitcher } from "@/components/TeamSwitcher";
const staticData = {
user: {
name: "shadcn",
@@ -84,12 +84,8 @@ const AppSidebarQuery = graphql`
query AppSidebarQuery {
viewer {
id
organization {
name
logoUrl
createdAt
updatedAt
}
...TeamSwitcher_organizations
...NavUser_viewer
}
}
`;
@@ -101,37 +97,18 @@ function AppSidebarContent({
queryRef: PreloadedQuery<AppSidebarQueryType>;
}) {
const data = usePreloadedQuery(AppSidebarQuery, queryRef);
const organization = data.viewer.organization;
return (
<Sidebar variant="inset" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<a href="#">
<img
src={organization.logoUrl}
alt={organization.name}
className="size-8 rounded-lg object-cover"
/>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{organization.name}
</span>
<span className="truncate text-xs">Enterprise</span>
</div>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<TeamSwitcher organizations={data.viewer} />
</SidebarHeader>
<SidebarContent>
<NavMain items={staticData.navMain} />
<NavSecondary items={staticData.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
<NavUser user={staticData.user} />
<NavUser viewer={data.viewer} />
</SidebarFooter>
</Sidebar>
);
@@ -162,7 +139,23 @@ function AppSidebarFallback(props: React.ComponentProps<typeof Sidebar>) {
<NavSecondary items={staticData.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
<NavUser user={staticData.user} />
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<a href="#">
<div className="h-8 w-8 rounded-lg bg-muted animate-pulse" />
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
</span>
<span className="truncate text-xs">
<div className="h-3 w-32 animate-pulse rounded bg-muted" />
</span>
</div>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);

View File

@@ -8,6 +8,7 @@ import {
LogOut,
Sparkles,
} from "lucide-react";
import { graphql, useFragment } from "react-relay";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
@@ -25,17 +26,22 @@ import {
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import {
NavUser_viewer$key,
NavUser_viewer$data,
} from "./__generated__/NavUser_viewer.graphql";
export function NavUser({
user,
}: {
user: {
name: string;
email: string;
avatar: string;
};
}) {
export const navUserFragment = graphql`
fragment NavUser_viewer on User {
id
fullName
email
}
`;
export function NavUser({ viewer }: { viewer: NavUser_viewer$key }) {
const { isMobile } = useSidebar();
const currentUser = useFragment(navUserFragment, viewer);
return (
<SidebarMenu>
@@ -47,12 +53,18 @@ export function NavUser({
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
<AvatarFallback className="rounded-lg">
{currentUser.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
<AvatarFallback className="rounded-lg">
{currentUser.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
<span className="truncate font-semibold">
{currentUser.fullName}
</span>
<span className="truncate text-xs">{currentUser.email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
@@ -66,12 +78,18 @@ export function NavUser({
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
<AvatarFallback className="rounded-lg">
{currentUser.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
<AvatarFallback className="rounded-lg">
{currentUser.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
<span className="truncate font-semibold">
{currentUser.fullName}
</span>
<span className="truncate text-xs">{currentUser.email}</span>
</div>
</div>
</DropdownMenuLabel>

View File

@@ -0,0 +1,139 @@
"use client";
import { useState, useEffect } from "react";
import { ChevronsUpDown, Plus } from "lucide-react";
import { graphql, useFragment } from "react-relay";
import { useOrganization } from "@/contexts/OrganizationContext";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import {
TeamSwitcher_organizations$key,
TeamSwitcher_organizations$data,
} from "./__generated__/TeamSwitcher_organizations.graphql";
export const teamSwitcherFragment = graphql`
fragment TeamSwitcher_organizations on User {
organizations {
id
name
logoUrl
}
}
`;
// Extended type to include plan field until Relay compiler generates the types
type Organization = TeamSwitcher_organizations$data["organizations"][0] & {
plan?: string;
};
export function TeamSwitcher({
organizations,
}: {
organizations: TeamSwitcher_organizations$key;
}) {
const { isMobile } = useSidebar();
const data = useFragment(teamSwitcherFragment, organizations);
const { currentOrganization, setCurrentOrganization } = useOrganization();
useEffect(() => {
if (
data.organizations &&
data.organizations.length > 0 &&
!currentOrganization
) {
// Type assertion to include plan field
setCurrentOrganization(data.organizations[0] as Organization);
}
}, [data.organizations, currentOrganization, setCurrentOrganization]);
// If no organizations or data is still loading
if (!currentOrganization) {
return null;
}
// Convert logoUrl to a React component
const LogoComponent = ({
org,
className,
}: {
org: Organization;
className?: string;
}) => {
if (org.logoUrl) {
return <img src={org.logoUrl} alt={org.name} className={className} />;
}
return null;
};
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<LogoComponent org={currentOrganization} className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{currentOrganization.name}
</span>
<span className="truncate text-xs">Free</span>
</div>
<ChevronsUpDown className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Organizations
</DropdownMenuLabel>
{data.organizations.map((org: Organization, index: number) => (
<DropdownMenuItem
key={org.id}
onClick={() => setCurrentOrganization(org)}
className="gap-2 p-2"
>
<div className="flex size-6 items-center justify-center rounded-sm border">
<LogoComponent org={org} className="size-4 shrink-0" />
</div>
{org.name}
<DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 p-2">
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
<Plus className="size-4" />
</div>
<div className="font-medium text-muted-foreground">
Add organization
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<717a5c6c939c65b7e0790f84e2c41bcd>>
* @generated SignedSource<<d060c85c907876f442133c643b917eee>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -9,16 +9,12 @@
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type AppSidebarQuery$variables = Record<PropertyKey, never>;
export type AppSidebarQuery$data = {
readonly viewer: {
readonly id: string;
readonly organization: {
readonly createdAt: any;
readonly logoUrl: string;
readonly name: string;
readonly updatedAt: any;
};
readonly " $fragmentSpreads": FragmentRefs<"NavUser_viewer" | "TeamSwitcher_organizations">;
};
};
export type AppSidebarQuery = {
@@ -33,34 +29,6 @@ var v0 = {
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "logoUrl",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
};
return {
"fragment": {
@@ -79,19 +47,14 @@ return {
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"concreteType": "Organization",
"kind": "LinkedField",
"name": "organization",
"plural": false,
"selections": [
(v1/*: any*/),
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/)
],
"storageKey": null
"kind": "FragmentSpread",
"name": "TeamSwitcher_organizations"
},
{
"args": null,
"kind": "FragmentSpread",
"name": "NavUser_viewer"
}
],
"storageKey": null
@@ -120,16 +83,40 @@ return {
"args": null,
"concreteType": "Organization",
"kind": "LinkedField",
"name": "organization",
"plural": false,
"name": "organizations",
"plural": true,
"selections": [
(v1/*: any*/),
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
(v0/*: any*/)
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "logoUrl",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"storageKey": null
}
],
"storageKey": null
@@ -137,16 +124,16 @@ return {
]
},
"params": {
"cacheID": "e50b9b44daf91d53b07f8912e6c2088f",
"cacheID": "48137f101a20a5ce116cbf46c2a8cd63",
"id": null,
"metadata": {},
"name": "AppSidebarQuery",
"operationKind": "query",
"text": "query AppSidebarQuery {\n viewer {\n id\n organization {\n name\n logoUrl\n createdAt\n updatedAt\n id\n }\n }\n}\n"
"text": "query AppSidebarQuery {\n viewer {\n id\n ...TeamSwitcher_organizations\n ...NavUser_viewer\n }\n}\n\nfragment NavUser_viewer on User {\n id\n fullName\n email\n}\n\nfragment TeamSwitcher_organizations on User {\n organizations {\n id\n name\n logoUrl\n }\n}\n"
}
};
})();
(node as any).hash = "d450e65862def2f7ebd6f4b8d0475be0";
(node as any).hash = "d5e28d934b5913169dd822042f4eca07";
export default node;

View File

@@ -0,0 +1,58 @@
/**
* @generated SignedSource<<0965f25c8dedb59a9316e4df4d773af6>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type NavUser_viewer$data = {
readonly email: string;
readonly fullName: string;
readonly id: string;
readonly " $fragmentType": "NavUser_viewer";
};
export type NavUser_viewer$key = {
readonly " $data"?: NavUser_viewer$data;
readonly " $fragmentSpreads": FragmentRefs<"NavUser_viewer">;
};
const node: ReaderFragment = {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "NavUser_viewer",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"storageKey": null
}
],
"type": "User",
"abstractKey": null
};
(node as any).hash = "7ee85a272f543b514c5a6a3148fd1613";
export default node;

View File

@@ -0,0 +1,71 @@
/**
* @generated SignedSource<<8ecb9304d0a498d483d1a8c251f8c49a>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type TeamSwitcher_organizations$data = {
readonly organizations: ReadonlyArray<{
readonly id: string;
readonly logoUrl: string;
readonly name: string;
}>;
readonly " $fragmentType": "TeamSwitcher_organizations";
};
export type TeamSwitcher_organizations$key = {
readonly " $data"?: TeamSwitcher_organizations$data;
readonly " $fragmentSpreads": FragmentRefs<"TeamSwitcher_organizations">;
};
const node: ReaderFragment = {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "TeamSwitcher_organizations",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Organization",
"kind": "LinkedField",
"name": "organizations",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "logoUrl",
"storageKey": null
}
],
"storageKey": null
}
],
"type": "User",
"abstractKey": null
};
(node as any).hash = "318f68b82b5b87500ec99ffcdd2929ab";
export default node;