Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit bd3f8cd

Browse files
committed
refactor(frontend): convert admin users detail page to multi-route structure
Transform admin users detail page from single-file query-parameter approach to multi-route architecture matching the teams pattern. Changes: - Replace /admin/users/:id with route-based navigation - Add redirect from /admin/users/:id to /admin/users/:id/general - Create separate routes for general and teams tabs - Extract useUserDetailCache composable for shared state management with EventBus caching - Add UserDetailTabs component for desktop sidebar and mobile navigation - Add UserDetailPageHeading component with breadcrumbs and actions slot - Extract ForceResetPasswordButton component from inline implementation - Create general.vue and teams.vue view files for each tab - Add UserService.getById() method for API calls - Update component barrel exports Routes: - /admin/users/:id → redirects to /admin/users/:id/general - /admin/users/:id/general → General tab (user info, role management) - /admin/users/:id/teams → Teams tab (team memberships)
1 parent 786c015 commit bd3f8cd

File tree

10 files changed

+589
-302
lines changed

10 files changed

+589
-302
lines changed
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<script setup lang="ts">
2+
import { ref, computed } from 'vue'
3+
import { useI18n } from 'vue-i18n'
4+
import { toast } from 'vue-sonner'
5+
import {
6+
AlertDialog,
7+
AlertDialogAction,
8+
AlertDialogCancel,
9+
AlertDialogContent,
10+
AlertDialogDescription,
11+
AlertDialogFooter,
12+
AlertDialogHeader,
13+
AlertDialogTitle,
14+
AlertDialogTrigger,
15+
} from '@/components/ui/alert-dialog'
16+
import { Button } from '@/components/ui/button'
17+
import { Spinner } from '@/components/ui/spinner'
18+
import { UserService } from '@/services/userService'
19+
import type { User } from '@/views/admin/users/types'
20+
21+
interface Props {
22+
user: User
23+
}
24+
25+
const props = defineProps<Props>()
26+
const { t } = useI18n()
27+
28+
const isResetLoading = ref(false)
29+
30+
const canResetPassword = computed(() => {
31+
return props.user?.auth_type === 'email_signup'
32+
})
33+
34+
const handlePasswordReset = async () => {
35+
if (!props.user) return
36+
37+
try {
38+
isResetLoading.value = true
39+
const result = await UserService.adminResetPassword(props.user.email)
40+
41+
if (result.success) {
42+
toast.success(t('adminUsers.userDetail.actions.resetPasswordSuccess', {
43+
email: props.user.email
44+
}))
45+
}
46+
} catch (error) {
47+
const errorKey = 'adminUsers.userDetail.actions.resetPasswordError'
48+
let errorText = error instanceof Error ? error.message : 'Unknown error'
49+
50+
if (error instanceof Error) {
51+
switch (error.message) {
52+
case 'INVALID_USER':
53+
errorText = 'User not found or not eligible for password reset'
54+
break
55+
case 'UNAUTHORIZED':
56+
errorText = 'You are not authorized to perform this action'
57+
break
58+
case 'FORBIDDEN':
59+
errorText = 'This action is forbidden'
60+
break
61+
case 'SERVICE_UNAVAILABLE':
62+
errorText = 'Email service is currently unavailable'
63+
break
64+
}
65+
}
66+
67+
toast.error(t(errorKey, { error: errorText }))
68+
} finally {
69+
isResetLoading.value = false
70+
}
71+
}
72+
</script>
73+
74+
<template>
75+
<AlertDialog>
76+
<AlertDialogTrigger as-child>
77+
<Button
78+
variant="outline"
79+
:disabled="!canResetPassword || isResetLoading"
80+
:title="!canResetPassword ? t('adminUsers.userDetail.actions.resetPasswordDisabled') : undefined"
81+
>
82+
<Spinner v-if="isResetLoading" class="mr-2" />
83+
{{ t('adminUsers.userDetail.actions.forceResetPassword') }}
84+
</Button>
85+
</AlertDialogTrigger>
86+
87+
<AlertDialogContent>
88+
<AlertDialogHeader>
89+
<AlertDialogTitle>
90+
{{ t('adminUsers.userDetail.actions.resetPasswordConfirm.title') }}
91+
</AlertDialogTitle>
92+
<AlertDialogDescription>
93+
{{ t('adminUsers.userDetail.actions.resetPasswordConfirm.description', {
94+
username: user.username
95+
}) }}
96+
</AlertDialogDescription>
97+
</AlertDialogHeader>
98+
99+
<AlertDialogFooter>
100+
<AlertDialogCancel>
101+
{{ t('adminUsers.userDetail.actions.resetPasswordConfirm.cancel') }}
102+
</AlertDialogCancel>
103+
<AlertDialogAction as-child>
104+
<Button
105+
@click="handlePasswordReset"
106+
:disabled="isResetLoading"
107+
>
108+
<Spinner v-if="isResetLoading" class="mr-2" />
109+
{{ t('adminUsers.userDetail.actions.resetPasswordConfirm.confirm') }}
110+
</Button>
111+
</AlertDialogAction>
112+
</AlertDialogFooter>
113+
</AlertDialogContent>
114+
</AlertDialog>
115+
</template>
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue'
3+
import { useI18n } from 'vue-i18n'
4+
import { DsPageHeading } from '@/components/ui/ds-page-heading'
5+
import { Skeleton } from '@/components/ui/skeleton'
6+
import {
7+
Breadcrumb,
8+
BreadcrumbItem,
9+
BreadcrumbLink,
10+
BreadcrumbList,
11+
BreadcrumbPage,
12+
BreadcrumbSeparator,
13+
} from '@/components/ui/breadcrumb'
14+
import type { User } from '@/views/admin/users/types'
15+
16+
interface Props {
17+
user: User | null
18+
isLoading?: boolean
19+
}
20+
21+
const props = defineProps<Props>()
22+
const { t } = useI18n()
23+
24+
// Display name: first_name + last_name or fallback to username
25+
const displayName = computed(() => {
26+
if (!props.user) return ''
27+
const firstName = props.user.first_name || ''
28+
const lastName = props.user.last_name || ''
29+
const fullName = `${firstName} ${lastName}`.trim()
30+
return fullName || props.user.username
31+
})
32+
</script>
33+
34+
<template>
35+
<!-- When user is loaded -->
36+
<DsPageHeading v-if="user" :title="displayName" :show-border="false">
37+
<Breadcrumb>
38+
<BreadcrumbList>
39+
<BreadcrumbItem>
40+
<BreadcrumbLink as-child>
41+
<RouterLink to="/admin/users">
42+
{{ t('adminUsers.title') }}
43+
</RouterLink>
44+
</BreadcrumbLink>
45+
</BreadcrumbItem>
46+
<BreadcrumbSeparator />
47+
<BreadcrumbItem>
48+
<BreadcrumbPage>{{ user.username }}</BreadcrumbPage>
49+
</BreadcrumbItem>
50+
</BreadcrumbList>
51+
</Breadcrumb>
52+
53+
<!-- Actions slot for Force Reset Password button -->
54+
<template #actions>
55+
<slot name="actions" />
56+
</template>
57+
</DsPageHeading>
58+
59+
<!-- Loading state (shows skeleton breadcrumb) -->
60+
<DsPageHeading v-else :title="t('adminUsers.userDetail.titleLoading')" :show-border="false">
61+
<Breadcrumb>
62+
<BreadcrumbList>
63+
<BreadcrumbItem>
64+
<BreadcrumbLink as-child>
65+
<RouterLink to="/admin/users">
66+
{{ t('adminUsers.title') }}
67+
</RouterLink>
68+
</BreadcrumbLink>
69+
</BreadcrumbItem>
70+
<BreadcrumbSeparator />
71+
<BreadcrumbItem>
72+
<Skeleton class="h-4 w-48" />
73+
</BreadcrumbItem>
74+
</BreadcrumbList>
75+
</Breadcrumb>
76+
</DsPageHeading>
77+
</template>
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue'
3+
import { useRoute, useRouter } from 'vue-router'
4+
import { SettingsMenu, SettingsMenuGroup, SettingsMenuItem } from '@/components/ui/settings-menu'
5+
import type { User } from '@/views/admin/users/types'
6+
7+
interface Props {
8+
user: User
9+
userId: string
10+
}
11+
12+
const props = defineProps<Props>()
13+
const route = useRoute()
14+
const router = useRouter()
15+
16+
// Navigation menu items
17+
const menuItems = computed(() => [
18+
{ id: 'general', label: 'General', path: `/admin/users/${props.userId}/general` },
19+
{ id: 'teams', label: 'Teams', path: `/admin/users/${props.userId}/teams` }
20+
])
21+
22+
// Map route names to section IDs
23+
const routeToSectionMap: Record<string, string> = {
24+
'AdminUserDetailGeneral': 'general',
25+
'AdminUserDetailTeams': 'teams',
26+
}
27+
28+
// Get current section from route name
29+
const currentSection = computed(() => {
30+
const routeName = route.name as string
31+
return routeToSectionMap[routeName] || 'general'
32+
})
33+
34+
// Navigate to a section (for mobile buttons)
35+
function navigateToSection(sectionId: string) {
36+
const item = menuItems.value.find(item => item.id === sectionId)
37+
if (item) {
38+
router.push(item.path)
39+
}
40+
}
41+
</script>
42+
43+
<template>
44+
<div class="flex flex-col space-y-8 md:flex-row md:space-x-12 md:space-y-0">
45+
<!-- Desktop Sidebar Navigation -->
46+
<aside class="hidden md:block w-56 shrink-0">
47+
<SettingsMenu>
48+
<SettingsMenuGroup>
49+
<SettingsMenuItem
50+
v-for="item in menuItems"
51+
:key="item.id"
52+
:to="item.path"
53+
:active="currentSection === item.id"
54+
>
55+
{{ item.label }}
56+
</SettingsMenuItem>
57+
</SettingsMenuGroup>
58+
</SettingsMenu>
59+
</aside>
60+
61+
<!-- Mobile Navigation -->
62+
<div class="block md:hidden">
63+
<nav class="flex space-x-1 p-1 bg-muted/50 rounded-lg">
64+
<button
65+
v-for="item in menuItems"
66+
:key="item.id"
67+
@click="navigateToSection(item.id)"
68+
class="flex-1 px-3 py-2 text-sm font-medium rounded-md transition-colors"
69+
:class="currentSection === item.id
70+
? 'bg-background text-foreground shadow-sm'
71+
: 'text-muted-foreground hover:text-foreground'"
72+
>
73+
{{ item.label }}
74+
</button>
75+
</nav>
76+
</div>
77+
78+
<!-- Content Area Slot -->
79+
<div class="flex-1">
80+
<slot />
81+
</div>
82+
</div>
83+
</template>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
export { default as UserDetailGeneral } from './UserDetailGeneral.vue'
22
export { default as UserDetailTeams } from './UserDetailTeams.vue'
33
export { default as UserTableColumns } from './UserTableColumns.vue'
4+
export { default as UserDetailTabs } from './UserDetailTabs.vue'
5+
export { default as UserDetailPageHeading } from './UserDetailPageHeading.vue'
6+
export { default as ForceResetPasswordButton } from './ForceResetPasswordButton.vue'

0 commit comments

Comments
 (0)