Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex w-full max-w-xl mx-auto flex-col gap-4 sm:items-center">
<Alert className="">
<AlertTitle>Switch to {org?.name}</AlertTitle>
<AlertAction>
<Button variant="outline" onClick={() => handleCancel()} size="xs">
Dismiss
</Button>
</AlertAction>
<AlertDescription>
You were redirected because your current switched organization does
not match the organization you are trying to access.
</AlertDescription>
</Alert>

<Card className="flex w-full flex-col gap-4">
<CardHeader className="flex flex-col gap-3">
<OrganizationAvatar
organization={org}
className="size-16 rounded-md"
/>
<div className="flex flex-col gap-2">
<CardTitle className="text-xl">{org.name}</CardTitle>
<CardDescription>
{org.description ?? "No description provided."}
</CardDescription>
</div>
<div className="flex gap-2">
<MemberRoleBadge role={role} />
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3 w-full sm:flex-row sm:justify-end">
<Button
variant="outline"
className="w-full sm:w-fit"
onClick={handleCancel}
disabled={isPending}
>
Cancel
</Button>
<Button
className="w-full sm:w-fit"
disabled={isPending}
onClick={handleSwitch}
>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Switching...
</>
) : (
<>
Switch to {org.name}
<ArrowRight />
</>
)}
</Button>
</CardContent>
</Card>
</div>
);
}

export function OrganizationRedirectCardSkeleton() {
return <Skeleton className="w-full max-w-lg h-40 mx-auto" />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
"use client";

import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { ArrowRight, Ban, Loader2 } from "lucide-react";
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,
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<string | undefined>(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 <Skeleton className="w-full max-w-lg h-64 mx-auto" />;
}
return (
<div className="flex h-full items-center justify-center">
<EmptyState
className="py-24"
icon={Ban}
iconClassName="size-8 sm:size-14"
title="No organizations found."
description="There are no organizations. Start by creating a new organization and inviting friends to start chatting."
/>
</div>
);
}

const selectedOrg = organizations.find((o) => o.id === selectedId);
const isSame = selectedId === activeId;
const isSwitching = isPending && !isSame;

return (
<div className="flex w-full mx-auto mt-10 flex-col gap-4 sm:items-center">
<Card className="mx-auto flex w-full max-w-xl flex-col gap-4">
<CardHeader className="flex flex-col gap-3">
<OrganizationAvatar
organization={activeOrganization!}
className="size-16 rounded-md"
/>
<div className="flex flex-col gap-2">
<CardTitle className="text-xl">
{activeOrganization?.name}
</CardTitle>
<CardDescription>
{activeOrganization?.description ?? "No description provided."}
</CardDescription>
</div>
<div className="flex gap-2">
<MemberRoleBadge role={activeRole} />
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 w-full">
<Select value={selectedId} onValueChange={setSelectedId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select organization">
{selectedOrg && (
<div className="flex min-w-0 items-center gap-2">
<OrganizationAvatar
organization={selectedOrg}
className="size-5 shrink-0 rounded"
/>
<span className="truncate">{selectedOrg.name}</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Your Organizations</SelectLabel>
{organizations.map((o) => (
<SelectItem className="group" key={o.id} value={o.id}>
<OrganizationAvatar
organization={o}
className="size-6 shrink-0 rounded-md"
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate max-w-[20ch]">{o.name}</span>
<div className="flex items-center gap-2">
<MemberRoleBadge role={o.role} />
{o.id === activeId && (
<Badge className="shrink-0 bg-primary text-primary-foreground group-hover:text-primary-foreground!">
Active
</Badge>
)}
</div>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>

<Button
className="ml-auto w-full sm:w-fit"
variant={isSame ? "outline" : "default"}
disabled={isPending || (!isSame && !selectedId)}
onClick={() => {
if (isSame && activeId) {
router.push(`/organizations/${activeId}`);
} else if (selectedId) {
handleSwitch(selectedId);
}
}}
>
{isSwitching ? (
<>
<Loader2 className="size-4 animate-spin" />
Switching...
</>
) : isSame ? (
<>
Visit {activeOrganization?.name}
<ArrowRight />
</>
) : (
"Switch"
)}
</Button>
</CardContent>
</Card>
</div>
);
}
9 changes: 7 additions & 2 deletions app/(organization)/organizations/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
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";
import { OrganizationHeader } from "./_components/header";
import { OrganizationList } from "./_components/organization-list";
import { OrganizationRedirectCard } from "./_components/organization-redirect-card";
import { OrganizationSwitcherCard } from "./_components/organization-switcher-card";

export const metadata: Metadata = {
title: "Organizations",
Expand All @@ -25,7 +27,10 @@ export default async function Organizations() {
</div>

<HydrateClient client={queryClient}>
<OrganizationList />
<Suspense fallback={null}>
<OrganizationRedirectCard />
</Suspense>
<OrganizationSwitcherCard />
</HydrateClient>
</div>
</div>
Expand Down
27 changes: 27 additions & 0 deletions app/(organization)/organizations/search-params.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading