EditUser.vue 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. <script setup lang="ts">
  2. import {
  3. defineAsyncComponent,
  4. ref,
  5. watch,
  6. onMounted,
  7. onBeforeUnmount
  8. } from "vue";
  9. import Toast from "toasters";
  10. import { storeToRefs } from "pinia";
  11. import validation from "@/validation";
  12. import { useEditUserStore } from "@/stores/editUser";
  13. import { useWebsocketsStore } from "@/stores/websockets";
  14. import { useModalsStore } from "@/stores/modals";
  15. import { useUserAuthStore } from "@/stores/userAuth";
  16. const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
  17. const QuickConfirm = defineAsyncComponent(
  18. () => import("@/components/QuickConfirm.vue")
  19. );
  20. const props = defineProps({
  21. modalUuid: { type: String, default: "" }
  22. });
  23. const editUserStore = useEditUserStore(props);
  24. const { socket } = useWebsocketsStore();
  25. const { userId, user } = storeToRefs(editUserStore);
  26. const { setUser } = editUserStore;
  27. const { closeCurrentModal } = useModalsStore();
  28. const { hasPermission } = useUserAuthStore();
  29. const ban = ref({ reason: "", expiresAt: "1h" });
  30. const init = () => {
  31. if (userId.value)
  32. socket.dispatch(`users.getUserFromId`, userId.value, res => {
  33. if (res.status === "success") {
  34. setUser(res.data);
  35. socket.dispatch("apis.joinRoom", `edit-user.${userId.value}`);
  36. socket.on(
  37. "event:user.removed",
  38. res => {
  39. if (res.data.userId === userId.value)
  40. closeCurrentModal();
  41. },
  42. { modalUuid: props.modalUuid }
  43. );
  44. } else {
  45. new Toast("User with that ID not found");
  46. closeCurrentModal();
  47. }
  48. });
  49. };
  50. const updateUsername = () => {
  51. const { username } = user.value;
  52. if (!validation.isLength(username, 2, 32))
  53. return new Toast("Username must have between 2 and 32 characters.");
  54. if (!validation.regex.custom("a-zA-Z0-9_-").test(username))
  55. return new Toast(
  56. "Invalid username format. Allowed characters: a-z, A-Z, 0-9, _ and -."
  57. );
  58. return socket.dispatch(
  59. `users.updateUsername`,
  60. user.value._id,
  61. username,
  62. res => {
  63. new Toast(res.message);
  64. }
  65. );
  66. };
  67. const updateEmail = () => {
  68. const email = user.value.email.address;
  69. if (!validation.isLength(email, 3, 254))
  70. return new Toast("Email must have between 3 and 254 characters.");
  71. if (
  72. email.indexOf("@") !== email.lastIndexOf("@") ||
  73. !validation.regex.emailSimple.test(email) ||
  74. !validation.regex.ascii.test(email)
  75. )
  76. return new Toast("Invalid email format.");
  77. return socket.dispatch(`users.updateEmail`, user.value._id, email, res => {
  78. new Toast(res.message);
  79. });
  80. };
  81. const updateRole = () => {
  82. socket.dispatch(
  83. `users.updateRole`,
  84. user.value._id,
  85. user.value.role,
  86. res => {
  87. new Toast(res.message);
  88. }
  89. );
  90. };
  91. const banUser = () => {
  92. const { reason } = ban.value;
  93. if (!validation.isLength(reason, 1, 64))
  94. return new Toast("Reason must have between 1 and 64 characters.");
  95. if (!validation.regex.ascii.test(reason))
  96. return new Toast(
  97. "Invalid reason format. Only ascii characters are allowed."
  98. );
  99. return socket.dispatch(
  100. `users.banUserById`,
  101. user.value._id,
  102. ban.value.reason,
  103. ban.value.expiresAt,
  104. res => {
  105. new Toast(res.message);
  106. }
  107. );
  108. };
  109. const resendVerificationEmail = () => {
  110. socket.dispatch(`users.resendVerifyEmail`, user.value._id, res => {
  111. new Toast(res.message);
  112. });
  113. };
  114. const requestPasswordReset = () => {
  115. socket.dispatch(`users.adminRequestPasswordReset`, user.value._id, res => {
  116. new Toast(res.message);
  117. });
  118. };
  119. const removeAccount = () => {
  120. socket.dispatch(`users.adminRemove`, user.value._id, res => {
  121. new Toast(res.message);
  122. });
  123. };
  124. const removeSessions = () => {
  125. socket.dispatch(`users.removeSessions`, user.value._id, res => {
  126. new Toast(res.message);
  127. });
  128. };
  129. // When the userId changes, run init. There can be a delay between the modal opening and the required data (userId) being available
  130. watch(userId, () => init());
  131. watch(
  132. () => hasPermission("users.get") && hasPermission("users.update"),
  133. value => {
  134. if (!value) closeCurrentModal();
  135. }
  136. );
  137. onMounted(() => {
  138. socket.onConnect(init);
  139. });
  140. onBeforeUnmount(() => {
  141. socket.dispatch("apis.leaveRoom", `edit-user.${userId.value}`, () => {});
  142. // Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
  143. editUserStore.$dispose();
  144. });
  145. </script>
  146. <template>
  147. <div>
  148. <modal title="Edit User">
  149. <template #body v-if="user && user._id">
  150. <div class="section">
  151. <label class="label"> Change username </label>
  152. <p class="control is-grouped">
  153. <span class="control is-expanded">
  154. <input
  155. v-model="user.username"
  156. class="input"
  157. type="text"
  158. placeholder="Username"
  159. autofocus
  160. />
  161. </span>
  162. <span
  163. v-if="hasPermission('users.update')"
  164. class="control"
  165. >
  166. <a class="button is-info" @click="updateUsername()"
  167. >Update Username</a
  168. >
  169. </span>
  170. </p>
  171. <label class="label"> Change email address </label>
  172. <p class="control is-grouped">
  173. <span class="control is-expanded">
  174. <input
  175. v-model="user.email.address"
  176. class="input"
  177. type="text"
  178. placeholder="Email Address"
  179. autofocus
  180. :disabled="
  181. !hasPermission('users.update.restricted')
  182. "
  183. />
  184. </span>
  185. <span
  186. v-if="hasPermission('users.update.restricted')"
  187. class="control"
  188. >
  189. <a class="button is-info" @click="updateEmail()"
  190. >Update Email Address</a
  191. >
  192. </span>
  193. </p>
  194. <label class="label"> Change user role </label>
  195. <div class="control is-grouped">
  196. <div class="control is-expanded select">
  197. <select
  198. v-model="user.role"
  199. :disabled="
  200. !hasPermission('users.update.restricted')
  201. "
  202. >
  203. <option>user</option>
  204. <option>moderator</option>
  205. <option>admin</option>
  206. </select>
  207. </div>
  208. <p
  209. v-if="hasPermission('users.update.restricted')"
  210. class="control"
  211. >
  212. <a class="button is-info" @click="updateRole()"
  213. >Update Role</a
  214. >
  215. </p>
  216. </div>
  217. </div>
  218. <div v-if="hasPermission('users.ban')" class="section">
  219. <label class="label"> Punish/Ban User </label>
  220. <p class="control is-grouped">
  221. <span class="control select">
  222. <select v-model="ban.expiresAt">
  223. <option value="1h">1 Hour</option>
  224. <option value="12h">12 Hours</option>
  225. <option value="1d">1 Day</option>
  226. <option value="1w">1 Week</option>
  227. <option value="1m">1 Month</option>
  228. <option value="3m">3 Months</option>
  229. <option value="6m">6 Months</option>
  230. <option value="1y">1 Year</option>
  231. </select>
  232. </span>
  233. <span class="control is-expanded">
  234. <input
  235. v-model="ban.reason"
  236. class="input"
  237. type="text"
  238. placeholder="Ban reason"
  239. autofocus
  240. />
  241. </span>
  242. <span class="control">
  243. <a class="button is-danger" @click="banUser()">
  244. Ban user
  245. </a>
  246. </span>
  247. </p>
  248. </div>
  249. </template>
  250. <template #footer>
  251. <quick-confirm
  252. v-if="hasPermission('users.resendVerifyEmail')"
  253. @confirm="resendVerificationEmail()"
  254. >
  255. <a class="button is-warning"> Resend verification email </a>
  256. </quick-confirm>
  257. <quick-confirm
  258. v-if="hasPermission('users.requestPasswordReset')"
  259. @confirm="requestPasswordReset()"
  260. >
  261. <a class="button is-warning"> Request password reset </a>
  262. </quick-confirm>
  263. <quick-confirm
  264. v-if="hasPermission('users.remove.sessions')"
  265. @confirm="removeSessions()"
  266. >
  267. <a class="button is-warning"> Remove all sessions </a>
  268. </quick-confirm>
  269. <quick-confirm
  270. v-if="hasPermission('users.remove')"
  271. @confirm="removeAccount()"
  272. >
  273. <a class="button is-danger"> Remove account </a>
  274. </quick-confirm>
  275. </template>
  276. </modal>
  277. </div>
  278. </template>
  279. <style lang="less" scoped>
  280. .night-mode .section {
  281. background-color: transparent !important;
  282. }
  283. .section {
  284. padding: 15px 0 !important;
  285. }
  286. .save-changes {
  287. color: var(--white);
  288. }
  289. .tag:not(:last-child) {
  290. margin-right: 5px;
  291. }
  292. .select:after {
  293. border-color: var(--primary-color);
  294. }
  295. </style>