Skip to content

Commit 29eb7b7

Browse files
author
Lasim
committed
feat: implement AppSidebar and DashboardLayout components with user and team management features
- Added AppSidebar component for navigation with user and team selection. - Integrated DashboardLayout to provide a consistent layout structure. - Implemented user data fetching and team management using TeamService. - Enhanced sidebar navigation with internationalization support. - Created UI components for avatar, dropdown menu, and scroll area. - Added placeholder pages for MCP Server, Provider, Credentials, and User Account. - Updated i18n localization for dashboard and user-related texts.
1 parent a6f4e00 commit 29eb7b7

34 files changed

+1059
-125
lines changed

services/backend/src/routes/users/index.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
22
import { ZodError } from 'zod';
33
import { UserService } from '../../services/userService';
4+
import { TeamService } from '../../services/teamService';
45
import { requirePermission, requireOwnershipOrAdmin, getUserIdFromParams } from '../../middleware/roleMiddleware';
56
import {
67
UpdateUserSchema,
@@ -293,4 +294,29 @@ export default async function usersRoute(fastify: FastifyInstance) {
293294
});
294295
}
295296
});
297+
298+
// GET /api/users/me/teams - Get current user's teams
299+
fastify.get('/api/users/me/teams', async (request, reply) => {
300+
try {
301+
if (!request.user) {
302+
return reply.status(401).send({
303+
success: false,
304+
error: 'Authentication required',
305+
});
306+
}
307+
308+
const teams = await TeamService.getUserTeams(request.user.id);
309+
310+
return reply.status(200).send({
311+
success: true,
312+
data: teams,
313+
});
314+
} catch (error) {
315+
fastify.log.error(error, 'Error fetching user teams');
316+
return reply.status(500).send({
317+
success: false,
318+
error: 'Failed to fetch user teams',
319+
});
320+
}
321+
});
296322
}
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
<script setup lang="ts">
2+
import { ref, onMounted, defineProps } from 'vue' // Added defineProps
3+
import { useRouter } from 'vue-router'
4+
import { useI18n } from 'vue-i18n'
5+
// import { cn } from '@/lib/utils' // cn might not be needed for root if $attrs.class is used directly
6+
// import { ScrollArea } from '@/components/ui/scroll-area' // SidebarContent should handle scrolling
7+
8+
// Re-add Sidebar specific imports
9+
import {
10+
Sidebar,
11+
SidebarContent,
12+
SidebarFooter,
13+
SidebarGroup,
14+
SidebarGroupContent,
15+
SidebarGroupLabel,
16+
SidebarHeader,
17+
SidebarMenu,
18+
SidebarMenuButton,
19+
SidebarMenuItem,
20+
type SidebarProps, // Import SidebarProps type if available for variant
21+
} from '@/components/ui/sidebar'
22+
23+
import {
24+
DropdownMenu,
25+
DropdownMenuContent,
26+
DropdownMenuItem,
27+
DropdownMenuSeparator,
28+
DropdownMenuTrigger,
29+
} from '@/components/ui/dropdown-menu'
30+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
31+
import { Button } from '@/components/ui/button' // Still used for DropdownMenuTrigger as-child
32+
import { TeamService, type Team } from '@/services/teamService'
33+
import {
34+
Server,
35+
Settings,
36+
Key,
37+
ChevronDown,
38+
User,
39+
LogOut,
40+
Users
41+
} from 'lucide-vue-next'
42+
43+
// Define props, including variant
44+
interface Props {
45+
variant?: SidebarProps['variant'] // Use type from SidebarProps if possible, else string
46+
// collapsible?: SidebarProps['collapsible']
47+
}
48+
const props = defineProps<Props>()
49+
50+
const router = useRouter()
51+
const { t } = useI18n()
52+
53+
// User data
54+
const userEmail = ref('')
55+
const userName = ref('')
56+
const userLoading = ref(true)
57+
58+
// Teams data
59+
const teams = ref<Team[]>([])
60+
const selectedTeam = ref<Team | null>(null)
61+
const teamsLoading = ref(true)
62+
const teamsError = ref('')
63+
64+
// Navigation items
65+
const navigationItems = [
66+
{
67+
title: t('sidebar.navigation.mcpServer'),
68+
icon: Server,
69+
url: '/mcp-server',
70+
},
71+
{
72+
title: t('sidebar.navigation.provider'),
73+
icon: Settings,
74+
url: '/provider',
75+
},
76+
{
77+
title: t('sidebar.navigation.credentials'),
78+
icon: Key,
79+
url: '/credentials',
80+
},
81+
]
82+
83+
// Fetch user data logic (remains the same)
84+
const fetchUserData = async () => {
85+
try {
86+
const apiUrl = import.meta.env.VITE_DEPLOYSTACK_APP_URL
87+
if (!apiUrl) {
88+
throw new Error('API URL not configured')
89+
}
90+
const response = await fetch(`${apiUrl}/api/users/me`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, credentials: 'include' })
91+
if (!response.ok) {
92+
if (response.status === 401) { router.push('/login'); return }
93+
throw new Error(`Failed to fetch user data: ${response.status}`)
94+
}
95+
const data = await response.json()
96+
if (data.success && data.data) { userEmail.value = data.data.email; userName.value = data.data.username; }
97+
} catch (error) { console.error('Error fetching user data:', error) } finally { userLoading.value = false }
98+
}
99+
100+
// Fetch teams logic (remains the same)
101+
const fetchTeams = async () => {
102+
try {
103+
teamsLoading.value = true; teamsError.value = '';
104+
const userTeams = await TeamService.getUserTeams(); teams.value = userTeams;
105+
if (userTeams.length > 0) { selectedTeam.value = userTeams[0]; }
106+
} catch (error) { console.error('Error fetching teams:', error); teamsError.value = error instanceof Error ? error.message : 'Failed to load teams'; } finally { teamsLoading.value = false; }
107+
}
108+
109+
const selectTeam = (team: Team) => { selectedTeam.value = team }
110+
const navigateTo = (url: string) => { router.push(url) }
111+
const goToAccount = () => { router.push('/user/account') }
112+
const logout = () => { router.push('/logout') }
113+
const getUserInitials = (name: string) => { return name.split(' ').map(word => word.charAt(0)).join('').toUpperCase().slice(0, 2) }
114+
115+
onMounted(() => {
116+
fetchUserData()
117+
fetchTeams()
118+
})
119+
</script>
120+
121+
<template>
122+
<Sidebar :variant="props.variant" :class="$attrs.class" collapsible="icon"> {/* Defaulting to collapsible icon, can be prop */}
123+
<SidebarHeader>
124+
<SidebarMenu>
125+
<SidebarMenuItem>
126+
<DropdownMenu>
127+
<DropdownMenuTrigger as-child>
128+
<!-- Using Button for consistency with shadcn-vue SidebarMenuButton structure -->
129+
<Button variant="ghost" size="lg" class="w-full justify-start items-center data-[state=open]:bg-accent data-[state=open]:text-accent-foreground px-2 h-auto py-2.5">
130+
<div class="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground mr-2 shrink-0">
131+
<Users class="size-4" />
132+
</div>
133+
<div class="grid flex-1 text-left text-sm leading-tight">
134+
<span class="truncate font-semibold">
135+
{{ selectedTeam?.name || t('sidebar.teams.selectTeam') }}
136+
</span>
137+
<span class="truncate text-xs text-muted-foreground">
138+
{{ teamsLoading ? t('sidebar.teams.loading') : teams.length > 0 ? `${teams.length} team${teams.length !== 1 ? 's' : ''}` : t('sidebar.teams.noTeams') }}
139+
</span>
140+
</div>
141+
<ChevronDown class="ml-2 size-4 text-muted-foreground shrink-0" />
142+
</Button>
143+
</DropdownMenuTrigger>
144+
<DropdownMenuContent
145+
class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
146+
side="bottom"
147+
align="start"
148+
>
149+
<div v-if="teamsLoading" class="p-2 text-sm text-muted-foreground">
150+
{{ t('sidebar.teams.loading') }}
151+
</div>
152+
<div v-else-if="teamsError" class="p-2 text-sm text-destructive">
153+
{{ teamsError }}
154+
</div>
155+
<div v-else-if="teams.length === 0" class="p-2 text-sm text-muted-foreground">
156+
{{ t('sidebar.teams.noTeams') }}
157+
</div>
158+
<template v-else>
159+
<DropdownMenuItem
160+
v-for="team_item in teams"
161+
:key="team_item.id"
162+
@click="selectTeam(team_item)"
163+
class="gap-2 p-2"
164+
>
165+
<div class="flex size-6 items-center justify-center rounded-sm border shrink-0">
166+
<Users class="size-4" />
167+
</div>
168+
<div class="flex flex-col">
169+
<span class="font-medium">{{ team_item.name }}</span>
170+
<span v-if="team_item.description" class="text-xs text-muted-foreground">
171+
{{ team_item.description }}
172+
</span>
173+
</div>
174+
</DropdownMenuItem>
175+
</template>
176+
</DropdownMenuContent>
177+
</DropdownMenu>
178+
</SidebarMenuItem>
179+
</SidebarMenu>
180+
</SidebarHeader>
181+
182+
<SidebarContent> {/* This should be scrollable by default if content overflows */}
183+
<SidebarGroup>
184+
<SidebarGroupLabel>{{ t('sidebar.navigation.title', 'Navigation') }}</SidebarGroupLabel>
185+
<SidebarGroupContent>
186+
<SidebarMenu>
187+
<SidebarMenuItem v-for="item in navigationItems" :key="item.title">
188+
<SidebarMenuButton
189+
@click="navigateTo(item.url)"
190+
:is-active="router.currentRoute.value.path === item.url"
191+
class="w-full justify-start"
192+
:aria-current="router.currentRoute.value.path === item.url ? 'page' : undefined"
193+
>
194+
<component :is="item.icon" class="mr-2 h-4 w-4 shrink-0" />
195+
<span>{{ item.title }}</span>
196+
</SidebarMenuButton>
197+
</SidebarMenuItem>
198+
</SidebarMenu>
199+
</SidebarGroupContent>
200+
</SidebarGroup>
201+
</SidebarContent>
202+
203+
<SidebarFooter>
204+
<SidebarMenu>
205+
<SidebarMenuItem>
206+
<DropdownMenu>
207+
<DropdownMenuTrigger as-child>
208+
<!-- Using Button for consistency -->
209+
<Button variant="ghost" size="lg" class="w-full justify-start items-center data-[state=open]:bg-accent data-[state=open]:text-accent-foreground px-2 h-auto py-2.5">
210+
<Avatar class="h-8 w-8 rounded-lg mr-2 shrink-0">
211+
<AvatarImage src="https://www.shadcn-vue.com/avatars/shadcn.jpg" :alt="userName" />
212+
<AvatarFallback class="rounded-lg">
213+
{{ userLoading ? '...' : getUserInitials(userName || userEmail) }}
214+
</AvatarFallback>
215+
</Avatar>
216+
<div class="grid flex-1 text-left text-sm leading-tight">
217+
<span class="truncate font-semibold">{{ userName || userEmail }}</span>
218+
<span class="truncate text-xs text-muted-foreground">{{ userEmail }}</span>
219+
</div>
220+
<ChevronDown class="ml-2 size-4 text-muted-foreground shrink-0" />
221+
</Button>
222+
</DropdownMenuTrigger>
223+
<DropdownMenuContent
224+
class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
225+
side="top"
226+
align="start"
227+
>
228+
<DropdownMenuItem @click="goToAccount" class="gap-2">
229+
<User class="size-4" />
230+
{{ t('sidebar.user.account') }}
231+
</DropdownMenuItem>
232+
<DropdownMenuSeparator />
233+
<DropdownMenuItem @click="logout" class="gap-2">
234+
<LogOut class="size-4" />
235+
{{ t('sidebar.user.logout') }}
236+
</DropdownMenuItem>
237+
</DropdownMenuContent>
238+
</DropdownMenu>
239+
</SidebarMenuItem>
240+
</SidebarMenu>
241+
</SidebarFooter>
242+
</Sidebar>
243+
</template>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<script setup lang="ts">
2+
import { computed, ref } from 'vue' // Added ref
3+
import type { StyleValue } from 'vue'
4+
import { SidebarProvider, SidebarInset, SidebarTrigger } from '@/components/ui/sidebar' // Assuming SidebarTrigger might be needed
5+
import AppSidebar from '@/components/AppSidebar.vue'
6+
7+
interface Props {
8+
title: string
9+
}
10+
const props = defineProps<Props>()
11+
12+
// TODO: Implement cookie-based persistence for defaultOpen if needed, like in the shadcn/ui example.
13+
// For now, defaulting to true.
14+
const defaultOpen = ref(true)
15+
16+
// Define sidebar width. The shadcn/ui example uses `calc(var(--spacing) * 72)`.
17+
// Assuming var(--spacing) is ~4px, this is 288px. Let's use 18rem (288px).
18+
// The shadcn-vue sidebar components should pick up this CSS variable.
19+
const sidebarStyle = computed(() => ({
20+
'--sidebar-width': '18rem',
21+
// '--sidebar-width-mobile': '16rem', // Optional: if your AppSidebar handles mobile width
22+
} as StyleValue))
23+
24+
// A simple ref for the SiteHeader, can be expanded later
25+
// For now, just using the title prop.
26+
</script>
27+
28+
<template>
29+
<div class="flex h-screen w-full overflow-hidden"> {/* Ensure full screen and no body scroll */}
30+
<SidebarProvider :default-open="defaultOpen" :style="sidebarStyle">
31+
<AppSidebar variant="inset" /> {/* AppSidebar needs to handle this variant prop */}
32+
<SidebarInset>
33+
<!-- SiteHeader equivalent -->
34+
<header class="sticky top-0 z-30 flex h-16 items-center justify-between gap-4 border-b bg-background px-4 sm:px-6">
35+
<div class="flex items-center gap-2">
36+
<SidebarTrigger class="-ml-1 lg:hidden" /> {/* Trigger for mobile/collapsible, hidden on lg if sidebar is always inset */}
37+
<h1 class="text-lg font-semibold md:text-xl">{{ props.title }}</h1>
38+
</div>
39+
<!-- Add other header elements like user menu, search, etc. here if needed -->
40+
</header>
41+
42+
<!-- Main content area -->
43+
<main class="flex flex-1 flex-col overflow-auto p-4 pt-2 md:gap-4 md:p-6 md:pt-4">
44+
<slot />
45+
</main>
46+
</SidebarInset>
47+
</SidebarProvider>
48+
</div>
49+
</template>
50+
51+
<style>
52+
/* Optional: Ensure body doesn't scroll if the layout is truly full-page */
53+
/* body {
54+
overflow: hidden;
55+
} */
56+
</style>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
3+
import { AvatarRoot } from 'reka-ui'
4+
import { cn } from '@/lib/utils'
5+
6+
const props = defineProps<{
7+
class?: HTMLAttributes['class']
8+
}>()
9+
</script>
10+
11+
<template>
12+
<AvatarRoot
13+
data-slot="avatar"
14+
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)"
15+
>
16+
<slot />
17+
</AvatarRoot>
18+
</template>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
3+
import { reactiveOmit } from '@vueuse/core'
4+
import { AvatarFallback, type AvatarFallbackProps } from 'reka-ui'
5+
import { cn } from '@/lib/utils'
6+
7+
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes['class'] }>()
8+
9+
const delegatedProps = reactiveOmit(props, 'class')
10+
</script>
11+
12+
<template>
13+
<AvatarFallback
14+
data-slot="avatar-fallback"
15+
v-bind="delegatedProps"
16+
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)"
17+
>
18+
<slot />
19+
</AvatarFallback>
20+
</template>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<script setup lang="ts">
2+
import type { AvatarImageProps } from 'reka-ui'
3+
import { AvatarImage } from 'reka-ui'
4+
5+
const props = defineProps<AvatarImageProps>()
6+
</script>
7+
8+
<template>
9+
<AvatarImage
10+
data-slot="avatar-image"
11+
v-bind="props"
12+
class="aspect-square size-full"
13+
>
14+
<slot />
15+
</AvatarImage>
16+
</template>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { default as Avatar } from './Avatar.vue'
2+
export { default as AvatarFallback } from './AvatarFallback.vue'
3+
export { default as AvatarImage } from './AvatarImage.vue'

0 commit comments

Comments
 (0)