main
vue 268 lines 6.87 KB
Raw
1 <template>
2 <div class="users-list">
3 <div class="mb-4 flex items-center justify-between gap-5">
4 <div>
5 Total:
6 <strong class="font-mono">{{ usersList.length }}</strong>
7 </div>
8 <div class="flex gap-2">
9 <n-button size="small" @click="showTagRbacSettings = true">
10 <template #icon>
11 <Icon :name="SettingsIcon" />
12 </template>
13 Tag RBAC Settings
14 </n-button>
15 <n-button size="small" type="primary" @click="showForm = true">
16 <template #icon>
17 <Icon :name="UserAddIcon" />
18 </template>
19 Add User
20 </n-button>
21 </div>
22 </div>
23
24 <n-spin :show="loading" content-class="min-h-32">
25 <n-scrollbar x-scrollable class="w-full">
26 <n-table class="min-h-50 min-w-max">
27 <thead>
28 <tr>
29 <th>ID</th>
30 <th>Username</th>
31 <th>Email</th>
32 <th>Role</th>
33 <th class="max-w-75"></th>
34 </tr>
35 </thead>
36 <tbody>
37 <tr
38 v-for="user of usersList"
39 :key="user.id"
40 :class="{ highlight: highlight === user.id.toString() }"
41 >
42 <td>#{{ user.id }}</td>
43 <td>
44 {{ user.username }}
45 </td>
46 <td>
47 {{ user.email }}
48 </td>
49 <td>
50 <n-tag :type="getRoleTagType(user.role_name)" size="small">
51 {{ user.role_name || "No Role" }}
52 </n-tag>
53 </td>
54 <td class="max-w-75">
55 <div v-if="isAdmin" class="flex justify-end">
56 <n-dropdown
57 trigger="click"
58 :options
59 display-directive="show"
60 :keyboard="false"
61 @click="selectedUser = user"
62 >
63 <n-button text>
64 <template #icon>
65 <Icon :name="DropdownIcon" :size="24" />
66 </template>
67 </n-button>
68 </n-dropdown>
69 </div>
70 </td>
71 </tr>
72 </tbody>
73 </n-table>
74 </n-scrollbar>
75 </n-spin>
76
77 <n-modal
78 v-model:show="showTagRbacSettings"
79 display-directive="show"
80 preset="card"
81 :style="{ maxWidth: 'min(700px, 90vw)', overflow: 'hidden' }"
82 title="Tag RBAC Settings"
83 :bordered="false"
84 segmented
85 >
86 <TagRbacSettings />
87 </n-modal>
88
89 <n-modal
90 v-model:show="showForm"
91 display-directive="show"
92 preset="card"
93 :style="{ maxWidth: 'min(600px, 90vw)', minHeight: 'min(300px, 90vh)', overflow: 'hidden' }"
94 title="Add a new User"
95 :bordered="false"
96 content-class="flex flex-col"
97 segmented
98 >
99 <SignUp
100 :unavailable-username-list="usernameList"
101 :unavailable-email-list="emailList"
102 @success="addUserSuccess()"
103 />
104 </n-modal>
105 </div>
106 </template>
107
108 <script setup lang="ts">
109 // TODO-FE: refactor
110 import type { User } from "@/types/user.d"
111 import { NButton, NDropdown, NModal, NScrollbar, NSpin, NTable, NTag, useMessage } from "naive-ui"
112 import { computed, defineAsyncComponent, h, onBeforeMount, ref } from "vue"
113 import Api from "@/api"
114 import Icon from "@/components/common/Icon.vue"
115 import { useAuthStore } from "@/stores/auth"
116
117 const { highlight } = defineProps<{ highlight: string | null | undefined }>()
118 const ChangePassword = defineAsyncComponent(() => import("./ChangePassword.vue"))
119 const DeleteUser = defineAsyncComponent(() => import("./DeleteUser.vue"))
120 const AssignRole = defineAsyncComponent(() => import("./AssignRole.vue"))
121 const AssignCustomer = defineAsyncComponent(() => import("./AssignCustomer.vue"))
122 const AssignTags = defineAsyncComponent(() => import("./AssignTags.vue"))
123 const TagRbacSettings = defineAsyncComponent(() => import("./TagRbacSettings.vue"))
124 const SignUp = defineAsyncComponent(() => import("@/components/auth/SignUp.vue"))
125
126 const UserAddIcon = "carbon:user-follow"
127 const SettingsIcon = "carbon:settings"
128 const DropdownIcon = "carbon:overflow-menu-horizontal"
129 const message = useMessage()
130 const loadingUsers = ref(false)
131 const loadingDelete = ref(false)
132 const showForm = ref(false)
133 const showTagRbacSettings = ref(false)
134 const usersList = ref<User[]>([])
135 const isAdmin = useAuthStore().isAdmin
136 const selectedUser = ref<User | null>(null)
137 const loading = computed(() => loadingUsers.value || loadingDelete.value)
138 const usernameList = computed(() => usersList.value.map(user => user.username))
139 const emailList = computed(() => usersList.value.map(user => user.email))
140
141 function getRoleTagType(roleName: string | null | undefined) {
142 switch (roleName?.toLowerCase()) {
143 case "admin":
144 return "error"
145 case "analyst":
146 return "warning"
147 case "scheduler":
148 return "info"
149 case "customer_user":
150 return "success"
151 default:
152 return "default"
153 }
154 }
155
156 // Computed (not a static array) so the dropdown re-renders whenever `selectedUser`
157 // changes. The render closures below read `selectedUser.value`, but the parent
158 // template never references it directly — if `options` were a stable array
159 // reference, NDropdown would never re-render and the child modals (kept mounted by
160 // `display-directive="show"`) would keep a stale `user` prop, intermittently
161 // showing the wrong user's data or nothing at all. See issue #899.
162 const options = computed(() => [
163 {
164 key: "AssignRole",
165 type: "render",
166 render: () =>
167 h(AssignRole, {
168 user: selectedUser.value || undefined,
169 onSuccess: getUsers
170 })
171 },
172 {
173 key: "AssignCustomer",
174 type: "render",
175 render: () =>
176 h(AssignCustomer, {
177 user: selectedUser.value || undefined,
178 onSuccess: getUsers
179 })
180 },
181 {
182 key: "AssignTags",
183 type: "render",
184 render: () =>
185 // TODO-FE: use button + modal (see AssignCustomer, AssignRole)
186 h(AssignTags, {
187 user: selectedUser.value || undefined,
188 onSuccess: getUsers
189 })
190 },
191 {
192 key: "ChangePassword",
193 type: "render",
194 render: () =>
195 h(ChangePassword, {
196 user: selectedUser.value || undefined,
197 quaternary: true,
198 className: "w-full! justify-start!"
199 })
200 },
201 {
202 key: "DeleteUser",
203 type: "render",
204 render: () =>
205 h(DeleteUser, {
206 user: selectedUser.value || undefined,
207 onSuccess: getUsers,
208 onLoading: updateLoadingDelete
209 })
210 }
211 ])
212
213 function updateLoadingDelete(value: boolean) {
214 loadingDelete.value = value
215 }
216
217 function addUserSuccess() {
218 getUsers()
219 showForm.value = false
220 }
221
222 function getUsers() {
223 loadingUsers.value = true
224
225 Api.users
226 .getUsers()
227 .then(res => {
228 if (res.data.success) {
229 usersList.value = res.data?.users || []
230 } else {
231 message.warning(res.data?.message || "An error occurred. Please try again later.")
232 }
233 })
234 .catch(err => {
235 usersList.value = []
236
237 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
238 })
239 .finally(() => {
240 loadingUsers.value = false
241 })
242 }
243
244 onBeforeMount(() => {
245 getUsers()
246 })
247 </script>
248
249 <style lang="scss" scoped>
250 .users-list {
251 border-radius: var(--border-radius);
252 overflow: hidden;
253
254 tr:hover {
255 td {
256 background-color: rgba(var(--primary-color-rgb) / 0.05);
257 }
258 }
259
260 .highlight {
261 td {
262 border-top: 1px solid rgba(var(--primary-color-rgb) / 0.3);
263 border-bottom: 1px solid rgba(var(--primary-color-rgb) / 0.3);
264 background-color: rgba(var(--primary-color-rgb) / 0.05);
265 }
266 }
267 }
268 </style>