Skip to content

Commit 9255f37

Browse files
feat: Add user team selection per session
1 parent f5a0b68 commit 9255f37

File tree

7 files changed

+183
-29
lines changed

7 files changed

+183
-29
lines changed

src/actions/session.action.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"use server";
2+
3+
import { z } from "zod";
4+
import { ZSAError, createServerAction } from "zsa";
5+
import { getSessionFromCookie } from "@/utils/auth";
6+
import { updateKVSessionSelectedTeam } from "@/utils/kv-session";
7+
8+
const updateSelectedTeamSchema = z.object({
9+
selectedTeam: z.string().optional(),
10+
});
11+
12+
/**
13+
* Update the selected team for the current user's session
14+
*/
15+
export const updateSelectedTeamAction = createServerAction()
16+
.input(updateSelectedTeamSchema)
17+
.handler(async ({ input }) => {
18+
try {
19+
const session = await getSessionFromCookie();
20+
21+
if (!session) {
22+
throw new ZSAError(
23+
"FORBIDDEN",
24+
"You must be logged in to update your selected team"
25+
);
26+
}
27+
28+
// Validate that the selected team exists in the user's teams (if provided)
29+
if (input.selectedTeam && session.teams) {
30+
const teamExists = session.teams.some(team => team.id === input.selectedTeam);
31+
if (!teamExists) {
32+
throw new ZSAError(
33+
"FORBIDDEN",
34+
"Team not found or you are not a member"
35+
);
36+
}
37+
}
38+
39+
const updatedSession = await updateKVSessionSelectedTeam(
40+
session.id,
41+
session.userId,
42+
input.selectedTeam
43+
);
44+
45+
if (!updatedSession) {
46+
throw new ZSAError(
47+
"INTERNAL_SERVER_ERROR",
48+
"Failed to update selected team"
49+
);
50+
}
51+
52+
return {
53+
success: true,
54+
selectedTeam: updatedSession.selectedTeam
55+
};
56+
} catch (error) {
57+
console.error("Failed to update selected team:", error);
58+
59+
if (error instanceof ZSAError) {
60+
throw error;
61+
}
62+
63+
throw new ZSAError(
64+
"INTERNAL_SERVER_ERROR",
65+
"Failed to update selected team"
66+
);
67+
}
68+
});

src/app/(dashboard)/dashboard/marketplace/components-catalog.tsx

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import SeparatorWithText from "@/components/separator-with-text"
55
import { NavUser } from "@/components/nav-user"
66
import { Button } from "@/components/ui/button"
77
import { PageHeader } from "@/components/page-header"
8+
import { ComponentProps } from "react"
89

910
interface MarketplaceComponent {
1011
id: string
@@ -15,22 +16,20 @@ interface MarketplaceComponent {
1516
preview: () => React.ReactNode
1617
}
1718

18-
interface Team {
19-
name: string
20-
iconName: string
21-
plan: string
22-
}
19+
type Team = ComponentProps<typeof TeamSwitcher>['teams'][number];
2320

2421
const demoTeams: Team[] = [
2522
{
23+
id: "acme-inc",
2624
name: "Acme Inc",
27-
iconName: "boxes",
28-
plan: "Pro Plan",
25+
logo: Boxes,
26+
role: "admin",
2927
},
3028
{
29+
id: "monsters-inc",
3130
name: "Monsters Inc",
32-
iconName: "boxes",
33-
plan: "Free Plan",
31+
logo: Boxes,
32+
role: "member",
3433
},
3534
]
3635

src/components/app-sidebar.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,10 @@ type Data = {
4646
email: string
4747
}
4848
teams: {
49+
id: string
4950
name: string
5051
logo: ComponentType
51-
plan: string
52+
role: string
5253
}[]
5354
navMain: NavMainItem[]
5455
projects: NavItem[]
@@ -64,11 +65,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
6465
// Map teams from session to the format expected by TeamSwitcher
6566
const teamData = session.teams.map(team => {
6667
return {
68+
id: team.id,
6769
name: team.name,
6870
// TODO Get the actual logo when we implement team avatars
6971
logo: Building2,
70-
// Default plan - you might want to add plan data to your team structure
71-
plan: team.role.name || "Member"
72+
role: team.role.name || "Member",
7273
};
7374
});
7475

src/components/team-switcher.tsx

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client"
22

3-
import * as React from "react"
3+
import { useCallback, useMemo, type ElementType } from "react"
44
import { Building2, ChevronsUpDown, Plus } from "lucide-react"
55

66
import {
@@ -19,28 +19,54 @@ import {
1919
useSidebar,
2020
} from "@/components/ui/sidebar"
2121
import Link from "next/link"
22+
import { useSessionStore } from "@/state/session"
23+
import { useServerAction } from "zsa-react"
24+
import { updateSelectedTeamAction } from "@/actions/session.action"
25+
import { toast } from "sonner"
2226

2327
export function TeamSwitcher({
2428
teams,
2529
}: {
2630
teams: {
31+
id: string
2732
name: string
28-
logo: React.ElementType
29-
plan: string
33+
logo: ElementType
34+
role: string
3035
}[]
3136
}) {
3237
const { isMobile, setOpenMobile } = useSidebar()
33-
const [activeTeam, setActiveTeam] = React.useState<typeof teams[0] | null>(null)
38+
const session = useSessionStore()
39+
const selectedTeamId = session.selectedTeam()
40+
const setSelectedTeam = session.setSelectedTeam
3441

35-
// Update activeTeam when teams change or on initial render
36-
React.useEffect(() => {
37-
if (teams.length > 0 && (!activeTeam || !teams.find(t => t.name === activeTeam.name))) {
38-
setActiveTeam(teams[0])
39-
}
40-
}, [teams, activeTeam])
42+
const { execute: updateSelectedTeam, isPending } = useServerAction(updateSelectedTeamAction)
43+
44+
// Find the active team based on selectedTeamId from session
45+
const activeTeam = useMemo(() => {
46+
if (!selectedTeamId) return teams[0] || null
47+
48+
// Find the matching team in the props by ID
49+
return teams.find(t => t.id === selectedTeamId) || teams[0] || null
50+
}, [selectedTeamId, teams])
4151

4252
const LogoComponent = activeTeam?.logo || Building2
4353

54+
const handleTeamChange = useCallback(async (team: typeof teams[0]) => {
55+
// Optimistically update local state
56+
setSelectedTeam(team.id)
57+
58+
// Call server action to persist
59+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
60+
const [_data, error] = await updateSelectedTeam({ selectedTeam: team.id })
61+
62+
if (error) {
63+
console.error("Failed to update selected team:", error)
64+
// Revert optimistic update
65+
setSelectedTeam(selectedTeamId)
66+
toast.error("Failed to update selected team")
67+
}
68+
}, [selectedTeamId, setSelectedTeam, updateSelectedTeam])
69+
4470
return (
4571
<SidebarMenu>
4672
<SidebarMenuItem>
@@ -57,7 +83,7 @@ export function TeamSwitcher({
5783
<span className="truncate font-semibold">
5884
{activeTeam?.name || "No Team"}
5985
</span>
60-
<span className="truncate text-xs">{activeTeam?.plan || "Select a team"}</span>
86+
<span className="truncate text-xs capitalize">{activeTeam?.role || "Member"}</span>
6187
</div>
6288
<ChevronsUpDown className="ml-auto" />
6389
</SidebarMenuButton>
@@ -74,9 +100,10 @@ export function TeamSwitcher({
74100
{teams.length > 0 ? (
75101
teams.map((team, index) => (
76102
<DropdownMenuItem
77-
key={team.name}
78-
onClick={() => setActiveTeam(team)}
103+
key={team.id}
104+
onClick={() => handleTeamChange(team)}
79105
className="gap-2 p-2"
106+
disabled={isPending}
80107
>
81108
<div className="flex size-6 items-center justify-center rounded-sm border">
82109
<team.logo className="size-4 shrink-0" />

src/state/session.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ interface SessionActions {
1717
setSession: (session: SessionValidationResult) => void;
1818
clearSession: () => void;
1919
refetchSession: () => void;
20+
setSelectedTeam: (teamId: string | undefined) => void;
2021

2122
// Team related selectors
2223
teams: () => KVSession['teams'] | undefined;
24+
selectedTeam: () => string | undefined;
2325
isTeamMember: (teamId: string) => boolean;
2426
hasTeamRole: (teamId: string, roleId: string, isSystemRole?: boolean) => boolean;
2527
hasTeamPermission: (teamId: string, permission: string) => boolean;
@@ -39,9 +41,23 @@ export const useSessionStore = create(
3941
clearSession: () => set({ session: null, isLoading: false, lastFetched: null }),
4042
refetchSession: () => set({ isLoading: true }),
4143

44+
setSelectedTeam: (teamId: string | undefined) => {
45+
const currentSession = get().session;
46+
if (currentSession) {
47+
set({
48+
session: {
49+
...currentSession,
50+
selectedTeam: teamId,
51+
}
52+
});
53+
}
54+
},
55+
4256
// Team related selectors
4357
teams: () => get().session?.teams,
4458

59+
selectedTeam: () => get().session?.selectedTeam,
60+
4561
isTeamMember: (teamId: string) => {
4662
return !!get().session?.teams?.some(team => team.id === teamId);
4763
},

src/utils/auth.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,8 @@ export async function createSession({
169169
user,
170170
authenticationType,
171171
passkeyCredentialId,
172-
teams: teamsWithPermissions
172+
teams: teamsWithPermissions,
173+
selectedTeam: teamsWithPermissions?.length > 0 ? teamsWithPermissions?.[0]?.id : undefined
173174
});
174175
}
175176

src/utils/kv-session.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ export interface KVSession {
4444
};
4545
permissions: string[];
4646
}[];
47+
/**
48+
* The ID of the currently selected team for this session
49+
*/
50+
selectedTeam?: string;
4751
/**
4852
* !!!!!!!!!!!!!!!!!!!!!
4953
* !!! IMPORTANT !!!
@@ -63,16 +67,17 @@ export interface KVSession {
6367
* IF YOU MAKE ANY CHANGES TO THE KVSESSION TYPE ABOVE, YOU NEED TO INCREMENT THIS VERSION.
6468
* THIS IS HOW WE TRACK WHEN WE NEED TO UPDATE THE SESSIONS IN THE KV STORE.
6569
*/
66-
export const CURRENT_SESSION_VERSION = 3;
70+
export const CURRENT_SESSION_VERSION = 4;
6771

6872
export async function getKV() {
6973
const { env } = getCloudflareContext();
7074
return env.NEXT_INC_CACHE_KV;
7175
}
7276

73-
export interface CreateKVSessionParams extends Omit<KVSession, "id" | "createdAt" | "expiresAt"> {
77+
export interface CreateKVSessionParams extends Omit<KVSession, "id" | "createdAt" | "expiresAt" | "selectedTeam"> {
7478
sessionId: string;
7579
expiresAt: Date;
80+
selectedTeam?: string;
7681
}
7782

7883
export async function createKVSession({
@@ -82,7 +87,8 @@ export async function createKVSession({
8287
user,
8388
authenticationType,
8489
passkeyCredentialId,
85-
teams
90+
teams,
91+
selectedTeam
8692
}: CreateKVSessionParams): Promise<KVSession> {
8793
const { cf } = getCloudflareContext();
8894
const headersList = await headers();
@@ -106,6 +112,7 @@ export async function createKVSession({
106112
authenticationType,
107113
passkeyCredentialId,
108114
teams,
115+
selectedTeam,
109116
version: CURRENT_SESSION_VERSION
110117
};
111118

@@ -222,6 +229,41 @@ export async function deleteKVSession(sessionId: string, userId: string): Promis
222229
await kv.delete(getSessionKey(userId, sessionId));
223230
}
224231

232+
export async function updateKVSessionSelectedTeam(sessionId: string, userId: string, selectedTeam: string | undefined): Promise<KVSession | null> {
233+
const session = await getKVSession(sessionId, userId);
234+
if (!session) return null;
235+
236+
const updatedSession: KVSession = {
237+
...session,
238+
selectedTeam,
239+
};
240+
241+
const kv = await getKV();
242+
243+
if (!kv) {
244+
throw new Error("Can't connect to KV store");
245+
}
246+
247+
// Calculate the remaining TTL from the existing expiration
248+
const remainingTtl = Math.floor((session.expiresAt - Date.now()) / 1000);
249+
250+
// Only update if the session hasn't expired
251+
if (remainingTtl > 0) {
252+
await kv.put(
253+
getSessionKey(userId, sessionId),
254+
JSON.stringify(updatedSession),
255+
{
256+
expirationTtl: remainingTtl
257+
}
258+
);
259+
} else {
260+
// Session has expired, return null
261+
return null;
262+
}
263+
264+
return updatedSession;
265+
}
266+
225267
export async function getAllSessionIdsOfUser(userId: string) {
226268
const kv = await getKV();
227269

0 commit comments

Comments
 (0)