فهرست منبع

add user management

Jason Gorst 4 روز پیش
والد
کامیت
0598b8095a

+ 97 - 0
app/components/UserEditor.vue

@@ -0,0 +1,97 @@
+<template>
+  <EditorCard
+    name="user"
+    :modelId="props.userId"
+    :action="props.action"
+    :fields="fields"
+    :initialValue="props.initialValue"
+    :options="options"
+    :isSaved="isSaved"
+    :redirectBack="{ name: 'admin:users' }"
+    :schema="schema"
+    @create="create"
+    @update="update"
+    @delete="destroy"
+  />
+</template>
+
+<script setup>
+const props = defineProps({
+  action: {
+    type: String,
+    default: "update",
+    validator: (value) => _includes(["create", "update"], value)
+  },
+
+  userId: {
+    type: String,
+    required: false,
+    validator: (value, props) => props.action === "create" || isPresent(value)
+  },
+
+  initialValue: {
+    type: Object,
+    default: {},
+    required: false
+  }
+})
+
+const {
+  $socketio: { socket }
+} = useNuxtApp()
+
+const toast = useToast()
+const { createUser } = useCreateUser()
+const { updateUser } = useUpdateUser()
+const { deleteUser } = useDeleteUser()
+
+const schema = props.action === "create" ? createUserSchema : updateUserSchema
+const options = _mapValues(userFields, "options")
+
+const fields = computed(() => props.action === "create" ? userFields : _omit(userFields, "password"))
+const isSaved = ref(false)
+
+async function create(user) {
+  createUser(user)
+  isSaved.value = true
+
+  toast.add({
+    severity: "success",
+    summary: "Saved.",
+    detail: "The user is saved.",
+    life: 3000
+  })
+
+  await navigateTo({ name: "admin:users" })
+}
+
+async function update(editedFields) {
+  updateUser(props.userId, editedFields)
+  isSaved.value = true
+
+  toast.add({
+    severity: "success",
+    summary: "Updated.",
+    detail: "The user is updated.",
+    life: 3000
+  })
+
+  await navigateTo({ name: "admin:users" })
+}
+
+async function destroy() {
+  deleteUser(props.userId)
+  isSaved.value = true
+
+  toast.add({
+    severity: "success",
+    summary: "Deleted.",
+    detail: "The user is deleted.",
+    life: 3000
+  })
+
+  await navigateTo({ name: "admin:users" })
+}
+</script>
+
+<style scoped></style>

+ 38 - 0
app/components/UserToolbar.vue

@@ -0,0 +1,38 @@
+<template>
+  <Toolbar :pt="{ root: 'min-h-11.5 rounded-b-none' }">
+    <template #start>
+      <div class="ps-6 text-sm whitespace-nowrap text-primary">
+        Showing
+        <strong class="font-semibold">{{ count }}</strong>
+        {{ pluralize("user", count) }}
+      </div>
+    </template>
+
+    <template #end>
+      <Button
+        class="border-none px-6! py-3.25"
+        variant="text"
+      >
+        <NuxtLink
+          class="flex items-center gap-0.5"
+          :to="{ name: 'admin:userCreate' }"
+        >
+          <span class="font-semibold">Add</span>
+
+          <Icon name="ph:plus-bold" />
+        </NuxtLink>
+      </Button>
+    </template>
+  </Toolbar>
+</template>
+
+<script setup>
+const props = defineProps({
+  count: {
+    type: Number,
+    required: true
+  }
+})
+</script>
+
+<style scoped></style>

+ 11 - 0
app/mutations/users/useCreateUser.js

@@ -0,0 +1,11 @@
+export const useCreateUser = defineMutation(() => {
+  const { mutate, ...mutation } = useMutation({
+    mutation: ({ user }) => useEmit("user:create", user),
+    onError: (error) => console.error("[useCreateUser] [onError]", error)
+  })
+
+  return {
+    ...mutation,
+    createUser: (user) => mutate({ user })
+  }
+})

+ 29 - 0
app/mutations/users/useDeleteUser.js

@@ -0,0 +1,29 @@
+import { useOptimisticUpdate } from "pinia-colada-plugin-normalizer"
+
+export const useDeleteUser = defineMutation(() => {
+  const { transaction } = useOptimisticUpdate()
+
+  const { mutate, ...mutation } = useMutation({
+    onMutate: ({ id })  => {
+      const tx = transaction()
+      tx.remove("user", id)
+
+      return tx
+    },
+
+    mutation: ({ id }) => useEmit("user:delete", id),
+
+    onError: (error, _vars, { rollback }) => {
+      rollback?.()
+
+      console.error("[useDeleteUser] [onError]", error)
+    },
+
+    onSuccess: (_data, _vars, { commit }) => commit?.()
+  })
+
+  return {
+    ...mutation,
+    deleteUser: (id) => mutate({ id })
+  }
+})

+ 29 - 0
app/mutations/users/useUpdateUser.js

@@ -0,0 +1,29 @@
+import { useOptimisticUpdate } from "pinia-colada-plugin-normalizer"
+
+export const useUpdateUser = defineMutation(() => {
+  const { transaction } = useOptimisticUpdate()
+
+  const { mutate, ...mutation } = useMutation({
+    onMutate({ id, updates }) {
+      const tx = transaction()
+      tx.set("user", id, updates)
+
+      return tx
+    },
+
+    mutation: ({ id, updates }) => useEmit("user:update", id, updates),
+
+    onError(error, _vars, { rollback }) {
+      rollback?.()
+
+      console.error("[useUpdateUser] [onError]", error)
+    },
+
+    onSuccess: (_data, _vars, { commit }) => commit?.()
+  })
+
+  return {
+    ...mutation,
+    updateUser: (id, updates) => mutate({ id, updates })
+  }
+})

+ 17 - 0
app/pages/admin/users/create.vue

@@ -0,0 +1,17 @@
+<template>
+  <UserEditor
+    action="create"
+    :initialValue="emptyUser"
+  />
+</template>
+
+<script setup>
+definePageMeta({
+  name: "admin:userCreate",
+  middleware: ["signed-in", "admin"]
+})
+
+const emptyUser = _mapValues(userFields, "initialValue")
+</script>
+
+<style scoped></style>

+ 29 - 0
app/pages/admin/users/edit.vue

@@ -0,0 +1,29 @@
+<!--suppress JSValidateTypes -->
+<template>
+  <div>
+    <SpinnerModal
+      v-if="isPending"
+      :visible="true"
+    />
+
+    <UserEditor
+      v-else
+      action="update"
+      :userId="userId"
+      :initialValue="user"
+    />
+  </div>
+</template>
+
+<script setup>
+definePageMeta({
+  name: "admin:userEdit",
+  path: "/admin/users/:userId([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})",
+  middleware: ["signed-in", "admin"]
+})
+
+const userId = useRoute().params?.userId
+const { data: user, isPending } = useQuery(() => userByIdQuery(userId))
+</script>
+
+<style scoped></style>

+ 105 - 0
app/pages/admin/users/index.vue

@@ -0,0 +1,105 @@
+<!--suppress JSValidateTypes -->
+<template>
+  <div>
+    <DataTable
+      :value="users"
+      datakey="id"
+      :loading="isPending"
+      scrollable
+      :scrollHeight="`calc(100vh - ${elementHeights}px)`"
+      selectionMode="single"
+      :pt="{
+        header: { id: 'datatable_header' },
+        footer: { id: 'datatable_footer' },
+        tbody: 'text-base'
+      }"
+      @rowSelect="showUserEdit"
+    >
+      <Column
+        v-for="column of columns"
+        :key="column"
+        :field="column"
+        :header="_upperCase(column)"
+      >
+        <template #body="slotProps">
+          <template v-if="_isDate(slotProps.data?.[column])">
+            {{ toNumericDate(slotProps.data?.[column]) }}
+          </template>
+
+          <template v-else>
+            {{ slotProps.data?.[column] }}
+          </template>
+        </template>
+      </Column>
+
+      <template #loading>
+        <SpinnerModal
+          :visible="true"
+          maskClass="bg-surface!"
+        />
+      </template>
+
+      <template #footer>
+        <UserToolbar
+          :class="isPending && 'hidden'"
+          :count="count"
+        />
+      </template>
+    </DataTable>
+  </div>
+</template>
+
+<script setup>
+definePageMeta({
+  name: "admin:users",
+  middleware: ["signed-in", "admin"]
+})
+
+const { data: users, isPending } = useQuery(userListQuery)
+
+const columns = ["email", "name", "username", "role", "createdAt"]
+const count = computed(() => users?.value ? users.value.length : 0)
+
+const DEFAULT_ELEMENT_HEIGHTS = 109
+const elementHeights = ref(DEFAULT_ELEMENT_HEIGHTS)
+
+onMounted(() => updateElementHeights())
+onUpdated(() => updateElementHeights())
+
+function toNumericDate(date) {
+  return new Intl.DateTimeFormat(undefined, {
+    year: "numeric",
+    month: "numeric",
+    day: "numeric",
+    hour: "numeric",
+    minute: "numeric"
+  }).format(date)
+}
+
+async function showUserEdit({ data: { id: userId } }) {
+  await navigateTo({ name: 'admin:userEdit', params: { userId } })
+}
+
+function updateElementHeights() {
+  elementHeights.value = totalElementHeights()
+}
+
+function totalElementHeights() {
+  // total height of non-datatable elements (in pixels)
+  const elements = ["navbar", "datatable_footer"]
+
+  // noinspection JSUnresolvedReference
+  let totalHeights = _reduce(
+    elements,
+    (acc, element) => acc + document?.getElementById(element)?.offsetHeight,
+    0
+  )
+
+  // plus 16px [--spacing(4)] navbar bottom margin
+  totalHeights += 16
+
+  return _isNaN(totalHeights) ? DEFAULT_ELEMENT_HEIGHTS : totalHeights
+}
+</script>
+
+<style scoped></style>

+ 14 - 0
app/queries/user.js

@@ -0,0 +1,14 @@
+export const userQueryKeys = {
+  root: ["users"],
+  byId: (id) => ["user", id]
+}
+
+export const userListQuery = defineQueryOptions({
+  key: userQueryKeys.root,
+  query: () => useEmit("user:list")
+})
+
+export const userByIdQuery = defineQueryOptions((id) => ({
+  key: userQueryKeys.byId(id),
+  query: () => useEmit("user:read", id)
+}))

+ 18 - 28
server/socketio/handlers/userHandlers.js

@@ -1,22 +1,14 @@
 export function userHandlers(_io, socket) {
   socket.on("user:read", readUser)
   socket.on("user:list", listUsers)
-  socket.on("user:paginate", paginateUsers)
-  socket.on("user:count", countUsers)
   socket.on("user:delete", deleteUser)
   socket.on("user:create", createUser)
   socket.on("user:update", updateUser)
 
-  const userHandlerOptions = {
-    user: socket.data?.user,
-    resource: "user",
-    idValidator: userSchema.shape.id.parse
-  }
-
   async function readUser(id, callback) {
     await userHandler({
-      id,
       callback,
+      id,
 
       query: ({ id }) =>
         prisma.user.findUnique({
@@ -38,24 +30,6 @@ export function userHandlers(_io, socket) {
     })
   }
 
-  async function paginateUsers(skip, take, callback) {
-    await userHandler({
-      callback,
-
-      query: () =>
-        prisma.user.findMany({
-          omit: { banned: true, banReason: true, banExpires: true },
-          orderBy: [{ createdAt: "asc" }],
-          skip,
-          take
-        })
-    })
-  }
-
-  async function countUsers(callback) {
-    await userHandler({ callback, query: () => prisma.user.count() })
-  }
-
   async function deleteUser(id, callback) {
     await userHandler({
       callback,
@@ -95,6 +69,22 @@ export function userHandlers(_io, socket) {
   }
 
   async function userHandler(options) {
-    return await executeQuery({ ...userHandlerOptions, ...options })
+    const defaultOptions = {
+      user: socket.data?.user,
+      resource: "user",
+      idValidator: userSchema.shape.id.parse
+    }
+
+    const typenameMutator = injectTypename("user")
+    let mutator
+
+    if (_has(options, "mutator")) {
+      mutator = _flow([options.mutator, typenameMutator])
+      _unset(options, "mutator")
+    } else {
+      mutator = typenameMutator
+    }
+
+    return await executeQuery({ ...defaultOptions, ...options, mutator })
   }
 }

+ 1 - 1
shared/utils/schema.js

@@ -1,2 +1,2 @@
 export * from "./schema/character.js"
-// export * from "./schema/user.js"
+export * from "./schema/user.js"

+ 12 - 0
shared/utils/userFields.js

@@ -0,0 +1,12 @@
+export default {
+  email: { type: "text", initialValue: "" },
+  password: { type: "password", initialValue: "" },
+  name: { type: "text", initialValue: "" },
+  username: { type: "text", initialValue: "" },
+
+  role: {
+    type: "select",
+    options: ["user", "admin"],
+    initialValue: "user"
+  }
+}