1
0

18 Коммиты 5f62bd0ac5 ... 1b669f43a4

Автор SHA1 Сообщение Дата
  Jason Gorst 1b669f43a4 call resetFilterFor() from resetGlobalFilter(), check field type in removeFilterValueFrom() 4 дней назад
  Jason Gorst 08ef5a2aaf log normalizer activity 4 дней назад
  Jason Gorst bbce76a87e move sleep() to shared 4 дней назад
  Jason Gorst 0598b8095a add user management 4 дней назад
  Jason Gorst 9c46916b01 add delay plugin, use explicit entity definition 4 дней назад
  Jason Gorst c1f546ebd2 stringify character id 4 дней назад
  Jason Gorst ca71e4b1a5 handle characters and options loading separately 4 дней назад
  Jason Gorst 7000277c99 add PasswordField 4 дней назад
  Jason Gorst e1788ecbc5 add logging hooks to normalizer plugin 4 дней назад
  Jason Gorst 7b41b7c3c1 use file name matching folder instead of index.js for subfolders 4 дней назад
  Jason Gorst 61a6f6fe18 coerce character id to number in schema 4 дней назад
  Jason Gorst c60039a8db add auth 4 дней назад
  Jason Gorst 9e89eb875d stringify ids in handler, refactor out injectTypename, duplicate id as characterId 4 дней назад
  Jason Gorst 6863cd59f4 serialize entity store 4 дней назад
  Jason Gorst f39809cad0 refactor out getContext() 4 дней назад
  Jason Gorst 8e53cbd61a add head assets 5 дней назад
  Jason Gorst 2782df02cc fix nuxt version compatability 5 дней назад
  Jason Gorst 1dff58ae17 add better-auth, testing, colada delay plugin 5 дней назад
58 измененных файлов с 1454 добавлено и 535 удалено
  1. 2 0
      app/app.vue
  2. 26 17
      app/components/CharacterEditor.vue
  3. 14 5
      app/components/EditorCard.vue
  4. 29 0
      app/components/PasswordField.vue
  5. 97 0
      app/components/UserEditor.vue
  6. 38 0
      app/components/UserToolbar.vue
  7. 21 0
      app/composables/useLogNormalizer.js
  8. 20 0
      app/middleware/admin.js
  9. 2 2
      app/mutations/characters/useDeleteCharacter.js
  10. 1 1
      app/mutations/characters/useUpdateCharacter.js
  11. 11 0
      app/mutations/users/useCreateUser.js
  12. 29 0
      app/mutations/users/useDeleteUser.js
  13. 29 0
      app/mutations/users/useUpdateUser.js
  14. 17 0
      app/pages/admin/users/create.vue
  15. 29 0
      app/pages/admin/users/edit.vue
  16. 105 0
      app/pages/admin/users/index.vue
  17. 3 2
      app/pages/edit.vue
  18. 20 15
      app/pages/index.vue
  19. 1 1
      app/pages/show.vue
  20. 21 0
      app/plugins/normalizePlugin.js
  21. 10 17
      app/plugins/socketio.js
  22. 5 4
      app/queries/character.js
  23. 14 0
      app/queries/user.js
  24. 6 2
      app/stores/useFiltersStore.js
  25. 15 2
      colada.options.js
  26. 1 1
      modules/lodash-shared/index.js
  27. 34 3
      nuxt.config.js
  28. 28 19
      package.json
  29. 24 0
      playwright.config.js
  30. 279 381
      pnpm-lock.yaml
  31. BIN
      public/apple-touch-icon.png
  32. BIN
      public/google-touch-icon.png
  33. 13 0
      public/manifest.json
  34. 22 0
      public/theater-masks-solid.svg
  35. 6 5
      server/plugins/socketio.js
  36. 2 0
      server/socketio/handlers.js
  37. 11 18
      server/socketio/handlers/characterHandlers.js
  38. 0 2
      server/socketio/handlers/index.js
  39. 18 28
      server/socketio/handlers/userHandlers.js
  40. 1 0
      server/socketio/middlewares.js
  41. 1 0
      server/socketio/middlewares/authMiddleware.js
  42. 0 1
      server/socketio/middlewares/index.js
  43. 3 1
      server/utils/AuthError.js
  44. 25 0
      server/utils/authorize.js
  45. 12 6
      server/utils/executeQuery.js
  46. 13 0
      server/utils/injectEntityId.js
  47. 13 0
      server/utils/injectTypename.js
  48. 9 0
      server/utils/stringifyIds.js
  49. 1 1
      shared/utils/schema.js
  50. 3 1
      shared/utils/schema/character.js
  51. 0 0
      shared/utils/sleep.js
  52. 12 0
      shared/utils/userFields.js
  53. 28 0
      test/mocks/components/Icon.mock.vue
  54. 41 0
      test/mocks/composables/useAuthClient.mock.js
  55. 14 0
      test/nuxt/app/components/ToastContainer.test.js
  56. 263 0
      test/nuxt/app/stores/useFiltersStore.test.js
  57. 0 0
      test/nuxt/setup.js
  58. 12 0
      vitest.config.js

+ 2 - 0
app/app.vue

@@ -16,6 +16,8 @@
 
 <script setup>
 import { PiniaColadaDevtools } from "@pinia/colada-devtools"
+
+useLogNormalizer()
 </script>
 
 <style>

+ 26 - 17
app/components/CharacterEditor.vue

@@ -1,18 +1,26 @@
 <template>
-  <EditorCard
-    name="character"
-    :modelId="props.characterId"
-    :action="props.action"
-    :fields="characterFields"
-    :initialValue="props.initialValue"
-    :options="options"
-    :isSaved="isSaved"
-    :redirectBack="{ name: 'characters' }"
-    :schema="schema"
-    @create="create"
-    @update="update"
-    @delete="destroy"
-  />
+  <div>
+    <SpinnerModal
+      v-if="isPending"
+      :visible="true"
+    />
+
+    <EditorCard
+      v-else
+      name="character"
+      :modelId="props.characterId"
+      :action="props.action"
+      :fields="characterFields"
+      :initialValue="props.initialValue"
+      :options="options"
+      :isSaved="isSaved"
+      :redirectBack="{ name: 'characters' }"
+      :schema="schema"
+      @create="create"
+      @update="update"
+      @delete="destroy"
+    />
+  </div>
 </template>
 
 <script setup>
@@ -24,8 +32,9 @@ const props = defineProps({
   },
 
   characterId: {
-    type: Number,
-    validator: (value, props) => props.action === "create" || _isInteger(value)
+    type: String,
+    required: false,
+    validator: (value, props) => props.action === "create" || isPresent(value)
   },
 
   initialValue: {
@@ -40,7 +49,7 @@ const { createCharacter } = useCreateCharacter()
 const { updateCharacter } = useUpdateCharacter()
 const { deleteCharacter } = useDeleteCharacter()
 
-const { data: options } = useQuery(characterOptionsQuery)
+const { data: options, isPending } = useQuery(characterOptionsQuery)
 
 const schema =
   props.action === "create" ? createCharacterSchema : updateCharacterSchema

+ 14 - 5
app/components/EditorCard.vue

@@ -1,4 +1,4 @@
-<!--suppress VueUnrecognizedSlot -->
+<!--suppress VueUnrecognizedSlot, JSUnresolvedReference -->
 <template>
   <Card class="mx-auto max-w-prose">
     <template #content>
@@ -23,7 +23,7 @@
             @inactive="validate(field)"
           >
             <template #inactive>
-              <div class="ml-1 block text-sm text-primary dark:text-primary">
+              <div class="ml-1 text-sm text-primary dark:text-primary">
                 {{ _startCase(field) }}
               </div>
 
@@ -46,7 +46,7 @@
             </template>
 
             <template #active="{ deactivate }">
-              <div class="ml-1 block text-sm text-primary dark:text-primary">
+              <div class="ml-1 text-sm text-primary dark:text-primary">
                 {{ _startCase(field) }}
               </div>
 
@@ -73,12 +73,20 @@
               <ComboBox
                 v-else-if="type === 'autocomplete'"
                 v-model="model[field]"
-                :tabindex="0"
                 :inputId="field"
+                :tabindex="0"
                 :suggestions="suggestions?.[field]"
                 @blur="waitForAnimation(deactivate)"
               />
 
+              <PasswordField
+                v-else-if="type === 'password'"
+                v-model="model[field]"
+                :inputId="field"
+                :tabindex="0"
+                @blur="waitForAnimation(deactivate)"
+              />
+
               <InputText
                 v-else
                 v-model="model[field]"
@@ -151,7 +159,7 @@ const props = defineProps({
   },
 
   modelId: {
-    type: [String, Number],
+    type: String,
     required: false,
     validator: (value, props) => props.action === "create" || isPresent(value)
   },
@@ -217,6 +225,7 @@ onBeforeRouteLeave(async () => {
 async function focusFormControl(field, type) {
   await nextTick()
   const control = document.getElementById(field)
+  // noinspection JSUnresolvedReference
   control.focus()
 
   switch (type) {

+ 29 - 0
app/components/PasswordField.vue

@@ -0,0 +1,29 @@
+<template>
+  <Password
+    :feedback="false"
+    fluid
+    toggleMask
+  >
+    <template #maskicon="{ toggleCallback }">
+      <Icon
+        class="absolute inset-e-3 top-1/2 -mt-2 h-4 w-4 text-surface-500
+          dark:text-surface-400"
+        name="ph:eye-slash-bold"
+        @click="toggleCallback"
+      />
+    </template>
+
+    <template #unmaskicon="{ toggleCallback }">
+      <Icon
+        class="absolute inset-e-3 top-1/2 -mt-2 h-4 w-4 text-surface-500
+          dark:text-surface-400"
+        name="ph:eye-bold"
+        @click="toggleCallback"
+      />
+    </template>
+  </Password>
+</template>
+
+<script setup></script>
+
+<style scoped></style>

+ 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>

+ 21 - 0
app/composables/useLogNormalizer.js

@@ -0,0 +1,21 @@
+import {
+  onEntityAdded,
+  onEntityUpdated,
+  onEntityRemoved
+} from "pinia-colada-plugin-normalizer"
+
+const getContext = () => import.meta.server ? "server" : "client"
+
+export default function useLogNormalizer() {
+  onEntityAdded("character", (event) =>
+    console.log("[onEntityAdded]", getContext(), event.key)
+  )
+
+  onEntityUpdated("character", (event) =>
+    console.log("[onEntityUpdated]", getContext(), event.key)
+  )
+
+  onEntityRemoved("character", (event) =>
+    console.log("[onEntityRemoved]", getContext(), event.key)
+  )
+}

+ 20 - 0
app/middleware/admin.js

@@ -0,0 +1,20 @@
+export default defineNuxtRouteMiddleware(async () => {
+  const nuxtApp = useNuxtApp()
+  const { fetchSession, user } = useAuthClient()
+  await fetchSession()
+
+  if (user.value.role !== "admin") {
+    return nuxtApp.runWithContext(() => {
+      const toast = useToast()
+
+      toast.add({
+        severity: "danger",
+        summary: "Not An Admin.",
+        detail: "Only an admin can access that.",
+        life: 3000
+      })
+
+      return abortNavigation()
+    })
+  }
+})

+ 2 - 2
app/mutations/characters/useDeleteCharacter.js

@@ -4,10 +4,10 @@ export const useDeleteCharacter = defineMutation(() => {
   const { transaction } = useOptimisticUpdate()
 
   const { mutate, ...mutation } = useMutation({
-    onMutate({ id }) {
+    onMutate: ({ id })=> {
       const optionsCache = useOptionsCache()
       const tx = transaction()
-      tx.remove("character", _toString(id))
+      tx.remove("character", id)
       optionsCache.update()
 
       return { ...tx, rollbackOptions: optionsCache.rollback }

+ 1 - 1
app/mutations/characters/useUpdateCharacter.js

@@ -7,7 +7,7 @@ export const useUpdateCharacter = defineMutation(() => {
     onMutate: ({ id, updates }) => {
       const optionsCache = useOptionsCache()
       const tx = transaction()
-      tx.set("character", _toString(id), updates)
+      tx.set("character", id, updates)
       optionsCache.update()
 
       return { ...tx, rollbackOptions: optionsCache.rollback }

+ 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>

+ 3 - 2
app/pages/edit.vue

@@ -10,6 +10,7 @@
       action="update"
       :characterId="characterId"
       :initialValue="character"
+      :class="isPlaceholderData && 'bg-primary/15'"
     />
   </div>
 </template>
@@ -21,8 +22,8 @@ definePageMeta({
   middleware: "signed-in"
 })
 
-const characterId = _toInteger(useRoute().params?.characterId)
-const { data: character, isLoading } = useQuery(() => characterByIdQuery(characterId))
+const characterId = _toString(useRoute("characterEdit").params?.characterId)
+const { data: character, isLoading, isPlaceholderData } = useQuery(() => characterByIdQuery(characterId))
 </script>
 
 <style scoped></style>

+ 20 - 15
app/pages/index.vue

@@ -1,15 +1,14 @@
-<!--suppress VueUnrecognizedSlot -->
 <template>
   <!--suppress JSValidateTypes -->
   <DataTable
     :value="characters"
     dataKey="id"
-    :loading="isLoading"
+    :loading="isLoadingCharacters"
     sortField="createdAt"
     :sortOrder="1"
     removableSort
     v-model:filters="filters"
-    :filterDisplay="showFilters ? 'row' : null"
+    :filterDisplay="showFilters && !isLoadingOptions ? 'row' : null"
     :globalFilterFields="globalFilterFieldNames"
     selectionMode="single"
     resizableColumns
@@ -76,7 +75,7 @@
     </Column>
 
     <template #header>
-      <div :class="isLoading && 'hidden'">
+      <div :class="isLoadingOptions && 'hidden'">
         <div class="flex justify-between gap-4 pb-2">
           <SearchField
             v-model="filters['global'].value"
@@ -137,7 +136,7 @@
     <template #empty>
       <div
         class="text-center text-2xl"
-        :class="isLoading && 'hidden'"
+        :class="isLoadingCharacters && 'hidden'"
       >
         <template v-if="hasGlobalFilter()">
           <template v-if="hasAnyColumnFilters()">
@@ -168,7 +167,7 @@
 
     <template #footer>
       <CharacterToolbar
-        :class="isLoading && 'hidden'"
+        :class="isLoadingCharacters && 'hidden'"
         :filteredCount="filteredCount"
         :count="count"
       />
@@ -180,7 +179,6 @@
 definePageMeta({ name: "characters" })
 
 const { isSignedIn } = useAuthClient()
-
 const filtersStore = useFiltersStore()
 
 const {
@@ -195,17 +193,16 @@ const {
 
 const { filters, showFilters } = storeToRefs(filtersStore)
 
-const { data: characters, isLoading: isLoadingCharacters } =
-  useQuery(characterListQuery)
+const {
+  data: characters,
+  isLoading: isLoadingCharacters,
+  refetch: refetchCharacters
+} = useQuery(characterListQuery)
 
 const { data: options, isLoading: isLoadingOptions } = useQuery(
   characterOptionsQuery
 )
 
-const isLoading = computed(
-  () => isLoadingCharacters.value || isLoadingOptions.value
-)
-
 const count = computed(() => _size(characters.value))
 const filteredCount = computed(() => _size(filteredCharacters.value))
 const filteredCharacters = ref(_cloneDeep(characters.value))
@@ -217,7 +214,11 @@ const filteredOptions = computed(() =>
 const DEFAULT_ELEMENT_HEIGHTS = 160
 const elementHeights = ref(DEFAULT_ELEMENT_HEIGHTS)
 
-onMounted(() => updateElementHeights())
+onMounted(() => {
+  refetchCharacters()
+  updateElementHeights()
+})
+
 onUpdated(() => updateElementHeights())
 
 function updateFilteredCharacters(filteredValue) {
@@ -240,9 +241,13 @@ function totalElementHeights() {
   // total height of non-datatable elements (in pixels)
   const elements = ["navbar", "datatable_header", "datatable_footer"]
 
+  // noinspection JSUnresolvedReference
   let totalHeights = _reduce(
     elements,
-    (acc, element) => acc + document?.getElementById(element)?.offsetHeight,
+    (acc, element) => {
+      // noinspection JSUnresolvedReference
+      return acc + document?.getElementById(element)?.offsetHeight
+    },
     0
   )
 

+ 1 - 1
app/pages/show.vue

@@ -20,7 +20,7 @@ definePageMeta({
   path: "/show/:characterId(\\d+)"
 })
 
-const characterId = _toInteger(useRoute().params?.characterId)
+const characterId = _toString(useRoute("characterEdit").params?.characterId)
 const { data: character, isLoading } = useQuery(() => characterByIdQuery(characterId))
 </script>
 

+ 21 - 0
app/plugins/normalizePlugin.js

@@ -0,0 +1,21 @@
+import { useEntityStore } from "pinia-colada-plugin-normalizer"
+
+export default defineNuxtPlugin({
+  name: "normalize-plugin",
+
+  setup: (nuxtApp) => {
+    const entityStore = useEntityStore()
+
+    if (import.meta.server) {
+      nuxtApp.hook("app:rendered", ({ ssrContext }) => {
+        ssrContext.payload.normalizer = entityStore.toJSON()
+
+        // console.log("[normalize-plugin] [entityStore.toJSON]")
+      })
+    } else if (nuxtApp.payload && nuxtApp.payload.normalizer) {
+      entityStore.hydrate(nuxtApp.payload.normalizer)
+
+      // console.log("[normalize-plugin] [entityStore.hydrate]")
+    }
+  }
+})

+ 10 - 17
app/plugins/socketio.js

@@ -1,10 +1,12 @@
 import { io } from "socket.io-client"
 
 const LOG_CONNECTIONS = false
-const LOG_EVENTS = false
+const LOG_EVENTS = true
 const LOG_LISTENERS = false
 const LOG_HOOKS = false
 
+const getContext = () => (import.meta.server ? "server" : "client")
+
 // noinspection JSUnusedGlobalSymbols
 export default defineNuxtPlugin({
   name: "socketio",
@@ -28,7 +30,7 @@ export default defineNuxtPlugin({
     if (LOG_EVENTS) {
       // log all outgoing events
       socket.onAnyOutgoing((eventName, ...args) =>
-        console.log("[app socketio] [outgoing]", eventName, args)
+        console.log("[app socketio] [outgoing]", getContext(), eventName, args)
       )
     }
 
@@ -43,7 +45,7 @@ export default defineNuxtPlugin({
       if (LOG_CONNECTIONS) {
         console.log(
           "[app socketio] [onConnect]",
-          import.meta.server ? "server" : "client",
+          getContext(),
           transport.value,
           isSignedIn.value ? user.value.username : ""
         )
@@ -54,7 +56,7 @@ export default defineNuxtPlugin({
       if (LOG_CONNECTIONS) {
         console.log(
           "[app socketio] [onDisconnect]",
-          import.meta.server ? "server" : "client",
+          getContext(),
           transport.value,
           isSignedIn.value ? user.value.username : ""
         )
@@ -82,10 +84,7 @@ export default defineNuxtPlugin({
         } = useNuxtApp()
 
         if (LOG_HOOKS) {
-          console.log(
-            "[app socketio] [app:mounted]",
-            import.meta.server ? "server" : "client"
-          )
+          console.log("[app socketio] [app:mounted]", getContext())
         }
 
         useEventHandlers()
@@ -95,24 +94,18 @@ export default defineNuxtPlugin({
 
           // noinspection JSUnresolvedReference
           _forIn(socket._callbacks, (callbacks, event) => {
-            console.log(`${event}`, callbacks)
+            console.log(event, callbacks)
           })
         }
 
         // reconnect on signin/signout
-        watch(
-          useAuthClient().isSignedIn,
-          () => socket.disconnect().connect()
-        )
+        watch(useAuthClient().isSignedIn, () => socket.disconnect().connect())
       })
     },
 
     "app:rendered": () => {
       if (LOG_HOOKS) {
-        console.log(
-          "[app socketio] [app:rendered]",
-          import.meta.server ? "server" : "client"
-        )
+        console.log("[app socketio] [app:rendered]", getContext())
       }
 
       useNuxtApp().$socketio.socket.disconnect()

+ 5 - 4
app/queries/character.js

@@ -1,5 +1,3 @@
-// noinspection JSCheckFunctionSignatures
-
 export const characterQueryKeys = {
   root: ["characters"],
   options: () => [...characterQueryKeys.root, "options"],
@@ -8,7 +6,8 @@ export const characterQueryKeys = {
 
 export const characterListQuery = defineQueryOptions({
   key: characterQueryKeys.root,
-  query: () => useEmit("character:list")
+  query: () => useEmit("character:list"),
+  normalize: true
 })
 
 export const characterOptionsQuery = defineQueryOptions({
@@ -18,5 +17,7 @@ export const characterOptionsQuery = defineQueryOptions({
 
 export const characterByIdQuery = defineQueryOptions((id) => ({
   key: characterQueryKeys.byId(id),
-  query: () => useEmit("character:read", id)
+  query: () => useEmit("character:read", id),
+  normalize: true,
+  redirect: { entityType: "character" }
 }))

+ 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)
+}))

+ 6 - 2
app/stores/useFiltersStore.js

@@ -57,7 +57,11 @@ export const useFiltersStore = defineStore("filters", () => {
   }
 
   function removeFilterValueFrom(field, value) {
-    filters.value[field].value = _without(filters.value[field].value, value)
+    if (characterFields[field].type !== "autocomplete") {
+      return false
+    } else {
+      filters.value[field].value = _without(filters.value[field].value, value)
+    }
   }
 
   function resetFilters() {
@@ -69,7 +73,7 @@ export const useFiltersStore = defineStore("filters", () => {
   }
 
   function resetGlobalFilter() {
-    filters.value.global.value = ""
+    resetFilterFor("global")
   }
 
   function toggleShowFilters() {

+ 15 - 2
colada.options.js

@@ -1,7 +1,20 @@
-import { PiniaColadaNormalizer } from "pinia-colada-plugin-normalizer";
+import { PiniaColadaDelay } from "@pinia/colada-plugin-delay"
+
+import {
+  defineEntity,
+  PiniaColadaNormalizer
+} from "pinia-colada-plugin-normalizer"
 
 export default {
   plugins: [
-    PiniaColadaNormalizer({ autoNormalize: true })
+    PiniaColadaDelay({ delay: 500 }),
+
+    PiniaColadaNormalizer({
+      entities: {
+        character: defineEntity({
+          idField: "characterId"
+        })
+      }
+    })
   ]
 }

+ 1 - 1
modules/lodash-shared/index.js

@@ -19,7 +19,7 @@ export default defineNuxtModule({
     // Compatibility constraints
     compatibility: {
       // Semver version of supported nuxt versions
-      nuxt: ">=4.0.0"
+      nuxt: "^4.0.0"
     }
   },
 

+ 34 - 3
nuxt.config.js

@@ -1,11 +1,40 @@
 import tailwindcss from "@tailwindcss/vite"
+import { fileURLToPath } from "node:url"
 
 // noinspection JSUnresolvedReference
 export default defineNuxtConfig({
-  compatibilityDate: "2026-03-13",
+  app: {
+    head: {
+      link: [
+        { rel: "apple-touch-icon", href: "/apple-touch-icon.png" },
+        { rel: "icon", href: "/theater-masks-solid.svg" },
+        { rel: "manifest", href: "/manifest.json" },
+        { rel: "mask-icon", href: "/theater-masks-solid.svg", color: "#9fe88d" }
+      ],
+
+      meta: [
+        { charset: "UTF-8" },
+        { name: "theme-color", content: "#9fe88d" },
+        { name: "viewport", content: "width=device-width, initial-scale=1.0" }
+      ],
+
+      title: "Dramatis Personae"
+    }
+  },
+
+  alias: {
+    test: fileURLToPath(new URL("./test", import.meta.url)),
+  },
+
+  compatibilityDate: "2026-04-13",
   css: ["~/assets/css/main.css"],
   // devServer: { port: 3000 },
-  devtools: { enabled: true, timeline: { enabled: true } },
+
+  devtools: {
+    enabled: true,
+    timeline: { enabled: true }
+  },
+
   experimental: { asyncContext: true },
   // future: { compatibilityVersion: 5 },
 
@@ -17,7 +46,8 @@ export default defineNuxtConfig({
     "@nuxt/icon",
     "@pinia/nuxt",
     "@pinia/colada-nuxt",
-    "@primevue/nuxt-module"
+    "@primevue/nuxt-module",
+    ...(process.env.NODE_ENV === "test" ? ["@nuxt/test-utils/module"] : [])
   ],
 
   nitro: {
@@ -45,6 +75,7 @@ export default defineNuxtConfig({
     optimizeDeps: {
       include: [
         "@pinia/colada-devtools",
+        "@pinia/colada-plugin-delay",
         "@primevue/core/api",
         "@primeuix/forms/resolvers/zod",
         "better-auth/client",

+ 28 - 19
package.json

@@ -12,54 +12,63 @@
     "postprisma:generate": "nuxt prepare",
     "prepare": "nuxt prepare",
     "preview": "nuxt preview -p 4001",
+    "auth:generate": "pnpx auth@latest generate --config ./server/utils/auth.js --output ./prisma/auth.prisma --yes",
     "prisma:generate": "prisma generate",
-    "prisma:push": "prisma db push"
+    "prisma:push": "prisma db push",
+    "test": "vitest"
   },
-  "packageManager": "pnpm@10.33.0",
+  "packageManager": "pnpm@10.33.2",
   "dependencies": {
     "nuxt": "^4.4.2",
-    "vue": "^3.5.32"
+    "vue": "^3.5.33"
   },
   "devDependencies": {
     "@iconify-json/fa6-solid": "^1.2.4",
     "@iconify-json/ph": "^1.2.2",
-    "@nuxt/devtools": "^3.2.4",
+    "@nuxt/devtools": "3.2.3",
     "@nuxt/icon": "^2.2.1",
     "@nuxt/kit": "^4.4.2",
-    "@pinia/colada": "^1.1.0",
-    "@pinia/colada-devtools": "^0.4.5",
-    "@pinia/colada-nuxt": "^0.3.2",
+    "@nuxt/test-utils": "^4.0.2",
+    "@pinia/colada-devtools": "^1.0.0",
+    "@pinia/colada-nuxt": "^1.0.0",
+    "@pinia/colada-plugin-delay": "^0.2.1",
     "@pinia/nuxt": "^0.11.3",
+    "@playwright/test": "^1.59.1",
     "@primeuix/forms": "^0.1.0",
     "@primevue/core": "^4.5.5",
     "@primevue/nuxt-module": "^4.5.5",
-    "@prisma/adapter-pg": "^7.7.0",
-    "@prisma/client": "^7.7.0",
+    "@prisma/adapter-pg": "^7.8.0",
+    "@prisma/client": "^7.8.0",
     "@tailwindcss/typography": "^0.5.19",
-    "@tailwindcss/vite": "^4.2.2",
-    "better-auth": "^1.6.2",
+    "@tailwindcss/vite": "^4.2.4",
+    "@vue/test-utils": "^2.4.8",
+    "better-auth": "^1.6.9",
     "defu": "^6.1.7",
-    "dotenv": "^17.4.1",
+    "dotenv": "^17.4.2",
     "engine.io": "^6.6.6",
+    "happy-dom": "^20.9.0",
     "html-to-text": "^9.0.5",
     "lodash-es": "^4.18.1",
-    "pinia": "^3.0.4",
-    "pinia-colada-plugin-normalizer": "^0.1.7",
-    "prettier": "^3.8.2",
+    "pinia-colada-plugin-normalizer": "^0.1.8",
+    "playwright-core": "^1.59.1",
+    "postmark": "^4.0.7",
+    "prettier": "^3.8.3",
     "prettier-plugin-classnames": "^0.10.1",
     "prettier-plugin-merge": "^0.10.1",
     "prettier-plugin-prisma": "^5.0.0",
-    "prettier-plugin-tailwindcss": "^0.7.2",
+    "prettier-plugin-tailwindcss": "^0.7.3",
     "primevue": "^4.5.5",
-    "prisma": "^7.7.0",
+    "prisma": "^7.8.0",
     "socket.io": "^4.8.3",
     "socket.io-client": "^4.8.3",
     "tailwind-merge": "^3.5.0",
-    "tailwindcss": "^4.2.2",
+    "tailwindcss": "^4.2.4",
     "tailwindcss-primeui": "^0.6.1",
     "trix": "^2.1.18",
     "tsx": "^4.21.0",
-    "vite": "^7.3.1",
+    "uuid": "^14.0.0",
+    "vite": "^7.3.2",
+    "vitest": "^4.1.5",
     "zod": "^4.3.6"
   }
 }

+ 24 - 0
playwright.config.js

@@ -0,0 +1,24 @@
+import { fileURLToPath } from "node:url"
+import { defineConfig, devices } from "@playwright/test"
+
+const devicesToTest = ["Desktop Chrome"]
+
+export default defineConfig({
+  testDir: "./test/e2e",
+  /* Run tests in files in parallel */
+  fullyParallel: true,
+  retries: 0,
+  workers: undefined,
+  timeout: undefined,
+  /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+  reporter: "html",
+  use: {
+    trace: "on-first-retry",
+    nuxt: {
+      rootDir: fileURLToPath(new URL(".", import.meta.url))
+    }
+  },
+  projects: devicesToTest.map((p) =>
+    typeof p === "string" ? { name: p, use: devices[p] } : p
+  )
+})

Разница между файлами не показана из-за своего большого размера
+ 279 - 381
pnpm-lock.yaml


BIN
public/apple-touch-icon.png


BIN
public/google-touch-icon.png


+ 13 - 0
public/manifest.json

@@ -0,0 +1,13 @@
+{
+  "name": "personae",
+  "short_name": "personae",
+  "icons": [
+    {
+      "src": "google-touch-icon.png",
+      "sizes": "512x512"
+    }
+  ],
+  "background_color": "#000000",
+  "theme_color": "#9fe88d",
+  "display": "fullscreen"
+}

+ 22 - 0
public/theater-masks-solid.svg

@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 26.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px"
+     y="0px" viewBox="0 0 768 768" style="enable-background:new 0 0 768 768;" xml:space="preserve">
+<g>
+  <path d="M138.65,501.81c41.7,36.1,108,82.5,166.1,73.7c6.1-0.9,12.1-2.5,18-4.5c-9.2-12.3-17.3-24.4-24.2-35.4
+    c-21.9-35-28.8-75.2-25.9-113.6c-20.6,4.1-39.2,13-54.7,25.4c-6.5,5.2-16.3,1.3-14.8-7c6.4-33.5,33-60.9,68.2-66.3
+    c2.6-0.4,5.3-0.7,7.9-0.8l19.4-131.3c2-13.8,8-32.7,25-45.9c18.6-14.3,50.9-30.5,103.6-35.3c-0.8-0.7-1.6-1.4-2.4-2.1
+    c-20.2-15.6-72.4-41.6-185.1-24.5s-155.2,57.4-170,78.3c-5.7,8-6.5,18.1-5.1,27.9l24.2,164.3c5.5,37.3,21.5,72.6,49.8,97.2V501.81z
+     M226.35,282.21c4.4-3.1,10.8-2,11.8,3.3c0.1,0.5,0.2,1.1,0.3,1.6c3.2,21.8-11.6,42-33.1,45.3c-21.5,3.3-41.5-11.8-44.7-33.5
+    c-0.1-0.5-0.1-1.1-0.2-1.6c-0.6-5.4,5.2-8.4,10.3-6.7c9,3,18.8,3.9,28.7,2.4s19.1-5.3,26.8-10.8H226.35z M325.65,518.61
+    c29.4,46.9,79.5,110.9,137.6,119.7c58.1,8.8,124.5-37.5,166.1-73.7c28.3-24.5,44.3-59.8,49.8-97.2l24.2-164.3
+    c1.4-9.8,0.6-19.9-5.1-27.9c-14.8-20.9-57.3-61.2-170-78.3s-164.8,8.9-185,24.5c-7.8,6-11.5,15.4-12.9,25.2l-24.2,164.3
+    c-5.5,37.3-0.4,75.8,19.6,107.7H325.65z M468.55,363.91c-7.7-5.5-16.8-9.3-26.8-10.8s-19.8-0.6-28.7,2.4
+    c-5.1,1.7-10.9-1.3-10.3-6.7c0.1-0.5,0.1-1.1,0.2-1.6c3.2-21.8,23.2-36.8,44.7-33.5c21.5,3.3,36.3,23.5,33.1,45.3
+    c-0.1,0.5-0.2,1.1-0.3,1.6c-1,5.3-7.4,6.4-11.8,3.3H468.55z M604.75,379.41c-1,5.3-7.4,6.4-11.8,3.3c-7.7-5.5-16.8-9.3-26.8-10.8
+    s-19.8-0.6-28.7,2.4c-5.1,1.7-10.9-1.3-10.3-6.7c0.1-0.5,0.1-1.1,0.2-1.6c3.2-21.8,23.2-36.8,44.7-33.5
+    c21.5,3.3,36.3,23.5,33.1,45.3c-0.1,0.5-0.2,1.1-0.3,1.6H604.75z M594.05,478.81c-19.6,44.7-66.8,72.5-116.8,64.9
+    c-50-7.6-87.1-48.2-93-96.7c-1-8.3,8.9-12.1,15.2-6.7c23.9,20.8,53.6,35.3,87,40.3s66.1,0.1,94.9-12.8c7.6-3.4,16,3.2,12.6,10.9
+    L594.05,478.81z"/>
+</g>
+</svg>

+ 6 - 5
server/plugins/socketio.js

@@ -1,8 +1,8 @@
-// noinspection JSAccessibilityCheck,JSUndefinedPropertyAssignment,JSUnresolvedReference
+// noinspection JSAccessibilityCheck,JSUnresolvedReference
 
 import { Server as Engine } from "engine.io"
 import { Server } from "socket.io"
-// import * as middlewares from "../socketio/middlewares"
+import * as middlewares from "../socketio/middlewares"
 import * as handlers from "../socketio/handlers"
 
 const LOG_CONNECTIONS = false
@@ -23,9 +23,10 @@ export default defineNitroPlugin((nitroApp) => {
   io.bind(engine)
 
   // register middlewares
-  // _forOwn(middlewares, (middleware) => io.use(middleware))
+  _forOwn(middlewares, (middleware) => io.use(middleware))
 
   // expose server instance
+  // noinspection JSUndefinedPropertyAssignment
   nitroApp.hooks.hook("request", (event) => (event.context.io = io))
 
   io.on("connection", async (socket) => {
@@ -37,7 +38,7 @@ export default defineNitroPlugin((nitroApp) => {
       console.log(
         "[server socketio] [connection]",
         socket.id,
-        // socket.data.user?.username ?? "unauthenticated"
+        socket.data.user?.username ?? "unauthenticated"
       )
 
       console.log(
@@ -50,7 +51,7 @@ export default defineNitroPlugin((nitroApp) => {
           "[server socketio] [disconnect]",
           reason,
           socket.id,
-          // socket.data.user?.username ?? "unauthenticated"
+          socket.data.user?.username ?? "unauthenticated"
         )
       })
     }

+ 2 - 0
server/socketio/handlers.js

@@ -0,0 +1,2 @@
+export * from "./handlers/characterHandlers.js"
+export * from "./handlers/userHandlers.js"

+ 11 - 18
server/socketio/handlers/characterHandlers.js

@@ -91,27 +91,20 @@ export function characterHandlers(_io, socket) {
       idValidator: characterSchema.shape.id.parse
     }
 
-    function typenameMutator(rawResult) {
-      if (_isPlainObject(rawResult) && _has(rawResult, "id")) {
-        _set(rawResult, "__typename", "character")
-      } else if (_isArray(rawResult)) {
-        _forEach(rawResult, (entry) =>
-          _set(entry, "__typename", "character")
-        )
-      }
-
-      return rawResult
-    }
-
-    let mutator
+    const mutators = [
+      stringifyIds,
+      injectTypename("character"),
+      injectEntityId("character")
+    ]
 
     if (_has(options, "mutator")) {
-      mutator = _flow([options.mutator, typenameMutator])
-      _unset(options, "mutator")
-    } else {
-      mutator = typenameMutator
+      mutators.unshift(options.mutator)
     }
 
-    return await executeQuery({ ...defaultOptions, ...options, mutator })
+    return await executeQuery({
+      ...defaultOptions,
+      ...options,
+      mutator: _flow(mutators)
+    })
   }
 }

+ 0 - 2
server/socketio/handlers/index.js

@@ -1,2 +0,0 @@
-export * from "./characterHandlers.js"
-// export * from "./userHandlers.js"

+ 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 - 0
server/socketio/middlewares.js

@@ -0,0 +1 @@
+export * from "./middlewares/authMiddleware.js"

+ 1 - 0
server/socketio/middlewares/authMiddleware.js

@@ -1,4 +1,5 @@
 export async function authMiddleware(socket, next) {
+  // noinspection JSUnresolvedReference
   const session = await auth.api.getSession({
     headers: socket.handshake.headers
   })

+ 0 - 1
server/socketio/middlewares/index.js

@@ -1 +0,0 @@
-export * from "./authMiddleware.js"

+ 3 - 1
server/utils/AuthError.js

@@ -1,4 +1,4 @@
-export default class AuthError extends Error {
+class AuthError extends Error {
   constructor(message, options) {
     super(message, options)
     this.name = "AuthError"
@@ -6,3 +6,5 @@ export default class AuthError extends Error {
     this.permissions = options?.permissions
   }
 }
+
+export default AuthError

+ 25 - 0
server/utils/authorize.js

@@ -0,0 +1,25 @@
+export default async function authorize(user, resource, permissions) {
+  if (_isNil(user)) {
+    throw new AuthError(`You must be signed in to modify ${resource}s.`, {
+      resource,
+      permissions
+    })
+  }
+
+  permissions = _flatten([permissions])
+
+  // noinspection JSUnresolvedReference
+  const result = await auth.api.userHasPermission({
+    body: {
+      userId: user.id,
+      permissions: { [resource]: permissions }
+    }
+  })
+
+  if (!result.success) {
+    throw new AuthError(
+      `${user.username} isn't allowed to modify ${resource}s.`,
+      { resource, permissions }
+    )
+  }
+}

+ 12 - 6
server/utils/executeQuery.js

@@ -1,10 +1,11 @@
 const LOG_RESULT = false
+const RESPONSE_DELAY = null
 
 export default async function executeQuery({
   callback,
-  // user,
-  // resource,
-  // permissions,
+  user,
+  resource,
+  permissions,
   id,
   data,
   idValidator = _identity,
@@ -13,9 +14,9 @@ export default async function executeQuery({
   query = _noop
 }) {
   try {
-    // if (permissions) {
-    //   await authorize(user, resource, permissions)
-    // }
+    if (permissions) {
+      await authorize(user, resource, permissions)
+    }
 
     let validId, validData
 
@@ -35,6 +36,11 @@ export default async function executeQuery({
       console.dir(result)
     }
 
+    if (RESPONSE_DELAY) {
+      console.log("[executeQuery] [response delay]", RESPONSE_DELAY)
+      await sleep(RESPONSE_DELAY)
+    }
+
     callback({ data: result })
     return result
   } catch (error) {

+ 13 - 0
server/utils/injectEntityId.js

@@ -0,0 +1,13 @@
+export default function injectEntityId(entity) {
+  function entityIdMutator(_entity, _rawResult) {
+    if (_isPlainObject(_rawResult) && _has(_rawResult, "id")) {
+      _set(_rawResult, `${_entity}Id`, _rawResult.id)
+    } else if (_isArray(_rawResult)) {
+      _forEach(_rawResult, (entry) => _set(entry, `${_entity}Id`, entry.id))
+    }
+
+    return _rawResult
+  }
+
+  return (rawResult) => entityIdMutator(entity, rawResult)
+}

+ 13 - 0
server/utils/injectTypename.js

@@ -0,0 +1,13 @@
+export default function injectTypename(typename) {
+  function typenameMutator(_typename, _rawResult) {
+    if (_isPlainObject(_rawResult) && _has(_rawResult, "id")) {
+      _set(_rawResult, "__typename", _typename)
+    } else if (_isArray(_rawResult)) {
+      _forEach(_rawResult, (entry) => _set(entry, "__typename", _typename))
+    }
+
+    return _rawResult
+  }
+
+  return (rawResult) => typenameMutator(typename, rawResult)
+}

+ 9 - 0
server/utils/stringifyIds.js

@@ -0,0 +1,9 @@
+export default function stringifyIds(rawResult) {
+  if (_isPlainObject(rawResult) && _has(rawResult, "id")) {
+    _update(rawResult, "id", _toString)
+  } else if (_isArray(rawResult)) {
+    _forEach(rawResult, (entry) => _update(entry, "id", _toString))
+  }
+
+  return rawResult
+}

+ 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"

+ 3 - 1
shared/utils/schema/character.js

@@ -1,5 +1,7 @@
 export const characterSchema = z.object({
-  id: z.number().int().positive(),
+  // coerce id to number since normalizer plugin wants string ids, and this way
+  //   we can just use strings on the client side
+  id: z.coerce.number().int().positive(),
   player: z.string().trim().min(1),
   mortalName: z.string().trim(),
   faeName: z.string().trim(),

+ 0 - 0
app/utils/sleep.js → shared/utils/sleep.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"
+  }
+}

+ 28 - 0
test/mocks/components/Icon.mock.vue

@@ -0,0 +1,28 @@
+<template>
+  <div data-testid="icon">
+    {{ name }}
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  name: {
+    type: String,
+    required: true
+  },
+
+  size: {
+    type: String,
+    required: false,
+    default: "1em"
+  },
+
+  mode: {
+    type: String,
+    required: false,
+    default: "svg"
+  }
+})
+</script>
+
+<style scoped></style>

+ 41 - 0
test/mocks/composables/useAuthClient.mock.js

@@ -0,0 +1,41 @@
+import { mockNuxtImport } from "@nuxt/test-utils/runtime"
+import { NIL as NIL_UUID } from "uuid"
+
+function inYearsFromNow(years) {
+  const now = new Date()
+  const YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000
+
+  return new Date().setTime(now.getTime() + years * YEAR_IN_MS)
+}
+
+const user = {
+  name: "Test User",
+  email: "test@example.com",
+  role: "user",
+  username: "test",
+  id: NIL_UUID
+}
+
+const session = {
+  token: "0".repeat(32),
+  userId: user.id,
+  id: NIL_UUID,
+  createdAt: now,
+  updatedAt: now,
+  expiresAt: inYearsFromNow(20)
+}
+
+mockNuxtImport("useAuthClient", () => {
+  return () => {
+    return {
+      user: ref(user),
+      session: ref(session),
+      isSignedIn: ref(true),
+      signIn: () => null,
+      signUp: () => null,
+      signOut: () => null,
+      fetchSession: () => ({ session, user }),
+      authClient: {}
+    }
+  }
+})

+ 14 - 0
test/nuxt/app/components/ToastContainer.test.js

@@ -0,0 +1,14 @@
+import { mockComponent, mountSuspended } from "@nuxt/test-utils/runtime"
+import ToastContainer from "~/components/ToastContainer.vue"
+
+mockComponent("Icon", () => import("test/mocks/components/Icon.mock.vue"))
+
+let wrapper
+
+beforeEach(() => {
+  wrapper = mountSuspended(ToastContainer)
+})
+
+test("should render", () => {
+  expect(wrapper).toBeDefined
+})

+ 263 - 0
test/nuxt/app/stores/useFiltersStore.test.js

@@ -0,0 +1,263 @@
+import { setActivePinia, createPinia } from "pinia"
+import { FilterMatchMode } from "@primevue/core/api"
+
+let filtersStore
+
+beforeEach(() => {
+  setActivePinia(createPinia())
+  filtersStore = useFiltersStore()
+})
+
+describe("initializes state", () => {
+  describe("inits emptyFilters", () => {
+    it("has the correct shape", () => {
+      expect(_keys(filtersStore.emptyFilters)).toEqual([
+        "global",
+        ..._keys(nameFields),
+        ..._keys(optionsFields)
+      ])
+    })
+
+    it("has correct defaults for string fields", () => {
+      expect(
+        _every(
+          _values(
+            _pick(filtersStore.emptyFilters, ["global", ..._keys(nameFields)])
+          ),
+          { value: "", matchMode: FilterMatchMode.CONTAINS }
+        )
+      ).toBe(true)
+    })
+
+    it("has correct defaults for array fields", () => {
+      expect(
+        _every(
+          _values(_pick(filtersStore.emptyFilters, _keys(optionsFields))),
+          { value: [], matchMode: FilterMatchMode.IN }
+        )
+      ).toBe(true)
+    })
+  })
+
+  it("inits filters from emptyFilters", () => {
+    expect(filtersStore.filters).toEqual(filtersStore.emptyFilters)
+  })
+
+  it("inits showFilters", () => {
+    expect(filtersStore.showFilters).toBe(false)
+  })
+})
+
+describe("getters", () => {
+  describe("hasFilterByField", () => {
+    it("has the correct shape", () => {
+      expect(_keys(filtersStore.hasFilterByField)).toEqual(
+        _keys(filtersStore.filters)
+      )
+    })
+
+    it("returns true for non-empty string filter value", () => {
+      filtersStore.filters.global.value = "foo"
+      expect(filtersStore.hasFilterByField.global).toBe(true)
+    })
+
+    it("returns true for non-empty array filter value", () => {
+      filtersStore.filters.player.value = ["foo"]
+      expect(filtersStore.hasFilterByField.player).toBe(true)
+    })
+
+    it("returns true for non-default matchMode", () => {
+      filtersStore.filters.faeName.matchMode = FilterMatchMode.STARTS_WITH
+      expect(filtersStore.hasFilterByField.faeName).toBe(true)
+    })
+  })
+})
+
+describe("actions", () => {
+  describe("hasAnyColumnFilters", () => {
+    it("returns false at initial state", () => {
+      expect(filtersStore.hasAnyColumnFilters()).toBe(false)
+    })
+
+    it("returns true for non-empty column filter", () => {
+      filtersStore.filters.faeName.value = "foo"
+      expect(filtersStore.hasAnyColumnFilters()).toBe(true)
+    })
+  })
+
+  describe("hasAnyCategoryFilters", () => {
+    it("returns false at initial state", () => {
+      expect(filtersStore.hasAnyCategoryFilters()).toBe(false)
+    })
+
+    it("returns true for non-empty column filter", () => {
+      filtersStore.filters.player.value = ["foo"]
+      expect(filtersStore.hasAnyCategoryFilters()).toBe(true)
+    })
+  })
+
+  describe("hasAnyFilters", () => {
+    it("returns false at initial state", () => {
+      expect(filtersStore.hasAnyFilters()).toBe(false)
+    })
+
+    it("returns true for non-empty filter", () => {
+      filtersStore.filters.global.value = "foo"
+      expect(filtersStore.hasAnyFilters()).toBe(true)
+    })
+  })
+
+  describe("hasAnyFiltersFor", () => {
+    it("returns false at initial state with a single field", () => {
+      expect(filtersStore.hasAnyFiltersFor("global")).toBe(false)
+    })
+
+    it("returns false at initial state with multiple fields", () => {
+      expect(
+        filtersStore.hasAnyFiltersFor(["global", "faeName", "player"])
+      ).toBe(false)
+    })
+
+    it("returns true for non-empty filter with a single field", () => {
+      filtersStore.filters.global.value = "foo"
+      expect(filtersStore.hasAnyFiltersFor("global")).toBe(true)
+    })
+
+    it("returns true for non-empty filter with multiple fields", () => {
+      filtersStore.filters.global.value = "foo"
+      expect(
+        filtersStore.hasAnyFiltersFor(["global", "faeName", "player"])
+      ).toBe(true)
+    })
+  })
+
+  describe("hasAnyNameFilters", () => {
+    it("returns false at initial state", () => {
+      expect(filtersStore.hasAnyNameFilters()).toBe(false)
+    })
+
+    it("returns true for non-empty name filter", () => {
+      filtersStore.filters.faeName.value = "foo"
+      expect(filtersStore.hasAnyNameFilters()).toBe(true)
+    })
+  })
+
+  describe("hasFilterFor", () => {
+    it("returns false at initial state", () => {
+      expect(filtersStore.hasFilterFor("global")).toBe(false)
+    })
+
+    it("returns false for non-empty filter with a different field", () => {
+      filtersStore.filters.global.value = "foo"
+      expect(filtersStore.hasFilterFor("player")).toBe(false)
+    })
+
+    it("returns true for non-empty filter and same field", () => {
+      filtersStore.filters.global.value = "foo"
+      expect(filtersStore.hasFilterFor("global")).toBe(true)
+    })
+  })
+
+  describe("hasGlobalFilter", () => {
+    it("returns false at initial state", () => {
+      expect(filtersStore.hasGlobalFilter()).toBe(false)
+    })
+
+    it("returns false for non-empty other filter", () => {
+      filtersStore.filters.faeName.value = "foo"
+      expect(filtersStore.hasGlobalFilter()).toBe(false)
+    })
+
+    it("returns true for non-empty global filter", () => {
+      filtersStore.filters.global.value = "foo"
+      expect(filtersStore.hasGlobalFilter()).toBe(true)
+    })
+  })
+
+  describe("removeFilterValueFrom", () => {
+    let oldFilters
+
+    beforeEach(() => {
+      filtersStore.filters.player.value = ["foo", "bar"]
+      oldFilters = _clone(filtersStore.filters)
+    })
+
+    it("returns false for non-array field", () => {
+      expect(filtersStore.removeFilterValueFrom("faeName", "foo")).toBe(false)
+    })
+
+    it("doesn't modify filters for non-array field", () => {
+      filtersStore.removeFilterValueFrom("faeName", "foo")
+      expect(filtersStore.filters).toEqual(oldFilters)
+    })
+
+    it("doesn't modify filter if it doesn't contain the value", () => {
+      filtersStore.removeFilterValueFrom("player", "qux")
+      expect(filtersStore.filters.player.value).toEqual(oldFilters.player.value)
+    })
+
+    it("removes correct value from array filter", () => {
+      filtersStore.removeFilterValueFrom("player", "foo")
+      expect(filtersStore.filters.player.value).toEqual(["bar"])
+    })
+  })
+
+  describe("resetFilters", () => {
+    it("resets all filters", () => {
+      filtersStore.filters.global = {
+        value: "foo",
+        matchMode: FilterMatchMode.STARTS_WITH
+      }
+      filtersStore.filters.faeName.value = "foo"
+      filtersStore.resetFilters()
+      expect(filtersStore.filters).toEqual(filtersStore.emptyFilters)
+    })
+  })
+
+  describe("resetFilterFor", () => {
+    it("resets the given filter", () => {
+      filtersStore.filters.player.value = ["foo", "bar"]
+      filtersStore.resetFilterFor("player")
+      expect(filtersStore.filters.player).toEqual(
+        filtersStore.emptyFilters.player
+      )
+    })
+
+    it("doesn't modify other filters", () => {
+      filtersStore.filters.faeName.value = "foo"
+      const filtersState = filtersStore.filters
+      filtersStore.resetFilterFor("player")
+      expect(filtersStore.filters).toEqual(filtersState)
+    })
+  })
+})
+
+describe("resetGlobalFilter", () => {
+  it("resets the global filter", () => {
+    filtersStore.filters.global = {
+      value: "foo",
+      matchMode: FilterMatchMode.STARTS_WITH
+    }
+    filtersStore.resetGlobalFilter()
+    expect(filtersStore.filters.global).toEqual(
+      filtersStore.emptyFilters.global
+    )
+  })
+
+  it("doesn't modify other filters", () => {
+    filtersStore.filters.faeName.value = "foo"
+    const filtersState = filtersStore.filters
+    filtersStore.resetGlobalFilter()
+    expect(filtersStore.filters.faeName).toEqual(filtersState.faeName)
+  })
+})
+
+describe("toggleShowFilters", () => {
+  it("toggles showFilters", () => {
+    filtersStore.toggleShowFilters()
+    expect(filtersStore.showFilters).toBe(true)
+
+    filtersStore.toggleShowFilters()
+    expect(filtersStore.showFilters).toBe(false)
+  })
+})

+ 0 - 0
test/nuxt/setup.js


+ 12 - 0
vitest.config.js

@@ -0,0 +1,12 @@
+import { defineVitestConfig } from "@nuxt/test-utils/config"
+
+export default defineVitestConfig({
+  test: {
+    environment: "nuxt",
+    include: "./test/**/*.test.js",
+    exclude: ["./test/e2e/**", "./test/mocks/**"],
+    setupFiles: "./test/nuxt/setup.js",
+    globals: true,
+    watch: false
+  }
+})

Некоторые файлы не были показаны из-за большого количества измененных файлов