Ver Fonte

Merge pull request #7753 from weseek/feat/123275-123281-ldap-group-sync-config

feat: 123275 123281 ldap group sync config
Ryoji Shimizu há 2 anos atrás
pai
commit
81552bfd79

+ 20 - 1
apps/app/public/static/locales/en_US/admin.json

@@ -9,6 +9,7 @@
   "specified_users": "Specified users",
   "only_me": "Only me",
   "only_inside_the_group": "Only inside the group",
+  "optional": "Optional",
   "security_settings": {
     "security_settings": "Security Settings",
     "scope_of_page_disclosure": "Scope of page disclosure",
@@ -89,7 +90,6 @@
     "enable_link_sharing": "Enable link sharing",
     "all_share_links": "All share links",
     "configuration": " Configuration",
-    "optional": "Optional",
     "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
     "Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
     "Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>email</code> match",
@@ -1037,6 +1037,25 @@
     "error_generate_growi_archive": "Failed to generate GROWI archive file",
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
+  "external_group": {
+    "management": "External Group Management",
+    "ldap": {
+      "group_sync_settings": "LDAP Group Sync Settings",
+      "group_search_base_DN": "Group Search Base DN",
+      "group_search_base_dn_detail": "The base DN from which to search for groups",
+      "membership_attribute": "Membership Attribute",
+      "membership_attribute_detail": "Attribute of the group object which indicates user membership info",
+      "membership_attribute_type": "Membership Attribute Type",
+      "membership_attribute_type_detail": "Whether membership attribute value is of type DN or UID",
+      "child_group_attribute": "Child Group Attribute",
+      "child_group_attribute_detail": "Attribute of the group object which indicates child group info. The attribute value needs to be the DN of the child group.",
+      "auto_generate_user_on_sync": "Auto Generate User on Sync",
+      "preserve_deleted_ldap_groups": "Preserve Deleted LDAP Groups",
+      "name_mapper_detail": "Attribute to map as group name",
+      "description_mapper_detail": "Attribute to map as group description. Description can be edited after sync. However, when a mapper is set, the edited value can possibly be overwritten by the next sync.",
+      "updated_group_sync_settings": "Updated LDAP group sync settings"
+    }
+  },
   "toaster": {
     "grant_user_admin": "Succeeded to grant {{username}} admin",
     "revoke_user_admin": "Succeeded to revoke {{username}} admin",

+ 20 - 1
apps/app/public/static/locales/ja_JP/admin.json

@@ -17,6 +17,7 @@
   "specified_users": "特定ユーザーのみ",
   "only_me": "自分のみ",
   "only_inside_the_group": "特定グループのみ",
+  "optional": "オプション",
   "security_settings": {
     "security_settings": "セキュリティ設定",
     "scope_of_page_disclosure": "ページの公開範囲",
@@ -97,7 +98,6 @@
     "enable_link_sharing": "リンクのシェアを許可",
     "all_share_links": "全てのシェアリンク",
     "configuration": "設定",
-    "optional": "オプション",
     "Treat username matching as identical": "新規ログイン時、<code>username</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
     "Treat username matching as identical_warn": "警告: <code>username</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
     "Treat email matching as identical": "新規ログイン時、<code>email</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
@@ -1045,6 +1045,25 @@
     "error_generate_growi_archive": "GROWI アーカイブファイルの作成に失敗しました",
     "error_send_growi_archive": "GROWI アーカイブファイルの送信に失敗しました"
   },
+  "external_group": {
+    "management": "外部グループ管理",
+    "ldap": {
+      "group_sync_settings": "LDAP グループ同期設定",
+      "group_search_base_DN": "グループ検索ベース DN",
+      "group_search_base_dn_detail": "グループ検索をするベース DN",
+      "membership_attribute": "所属メンバーを表す LDAP 属性",
+      "membership_attribute_detail": "グループの所属メンバーを表すグループオブジェクトの属性",
+      "membership_attribute_type": "「所属メンバーを表す LDAP 属性」値の種類",
+      "membership_attribute_type_detail": "グループの所属メンバーを表すグループオブジェクトの属性値は DN か UID か",
+      "child_group_attribute": "子グループを表す LDAP 属性",
+      "child_group_attribute_detail": "グループに所属する子グループを表すグループオブジェクトの属性。属性値は DN である必要があります。",
+      "auto_generate_user_on_sync": "作成されていない GROWI アカウントを自動生成する",
+      "preserve_deleted_ldap_groups": "LDAP から削除されたグループを GROWI に残す",
+      "name_mapper_detail": "グループの「名前」として読み込む属性",
+      "description_mapper_detail": "グループの「説明」として読み込む属性。「説明」は同期後に編集可能です。ただし、mapper が設定されている場合、編集内容は再同期によって上書きされます。",
+      "updated_group_sync_settings": "LDAP グループ同期設定を更新しました"
+    }
+  },
   "toaster": {
     "grant_user_admin": "{{username}}を管理者に設定しました",
     "revoke_user_admin": "{{username}}を管理者から外しました",

+ 20 - 1
apps/app/public/static/locales/zh_CN/admin.json

@@ -17,6 +17,7 @@
   "specified_users": "仅指定用户",
   "only_me": "只有我",
   "only_inside_the_group": "仅组内",
+  "optional": "可选的",
   "security_settings": {
     "security_settings": "安全设置",
     "scope_of_page_disclosure": "页面公开范围",
@@ -97,7 +98,6 @@
     "enable_link_sharing": "启用链接共享",
     "all_share_links": "所有共享链接",
 		"configuration": " 配置",
-		"optional": "可选的",
 		"Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
 		"Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
 		"Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>email</code> match",
@@ -1045,6 +1045,25 @@
     "error_generate_growi_archive": "Failed to generate GROWI archive file",
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
+  "external_group": {
+    "management": "External Group Management",
+    "ldap": {
+      "group_sync_settings": "LDAP Group Sync Settings",
+      "group_search_base_DN": "Group Search Base DN",
+      "group_search_base_dn_detail": "The base DN from which to search for groups",
+      "membership_attribute": "Membership Attribute",
+      "membership_attribute_detail": "Attribute of the group object which indicates user membership info",
+      "membership_attribute_type": "Membership Attribute Type",
+      "membership_attribute_type_detail": "Whether membership attribute value is of type DN or UID",
+      "child_group_attribute": "Child Group Attribute",
+      "child_group_attribute_detail": "Attribute of the group object which indicates child group info. The attribute value needs to be the DN of the child group.",
+      "auto_generate_user_on_sync": "Auto Generate User on Sync",
+      "preserve_deleted_ldap_groups": "Preserve Deleted LDAP Groups",
+      "name_mapper_detail": "Attribute to map as group name",
+      "description_mapper_detail": "Attribute to map as group description. Description can be edited after sync. However, when a mapper is set, the edited value can possibly be overwritten by the next sync.",
+      "updated_sync_settings": "Updated LDAP group sync settings"
+    }
+  },
   "toaster": {
     "grant_user_admin": "Succeeded to grant {{username}} admin",
     "revoke_user_admin": "Succeeded to revoke {{username}} admin",

+ 2 - 2
apps/app/src/components/Admin/Security/LdapSecuritySettingContents.jsx

@@ -235,7 +235,7 @@ class LdapSecuritySettingContents extends React.Component {
             </div>
 
             <h3 className="alert-anchor border-bottom">
-              Attribute Mapping ({t('security_settings.optional')})
+              Attribute Mapping ({t('optional')})
             </h3>
 
             <div className="form-group row">
@@ -325,7 +325,7 @@ class LdapSecuritySettingContents extends React.Component {
 
 
             <h3 className="alert-anchor border-bottom">
-              {t('security_settings.ldap.group_search_filter')} ({t('security_settings.optional')})
+              {t('security_settings.ldap.group_search_filter')} ({t('optional')})
             </h3>
 
             <div className="form-group row">

+ 1 - 1
apps/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx

@@ -296,7 +296,7 @@ class OidcSecurityManagementContents extends React.Component {
             </div>
 
             <h3 className="alert-anchor border-bottom">
-              Attribute Mapping ({t('security_settings.optional')})
+              Attribute Mapping ({t('optional')})
             </h3>
 
             <div className="row mb-5 form-group">

+ 44 - 0
apps/app/src/components/Admin/UserGroup/ExternalGroup/ExternalGroupManagement.tsx

@@ -0,0 +1,44 @@
+import { FC, useMemo, useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { TabContent, TabPane } from 'reactstrap';
+
+import CustomNav from '~/components/CustomNavigation/CustomNav';
+
+import { LDAPGroupSyncSettingsForm } from './LDAPGroupSyncSettingsForm';
+
+export const ExternalGroupManagement: FC = () => {
+  const [activeTab, setActiveTab] = useState('ldap');
+  const [activeComponents, setActiveComponents] = useState(new Set(['ldap']));
+  const { t } = useTranslation('admin');
+
+  const switchActiveTab = (selectedTab) => {
+    setActiveTab(selectedTab);
+    setActiveComponents(activeComponents.add(selectedTab));
+  };
+
+  const navTabMapping = useMemo(() => {
+    return {
+      ldap: {
+        Icon: () => <i className="fa fa-sitemap" />,
+        i18n: 'LDAP',
+      },
+    };
+  }, []);
+
+  return <>
+    <h2 className="border-bottom">{t('external_group.management')}</h2>
+    <CustomNav
+      activeTab={activeTab}
+      navTabMapping={navTabMapping}
+      onNavSelected={switchActiveTab}
+      hideBorderBottom
+      breakpointToSwitchDropdownDown="md"
+    />
+    <TabContent activeTab={activeTab} className="p-5">
+      <TabPane tabId="ldap">
+        {activeComponents.has('ldap') && <LDAPGroupSyncSettingsForm />}
+      </TabPane>
+    </TabContent>
+  </>;
+};

+ 233 - 0
apps/app/src/components/Admin/UserGroup/ExternalGroup/LDAPGroupSyncSettingsForm.tsx

@@ -0,0 +1,233 @@
+import {
+  FC, useCallback, useEffect, useState,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { LDAPGroupSyncSettings } from '~/interfaces/external-user-group';
+import { useSWRxLDAPGroupSyncSettings } from '~/stores/external-user-group';
+
+export const LDAPGroupSyncSettingsForm: FC = () => {
+  const { t } = useTranslation('admin');
+
+  const { data: ldapGroupSyncSettings } = useSWRxLDAPGroupSyncSettings();
+
+  const [formValues, setFormValues] = useState<LDAPGroupSyncSettings>({
+    ldapGroupsDN: '',
+    ldapGroupMembershipAttribute: '',
+    ldapGroupMembershipAttributeType: '',
+    ldapGroupChildGroupAttribute: '',
+    autoGenerateUserOnLDAPGroupSync: false,
+    preserveDeletedLDAPGroups: false,
+    ldapGroupNameAttribute: '',
+    ldapGroupDescriptionAttribute: '',
+  });
+
+  useEffect(() => {
+    if (ldapGroupSyncSettings != null) {
+      setFormValues(ldapGroupSyncSettings);
+    }
+  }, [ldapGroupSyncSettings, setFormValues]);
+
+  const submitHandler = useCallback(async(e) => {
+    e.preventDefault();
+    try {
+      await apiv3Put('/external-user-groups/ldap/sync-settings', formValues);
+      toastSuccess(t('external_group.ldap.updated_group_sync_settings'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [formValues, t]);
+
+  return <>
+    <h3 className="border-bottom">{t('external_group.ldap.group_sync_settings')}</h3>
+    <form onSubmit={submitHandler}>
+      <div className="row form-group">
+        <label htmlFor="ldapGroupsDN" className="text-left text-md-right col-md-3 col-form-label">{t('external_group.ldap.group_search_base_DN')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            required
+            type="text"
+            name="ldapGroupsDN"
+            id="ldapGroupsDN"
+            value={formValues.ldapGroupsDN}
+            onChange={e => setFormValues({ ...formValues, ldapGroupsDN: e.target.value })}
+          />
+          <p className="form-text text-muted">
+            <small>{t('external_group.ldap.group_search_base_dn_detail')}</small>
+          </p>
+        </div>
+      </div>
+      <div className="row form-group">
+        <label htmlFor="ldapGroupMembershipAttribute" className="text-left text-md-right col-md-3 col-form-label">
+          {t('external_group.ldap.membership_attribute')}
+        </label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            required
+            type="text"
+            name="ldapGroupMembershipAttribute"
+            id="ldapGroupMembershipAttribute"
+            value={formValues.ldapGroupMembershipAttribute}
+            onChange={e => setFormValues({ ...formValues, ldapGroupMembershipAttribute: e.target.value })}
+          />
+          <p className="form-text text-muted">
+            <small>
+              {t('external_group.ldap.membership_attribute_detail')} <br />
+            e.g.) <code>member</code>, <code>memberUid</code>
+            </small>
+          </p>
+        </div>
+      </div>
+      <div className="row form-group">
+        <label htmlFor="ldapGroupMembershipAttributeType" className="text-left text-md-right col-md-3 col-form-label">
+          {t('external_group.ldap.membership_attribute_type')}
+        </label>
+        <div className="col-md-6">
+          <select
+            className="form-control"
+            required
+            name="ldapGroupMembershipAttributeType"
+            id="ldapGroupMembershipAttributeType"
+            value={formValues.ldapGroupMembershipAttributeType}
+            onChange={e => setFormValues({ ...formValues, ldapGroupMembershipAttributeType: e.target.value })}>
+            <option value="DN">DN</option>
+            <option value="UID">UID</option>
+          </select>
+          <p className="form-text text-muted">
+            <small>
+              {t('external_group.ldap.membership_attribute_type_detail')}
+            </small>
+          </p>
+        </div>
+      </div>
+      <div className="row form-group">
+        <label htmlFor="ldapGroupChildGroupAttribute" className="text-left text-md-right col-md-3 col-form-label">
+          {t('external_group.ldap.child_group_attribute')}
+        </label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            required
+            type="text"
+            name="ldapGroupChildGroupAttribute"
+            id="ldapGroupChildGroupAttribute"
+            value={formValues.ldapGroupChildGroupAttribute}
+            onChange={e => setFormValues({ ...formValues, ldapGroupChildGroupAttribute: e.target.value })}/>
+          <p className="form-text text-muted">
+            <small>
+              {t('external_group.ldap.child_group_attribute_detail')}<br />
+            e.g.) <code>member</code>
+            </small>
+          </p>
+        </div>
+      </div>
+      <div className="row form-group">
+        <label
+          className="text-left text-md-right col-md-3 col-form-label"
+        >
+          {/* {t('external_group.ldap.auto_generate_user_on_sync')} */}
+        </label>
+        <div className="col-md-6">
+          <div className="custom-control custom-checkbox custom-checkbox-info">
+            <input
+              type="checkbox"
+              className="custom-control-input"
+              name="autoGenerateUserOnLDAPGroupSync"
+              id="autoGenerateUserOnLDAPGroupSync"
+              checked={formValues.autoGenerateUserOnLDAPGroupSync}
+              onChange={() => setFormValues({ ...formValues, autoGenerateUserOnLDAPGroupSync: !formValues.autoGenerateUserOnLDAPGroupSync })}
+            />
+            <label
+              className="custom-control-label"
+              htmlFor="autoGenerateUserOnLDAPGroupSync"
+            >
+              {t('external_group.ldap.auto_generate_user_on_sync')}
+            </label>
+          </div>
+        </div>
+      </div>
+      <div className="row form-group">
+        <label
+          className="text-left text-md-right col-md-3 col-form-label"
+        >
+          {/* {t('external_group.ldap.preserve_deleted_ldap_groups')} */}
+        </label>
+        <div className="col-md-6">
+          <div className="custom-control custom-checkbox custom-checkbox-info">
+            <input
+              type="checkbox"
+              className="custom-control-input"
+              name="preserveDeletedLDAPGroups"
+              id="preserveDeletedLDAPGroups"
+              checked={formValues.preserveDeletedLDAPGroups}
+              onChange={() => setFormValues({ ...formValues, preserveDeletedLDAPGroups: !formValues.preserveDeletedLDAPGroups })}
+            />
+            <label
+              className="custom-control-label"
+              htmlFor="preserveDeletedLDAPGroups"
+            >
+              {t('external_group.ldap.preserve_deleted_ldap_groups')}
+            </label>
+          </div>
+        </div>
+      </div>
+      <h3 className="border-bottom">Attribute Mapping ({t('optional')})</h3>
+      <div className="row form-group">
+        <label htmlFor="ldapGroupNameAttribute" className="text-left text-md-right col-md-3 col-form-label">{t('Name')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            name="ldapGroupNameAttribute"
+            id="ldapGroupNameAttribute"
+            value={formValues.ldapGroupNameAttribute}
+            onChange={e => setFormValues({ ...formValues, ldapGroupNameAttribute: e.target.value })}
+            placeholder="Default: cn"
+          />
+          <p className="form-text text-muted">
+            <small>
+              {t('external_group.ldap.name_mapper_detail')}
+            </small>
+          </p>
+        </div>
+      </div>
+      <div className="row form-group">
+        <label htmlFor="ldapGroupDescriptionAttribute" className="text-left text-md-right col-md-3 col-form-label">
+          {t('Description')}
+        </label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            name="ldapGroupDescriptionAttribute"
+            id="ldapGroupDescriptionAttribute"
+            value={formValues.ldapGroupDescriptionAttribute}
+            onChange={e => setFormValues({ ...formValues, ldapGroupDescriptionAttribute: e.target.value })}
+          />
+          <p className="form-text text-muted">
+            <small>
+              {t('external_group.ldap.description_mapper_detail')}
+            </small>
+          </p>
+        </div>
+      </div>
+
+      <div className="row my-3">
+        <div className="offset-3 col-5">
+          <button
+            type="submit"
+            className="btn btn-primary"
+          >
+            {t('Update')}
+          </button>
+        </div>
+      </div>
+    </form>
+  </>;
+};

+ 6 - 0
apps/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -9,6 +9,8 @@ import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import { useIsAclEnabled } from '~/stores/context';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 
+import { ExternalGroupManagement } from './ExternalGroup/ExternalGroupManagement';
+
 const UserGroupDeleteModal = dynamic(() => import('./UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
 const UserGroupModal = dynamic(() => import('./UserGroupModal').then(mod => mod.UserGroupModal), { ssr: false });
 const UserGroupTable = dynamic(() => import('./UserGroupTable').then(mod => mod.UserGroupTable), { ssr: false });
@@ -146,6 +148,7 @@ export const UserGroupPage: FC = () => {
 
   return (
     <div data-testid="admin-user-groups">
+      <h2 className="border-bottom">{t('admin:user_group_management.user_group_management')}</h2>
       {
         isAclEnabled ? (
           <div className="mb-3">
@@ -190,6 +193,9 @@ export const UserGroupPage: FC = () => {
         isShow={isDeleteModalShown}
         onHide={hideDeleteModal}
       />
+      <div className="mt-5">
+        <ExternalGroupManagement />
+      </div>
     </div>
   );
 };

+ 1 - 1
apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -126,7 +126,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
 
   return (
     <div data-testid="grw-user-group-table">
-      <h2>{props.headerLabel}</h2>
+      <h3>{props.headerLabel}</h3>
 
       <table className="table table-bordered table-user-list">
         <thead>

+ 10 - 0
apps/app/src/interfaces/external-user-group.ts

@@ -0,0 +1,10 @@
+export interface LDAPGroupSyncSettings {
+  ldapGroupsDN: string
+  ldapGroupMembershipAttribute: string
+  ldapGroupMembershipAttributeType: string
+  ldapGroupChildGroupAttribute: string
+  autoGenerateUserOnLDAPGroupSync: boolean
+  preserveDeletedLDAPGroups: boolean
+  ldapGroupNameAttribute: string
+  ldapGroupDescriptionAttribute?: string
+}

+ 82 - 0
apps/app/src/server/routes/apiv3/external-user-group.ts

@@ -0,0 +1,82 @@
+import { Router, Request } from 'express';
+import { body, validationResult } from 'express-validator';
+
+import Crowi from '~/server/crowi';
+import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:routes:apiv3:external-user-group');
+
+const router = Router();
+
+interface AuthorizedRequest extends Request {
+  user?: any
+}
+
+module.exports = (crowi: Crowi): Router => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+  const adminRequired = require('../../middlewares/admin-required')(crowi);
+
+  const validators = {
+    ldapSyncSettings: [
+      body('ldapGroupsDN').exists({ checkFalsy: true }).isString(),
+      body('ldapGroupMembershipAttribute').exists({ checkFalsy: true }).isString(),
+      body('ldapGroupMembershipAttributeType').exists({ checkFalsy: true }).isString(),
+      body('ldapGroupChildGroupAttribute').exists({ checkFalsy: true }).isString(),
+      body('autoGenerateUserOnLDAPGroupSync').exists().isBoolean(),
+      body('preserveDeletedLDAPGroups').exists().isBoolean(),
+      body('ldapGroupNameAttribute').optional({ nullable: true }).isString(),
+      body('ldapGroupDescriptionAttribute').optional({ nullable: true }).isString(),
+    ],
+  };
+
+  router.get('/ldap/sync-settings', loginRequiredStrictly, adminRequired, validators.ldapSyncSettings, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const settings = {
+      ldapGroupsDN: await crowi.configManager?.getConfig('crowi', 'external-user-group:ldap:groupsDN'),
+      ldapGroupMembershipAttribute: await crowi.configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttribute'),
+      ldapGroupMembershipAttributeType: await crowi.configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttributeType'),
+      ldapGroupChildGroupAttribute: await crowi.configManager?.getConfig('crowi', 'external-user-group:ldap:groupChildGroupAttribute'),
+      autoGenerateUserOnLDAPGroupSync: await crowi.configManager?.getConfig('crowi', 'external-user-group:ldap:autoGenerateUserOnGroupSync'),
+      preserveDeletedLDAPGroups: await crowi.configManager?.getConfig('crowi', 'external-user-group:ldap:preserveDeletedGroups'),
+      ldapGroupNameAttribute: await crowi.configManager?.getConfig('crowi', 'external-user-group:ldap:groupNameAttribute'),
+      ldapGroupDescriptionAttribute: await crowi.configManager?.getConfig('crowi', 'external-user-group:ldap:groupDescriptionAttribute'),
+    };
+
+    return res.apiv3(settings);
+  });
+
+  router.put('/ldap/sync-settings', loginRequiredStrictly, adminRequired, validators.ldapSyncSettings, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const errors = validationResult(req);
+    if (!errors.isEmpty()) {
+      return res.status(400).json({ error: 'invalid body params' });
+    }
+
+    const params = {
+      'external-user-group:ldap:groupsDN': req.body.ldapGroupsDN,
+      'external-user-group:ldap:groupMembershipAttribute': req.body.ldapGroupMembershipAttribute,
+      'external-user-group:ldap:groupMembershipAttributeType': req.body.ldapGroupMembershipAttributeType,
+      'external-user-group:ldap:groupChildGroupAttribute': req.body.ldapGroupChildGroupAttribute,
+      'external-user-group:ldap:autoGenerateUserOnGroupSync': req.body.autoGenerateUserOnLDAPGroupSync,
+      'external-user-group:ldap:preserveDeletedGroups': req.body.preserveDeletedLDAPGroups,
+      'external-user-group:ldap:groupNameAttribute': req.body.ldapGroupNameAttribute,
+      'external-user-group:ldap:groupDescriptionAttribute': req.body.ldapGroupDescriptionAttribute,
+    };
+
+    if (params['external-user-group:ldap:groupNameAttribute'] == null || params['external-user-group:ldap:groupNameAttribute'] === '') {
+      // default is cn
+      params['external-user-group:ldap:groupNameAttribute'] = 'cn';
+    }
+
+    try {
+      await crowi.configManager?.updateConfigsInTheSameNamespace('crowi', params, true);
+      return res.apiv3({}, 204);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
+  return router;
+
+};

+ 1 - 0
apps/app/src/server/routes/apiv3/index.js

@@ -34,6 +34,7 @@ module.exports = (crowi, app) => {
   routerForAdmin.use('/notification-setting', require('./notification-setting')(crowi));
   routerForAdmin.use('/users', require('./users')(crowi));
   routerForAdmin.use('/user-groups', require('./user-group')(crowi));
+  routerForAdmin.use('/external-user-groups', require('./external-user-group')(crowi));
   routerForAdmin.use('/export', require('./export')(crowi));
   routerForAdmin.use('/import', importRoute(crowi));
   routerForAdmin.use('/search', require('./search')(crowi));

+ 13 - 0
apps/app/src/stores/external-user-group.ts

@@ -0,0 +1,13 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { LDAPGroupSyncSettings } from '~/interfaces/external-user-group';
+
+export const useSWRxLDAPGroupSyncSettings = (): SWRResponse<LDAPGroupSyncSettings, Error> => {
+  return useSWR(
+    '/external-user-groups/ldap/sync-settings',
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return response.data;
+    }),
+  );
+};