Просмотр исходного кода

Merge pull request #9731 from weseek/feat/85961-162100-access-token-can-be-created-and-deleted-from-api-settings

feat: Access token can be created and deleted from API settings
reiji-h 1 год назад
Родитель
Сommit
ce7ecb8e53

+ 23 - 0
apps/app/playwright/60-home/home.spec.ts

@@ -97,6 +97,29 @@ test('Access API setting', async({ page }) => {
   await expect(page.locator('.Toastify__toast')).toBeVisible();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
 });
 });
 
 
+test('Access Access Token setting', async({ page }) => {
+  await page.goto('/me');
+
+  // Click ApiSettingTabButton
+  await expect(page.getByTestId('grw-user-settings')).toBeVisible();
+  await page.getByTestId('api-settings-tab-button').first().click();
+
+  // Expect a success toaster to be displayed when new Access Token is generated
+  await page.getByTestId('btn-accesstoken-toggleform').click();
+  await page.getByTestId('grw-accesstoken-textare-description').fill('dummy');
+  await page.getByTestId('grw-accesstoken-create-button').click();
+  await expect(page.locator('.Toastify__toast')).toBeVisible();
+  await expect(page.locator('grw-accesstokne-new-token-display')).toBeVisible();
+
+  // Expect a success toaster to be displayed when the Access Token is deleted
+  await page.getByTestId('grw-accesstoken-delete-button').click();
+  await page.getByTestId('grw-accesstoken-cancel-button-in-modal').click();
+  await page.getByTestId('grw-accesstoken-delete-button').click();
+  await page.getByTestId('grw-accesstoken-delete-button-in-modal').click();
+  await expect(page.locator('.Toastify__toast')).toBeVisible();
+
+});
+
 test('Access In-App Notification setting', async({ page }) => {
 test('Access In-App Notification setting', async({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 

+ 28 - 11
apps/app/public/static/locales/en_US/translation.json

@@ -219,6 +219,9 @@
       "profile_image2": "Set up AWS or enable local uploads."
       "profile_image2": "Set up AWS or enable local uploads."
     }
     }
   },
   },
+  "API Token Settings": "API token settings",
+  "Current API Token": "Current API token",
+  "Update API Token": "Update API token",
   "page_me_apitoken": {
   "page_me_apitoken": {
     "api_token": "API Token",
     "api_token": "API Token",
     "notice": {
     "notice": {
@@ -228,14 +231,34 @@
     },
     },
     "form_help": {}
     "form_help": {}
   },
   },
+  "Access Token Settings": "Access token settings",
   "page_me_access_token": {
   "page_me_access_token": {
     "access_token": "Access token",
     "access_token": "Access token",
-    "notice": {
-      "access_token_issued": "Access token is not issued.",
-      "update_token1": "You can update to generate a new Access token.",
-      "update_token2": "You will need to update the Access token in any existing processes."
+    "expiredAt": "Expiration date",
+    "description": "Description",
+    "scope": "Scope",
+    "scope_read": "Read",
+    "action": "Action",
+    "create_token": "Create Token",
+    "no_tokens_found": "No access tokens found",
+    "new_token": {
+      "title": "New access token",
+      "copy_to_clipboard": "Copy to clipboard",
+      "message": "This token will only be displayed once. Please save it securely."
     },
     },
-    "form_help": {}
+    "modal": {
+      "message": "Are you sure you want to delete this access token?",
+      "alert": "This action cannot be undone.",
+      "delete_token": "Delete Token"
+    },
+    "form": {
+      "title": "Create New Access Token",
+      "expiredAt_desc": "Select when this access token should expire.",
+      "description_desc": "Provide a description to help you identify this token later",
+      "description_max_length": "Description must be less than {{length}} characters",
+      "scope_desc": "Select the scope of the access token."
+    },
+    "copy_to_clipboard": "Copy to clipboard"
   },
   },
   "Password": "Password",
   "Password": "Password",
   "Password Settings": "Password settings",
   "Password Settings": "Password settings",
@@ -267,12 +290,6 @@
   },
   },
   "API Settings": "API settings",
   "API Settings": "API settings",
   "Other Settings": "Other Settings",
   "Other Settings": "Other Settings",
-  "API Token Settings": "API token settings",
-  "Current API Token": "Current API token",
-  "Update API Token": "Update API token",
-  "Access Token Settings": "Access token settings",
-  "Current Access Token": "Current Access token",
-  "Update Access Token": "Update Access token",
   "in_app_notification_settings": {
   "in_app_notification_settings": {
     "in_app_notification_settings": "In-App Notification Settings",
     "in_app_notification_settings": "In-App Notification Settings",
     "subscribe_settings": "Settings to automatically subscribe (Receive notifications) to pages",
     "subscribe_settings": "Settings to automatically subscribe (Receive notifications) to pages",

+ 29 - 12
apps/app/public/static/locales/fr_FR/translation.json

@@ -220,6 +220,9 @@
       "profile_image2": "Configurer AWS ou activer le stockage local."
       "profile_image2": "Configurer AWS ou activer le stockage local."
     }
     }
   },
   },
+  "API Token Settings": "Jetons d'API",
+  "Current API Token": "Mon jeton d'API",
+  "Update API Token": "Regénérer",
   "page_me_apitoken": {
   "page_me_apitoken": {
     "api_token": "Jeton API",
     "api_token": "Jeton API",
     "notice": {
     "notice": {
@@ -229,14 +232,34 @@
     },
     },
     "form_help": {}
     "form_help": {}
   },
   },
+  "Access Token Settings": "Jeton d'accès",
   "page_me_access_token": {
   "page_me_access_token": {
-    "access_token": "Jeton Access",
-    "notice": {
-      "access_token_issued": "Aucun jeton d'Access existant.",
-      "update_token1": "Un nouveau jeton peut être généré.",
-      "update_token2": "Modifiez le jeton aux endroits où celui-ci est utilisé."
+    "access_token": "Jeton d'accès",
+    "expiredAt": "Date d'expiration",
+    "description": "Description",
+    "scope": "Portée",
+    "scope_read": "Lecture",
+    "action": "Action",
+    "create_token": "Créer un jeton",
+    "no_tokens_found": "Aucun jeton d'accès trouvé",
+    "new_token": {
+      "title": "Nouveau jeton d'accès",
+      "copy_to_clipboard": "Copier dans le presse-papiers",
+      "message": "Ce jeton ne sera affiché qu'une seule fois. Veuillez le sauvegarder en lieu sûr."
     },
     },
-    "form_help": {}
+    "modal": {
+      "message": "Êtes-vous sûr de vouloir supprimer ce jeton d'accès ?",
+      "alert": "Cette action ne peut pas être annulée.",
+      "delete_token": "Supprimer le jeton"
+    },
+    "form": {
+      "title": "Créer un nouveau jeton d'accès",
+      "expiredAt_desc": "Sélectionnez la date d'expiration de ce jeton d'accès.",
+      "description_desc": "Fournissez une description pour vous aider à identifier ce jeton ultérieurement.",
+      "description_max_length": "Veuillez saisir jusqu'à {{length}} caractères."
+      "scope_desc": "Sélectionnez la portée du jeton d'accès."
+    },
+    "copy_to_clipboard": "Copier dans le presse-papiers"
   },
   },
   "Password": "Mot de passe",
   "Password": "Mot de passe",
   "Password Settings": "Sécurité",
   "Password Settings": "Sécurité",
@@ -268,12 +291,6 @@
   },
   },
   "API Settings": "API GROWI",
   "API Settings": "API GROWI",
   "Other Settings": "Autres paramètres",
   "Other Settings": "Autres paramètres",
-  "API Token Settings": "Jetons d'API",
-  "Current API Token": "Mon jeton d'API",
-  "Update API Token": "Regénérer",
-  "Access Token Settings": "Jetons d'Access",
-  "Current Access Token": "Mon jeton d'Access",
-  "Update Access Token": "Regénérer",
   "in_app_notification_settings": {
   "in_app_notification_settings": {
     "in_app_notification_settings": "Notifications",
     "in_app_notification_settings": "Notifications",
     "subscribe_settings": "Paramètres d'abonnement automatique aux notifications de pages",
     "subscribe_settings": "Paramètres d'abonnement automatique aux notifications de pages",

+ 28 - 11
apps/app/public/static/locales/ja_JP/translation.json

@@ -220,6 +220,9 @@
       "profile_image2": "アップロードできるようにするには、AWS またはローカルアップロードの設定をしてください。"
       "profile_image2": "アップロードできるようにするには、AWS またはローカルアップロードの設定をしてください。"
     }
     }
   },
   },
+  "API Token Settings": "API Token設定",
+  "Current API Token": "現在のAPI Token",
+  "Update API Token": "API Tokenを更新",
   "page_me_apitoken": {
   "page_me_apitoken": {
     "api_token": "API Token",
     "api_token": "API Token",
     "notice": {
     "notice": {
@@ -229,14 +232,34 @@
     },
     },
     "form_help": {}
     "form_help": {}
   },
   },
+  "Access Token Settings": "Access token 設定",
   "page_me_access_token": {
   "page_me_access_token": {
     "access_token": "Access token",
     "access_token": "Access token",
-    "notice": {
-      "access_token_issued": "Access token が設定されていません。",
-      "update_token1": "Access token を更新すると、自動的に新しい Token が生成されます。",
-      "update_token2": "現在の Token を利用している処理は動かなくなります。"
+    "expiredAt": "有効期限",
+    "description": "説明",
+    "scope": "スコープ",
+    "scope_read": "Read",
+    "action": "アクション",
+    "create_token": "トークンを作成",
+    "no_tokens_found":"アクセストークンが見つかりません",
+    "new_token": {
+      "title": "新しいアクセストークン",
+      "copy_to_clipboard": "クリップボードにコピーしました",
+      "message": "このアクセストークンは一度しか表示されません。安全に保存してください"
     },
     },
-    "form_help": {}
+    "modal": {
+      "message": "このアクセストークンを削除しますか?",
+      "alert": "この操作は取り消せません",
+      "delete_token": "トークンを削除"
+    },
+    "form": {
+      "title": "アクセストークンを作成",
+      "expiredAt_desc": "アクセストークンの有効期限を選択します。",
+      "description_desc": "このトークンを後で識別するための説明を入力します。",
+      "description_max_length": "{{length}}文字以内で入力してください。",
+      "scope_desc": "スコープによって、このトークンで行える操作を制限します。"
+    },
+    "copy_to_clipboard": "Copy to clipboard"
   },
   },
   "Password": "パスワード",
   "Password": "パスワード",
   "Password Settings": "パスワード設定",
   "Password Settings": "パスワード設定",
@@ -268,12 +291,6 @@
   },
   },
   "API Settings": "API設定",
   "API Settings": "API設定",
   "Other Settings": "その他の設定",
   "Other Settings": "その他の設定",
-  "API Token Settings": "API Token設定",
-  "Current API Token": "現在のAPI Token",
-  "Update API Token": "API Tokenを更新",
-  "Access Token Settings": "Access token 設定",
-  "Current Access Token": "現在のAccess token",
-  "Update Access Token": "Access tokenを更新",
   "in_app_notification_settings": {
   "in_app_notification_settings": {
     "in_app_notification_settings": "アプリ内通知設定",
     "in_app_notification_settings": "アプリ内通知設定",
     "subscribe_settings": "自動でページをサブスクライブする(通知を受け取る)設定",
     "subscribe_settings": "自動でページをサブスクライブする(通知を受け取る)設定",

+ 29 - 12
apps/app/public/static/locales/zh_CN/translation.json

@@ -227,6 +227,9 @@
       "profile_image2": "设置AWS或启用本地上传。"
       "profile_image2": "设置AWS或启用本地上传。"
     }
     }
   },
   },
+  "API Token Settings": "API token 设置",
+  "Current API Token": "当前 API token",
+  "Update API Token": "更新 API token",
   "page_me_apitoken": {
   "page_me_apitoken": {
     "api_token": "API Token",
     "api_token": "API Token",
     "notice": {
     "notice": {
@@ -236,14 +239,34 @@
     },
     },
     "form_help": {}
     "form_help": {}
   },
   },
+  "Access Token Settings": "Access token 设置",
   "page_me_access_token": {
   "page_me_access_token": {
-    "access_token": "Access token",
-    "notice": {
-      "access_token_issued": "Access token 未发布。",
-      "update_token1": "您可以更新以生成新的API令牌。",
-      "update_token2": "您需要更新任何现有进程中的API令牌。"
+    "access_token": "访问令牌",
+    "expiredAt": "过期日期",
+    "description": "描述",
+    "scope": "范围",
+    "scope_read": "读取",
+    "action": "操作",
+    "create_token": "创建令牌",
+    "no_tokens_found": "未找到访问令牌",
+    "new_token": {
+      "title": "新访问令牌",
+      "copy_to_clipboard": "复制到剪贴板",
+      "message": "此令牌仅显示一次。请安全保存。"
     },
     },
-    "form_help": {}
+    "modal": {
+      "message": "确定要删除此访问令牌吗?",
+      "alert": "此操作无法撤消。",
+      "delete_token": "删除令牌"
+    },
+    "form": {
+      "title": "创建新访问令牌",
+      "expiredAt_desc": "选择此访问令牌的过期时间。",
+      "description_desc": "提供描述以帮助您稍后识别此令牌。",
+      "description_max_length": "请输入最多 {{length}} 个字符",
+      "scope_desc": "选择访问令牌的范围。"
+    },
+    "copy_to_clipboard": "复制到剪贴板"
   },
   },
   "Password": "密码",
   "Password": "密码",
   "Password Settings": "密码设置",
   "Password Settings": "密码设置",
@@ -259,12 +282,6 @@
   },
   },
   "API Settings": "API设置",
   "API Settings": "API设置",
   "Other Settings": "其他设置",
   "Other Settings": "其他设置",
-  "API Token Settings": "API token 设置",
-  "Current API Token": "当前 API token",
-  "Update API Token": "更新 API token",
-  "Access Token Settings": "Access token 设置",
-  "Current Access Token": "当前 Access token",
-  "Update Access Token": "更新 Access token",
   "in_app_notification_settings": {
   "in_app_notification_settings": {
     "in_app_notification_settings": "在应用程序通知设置",
     "in_app_notification_settings": "在应用程序通知设置",
     "subscribe_settings": "自动订阅(接收通知)页面的设置",
     "subscribe_settings": "自动订阅(接收通知)页面的设置",

+ 130 - 0
apps/app/src/client/components/Me/AccessTokenForm.tsx

@@ -0,0 +1,130 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
+
+
+import type { IAccessTokenInfo } from '~/interfaces/access-token';
+
+const MAX_DESCRIPTION_LENGTH = 200;
+
+type AccessTokenFormProps = {
+  submitHandler: (info: IAccessTokenInfo) => Promise<void>;
+}
+
+type FormInputs = {
+  expiredAt: string;
+  description: string;
+  // TODO: Implement scope selection
+  // scopes: string[];
+}
+
+// TODO: Implement scope selection
+export const AccessTokenForm = React.memo((props: AccessTokenFormProps): JSX.Element => {
+  const { submitHandler } = props;
+  const { t } = useTranslation();
+
+  const defaultExpiredAt = new Date();
+  defaultExpiredAt.setMonth(defaultExpiredAt.getMonth() + 1);
+  const defaultExpiredAtStr = defaultExpiredAt.toISOString().split('T')[0];
+  const todayStr = new Date().toISOString().split('T')[0];
+
+  const {
+    register,
+    handleSubmit,
+    formState: { errors, isValid },
+  } = useForm<FormInputs>({
+    defaultValues: {
+      expiredAt: defaultExpiredAtStr,
+      description: '',
+    },
+  });
+
+  const onSubmit = (data: FormInputs) => {
+    const expiredAtDate = new Date(data.expiredAt);
+    const scope = []; // TODO: Implement scope selection
+
+    submitHandler({
+      expiredAt: expiredAtDate,
+      description: data.description,
+      scope,
+    });
+  };
+
+  return (
+    <div className="card mt-3 mb-4">
+      <div className="card-header">{t('page_me_access_token.form.title')}</div>
+      <div className="card-body">
+        <form onSubmit={handleSubmit(onSubmit)}>
+          <div className="mb-3">
+            <label htmlFor="expiredAt" className="form-label">{t('page_me_access_token.expiredAt')}</label>
+            <div className="row">
+              <div className="col-16 col-sm-4 col-md-4 col-lg-3">
+                <div className="input-group">
+                  <input
+                    type="date"
+                    className={`form-control ${errors.expiredAt ? 'is-invalid' : ''}`}
+                    data-testid="grw-accesstoken-input-expiredAt"
+                    min={todayStr}
+                    {...register('expiredAt', {
+                      required: t('input_validation.message.required', { param: t('page_me_access_token.expiredAt') }),
+                      validate: (value) => {
+                        const date = new Date(value);
+                        const now = new Date();
+                        return date > now || 'Expiration date must be in the future';
+                      },
+                    })}
+                  />
+                </div>
+                {errors.expiredAt && (
+                  <div className="invalid-feedback d-block">
+                    {errors.expiredAt.message}
+                  </div>
+                )}
+              </div>
+            </div>
+            <div className="form-text">{t('page_me_access_token.form.expiredAt_desc')}</div>
+          </div>
+
+          <div className="mb-3">
+            <label htmlFor="description" className="form-label">{t('page_me_access_token.description')}</label>
+            <textarea
+              className={`form-control ${errors.description ? 'is-invalid' : ''}`}
+              rows={3}
+              data-testid="grw-accesstoken-textarea-description"
+              {...register('description', {
+                required: t('input_validation.message.required', { param: t('page_me_access_token.description') }),
+                maxLength: {
+                  value: MAX_DESCRIPTION_LENGTH,
+                  message: t('page_me_access_token.form.description_max_length', { length: MAX_DESCRIPTION_LENGTH }),
+                },
+              })}
+            />
+            {errors.description && (
+              <div className="invalid-feedback">
+                {errors.description.message}
+              </div>
+            )}
+            <div className="form-text">{t('page_me_access_token.form.description_desc')}</div>
+          </div>
+
+          <div className="mb-3">
+            <label htmlFor="scope" className="form-label">{t('page_me_access_token.scope')}</label>
+            <div className="form-text mb-2">{t('page_me_access_token.form.scope_desc')}</div>
+            <div className="form-text mb-2">(TBD)</div>
+          </div>
+
+          <button
+            type="submit"
+            className="btn btn-primary"
+            data-testid="grw-accesstoken-create-button"
+            disabled={!isValid}
+          >
+            {t('page_me_access_token.create_token')}
+          </button>
+        </form>
+      </div>
+    </div>
+  );
+});
+AccessTokenForm.displayName = 'AccessTokenForm';

+ 105 - 0
apps/app/src/client/components/Me/AccessTokenList.tsx

@@ -0,0 +1,105 @@
+import React, { useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter, Button,
+} from 'reactstrap';
+
+import type { IResGetAccessToken } from '~/interfaces/access-token';
+
+type AccessTokenListProps = {
+  accessTokens: IResGetAccessToken[];
+  deleteHandler?: (tokenId: string) => void;
+}
+export const AccessTokenList = React.memo((props: AccessTokenListProps): JSX.Element => {
+
+
+  const { t } = useTranslation();
+  const { accessTokens, deleteHandler } = props;
+  const [tokenToDelete, setTokenToDelete] = useState<string | null>(null);
+
+  const handleDeleteClick = (tokenId: string) => {
+    setTokenToDelete(tokenId);
+  };
+
+  const handleConfirmDelete = () => {
+    if (tokenToDelete != null && deleteHandler != null) {
+      deleteHandler(tokenToDelete);
+      setTokenToDelete(null);
+    }
+  };
+
+  const toggleModal = () => {
+    setTokenToDelete(null);
+  };
+
+  return (
+    <>
+      <div className="table">
+        <table className="table table-bordered">
+          <thead>
+            <tr>
+              <th>{t('page_me_access_token.description')}</th>
+              <th>{t('page_me_access_token.expiredAt')}</th>
+              <th>{t('page_me_access_token.scope')}</th>
+              <th>{t('page_me_access_token.action')}</th>
+            </tr>
+          </thead>
+          <tbody>
+            {(accessTokens.length === 0)
+              ? (
+                <tr>
+                  <td colSpan={4} className="text-center">
+                    {t('page_me_access_token.no_tokens_found')}
+                  </td>
+                </tr>
+              )
+              : (
+                <>{
+                  accessTokens.map(token => (
+                    <tr key={token._id}>
+                      <td className="text-break">{token.description}</td>
+                      <td>{token.expiredAt.toString().split('T')[0]}</td>
+                      <td>{token.scope.join(', ')}</td>
+                      <td>
+                        <button
+                          className="btn btn-danger"
+                          type="button"
+                          onClick={() => handleDeleteClick(token._id)}
+                          data-testid="grw-accesstoken-delete-button"
+                        >
+                          {t('Delete')}
+                        </button>
+                      </td>
+                    </tr>
+                  ))
+                }
+                </>
+              )}
+          </tbody>
+        </table>
+      </div>
+
+      {/* Confirmation Modal using Reactstrap */}
+      <Modal isOpen={tokenToDelete !== null} toggle={toggleModal} centered>
+        <ModalHeader tag="h4" toggle={toggleModal} className="bg-danger text-white">
+          <span className="material-symbols-outlined me-1">warning</span>
+          {t('Warning')}
+        </ModalHeader>
+        <ModalBody>
+          <p>{t('page_me_access_token.modal.message')}</p>
+          <p className="text-danger fw-bold">{t('page_me_access_token.modal.alert')}</p>
+        </ModalBody>
+        <ModalFooter>
+          <Button color="secondary" onClick={toggleModal} data-testid="grw-accesstoken-cancel-button-in-modal">
+            {t('Cancel')}
+          </Button>
+          <Button color="danger" onClick={handleConfirmDelete} data-testid="grw-accesstoken-delete-button-in-modal">
+            {t('page_me_access_token.modal.delete_token')}
+          </Button>
+        </ModalFooter>
+      </Modal>
+    </>
+  );
+});
+AccessTokenList.displayName = 'AccessTokenList';

+ 135 - 0
apps/app/src/client/components/Me/AccessTokenSettings.tsx

@@ -0,0 +1,135 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import CopyToClipboard from 'react-copy-to-clipboard';
+
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import type { IAccessTokenInfo } from '~/interfaces/access-token';
+import { useSWRxAccessToken } from '~/stores/personal-settings';
+
+import { AccessTokenForm } from './AccessTokenForm';
+import { AccessTokenList } from './AccessTokenList';
+
+
+const NewTokenDisplay = React.memo(({ newToken, closeNewTokenDisplay }: { newToken?: string, closeNewTokenDisplay: () => void }): JSX.Element => {
+
+  const { t } = useTranslation();
+
+  // Handle successful copy
+  const handleCopySuccess = useCallback(() => {
+    toastSuccess(t('page_me_access_token.new_token.copy_to_clipboard'));
+  }, [t]);
+
+  if (newToken == null) {
+    return <></>;
+  }
+
+  return (
+    <div className="alert alert-success mb-4" role="alert" data-testid="grw-accesstoken-new-token-display">
+      <div className="d-flex justify-content-between align-items-center mb-2">
+        <h5 className="mb-0">
+          {t('page_me_access_token.new_token.title')}
+        </h5>
+        <button
+          type="button"
+          className="btn-close"
+          onClick={closeNewTokenDisplay}
+          aria-label="Close"
+        >
+        </button>
+      </div>
+
+      <p className="fw-bold mb-2">{t('page_me_access_token.new_token.message')}</p>
+
+      <div className="input-group mb-2">
+        <input
+          type="text"
+          className="form-control font-monospace"
+          value={newToken}
+          readOnly
+          data-vrt-blackout
+        />
+        <CopyToClipboard text={newToken} onCopy={handleCopySuccess}>
+          <button
+            className="btn btn-outline-secondary"
+            type="button"
+          >
+            <span className="material-symbols-outlined">content_copy</span>
+          </button>
+        </CopyToClipboard>
+      </div>
+    </div>
+  );
+});
+
+export const AccessTokenSettings = React.memo((): JSX.Element => {
+
+  const { t } = useTranslation();
+
+  const [isFormOpen, setIsFormOpen] = React.useState<boolean>(false);
+  const toggleFormOpen = useCallback(() => {
+    setIsFormOpen(prev => !prev);
+  }, []);
+
+  const [newToken, setNewToken] = React.useState<string | undefined>(undefined);
+
+  const {
+    data: accessTokens, mutate, generateAccessToken, deleteAccessToken,
+  } = useSWRxAccessToken();
+
+  const closeNewTokenDisplay = useCallback(() => {
+    setNewToken(undefined);
+  }, []);
+
+  const submitHandler = useCallback(async(info: IAccessTokenInfo) => {
+    try {
+      const result = await generateAccessToken(info);
+      mutate();
+      setIsFormOpen(false);
+
+      // Store the newly generated token to display to the user
+      if (result?.token) {
+        setNewToken(result.token);
+      }
+
+      toastSuccess(t('toaster.add_succeeded', { target: t('page_me_access_token.access_token'), ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, generateAccessToken, mutate, setIsFormOpen]);
+
+  const deleteHandler = useCallback(async(tokenId: string) => {
+    try {
+      await deleteAccessToken(tokenId);
+      mutate();
+      toastSuccess(t('toaster.delete_succeeded', { target: t('page_me_access_token.access_token'), ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [deleteAccessToken, mutate, t]);
+
+  return (
+    <>
+
+      <div className="container p-0">
+
+        <NewTokenDisplay newToken={newToken} closeNewTokenDisplay={closeNewTokenDisplay} />
+        <AccessTokenList accessTokens={accessTokens ?? []} deleteHandler={deleteHandler} />
+
+        <button
+          className="btn btn-outline-secondary d-block mx-auto px-5"
+          type="button"
+          onClick={toggleFormOpen}
+          data-testid="btn-accesstoken-toggleform"
+        >
+          {isFormOpen ? t('Close') : t('New')}
+        </button>
+        {isFormOpen && <AccessTokenForm submitHandler={submitHandler} />}
+      </div>
+    </>
+  );
+});
+
+AccessTokenSettings.displayName = 'AccessTokenSettings';

+ 10 - 179
apps/app/src/client/components/Me/ApiSettings.tsx

@@ -1,194 +1,25 @@
-import React, { useCallback } from 'react';
+import React from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-import {
-  apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
-} from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
-import { usePersonalSettings, useSWRxPersonalSettings } from '~/stores/personal-settings';
+import { AccessTokenSettings } from './AccessTokenSettings';
+import { ApiTokenSettings } from './ApiTokenSettings';
 
 
 
 
-const ApiTokenSettings = React.memo((): JSX.Element => {
-
-  const { t } = useTranslation();
-  const { mutate: mutateDatabaseData } = useSWRxPersonalSettings();
-  const { data: personalSettingsData } = usePersonalSettings();
-
-  const submitHandler = useCallback(async() => {
-
-    try {
-      await apiv3Put('/personal-setting/api-token');
-      mutateDatabaseData();
-
-      toastSuccess(t('toaster.update_successed', { target: t('page_me_apitoken.api_token'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-
-  }, [mutateDatabaseData, t]);
-
-  return (
-    <>
-      <div className="row mb-3">
-        <label htmlFor="apiToken" className="col-md-3 text-md-end col-form-label">{t('Current API Token')}</label>
-        <div className="col-md-6">
-          {personalSettingsData?.apiToken != null
-            ? (
-              <input
-                data-testid="grw-api-settings-input"
-                data-vrt-blackout
-                className="form-control"
-                type="text"
-                name="apiToken"
-                value={personalSettingsData.apiToken}
-                readOnly
-              />
-            )
-            : (
-              <p>
-                { t('page_me_apitoken.notice.apitoken_issued') }
-              </p>
-            )}
-        </div>
-      </div>
-
-
-      <div className="row">
-        <div className="offset-lg-2 col-lg-7">
-
-          <p className="alert alert-warning">
-            { t('page_me_apitoken.notice.update_token1') }<br />
-            { t('page_me_apitoken.notice.update_token2') }
-          </p>
-
-        </div>
-      </div>
-
-      <div className="row my-3">
-        <div className="offset-4 col-5">
-          <button
-            data-testid="grw-api-settings-update-button"
-            type="button"
-            className="btn btn-primary text-nowrap"
-            onClick={submitHandler}
-          >
-            {t('Update API Token')}
-          </button>
-        </div>
-      </div>
-
-    </>
-
-  );
-
-
-});
-
-
-/**
- * TODO: support managing multiple access tokens.
- */
-const AccessTokenSettings = React.memo((): JSX.Element => {
+const ApiSettings = React.memo((): JSX.Element => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const [accessToken, setAccessToken] = React.useState<string | null>(null);
-
-  const submitHandler = useCallback(async() => {
-
-    try {
-      await apiv3Delete('/personal-setting/access-token/all');
-      const expiredAt = new Date('2099-12-31T23:59:59.999Z');
-      const result = await apiv3Post('/personal-setting/access-token', { expiredAt });
-      setAccessToken(result.data.token);
-
-      toastSuccess(t('toaster.update_successed', { target: t('page_me_access_token.access_token'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-
-  }, [t]);
-
-  React.useEffect(() => {
-    const fetchData = async() => {
-      try {
-        const result = await apiv3Get('/personal-setting/access-token');
-        setAccessToken(result.data.accessTokens.length > 0 ? '*******************' : null);
-      }
-      catch (err) {
-        toastError(err);
-      }
-    };
-
-    fetchData();
-  }, []);
 
 
   return (
   return (
     <>
     <>
-      <div className="row mb-3">
-        <label htmlFor="apiToken" className="col-md-3 text-md-end col-form-label">{t('Current Access Token')}</label>
-        <div className="col-md-6">
-          {accessToken != null
-            ? (
-              <input
-                // data-testid="grw-api-settings-input"
-                // data-vrt-blackout
-                className="form-control"
-                type="text"
-                name="apiToken"
-                value={accessToken}
-                readOnly
-              />
-            )
-            : (
-              <p>
-                { t('page_me_access_token.notice.access_token_issued') }
-              </p>
-            )}
-        </div>
+      <div className="mt-4">
+        <h2 className="border-bottom pb-2 my-4 fs-4">{ t('API Token Settings') }</h2>
+        <ApiTokenSettings />
       </div>
       </div>
-
-
-      <div className="row">
-        <div className="offset-lg-2 col-lg-7">
-
-          <p className="alert alert-warning">
-            { t('page_me_access_token.notice.update_token1') }<br />
-            { t('page_me_access_token.notice.update_token2') }
-          </p>
-
-        </div>
+      <div className="mt-4">
+        <h2 className="border-bottom pb-2 my-4 fs-4">{ t('Access Token Settings') }</h2>
+        <AccessTokenSettings />
       </div>
       </div>
-
-      <div className="row my-3">
-        <div className="offset-4 col-5">
-          <button
-            // data-testid="grw-api-settings-update-button"
-            type="button"
-            className="btn btn-primary text-nowrap"
-            onClick={submitHandler}
-          >
-            {t('Update Access Token')}
-          </button>
-        </div>
-      </div>
-    </>
-  );
-});
-
-const ApiSettings = React.memo((): JSX.Element => {
-
-  const { t } = useTranslation();
-
-  return (
-    <>
-      <h3 className="border-bottom pb-2 my-4 fs-5">{ t('API Token Settings') }</h3>
-      <ApiTokenSettings />
-
-      <h3 className="border-bottom pb-2 my-4 fs-5">{ t('Access Token Settings') }</h3>
-      <AccessTokenSettings />
     </>
     </>
   );
   );
 });
 });

+ 85 - 0
apps/app/src/client/components/Me/ApiTokenSettings.tsx

@@ -0,0 +1,85 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import {
+  apiv3Put,
+} from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { usePersonalSettings, useSWRxPersonalSettings } from '~/stores/personal-settings';
+
+
+export const ApiTokenSettings = React.memo((): JSX.Element => {
+
+  const { t } = useTranslation();
+  const { mutate: mutateDatabaseData } = useSWRxPersonalSettings();
+  const { data: personalSettingsData } = usePersonalSettings();
+
+  const submitHandler = useCallback(async() => {
+
+    try {
+      await apiv3Put('/personal-setting/api-token');
+      mutateDatabaseData();
+
+      toastSuccess(t('toaster.update_successed', { target: t('page_me_apitoken.api_token'), ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }, [mutateDatabaseData, t]);
+
+  return (
+    <>
+      <div className="row mb-3">
+        <label htmlFor="apiToken" className="col-md-3 text-md-end col-form-label">{t('Current API Token')}</label>
+        <div className="col-md-6">
+          {personalSettingsData?.apiToken != null
+            ? (
+              <input
+                data-testid="grw-api-settings-input"
+                data-vrt-blackout
+                className="form-control"
+                type="text"
+                name="apiToken"
+                value={personalSettingsData.apiToken}
+                readOnly
+              />
+            )
+            : (
+              <p>
+                { t('page_me_apitoken.notice.apitoken_issued') }
+              </p>
+            )}
+        </div>
+      </div>
+
+
+      <div className="row">
+        <div className="offset-lg-2 col-lg-7">
+
+          <p className="alert alert-warning">
+            { t('page_me_apitoken.notice.update_token1') }<br />
+            { t('page_me_apitoken.notice.update_token2') }
+          </p>
+
+        </div>
+      </div>
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <button
+            data-testid="grw-api-settings-update-button"
+            type="button"
+            className="btn btn-primary text-nowrap"
+            onClick={submitHandler}
+          >
+            {t('Update API Token')}
+          </button>
+        </div>
+      </div>
+
+    </>
+
+  );
+});

+ 15 - 0
apps/app/src/interfaces/access-token.ts

@@ -0,0 +1,15 @@
+
+export type IAccessTokenInfo = {
+  expiredAt: Date,
+  description: string,
+  scope: string[],
+}
+
+export type IResGenerateAccessToken = IAccessTokenInfo & {
+  token: string,
+  _id: string,
+}
+
+export type IResGetAccessToken = IAccessTokenInfo & {
+  _id: string,
+}

+ 41 - 1
apps/app/src/stores/personal-settings.tsx

@@ -1,13 +1,20 @@
+import { useCallback } from 'react';
+
 import type { HasObjectId, IExternalAccount, IUser } from '@growi/core/dist/interfaces';
 import type { HasObjectId, IExternalAccount, IUser } from '@growi/core/dist/interfaces';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import type { SWRConfiguration, SWRResponse } from 'swr';
 import type { SWRConfiguration, SWRResponse } from 'swr';
 import useSWR from 'swr';
 import useSWR from 'swr';
 
 
+import type {
+  IResGenerateAccessToken, IResGetAccessToken, IAccessTokenInfo,
+} from '~/interfaces/access-token';
 import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import { useIsGuestUser } from '~/stores-universal/context';
 import { useIsGuestUser } from '~/stores-universal/context';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { apiv3Get, apiv3Put } from '../client/util/apiv3-client';
+import {
+  apiv3Delete, apiv3Get, apiv3Put, apiv3Post,
+} from '../client/util/apiv3-client';
 
 
 import { useStaticSWR } from './use-static-swr';
 import { useStaticSWR } from './use-static-swr';
 
 
@@ -110,3 +117,36 @@ export const useSWRxPersonalExternalAccounts = (): SWRResponse<(IExternalAccount
     endpoint => apiv3Get(endpoint).then(response => response.data.externalAccounts),
     endpoint => apiv3Get(endpoint).then(response => response.data.externalAccounts),
   );
   );
 };
 };
+
+
+interface IAccessTokenOption {
+  generateAccessToken: (info: IAccessTokenInfo) => Promise<IResGenerateAccessToken>,
+  deleteAccessToken: (tokenId: string) => Promise<void>,
+  deleteAllAccessTokens: (userId: string) => Promise<void>,
+}
+
+export const useSWRxAccessToken = (): SWRResponse< IResGetAccessToken[] | null, Error> & IAccessTokenOption => {
+  const generateAccessToken = useCallback(async(info) => {
+    const res = await apiv3Post<IResGenerateAccessToken>('/personal-setting/access-token', info);
+    return res.data;
+  }, []);
+  const deleteAccessToken = useCallback(async(tokenId: string) => {
+    await apiv3Delete('/personal-setting/access-token', { tokenId });
+  }, []);
+  const deleteAllAccessTokens = useCallback(async() => {
+    await apiv3Delete('/personal-setting/access-token/all');
+  }, []);
+
+  const swrResult = useSWR(
+    '/personal-setting/access-token',
+    endpoint => apiv3Get(endpoint).then(response => response.data.accessTokens),
+  );
+
+  return {
+    ...swrResult,
+    generateAccessToken,
+    deleteAccessToken,
+    deleteAllAccessTokens,
+  };
+
+};