Browse Source

feat: enable manage user and role for zilliz cloud dedicated server (#803)

Signed-off-by: ryjiang <jiangruiyi@gmail.com>
ryjiang 3 months ago
parent
commit
47865d2aee

+ 4 - 2
client/src/context/Auth.tsx

@@ -19,11 +19,12 @@ export const authContext = createContext<AuthContextType>({
     database: '',
     checkHealth: true,
     clientId: '',
-    ssl: false
+    ssl: false,
   },
   setAuthReq: () => {},
   isManaged: false,
   isServerless: false,
+  isDedicated: false,
   isAuth: false,
   login: async () => {
     return { clientId: '', database: '' };
@@ -106,7 +107,8 @@ export const AuthProvider = (props: { children: React.ReactNode }) => {
         clientId,
         isAuth: !!clientId,
         isManaged: isManaged,
-        isServerless:isServerless,
+        isServerless: isServerless,
+        isDedicated: !isServerless && isManaged,
       }}
     >
       {props.children}

+ 1 - 0
client/src/context/Types.ts

@@ -75,6 +75,7 @@ export type AuthContextType = {
   clientId: string;
   isManaged: boolean;
   isServerless: boolean;
+  isDedicated: boolean;
   isAuth: boolean;
   logout: (pass?: boolean) => void;
   login: (params: AuthReq) => Promise<AuthObject>;

+ 1 - 0
client/src/i18n/cn/warning.ts

@@ -11,6 +11,7 @@ const warningTrans = {
   noSupportIndexType: 'Attu 还不支持 {{type}}。请更换其他字段。',
   valueLength: '{{name}} 长度应在 {{min}} ~ {{max}} 之间。',
   username: ' 用户名不能为空,长度不能超过32个字符。必须以字母开头,只能包含下划线、字母或数字。',
+  cloudPassword: `包括以下三种:大写字母、小写字母、数字和特殊字符。`,
 };
 
 export default warningTrans;

+ 3 - 1
client/src/i18n/en/warning.ts

@@ -11,7 +11,9 @@ const warningTrans = {
   noSupportIndexType:
     'Attu has not supported {{type}} yet. Please change another field.',
   valueLength: '{{name}} length should be in {{min}} ~ {{max}}.',
-  username: ' Username must not be empty, and must not exceed 32 characters in length. It must start with a letter, and only contains underscores, letters, or numbers.',
+  username:
+    'Username must not be empty, and must not exceed 32 characters in length. It must start with a letter, and only contains underscores, letters, or numbers.',
+  cloudPassword: `Includ three of the following: uppercase letters, lowercase letters, numbers, and special characters.`,
 };
 
 export default warningTrans;

+ 21 - 14
client/src/pages/index.tsx

@@ -41,14 +41,20 @@ const useStyles = makeStyles((theme: Theme) => ({
 }));
 
 function Index() {
-  const navigate = useNavigate();
-  const { isAuth, isManaged } = useContext(authContext);
+  // context
+  const { isAuth, isManaged, isDedicated } = useContext(authContext);
   const { database } = useContext(dataContext);
   const { versionInfo } = useContext(rootContext);
+  // i18n
   const { t: navTrans } = useTranslation('nav');
+  // hooks
+  const navigate = useNavigate();
   const classes = useStyles();
   const location = useLocation();
+
+  // compute data
   const isIndex = location.pathname === '/';
+  const enableUser = !isManaged || isDedicated;
   const defaultActive = useMemo(() => {
     if (location.pathname.includes('databases')) {
       return navTrans('database');
@@ -91,19 +97,20 @@ function Index() {
     // },
   ];
 
+  if (enableUser) {
+    menuItems.push({
+      icon: icons.navPerson,
+      label: navTrans('user'),
+      onClick: () => navigate('/users'),
+    });
+  }
+
   if (!isManaged) {
-    menuItems.push(
-      {
-        icon: icons.navPerson,
-        label: navTrans('user'),
-        onClick: () => navigate('/users'),
-      },
-      {
-        icon: icons.navSystem,
-        label: navTrans('system'),
-        onClick: () => navigate('/system'),
-      }
-    );
+    menuItems.push({
+      icon: icons.navSystem,
+      label: navTrans('system'),
+      onClick: () => navigate('/system'),
+    });
   }
 
   // check if is connected

+ 1 - 1
client/src/pages/user/D3PrivilegeTree.tsx

@@ -234,7 +234,7 @@ const D3PrivilegeTree: React.FC<Props> = ({
     svg.call(zoom); // Apply zoom to the SVG
 
     svg.transition().duration(0).call(zoom.transform, d3.zoomIdentity);
-  }, [privileges, margin, theme, groupPrivileges, role]);
+  }, [JSON.stringify({ privileges, margin, theme, groupPrivileges, role })]);
 
   // UI handler
   const handleDownload = () => {

+ 53 - 22
client/src/pages/user/dialogs/CreateUserDialog.tsx

@@ -5,16 +5,20 @@ import {
   FormControlLabel,
   Typography,
 } from '@mui/material';
-import { FC, useMemo, useState } from 'react';
+import { FC, useMemo, useState, useContext } from 'react';
 import { useTranslation } from 'react-i18next';
 import DialogTemplate from '@/components/customDialog/DialogTemplate';
 import CustomInput from '@/components/customInput/CustomInput';
+import { authContext } from '@/context';
 import { useFormValidation } from '@/hooks';
 import { formatForm } from '@/utils';
 import { makeStyles } from '@mui/styles';
 import type { CreateUserProps, CreateUserParams } from '../Types';
 import type { Option as RoleOption } from '@/components/customSelector/Types';
-import type { ITextfieldConfig } from '@/components/customInput/Types';
+import type {
+  ITextfieldConfig,
+  IValidation,
+} from '@/components/customInput/Types';
 
 const useStyles = makeStyles((theme: Theme) => ({
   input: {
@@ -33,12 +37,16 @@ const CreateUser: FC<CreateUserProps> = ({
   handleClose,
   roleOptions,
 }) => {
+  // context
+  const { isDedicated } = useContext(authContext);
+  // i18n
   const { t: commonTrans } = useTranslation();
   const { t: userTrans } = useTranslation('user');
   const { t: btnTrans } = useTranslation('btn');
   const { t: warningTrans } = useTranslation('warning');
   const attuTrans = commonTrans('attu');
 
+  // UI states
   const [form, setForm] = useState<CreateUserParams>({
     username: '',
     password: '',
@@ -52,12 +60,54 @@ const CreateUser: FC<CreateUserProps> = ({
 
   const { validation, checkIsValid, disabled } = useFormValidation(checkedForm);
 
+  // styles
   const classes = useStyles();
 
+  // UI handlers
   const handleInputChange = (key: 'username' | 'password', value: string) => {
     setForm(v => ({ ...v, [key]: value }));
   };
 
+  const opensourceUserPassRule: IValidation[] = [
+    {
+      rule: 'valueLength',
+      errorText: warningTrans('valueLength', {
+        name: attuTrans.password,
+        min: 6,
+        max: 256,
+      }),
+      extraParam: {
+        min: 6,
+        max: 256,
+      },
+    },
+  ];
+
+  const cloudUserPassRule: IValidation[] = [
+    {
+      rule: 'require',
+      errorText: warningTrans('required', {
+        name: attuTrans.password,
+      }),
+    },
+    {
+      rule: 'valueLength',
+      errorText: warningTrans('valueLength', {
+        name: attuTrans.password,
+        min: 8,
+        max: 64,
+      }),
+      extraParam: {
+        min: 8,
+        max: 64,
+      },
+    },
+    {
+      rule: 'cloudPassword',
+      errorText: warningTrans('cloudPassword'),
+    },
+  ];
+
   const createConfigs: ITextfieldConfig[] = [
     {
       label: attuTrans.username,
@@ -90,26 +140,7 @@ const CreateUser: FC<CreateUserProps> = ({
       placeholder: attuTrans.password,
       fullWidth: true,
       type: 'password',
-      validations: [
-        {
-          rule: 'require',
-          errorText: warningTrans('required', {
-            name: attuTrans.password,
-          }),
-        },
-        {
-          rule: 'valueLength',
-          errorText: warningTrans('valueLength', {
-            name: attuTrans.password,
-            min: 6,
-            max: 256,
-          }),
-          extraParam: {
-            min: 6,
-            max: 256,
-          },
-        },
-      ],
+      validations: !isDedicated ? opensourceUserPassRule : cloudUserPassRule,
       defaultValue: form.username,
     },
   ];

+ 18 - 14
client/src/pages/user/dialogs/DBCollectionSelector.tsx

@@ -1,13 +1,13 @@
-import { useState, useCallback, useEffect } from 'react';
+import { useState, useCallback, useEffect, useContext } from 'react';
 import {
   TextField,
-  Typography,
   FormControlLabel,
   Checkbox,
   Tabs,
   Tab,
   Radio,
 } from '@mui/material';
+import { authContext } from '@/context';
 import Autocomplete from '@mui/material/Autocomplete';
 import { CollectionService } from '@/http';
 import { useTranslation } from 'react-i18next';
@@ -30,6 +30,8 @@ export default function DBCollectionsSelector(
   // i18n
   const { t: searchTrans } = useTranslation('search');
   const { t: userTrans } = useTranslation('user');
+  // context
+  const { isDedicated } = useContext(authContext);
 
   // UI states
   const [selectedDB, setSelectedDB] = useState<DBOption | null>(null);
@@ -276,17 +278,19 @@ export default function DBCollectionsSelector(
           />
           {userTrans('privilegeGroups')}
         </label>
-        <label className="toggle-label">
-          <Radio
-            checked={privilegeOptionType === 'custom'}
-            onChange={() => setPrivilegeOptionType('custom')}
-            value="custom"
-            name="custom"
-            size="small"
-            inputProps={{ 'aria-label': 'custom' }}
-          />
-          {userTrans('privileges')}
-        </label>
+        {!isDedicated && (
+          <label className="toggle-label">
+            <Radio
+              checked={privilegeOptionType === 'custom'}
+              onChange={() => setPrivilegeOptionType('custom')}
+              value="custom"
+              name="custom"
+              size="small"
+              inputProps={{ 'aria-label': 'custom' }}
+            />
+            {userTrans('privileges')}
+          </label>
+        )}
       </div>
       {/* Tabs for cluster, Database, Collection */}
       <Tabs
@@ -468,7 +472,7 @@ const PrivilegeSelector = (props: {
           {privilegeOptions
             .filter(v => {
               if (privilegeOptionType === 'group') {
-                return v[0].includes('Groups');
+                return v[0].includes('Groups') && Object.keys(v[1]).length > 0;
               } else {
                 return !v[0].includes('Groups');
               }

+ 5 - 3
client/src/router/Router.tsx

@@ -10,7 +10,9 @@ import System from '@/pages/system/SystemView';
 import SystemHealthy from '@/pages/systemHealthy/SystemHealthyView';
 
 const RouterComponent = () => {
-  const { isManaged } = useContext(authContext);
+  const { isManaged, isDedicated } = useContext(authContext);
+
+  const enableManageUsers = !isManaged || !isDedicated;
 
   return (
     <Router>
@@ -30,14 +32,14 @@ const RouterComponent = () => {
 
           <Route path="search" element={<Search />} />
           <Route path="system_healthy" element={<SystemHealthy />} />
-          {!isManaged && (
+          {enableManageUsers && (
             <>
               <Route path="users" element={<Users />} />
               <Route path="roles" element={<Users />} />
               <Route path="privilege-groups" element={<Users />} />
-              <Route path="system" element={<System />} />
             </>
           )}
+          {!isManaged && <Route path="system" element={<System />} />}
         </Route>
         <Route path="connect" element={<Connect />} />
       </Routes>

+ 9 - 0
client/src/utils/Validation.ts

@@ -22,6 +22,7 @@ export type ValidType =
   | 'duplicate'
   | 'valueLength'
   | 'username'
+  | 'cloudPassword'
   | 'custom';
 export interface ICheckMapParam {
   value: string;
@@ -234,6 +235,13 @@ export const checkUserName = (value: string): boolean => {
   return re.test(value);
 };
 
+// includ 3 of 4 types of characters: uppercase, lowercase, number, special character
+export const checkCloudPassword = (value: string): boolean => {
+  const re =
+    /^(?![A-Za-z]+$)(?![A-Z\d]+$)(?![A-Z\W]+$)(?![a-z\d]+$)(?![a-z\W]+$)(?![\d\W]+$).{3,}$/;
+  return re.test(value);
+};
+
 export const getCheckResult = (param: ICheckMapParam): boolean => {
   const { value, extraParam = {}, rule } = param;
   const numberValue = Number(value);
@@ -251,6 +259,7 @@ export const getCheckResult = (param: ICheckMapParam): boolean => {
       type: extraParam?.type,
     }),
     password: checkPasswordStrength(value),
+    cloudPassword: checkCloudPassword(value),
     clusterName: checkClusterName(value),
     CIDRorIP: checkIpOrCIDR(value),
     integer: !isNaN(numberValue) && Number.isInteger(numberValue),