From 6dd1fdc546fa521618a8875f9cb7fbf5db7fcc97 Mon Sep 17 00:00:00 2001
From: Sean Hatfield <seanhatfield5@gmail.com>
Date: Thu, 27 Feb 2025 07:23:24 +0800
Subject: [PATCH] Add bio field to user (#3346)

* add bio to users table

* lint

* add bio field to edit user admin page

* fix bio saving on new user

* simplify updating localstorage user

* linting

---------

Co-authored-by: timothycarambat <rambat1010@gmail.com>
---
 .../UserMenu/AccountModal/index.jsx           | 16 +++++++++-
 .../pages/Admin/Users/NewUserModal/index.jsx  | 15 ++++++++++
 .../Users/UserRow/EditUserModal/index.jsx     | 29 ++++++++++++++++++-
 server/endpoints/system.js                    | 10 +++----
 server/models/user.js                         |  9 ++++++
 .../20250226005538_init/migration.sql         |  2 ++
 server/prisma/schema.prisma                   |  1 +
 7 files changed, 74 insertions(+), 8 deletions(-)
 create mode 100644 server/prisma/migrations/20250226005538_init/migration.sql

diff --git a/frontend/src/components/UserMenu/AccountModal/index.jsx b/frontend/src/components/UserMenu/AccountModal/index.jsx
index 9de868934..a77c51457 100644
--- a/frontend/src/components/UserMenu/AccountModal/index.jsx
+++ b/frontend/src/components/UserMenu/AccountModal/index.jsx
@@ -50,9 +50,9 @@ export default function AccountModal({ user, hideModal }) {
     const { success, error } = await System.updateUser(data);
     if (success) {
       let storedUser = JSON.parse(localStorage.getItem(AUTH_USER));
-
       if (storedUser) {
         storedUser.username = data.username;
+        storedUser.bio = data.bio;
         localStorage.setItem(AUTH_USER, JSON.stringify(storedUser));
       }
       showToast("Profile updated.", "success", { clear: true });
@@ -164,6 +164,20 @@ export default function AccountModal({ user, hideModal }) {
                   Password must be at least 8 characters long
                 </p>
               </div>
+              <div>
+                <label
+                  htmlFor="bio"
+                  className="block mb-2 text-sm font-medium text-white"
+                >
+                  Bio
+                </label>
+                <textarea
+                  name="bio"
+                  className="border-none bg-theme-settings-input-bg placeholder:text-theme-settings-input-placeholder border-gray-500 text-white text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5 min-h-[100px] resize-y"
+                  placeholder="Tell us about yourself..."
+                  defaultValue={user.bio}
+                />
+              </div>
               <div className="flex flex-row gap-x-8">
                 <ThemePreference />
                 <LanguagePreference />
diff --git a/frontend/src/pages/Admin/Users/NewUserModal/index.jsx b/frontend/src/pages/Admin/Users/NewUserModal/index.jsx
index 54dab87ee..8b11c1a11 100644
--- a/frontend/src/pages/Admin/Users/NewUserModal/index.jsx
+++ b/frontend/src/pages/Admin/Users/NewUserModal/index.jsx
@@ -95,6 +95,21 @@ export default function NewUserModal({ closeModal }) {
                   Password must be at least 8 characters long
                 </p>
               </div>
+              <div>
+                <label
+                  htmlFor="bio"
+                  className="block mb-2 text-sm font-medium text-white"
+                >
+                  Bio
+                </label>
+                <textarea
+                  name="bio"
+                  className="border-none bg-theme-settings-input-bg w-full text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
+                  placeholder="User's bio"
+                  autoComplete="off"
+                  rows={3}
+                />
+              </div>
               <div>
                 <label
                   htmlFor="role"
diff --git a/frontend/src/pages/Admin/Users/UserRow/EditUserModal/index.jsx b/frontend/src/pages/Admin/Users/UserRow/EditUserModal/index.jsx
index 8908b17e6..5a88e4d89 100644
--- a/frontend/src/pages/Admin/Users/UserRow/EditUserModal/index.jsx
+++ b/frontend/src/pages/Admin/Users/UserRow/EditUserModal/index.jsx
@@ -2,6 +2,7 @@ import React, { useState } from "react";
 import { X } from "@phosphor-icons/react";
 import Admin from "@/models/admin";
 import { MessageLimitInput, RoleHintDisplay } from "../..";
+import { AUTH_USER } from "@/utils/constants";
 
 export default function EditUserModal({ currentUser, user, closeModal }) {
   const [role, setRole] = useState(user.role);
@@ -27,7 +28,17 @@ export default function EditUserModal({ currentUser, user, closeModal }) {
     }
 
     const { success, error } = await Admin.updateUser(user.id, data);
-    if (success) window.location.reload();
+    if (success) {
+      // Update local storage if we're editing our own user
+      if (currentUser && currentUser.id === user.id) {
+        currentUser.username = data.username;
+        currentUser.bio = data.bio;
+        currentUser.role = data.role;
+        localStorage.setItem(AUTH_USER, JSON.stringify(currentUser));
+      }
+
+      window.location.reload();
+    }
     setError(error);
   };
 
@@ -92,6 +103,22 @@ export default function EditUserModal({ currentUser, user, closeModal }) {
                   Password must be at least 8 characters long
                 </p>
               </div>
+              <div>
+                <label
+                  htmlFor="bio"
+                  className="block mb-2 text-sm font-medium text-white"
+                >
+                  Bio
+                </label>
+                <textarea
+                  name="bio"
+                  className="border-none bg-theme-settings-input-bg w-full text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
+                  placeholder="User's bio"
+                  defaultValue={user.bio}
+                  autoComplete="off"
+                  rows={3}
+                />
+              </div>
               <div>
                 <label
                   htmlFor="role"
diff --git a/server/endpoints/system.js b/server/endpoints/system.js
index a11edec62..8d1877269 100644
--- a/server/endpoints/system.js
+++ b/server/endpoints/system.js
@@ -1089,7 +1089,7 @@ function systemEndpoints(app) {
   app.post("/system/user", [validatedRequest], async (request, response) => {
     try {
       const sessionUser = await userFromSession(request, response);
-      const { username, password } = reqBody(request);
+      const { username, password, bio } = reqBody(request);
       const id = Number(sessionUser.id);
 
       if (!id) {
@@ -1098,12 +1098,10 @@ function systemEndpoints(app) {
       }
 
       const updates = {};
-      if (username) {
+      if (username)
         updates.username = User.validations.username(String(username));
-      }
-      if (password) {
-        updates.password = String(password);
-      }
+      if (password) updates.password = String(password);
+      if (bio) updates.bio = String(bio);
 
       if (Object.keys(updates).length === 0) {
         response
diff --git a/server/models/user.js b/server/models/user.js
index e6915d9dc..5979de347 100644
--- a/server/models/user.js
+++ b/server/models/user.js
@@ -22,6 +22,7 @@ const User = {
     "role",
     "suspended",
     "dailyMessageLimit",
+    "bio",
   ],
   validations: {
     username: (newValue = "") => {
@@ -54,6 +55,12 @@ const User = {
       }
       return limit;
     },
+    bio: (bio = "") => {
+      if (!bio || typeof bio !== "string") return "";
+      if (bio.length > 1000)
+        throw new Error("Bio cannot be longer than 1,000 characters");
+      return String(bio);
+    },
   },
   // validations for the above writable fields.
   castColumnValue: function (key, value) {
@@ -77,6 +84,7 @@ const User = {
     password,
     role = "default",
     dailyMessageLimit = null,
+    bio = "",
   }) {
     const passwordCheck = this.checkPasswordComplexity(password);
     if (!passwordCheck.checkedOK) {
@@ -97,6 +105,7 @@ const User = {
           username: this.validations.username(username),
           password: hashedPassword,
           role: this.validations.role(role),
+          bio: this.validations.bio(bio),
           dailyMessageLimit:
             this.validations.dailyMessageLimit(dailyMessageLimit),
         },
diff --git a/server/prisma/migrations/20250226005538_init/migration.sql b/server/prisma/migrations/20250226005538_init/migration.sql
new file mode 100644
index 000000000..4902fbc68
--- /dev/null
+++ b/server/prisma/migrations/20250226005538_init/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "users" ADD COLUMN "bio" TEXT DEFAULT '';
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index 37c82d4dd..8372d33a4 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -68,6 +68,7 @@ model users {
   createdAt                   DateTime                      @default(now())
   lastUpdatedAt               DateTime                      @default(now())
   dailyMessageLimit           Int?
+  bio                         String?                       @default("")
   workspace_chats             workspace_chats[]
   workspace_users             workspace_users[]
   embed_configs               embed_configs[]
-- 
GitLab