From 43f22a6d8a0851c2b32728ba3f45edd8e92912ef Mon Sep 17 00:00:00 2001 From: Ali Raza Date: Sat, 14 Nov 2026 00:54:04 +0500 Subject: [PATCH 1/3] feat: add organization switcher card to organizations page --- .../organization-switcher-card.tsx | 194 ++++++++++++++++++ app/(organization)/organizations/page.tsx | 4 +- 2 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 app/(organization)/organizations/_components/organization-switcher-card.tsx diff --git a/app/(organization)/organizations/_components/organization-switcher-card.tsx b/app/(organization)/organizations/_components/organization-switcher-card.tsx new file mode 100644 index 0000000..0f1c3c4 --- /dev/null +++ b/app/(organization)/organizations/_components/organization-switcher-card.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { ArrowRight, Ban, Check, Loader2, RefreshCcw } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState, useTransition } from "react"; +import { toast } from "sonner"; +import { EmptyState } from "@/components/general/empty-state"; +import { MemberRoleBadge } from "@/components/general/member-role-badge"; +import { OrganizationAvatar } from "@/components/general/organization-avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { authClient } from "@/lib/auth-client"; +import { orpc } from "@/lib/orpc"; + +export function OrganizationSwitcherCard() { + const [isPending, startTransition] = useTransition(); + const [selectedId, setSelectedId] = useState(undefined); + + const { + data: { organizations, currentOrganization }, + isFetching, + } = useSuspenseQuery(orpc.organization.list.queryOptions()); + + const queryClient = useQueryClient(); + const router = useRouter(); + + const activeOrganization = + organizations.find((w) => w.id === currentOrganization?.id) ?? + currentOrganization; + + const activeId = activeOrganization?.id; + const activeRole = + organizations.find((w) => w.id === activeId)?.role ?? "member"; + + useEffect(() => { + setSelectedId(activeId); + }, [activeId]); + + async function handleSwitch(organizationId: string) { + startTransition(async () => { + const { data, error } = await authClient.organization.setActive({ + organizationId, + }); + + if (error) { + toast.error("Failed to switch organization!", { + description: error.message ?? "Unknown error", + }); + return; + } + router.push(`/organizations/${organizationId}`); + + toast.success( + `Switched to ${data?.name ?? "organization"} successfully, Redirecting please wait...` + ); + await queryClient.invalidateQueries( + orpc.organization.list.queryOptions() + ); + }); + } + + if (!organizations || organizations.length === 0) { + if (isFetching) { + return ; + } + return ( +
+ +
+ ); + } + + const selectedOrg = organizations.find((o) => o.id === selectedId); + const isSame = selectedId === activeId; + const isSwitching = isPending && !isSame; + + return ( +
+ + + +
+ + {activeOrganization?.name} + + + {activeOrganization?.description ?? "No description provided."} + +
+
+ +
+
+ + + + + +
+
+ ); +} diff --git a/app/(organization)/organizations/page.tsx b/app/(organization)/organizations/page.tsx index 8de5981..51b611b 100644 --- a/app/(organization)/organizations/page.tsx +++ b/app/(organization)/organizations/page.tsx @@ -3,7 +3,7 @@ import { orpc } from "@/lib/orpc"; import { getQueryClient, HydrateClient } from "@/lib/query/hydration"; import { CreateOrganizationDialog } from "./_components/create-organization-dialog"; import { OrganizationHeader } from "./_components/header"; -import { OrganizationList } from "./_components/organization-list"; +import { OrganizationSwitcherCard } from "./_components/organization-switcher-card"; export const metadata: Metadata = { title: "Organizations", @@ -25,7 +25,7 @@ export default async function Organizations() { - + From c60f2860e54d603ddc3f9faf77b1fffb05cd32ca Mon Sep 17 00:00:00 2001 From: Ali Raza Date: Sat, 14 Nov 2026 14:34:43 +0500 Subject: [PATCH 2/3] Add organization mismatch redirect card and split redirectTo into organizationId + next --- .../organizations/[organizationId]/access.ts | 2 +- .../organization-redirect-card.tsx | 143 ++++++++++++++++++ .../organization-switcher-card.tsx | 4 +- app/(organization)/organizations/page.tsx | 2 + .../organizations/search-params.ts | 27 ++++ components/ui/alert.tsx | 75 +++++++++ lib/query/client.ts | 9 +- lib/urls.ts | 9 +- 8 files changed, 260 insertions(+), 11 deletions(-) create mode 100644 app/(organization)/organizations/_components/organization-redirect-card.tsx create mode 100644 app/(organization)/organizations/search-params.ts create mode 100644 components/ui/alert.tsx diff --git a/app/(organization)/organizations/[organizationId]/access.ts b/app/(organization)/organizations/[organizationId]/access.ts index 39190d3..54f3c89 100644 --- a/app/(organization)/organizations/[organizationId]/access.ts +++ b/app/(organization)/organizations/[organizationId]/access.ts @@ -36,7 +36,7 @@ export async function requireOrganizationAccess( requestHeaders.get(CURRENT_PATH_HEADER) ?? `/organizations/${organizationId}`; - redirect(buildOrganizationsRedirect(currentPath) as Route); + redirect(buildOrganizationsRedirect(organizationId, currentPath) as Route); } return organizationList; diff --git a/app/(organization)/organizations/_components/organization-redirect-card.tsx b/app/(organization)/organizations/_components/organization-redirect-card.tsx new file mode 100644 index 0000000..3e88cb5 --- /dev/null +++ b/app/(organization)/organizations/_components/organization-redirect-card.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { ArrowRight, Loader2 } from "lucide-react"; +import type { Route } from "next"; +import { useRouter } from "next/navigation"; +import { useTransition } from "react"; +import { toast } from "sonner"; +import { MemberRoleBadge } from "@/components/general/member-role-badge"; +import { OrganizationAvatar } from "@/components/general/organization-avatar"; +import { + Alert, + AlertAction, + AlertDescription, + AlertTitle, +} from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { authClient } from "@/lib/auth-client"; +import { orpc } from "@/lib/orpc"; +import { useOrganizationRedirectParams } from "../search-params"; + +export function OrganizationRedirectCard() { + const [isPending, startTransition] = useTransition(); + const [{ organizationId, next }, setParams] = useOrganizationRedirectParams(); + const { + data: { organizations }, + } = useSuspenseQuery(orpc.organization.list.queryOptions()); + + const queryClient = useQueryClient(); + const router = useRouter(); + + if (!organizationId) return null; + + const org = organizations.find((o) => o.id === organizationId); + if (!org) return null; + + const role = org.role; + + async function handleSwitch() { + if (!organizationId) return; + startTransition(async () => { + const { data, error } = await authClient.organization.setActive({ + organizationId, + }); + + if (error) { + toast.error("Failed to switch organization!", { + description: error.message ?? "Unknown error", + }); + return; + } + + toast.success( + `Switched to ${data?.name ?? org?.name ?? "organization"} successfully, Redirecting please wait...` + ); + + await queryClient.invalidateQueries( + orpc.organization.list.queryOptions() + ); + + router.push((next ?? `/organizations/${organizationId}`) as Route); + }); + } + + function handleCancel() { + setParams({ organizationId: null, next: null }); + } + + return ( +
+ + Switch to {org?.name} + + + + + You were redirected because your current switched organization does + not match the organization you are trying to access. + + + + + + +
+ {org.name} + + {org.description ?? "No description provided."} + +
+
+ +
+
+ + + + +
+
+ ); +} + +export function OrganizationRedirectCardSkeleton() { + return ; +} diff --git a/app/(organization)/organizations/_components/organization-switcher-card.tsx b/app/(organization)/organizations/_components/organization-switcher-card.tsx index 0f1c3c4..65b8a40 100644 --- a/app/(organization)/organizations/_components/organization-switcher-card.tsx +++ b/app/(organization)/organizations/_components/organization-switcher-card.tsx @@ -1,8 +1,7 @@ "use client"; import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; -import { ArrowRight, Ban, Check, Loader2, RefreshCcw } from "lucide-react"; -import Link from "next/link"; +import { ArrowRight, Ban, Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect, useState, useTransition } from "react"; import { toast } from "sonner"; @@ -15,7 +14,6 @@ import { Card, CardContent, CardDescription, - CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; diff --git a/app/(organization)/organizations/page.tsx b/app/(organization)/organizations/page.tsx index 51b611b..2cf14a7 100644 --- a/app/(organization)/organizations/page.tsx +++ b/app/(organization)/organizations/page.tsx @@ -3,6 +3,7 @@ import { orpc } from "@/lib/orpc"; import { getQueryClient, HydrateClient } from "@/lib/query/hydration"; import { CreateOrganizationDialog } from "./_components/create-organization-dialog"; import { OrganizationHeader } from "./_components/header"; +import { OrganizationRedirectCard } from "./_components/organization-redirect-card"; import { OrganizationSwitcherCard } from "./_components/organization-switcher-card"; export const metadata: Metadata = { @@ -25,6 +26,7 @@ export default async function Organizations() { + diff --git a/app/(organization)/organizations/search-params.ts b/app/(organization)/organizations/search-params.ts new file mode 100644 index 0000000..14296b5 --- /dev/null +++ b/app/(organization)/organizations/search-params.ts @@ -0,0 +1,27 @@ +import { + type Options, + parseAsString, + useQueryState, + useQueryStates, +} from "nuqs"; + +export const organizationRedirectParsers = { + organizationId: parseAsString, + next: parseAsString, +}; + +export type OrganizationRedirectSearchParams = { + organizationId: string | null; + next: string | null; +}; + +export function useOrganizationRedirectParams(options: Options = {}) { + return useQueryStates(organizationRedirectParsers, { + shallow: true, + ...options, + }); +} + +export function useOrganizationRedirectParam() { + return useQueryState("organizationId", parseAsString); +} diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..b84571e --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,75 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "cn"; +import * as React from "react"; + +const alertVariants = cva( + "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + className + )} + {...props} + /> + ); +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Alert, AlertAction, AlertDescription, AlertTitle }; diff --git a/lib/query/client.ts b/lib/query/client.ts index 6a44c86..271fed3 100644 --- a/lib/query/client.ts +++ b/lib/query/client.ts @@ -12,11 +12,10 @@ function redirectOnOrgMismatch(error: unknown) { if (!isOrpcErrorWithCode(error, "ORGANIZATION_MISMATCH")) return; if (typeof window === "undefined") return; - window.location.assign( - buildOrganizationsRedirect( - window.location.pathname + window.location.search - ) - ); + const path = window.location.pathname + window.location.search; + const match = window.location.pathname.match(/^\/organizations\/([^/]+)/); + const organizationId = match?.[1] ?? ""; + window.location.assign(buildOrganizationsRedirect(organizationId, path)); } export function createQueryClient() { diff --git a/lib/urls.ts b/lib/urls.ts index 821a667..c4e9029 100644 --- a/lib/urls.ts +++ b/lib/urls.ts @@ -1,7 +1,12 @@ import { ORGANIZATIONS_PATH } from "@/lib/app/constants"; -export function buildOrganizationsRedirect(currentPath: string): string { - return `${ORGANIZATIONS_PATH}?redirectTo=${encodeURIComponent(currentPath)}`; +export function buildOrganizationsRedirect( + organizationId: string, + next: string +): string { + return `${ORGANIZATIONS_PATH}?organizationId=${encodeURIComponent( + organizationId + )}&next=${encodeURIComponent(next)}`; } export function normalize(urlOrPath: string) { From 2a879a8f257cee4b85f2cd27b21068fdf17925bf Mon Sep 17 00:00:00 2001 From: Ali Raza Date: Sat, 14 Nov 2026 14:43:38 +0500 Subject: [PATCH 3/3] Wrap organization redirect card in Suspense for useSearchParams --- app/(organization)/organizations/page.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/(organization)/organizations/page.tsx b/app/(organization)/organizations/page.tsx index 2cf14a7..ab0ddad 100644 --- a/app/(organization)/organizations/page.tsx +++ b/app/(organization)/organizations/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { Suspense } from "react"; import { orpc } from "@/lib/orpc"; import { getQueryClient, HydrateClient } from "@/lib/query/hydration"; import { CreateOrganizationDialog } from "./_components/create-organization-dialog"; @@ -26,7 +27,9 @@ export default async function Organizations() {
- + + +