Repository: weseek/growi
Pull request #9704: feat: Access token parser support access token model
Author: login: reiji-h
Created: 2025-03-04T07:23:08Z | Merged: 2025-03-10T05:33:22Z

# Summary
- access token parser が Access Token Model を扱えるように変更
- Api Token には対応したまま


# Task
- https://redmine.weseek.co.jp/issues/85969
  - https://redmine.weseek.co.jp/issues/85966

# Note
- スコープは対象外
- Api Token を扱う API の代わりに Access Token を扱う API を新設
- ApiSettings は一つだけ Access Token を扱える形で変更。別タスクで複数対応予定。
  - 別タスク：https://redmine.weseek.co.jp/issues/85961


Diff:
diff --git a/apps/app/public/static/locales/en_US/translation.json b/apps/app/public/static/locales/en_US/translation.json
index 3d3778e3e3b..bb58b1a3754 100644
--- a/apps/app/public/static/locales/en_US/translation.json
+++ b/apps/app/public/static/locales/en_US/translation.json
@@ -228,6 +228,15 @@
     },
     "form_help": {}
   },
+  "page_me_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."
+    },
+    "form_help": {}
+  },
   "Password": "Password",
   "Password Settings": "Password settings",
   "personal_settings": {
@@ -261,6 +270,9 @@
   "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",
     "subscribe_settings": "Settings to automatically subscribe (Receive notifications) to pages",
diff --git a/apps/app/public/static/locales/fr_FR/translation.json b/apps/app/public/static/locales/fr_FR/translation.json
index 1323b261c48..4805fd4ce89 100644
--- a/apps/app/public/static/locales/fr_FR/translation.json
+++ b/apps/app/public/static/locales/fr_FR/translation.json
@@ -229,6 +229,15 @@
     },
     "form_help": {}
   },
+  "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é."
+    },
+    "form_help": {}
+  },
   "Password": "Mot de passe",
   "Password Settings": "Sécurité",
   "personal_settings": {
@@ -262,6 +271,9 @@
   "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": "Notifications",
     "subscribe_settings": "Paramètres d'abonnement automatique aux notifications de pages",
diff --git a/apps/app/public/static/locales/ja_JP/translation.json b/apps/app/public/static/locales/ja_JP/translation.json
index 3489091d212..e6a2183a536 100644
--- a/apps/app/public/static/locales/ja_JP/translation.json
+++ b/apps/app/public/static/locales/ja_JP/translation.json
@@ -229,6 +229,15 @@
     },
     "form_help": {}
   },
+  "page_me_access_token": {
+    "access_token": "Access token",
+    "notice": {
+      "access_token_issued": "Access token が設定されていません。",
+      "update_token1": "Access token を更新すると、自動的に新しい Token が生成されます。",
+      "update_token2": "現在の Token を利用している処理は動かなくなります。"
+    },
+    "form_help": {}
+  },
   "Password": "パスワード",
   "Password Settings": "パスワード設定",
   "personal_settings": {
@@ -262,6 +271,9 @@
   "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": "アプリ内通知設定",
     "subscribe_settings": "自動でページをサブスクライブする（通知を受け取る）設定",
diff --git a/apps/app/public/static/locales/zh_CN/translation.json b/apps/app/public/static/locales/zh_CN/translation.json
index a00244a3056..8a7d3ebb4e7 100644
--- a/apps/app/public/static/locales/zh_CN/translation.json
+++ b/apps/app/public/static/locales/zh_CN/translation.json
@@ -233,7 +233,17 @@
       "apitoken_issued": "API token 未发布。",
       "update_token1": "您可以更新以生成新的API令牌。",
       "update_token2": "您需要更新任何现有进程中的API令牌。"
-    }
+    },
+    "form_help": {}
+  },
+  "page_me_access_token": {
+    "access_token": "Access token",
+    "notice": {
+      "access_token_issued": "Access token 未发布。",
+      "update_token1": "您可以更新以生成新的API令牌。",
+      "update_token2": "您需要更新任何现有进程中的API令牌。"
+    },
+    "form_help": {}
   },
   "Password": "密码",
   "Password Settings": "密码设置",
@@ -252,6 +262,9 @@
   "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": "在应用程序通知设置",
     "subscribe_settings": "自动订阅（接收通知）页面的设置",
diff --git a/apps/app/src/client/components/Me/ApiSettings.tsx b/apps/app/src/client/components/Me/ApiSettings.tsx
index 802daaca652..e435805d85e 100644
--- a/apps/app/src/client/components/Me/ApiSettings.tsx
+++ b/apps/app/src/client/components/Me/ApiSettings.tsx
@@ -2,12 +2,14 @@ import React, { useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
-import { apiv3Put } from '~/client/util/apiv3-client';
+import {
+  apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
+} from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSWRxPersonalSettings, usePersonalSettings } from '~/stores/personal-settings';
+import { usePersonalSettings, useSWRxPersonalSettings } from '~/stores/personal-settings';
 
 
-const ApiSettings = React.memo((): JSX.Element => {
+const ApiTokenSettings = React.memo((): JSX.Element => {
 
   const { t } = useTranslation();
   const { mutate: mutateDatabaseData } = useSWRxPersonalSettings();
@@ -29,9 +31,6 @@ const ApiSettings = React.memo((): JSX.Element => {
 
   return (
     <>
-
-      <h2 className="border-bottom pb-2 my-4 fs-4">{ t('API Token Settings') }</h2>
-
       <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">
@@ -84,6 +83,114 @@ const ApiSettings = React.memo((): JSX.Element => {
 
   );
 
+
+});
+
+
+/**
+ * TODO: support managing multiple access tokens.
+ */
+const AccessTokenSettings = React.memo((): JSX.Element => {
+
+  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 (
+    <>
+      <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>
+
+
+      <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>
+
+      <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 />
+    </>
+  );
 });
 
 ApiSettings.displayName = 'ApiSettings';
diff --git a/apps/app/src/interfaces/activity.ts b/apps/app/src/interfaces/activity.ts
index a0b8cde1dbd..24fd1eafc87 100644
--- a/apps/app/src/interfaces/activity.ts
+++ b/apps/app/src/interfaces/activity.ts
@@ -24,7 +24,8 @@ const ACTION_USER_IMAGE_TYPE_UPDATE = 'USER_IMAGE_TYPE_UPDATE';
 const ACTION_USER_LDAP_ACCOUNT_ASSOCIATE = 'USER_LDAP_ACCOUNT_ASSOCIATE';
 const ACTION_USER_LDAP_ACCOUNT_DISCONNECT = 'USER_LDAP_ACCOUNT_DISCONNECT';
 const ACTION_USER_PASSWORD_UPDATE = 'USER_PASSWORD_UPDATE';
-const ACTION_USER_API_TOKEN_UPDATE = 'USER_API_TOKEN_UPDATE';
+const ACTION_USER_ACCESS_TOKEN_CREATE = 'USER_ACCESS_TOKEN_CREATE';
+const ACTION_USER_ACCESS_TOKEN_DELETE = 'USER_ACCESS_TOKEN_DELETE';
 const ACTION_USER_EDITOR_SETTINGS_UPDATE = 'USER_EDITOR_SETTINGS_UPDATE';
 const ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE = 'USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE';
 const ACTION_PAGE_VIEW = 'PAGE_VIEW';
@@ -203,7 +204,8 @@ export const SupportedAction = {
   ACTION_USER_LDAP_ACCOUNT_ASSOCIATE,
   ACTION_USER_LDAP_ACCOUNT_DISCONNECT,
   ACTION_USER_PASSWORD_UPDATE,
-  ACTION_USER_API_TOKEN_UPDATE,
+  ACTION_USER_ACCESS_TOKEN_CREATE,
+  ACTION_USER_ACCESS_TOKEN_DELETE,
   ACTION_USER_EDITOR_SETTINGS_UPDATE,
   ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE,
   ACTION_PAGE_VIEW,
@@ -394,7 +396,8 @@ export const MediumActionGroup = {
   ACTION_USER_LDAP_ACCOUNT_ASSOCIATE,
   ACTION_USER_LDAP_ACCOUNT_DISCONNECT,
   ACTION_USER_PASSWORD_UPDATE,
-  ACTION_USER_API_TOKEN_UPDATE,
+  ACTION_USER_ACCESS_TOKEN_CREATE,
+  ACTION_USER_ACCESS_TOKEN_DELETE,
   ACTION_USER_EDITOR_SETTINGS_UPDATE,
   ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE,
   ACTION_PAGE_LIKE,
diff --git a/apps/app/src/server/middlewares/access-token-parser/access-token-parser.integ.ts b/apps/app/src/server/middlewares/access-token-parser/access-token-parser.integ.ts
index 55774dde16f..b91d4c90bb2 100644
--- a/apps/app/src/server/middlewares/access-token-parser/access-token-parser.integ.ts
+++ b/apps/app/src/server/middlewares/access-token-parser/access-token-parser.integ.ts
@@ -5,7 +5,7 @@ import { mock } from 'vitest-mock-extended';
 
 import type Crowi from '~/server/crowi';
 import type UserEvent from '~/server/events/user';
-
+import { AccessToken } from '~/server/models/access-token';
 
 import type { AccessTokenParserReq } from './interfaces';
 
@@ -72,7 +72,7 @@ describe('access-token-parser middleware', () => {
     expect(nextMock).toHaveBeenCalled();
   });
 
-  it('should set req.user with a valid access token in query', async() => {
+  it('should set req.user with a valid api token in query', async() => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -102,7 +102,7 @@ describe('access-token-parser middleware', () => {
     expect(nextMock).toHaveBeenCalled();
   });
 
-  it('should set req.user with a valid access token in body', async() => {
+  it('should set req.user with a valid api token in body', async() => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -133,3 +133,84 @@ describe('access-token-parser middleware', () => {
   });
 
 });
+
+
+describe('access-token-parser middleware for access token', () => {
+
+  let User;
+
+  beforeAll(async() => {
+    const crowiMock = mock<Crowi>({
+      event: vi.fn().mockImplementation((eventName) => {
+        if (eventName === 'user') {
+          return mock<UserEvent>({
+            on: vi.fn(),
+          });
+        }
+      }),
+    });
+    const userModelFactory = (await import('../../models/user')).default;
+    User = userModelFactory(crowiMock);
+  });
+
+  it('should set req.user with a valid access token in query', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+    const nextMock = vi.fn();
+
+    expect(reqMock.user).toBeUndefined();
+
+    // prepare a user with an access token
+    const targetUser = await User.create({
+      name: faker.person.fullName(),
+      username: faker.string.uuid(),
+      password: faker.internet.password(),
+      lang: 'en_US',
+    });
+
+    // act
+    const { token } = await AccessToken.generateToken(targetUser._id, new Date(Date.now() + 1000 * 60 * 60 * 24), []);
+    reqMock.query.access_token = token;
+    await accessTokenParser(reqMock, resMock, nextMock);
+
+    // assert
+    expect(reqMock.user).toBeDefined();
+    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
+    expect(serializeUserSecurely).toHaveBeenCalledOnce();
+    expect(nextMock).toHaveBeenCalled();
+  });
+
+  it('should set req.user with a valid access token in body', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+    const nextMock = vi.fn();
+
+    expect(reqMock.user).toBeUndefined();
+
+    // prepare a user with an access token
+    const targetUser = await User.create({
+      name: faker.person.fullName(),
+      username: faker.string.uuid(),
+      password: faker.internet.password(),
+      lang: 'en_US',
+    });
+
+    // act
+    const { token } = await AccessToken.generateToken(targetUser._id, new Date(Date.now() + 1000 * 60 * 60 * 24), []);
+    reqMock.query.access_token = token;
+    await accessTokenParser(reqMock, resMock, nextMock);
+
+    // assert
+    expect(reqMock.user).toBeDefined();
+    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
+    expect(serializeUserSecurely).toHaveBeenCalledOnce();
+    expect(nextMock).toHaveBeenCalled();
+  });
+
+});
diff --git a/apps/app/src/server/middlewares/access-token-parser/access-token-parser.ts b/apps/app/src/server/middlewares/access-token-parser/access-token-parser.ts
index 414465d0017..a60666f606e 100644
--- a/apps/app/src/server/middlewares/access-token-parser/access-token-parser.ts
+++ b/apps/app/src/server/middlewares/access-token-parser/access-token-parser.ts
@@ -4,6 +4,7 @@ import type { NextFunction, Response } from 'express';
 import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 
+import { AccessToken } from '~/server/models/access-token';
 import loggerFactory from '~/utils/logger';
 
 import type { AccessTokenParserReq } from './interfaces';
@@ -18,14 +19,28 @@ export const accessTokenParser = async(req: AccessTokenParserReq, res: Response,
     return next();
   }
 
-  const User = mongoose.model<HydratedDocument<IUser>, { findUserByApiToken }>('User');
-
   logger.debug('accessToken is', accessToken);
 
-  const user: IUserHasId = await User.findUserByApiToken(accessToken);
+  // check the api token is valid
+  const User = mongoose.model<HydratedDocument<IUser>, { findUserByApiToken }>('User');
+  const userByApiToken: IUserHasId = await User.findUserByApiToken(accessToken);
+  if (userByApiToken != null) {
+    req.user = serializeUserSecurely(userByApiToken);
+    logger.debug('API token parsed.');
+    return next();
+  }
+
+  // check the access token is valid
+  const userId = await AccessToken.findUserIdByToken(accessToken);
+  if (userId == null) {
+    logger.debug('The access token is invalid');
+    return next();
+  }
 
+  // check the user is valid
+  const { user }: {user: IUserHasId} = await userId.populate('user');
   if (user == null) {
-    logger.debug('The access token is invalid');
+    logger.debug('The access token\'s associated user is invalid');
     return next();
   }
 
diff --git a/apps/app/src/server/models/access-token.ts b/apps/app/src/server/models/access-token.ts
index 3703d66742d..49a7c5e1e32 100644
--- a/apps/app/src/server/models/access-token.ts
+++ b/apps/app/src/server/models/access-token.ts
@@ -1,7 +1,9 @@
 import crypto from 'crypto';
 
 import type { Ref, IUserHasId } from '@growi/core/dist/interfaces';
-import type { Document, Model, Types } from 'mongoose';
+import type {
+  Document, Model, Types, HydratedDocument,
+} from 'mongoose';
 import { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
@@ -14,6 +16,14 @@ const logger = loggerFactory('growi:models:access-token');
 
 const generateTokenHash = (token: string) => crypto.createHash('sha256').update(token).digest('hex');
 
+type GenerateTokenResult = {
+  token: string,
+  _id: Types.ObjectId,
+  expiredAt: Date,
+  scope?: string[],
+  description?: string,
+}
+
 export type IAccessToken = {
   user: Ref<IUserHasId>,
   tokenHash: string,
@@ -27,13 +37,13 @@ export interface IAccessTokenDocument extends IAccessToken, Document {
 }
 
 export interface IAccessTokenModel extends Model<IAccessTokenDocument> {
-  generateToken: (userId: Types.ObjectId, expiredAt: Date, scope: string[], description?: string,) => Promise<string>
+  generateToken: (userId: Types.ObjectId | string, expiredAt: Date, scope?: string[], description?: string,) => Promise<GenerateTokenResult>
   deleteToken: (token: string) => Promise<void>
-  deleteTokenById: (tokenId: Types.ObjectId) => Promise<void>
-  deleteAllTokensByUserId: (userId: Types.ObjectId) => Promise<void>
+  deleteTokenById: (tokenId: Types.ObjectId | string) => Promise<void>
+  deleteAllTokensByUserId: (userId: Types.ObjectId | string) => Promise<void>
   deleteExpiredToken: () => Promise<void>
-  findUserIdByToken: (token: string) => Promise<Types.ObjectId>
-  findTokenByUserId: (userId: Types.ObjectId) => Promise<IAccessTokenDocument[]>
+  findUserIdByToken: (token: string) => Promise<HydratedDocument<IAccessTokenDocument> | null>
+  findTokenByUserId: (userId: Types.ObjectId | string) => Promise<HydratedDocument<IAccessTokenDocument>[] | null>
   validateTokenScopes: (token: string, requiredScope: string[]) => Promise<boolean>
 }
 
@@ -50,7 +60,7 @@ const accessTokenSchema = new Schema<IAccessTokenDocument, IAccessTokenModel>({
 accessTokenSchema.plugin(mongoosePaginate);
 accessTokenSchema.plugin(uniqueValidator);
 
-accessTokenSchema.statics.generateToken = async function(userId: Types.ObjectId, expiredAt: Date, scope?: string[], description?: string) {
+accessTokenSchema.statics.generateToken = async function(userId: Types.ObjectId | string, expiredAt: Date, scope?: string[], description?: string) {
 
   const token = crypto.randomBytes(32).toString('hex');
   const tokenHash = generateTokenHash(token);
@@ -73,20 +83,20 @@ accessTokenSchema.statics.generateToken = async function(userId: Types.ObjectId,
 
 accessTokenSchema.statics.deleteToken = async function(token: string) {
   const tokenHash = generateTokenHash(token);
-  return this.deleteOne({ tokenHash });
+  await this.deleteOne({ tokenHash });
 };
 
-accessTokenSchema.statics.deleteTokenById = async function(tokenId: Types.ObjectId) {
-  return this.deleteOne({ _id: tokenId });
+accessTokenSchema.statics.deleteTokenById = async function(tokenId: Types.ObjectId | string) {
+  await this.deleteOne({ _id: tokenId });
 };
 
-accessTokenSchema.statics.deleteAllTokensByUserId = async function(userId: Types.ObjectId) {
-  return this.deleteMany({ user: userId });
+accessTokenSchema.statics.deleteAllTokensByUserId = async function(userId: Types.ObjectId | string) {
+  await this.deleteMany({ user: userId });
 };
 
 accessTokenSchema.statics.deleteExpiredToken = async function() {
   const now = new Date();
-  return this.deleteMany({ expiredAt: { $lte: now } });
+  await this.deleteMany({ expiredAt: { $lte: now } });
 };
 
 accessTokenSchema.statics.findUserIdByToken = async function(token: string) {
@@ -95,7 +105,7 @@ accessTokenSchema.statics.findUserIdByToken = async function(token: string) {
   return this.findOne({ tokenHash, expiredAt: { $gt: now } }).select('user');
 };
 
-accessTokenSchema.statics.findTokenByUserId = async function(userId: Types.ObjectId) {
+accessTokenSchema.statics.findTokenByUserId = async function(userId: Types.ObjectId | string) {
   const now = new Date();
   return this.find({ user: userId, expiredAt: { $gt: now } }).select('_id expiredAt scope description');
 };
@@ -111,4 +121,4 @@ accessTokenSchema.methods.isExpired = function() {
   return this.expiredAt < new Date();
 };
 
-export const AccessToken = getOrCreateModel<IAccessTokenDocument, IAccessToken>('AccessToken', accessTokenSchema);
+export const AccessToken = getOrCreateModel<IAccessTokenDocument, IAccessTokenModel>('AccessToken', accessTokenSchema);
diff --git a/apps/app/src/server/routes/apiv3/personal-setting/delete-access-token.ts b/apps/app/src/server/routes/apiv3/personal-setting/delete-access-token.ts
new file mode 100644
index 00000000000..3eed7a892ba
--- /dev/null
+++ b/apps/app/src/server/routes/apiv3/personal-setting/delete-access-token.ts
@@ -0,0 +1,62 @@
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import { query } from 'express-validator';
+
+import { SupportedAction } from '~/interfaces/activity';
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import { AccessToken } from '~/server/models/access-token';
+import loggerFactory from '~/utils/logger';
+
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:personal-setting:generate-access-tokens');
+
+type ReqQuery = {
+  tokenId: string,
+}
+
+type DeleteAccessTokenRequest = Request<undefined, ApiV3Response, undefined, ReqQuery>;
+
+type DeleteAccessTokenHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+const validator = [
+  query('tokenId')
+    .exists()
+    .withMessage('tokenId is required')
+    .isString()
+    .withMessage('tokenId must be a string'),
+];
+
+export const deleteAccessTokenHandlersFactory: DeleteAccessTokenHandlersFactory = (crowi) => {
+
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const addActivity = generateAddActivityMiddleware();
+  const activityEvent = crowi.event('activity');
+
+  return [
+    accessTokenParser,
+    loginRequiredStrictly,
+    addActivity,
+    validator,
+    apiV3FormValidator,
+    async(req: DeleteAccessTokenRequest, res: ApiV3Response) => {
+      const { query } = req;
+      const { tokenId } = query;
+
+      try {
+        await AccessToken.deleteTokenById(tokenId);
+
+        const parameters = { action: SupportedAction.ACTION_USER_ACCESS_TOKEN_DELETE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({});
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3(err.toString(), 'delete-access-token-failed'));
+      }
+    }];
+};
diff --git a/apps/app/src/server/routes/apiv3/personal-setting/delete-all-access-tokens.ts b/apps/app/src/server/routes/apiv3/personal-setting/delete-all-access-tokens.ts
new file mode 100644
index 00000000000..b45ea84b057
--- /dev/null
+++ b/apps/app/src/server/routes/apiv3/personal-setting/delete-all-access-tokens.ts
@@ -0,0 +1,47 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+
+import { SupportedAction } from '~/interfaces/activity';
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { AccessToken } from '~/server/models/access-token';
+import loggerFactory from '~/utils/logger';
+
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:personal-setting:generate-access-tokens');
+
+interface DeleteAllAccessTokensRequest extends Request<undefined, ApiV3Response, undefined> {
+  user: IUserHasId,
+}
+
+type DeleteAllAccessTokensHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+export const deleteAllAccessTokensHandlersFactory: DeleteAllAccessTokensHandlersFactory = (crowi) => {
+
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const addActivity = generateAddActivityMiddleware();
+  const activityEvent = crowi.event('activity');
+
+  return [
+    accessTokenParser, loginRequiredStrictly,
+    addActivity,
+    async(req: DeleteAllAccessTokensRequest, res: ApiV3Response) => {
+      const { user } = req;
+
+      try {
+        await AccessToken.deleteAllTokensByUserId(user._id);
+
+        const parameters = { action: SupportedAction.ACTION_USER_ACCESS_TOKEN_DELETE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({});
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3(err.toString(), 'delete-all-access-token-failed'));
+      }
+    }];
+};
diff --git a/apps/app/src/server/routes/apiv3/personal-setting/generate-access-token.ts b/apps/app/src/server/routes/apiv3/personal-setting/generate-access-token.ts
new file mode 100644
index 00000000000..d53e668378d
--- /dev/null
+++ b/apps/app/src/server/routes/apiv3/personal-setting/generate-access-token.ts
@@ -0,0 +1,100 @@
+import type {
+  IUserHasId,
+} from '@growi/core/dist/interfaces';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import { body } from 'express-validator';
+
+import { SupportedAction } from '~/interfaces/activity';
+import type Crowi from '~/server/crowi';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { AccessToken } from '~/server/models/access-token';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:personal-setting:generate-access-tokens');
+
+type ReqBody = {
+  expiredAt: Date,
+  description?: string,
+  scope?: string[],
+}
+
+interface GenerateAccessTokenRequest extends Request<undefined, ApiV3Response, ReqBody> {
+  user: IUserHasId,
+}
+
+type GenerateAccessTokenHandlerFactory = (crowi: Crowi) => RequestHandler[];
+
+const validator = [
+  body('expiredAt')
+    .exists()
+    .withMessage('expiredAt is required')
+    .custom((value) => {
+      const expiredAt = new Date(value);
+      const now = new Date();
+
+      // Check if date is valid
+      if (Number.isNaN(expiredAt.getTime())) {
+        throw new Error('Invalid date format');
+      }
+
+      // Check if date is in the future
+      if (expiredAt <= now) {
+        throw new Error('Expiration date must be in the future');
+      }
+
+      return true;
+    }),
+
+  body('description')
+    .optional()
+    .isString()
+    .withMessage('description must be a string')
+    .isLength({ max: 200 })
+    .withMessage('description must be less than or equal to 200 characters'),
+
+  body('scope')
+    .optional()
+    .isArray()
+    .withMessage('scope must be an array')
+    .custom(() => {
+      // TODO: Check if all values are valid
+      return true;
+    }),
+];
+
+export const generateAccessTokenHandlerFactory: GenerateAccessTokenHandlerFactory = (crowi) => {
+
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const activityEvent = crowi.event('activity');
+  const addActivity = generateAddActivityMiddleware();
+
+
+  return [
+    loginRequiredStrictly,
+    addActivity,
+    validator,
+    apiV3FormValidator,
+    async(req: GenerateAccessTokenRequest, res: ApiV3Response) => {
+
+      const { user, body } = req;
+      const { expiredAt, description, scope } = body;
+
+      try {
+        const tokenData = await AccessToken.generateToken(user._id, expiredAt, scope, description);
+
+        const parameters = { action: SupportedAction.ACTION_USER_ACCESS_TOKEN_CREATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3(tokenData);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3(err.toString(), 'generate-access-token-failed'));
+      }
+    },
+  ];
+};
diff --git a/apps/app/src/server/routes/apiv3/personal-setting/get-access-tokens.ts b/apps/app/src/server/routes/apiv3/personal-setting/get-access-tokens.ts
new file mode 100644
index 00000000000..20455ce0987
--- /dev/null
+++ b/apps/app/src/server/routes/apiv3/personal-setting/get-access-tokens.ts
@@ -0,0 +1,42 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { AccessToken } from '~/server/models/access-token';
+import loggerFactory from '~/utils/logger';
+
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:personal-setting:get-access-tokens');
+
+interface GetAccessTokenRequest extends Request<undefined, ApiV3Response, undefined> {
+  user: IUserHasId,
+}
+
+type GetAccessTokenHandlerFactory = (crowi: Crowi) => RequestHandler[];
+
+export const getAccessTokenHandlerFactory: GetAccessTokenHandlerFactory = (crowi) => {
+
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const addActivity = generateAddActivityMiddleware();
+
+  return [
+    accessTokenParser, loginRequiredStrictly,
+    addActivity,
+    async(req: GetAccessTokenRequest, res: ApiV3Response) => {
+      const { user } = req;
+
+      try {
+        const accessTokens = await AccessToken.findTokenByUserId(user._id);
+        return res.apiv3({ accessTokens });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3(err.toString(), 'colud_not_get_access_token'));
+      }
+    },
+  ];
+};
diff --git a/apps/app/src/server/routes/apiv3/personal-setting.js b/apps/app/src/server/routes/apiv3/personal-setting/index.js
similarity index 86%
rename from apps/app/src/server/routes/apiv3/personal-setting.js
rename to apps/app/src/server/routes/apiv3/personal-setting/index.js
index 17a4ab454ac..97f4f0aba4b 100644
--- a/apps/app/src/server/routes/apiv3/personal-setting.js
+++ b/apps/app/src/server/routes/apiv3/personal-setting/index.js
@@ -8,11 +8,16 @@ import { SupportedAction } from '~/interfaces/activity';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import loggerFactory from '~/utils/logger';
 
-import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
-import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-import EditorSettings from '../../models/editor-settings';
-import ExternalAccount from '../../models/external-account';
-import InAppNotificationSettings from '../../models/in-app-notification-settings';
+import { generateAddActivityMiddleware } from '../../../middlewares/add-activity';
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import EditorSettings from '../../../models/editor-settings';
+import ExternalAccount from '../../../models/external-account';
+import InAppNotificationSettings from '../../../models/in-app-notification-settings';
+
+import { deleteAccessTokenHandlersFactory } from './delete-access-token';
+import { deleteAllAccessTokensHandlersFactory } from './delete-all-access-tokens';
+import { generateAccessTokenHandlerFactory } from './generate-access-token';
+import { getAccessTokenHandlerFactory } from './get-access-tokens';
 
 
 const logger = loggerFactory('growi:routes:apiv3:personal-setting');
@@ -68,7 +73,7 @@ const router = express.Router();
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const { User } = crowi.models;
@@ -414,6 +419,89 @@ module.exports = (crowi) => {
 
   });
 
+  /**
+   * @swagger
+   *   /personal-setting/access-token:
+   *     get:
+   *       tags: [GeneralSetting]
+   *       operationId: getAccessToken
+   *       summary: /personal-setting/access-token
+   *       description: Get access token
+   *       responses:
+   *         200:
+   *           description: succded to get access token
+   *           content:
+   *           application/json:
+   *             schema:
+   *               properties:
+   *                 accessTokens:
+   *                   type: objet
+   *                   description: array of access tokens
+   */
+  router.get('/access-token', getAccessTokenHandlerFactory(crowi));
+
+  /**
+   * @swagger
+   *   /personal-setting/access-token:
+   *     post:
+   *       tags: [GeneralSetting]
+   *       operationId: generateccessToken
+   *       summary: /personal-setting/access-token
+   *       description: Generate access token
+   *       responses:
+   *         200:
+   *           description: succeded to create access token
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 properties:
+   *                   _id:
+   *                     type: string
+   *                     description: id of access token
+   *                   token:
+   *                     type: string
+   *                     description: access token
+   *                   expiredAt:
+   *                     type: Date
+   *                     description: expired date
+   *                   description:
+   *                     type: string
+   *                     description: description of access token
+   *                   scope:
+   *                     type: string[]
+   *                     description: scope of access token
+   */
+  router.post('/access-token', generateAccessTokenHandlerFactory(crowi));
+
+  /**
+   * @swagger
+   *   /personal-setting/access-token/:
+   *     delete:
+   *     tags: [GeneralSetting]
+   *     operationId: deleteAccessToken
+   *     summary: /personal-setting/access-token
+   *     description: Delete access token
+   *     responses:
+   *       200:
+   *         description: succeded to delete access token
+   *
+   */
+  router.delete('/access-token', deleteAccessTokenHandlersFactory(crowi));
+
+  /**
+   * @swagger
+   *   /personal-setting/access-token/all:
+   *     delete:
+   *       tags: [GeneralSetting]
+   *       operationId: deleteAllAccessToken
+   *       summary: /personal-setting/access-token/all
+   *       description: Delete all access tokens
+   *       responses:
+   *         200:
+   *           description: succeded to delete all access tokens
+   */
+  router.delete('/access-token/all', deleteAllAccessTokensHandlersFactory(crowi));
+
   /**
    * @swagger
    *


Reviews:
user: login: yuki-takei
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T11:07:00Z
user: login: reiji-h
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T02:31:54Z
user: login: reiji-h
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T02:32:04Z
user: login: reiji-h
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T02:32:43Z
user: login: reiji-h
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T04:54:30Z
user: login: reiji-h
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T05:12:49Z
user: login: reiji-h
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T06:43:15Z
user: login: yuki-takei
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T12:02:18Z
user: login: reiji-h
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T03:54:54Z

Review comments:
path: apps/app/src/client/components/Me/ApiSettings.tsx
user: login: yuki-takei
body: このページは、旧アクセストークンも残したい
(別コンポーネントにしてもいい)
created_at: 2025-03-05T11:03:07Z
author_association: CONTRIBUTOR
path: apps/app/src/server/middlewares/access-token-parser/access-token-parser.integ.ts
user: login: yuki-takei
body: これも新旧両対応
created_at: 2025-03-05T11:03:41Z
author_association: CONTRIBUTOR
path: apps/app/src/server/middlewares/access-token-parser/access-token-parser.ts
user: login: yuki-takei
body: 新旧両対応が必要
created_at: 2025-03-05T11:04:10Z
author_association: CONTRIBUTOR
path: apps/app/src/server/models/user.js
user: login: yuki-takei
body: 旧コードはそのまま
created_at: 2025-03-05T11:05:10Z
author_association: CONTRIBUTOR
path: apps/app/src/server/routes/apiv3/personal-setting.js
user: login: yuki-takei
body: 新旧両対応が必要。
このファイルはそのまま残して、新実装は ts にしたい。
`routes/apiv3/page` 配下のように、1エンドポイント1モジュールに。
created_at: 2025-03-05T11:06:31Z
author_association: CONTRIBUTOR
path: packages/core/src/interfaces/user.ts
user: login: yuki-takei
body: 旧コードも残す
created_at: 2025-03-05T11:06:49Z
author_association: CONTRIBUTOR
path: packages/core/src/models/serializers/user-serializer.ts
user: login: yuki-takei
body: 旧コードも残す
created_at: 2025-03-05T11:06:54Z
author_association: CONTRIBUTOR
path: packages/core/src/models/serializers/user-serializer.ts
user: login: reiji-h
body: 変更前に戻しました
created_at: 2025-03-06T02:31:54Z
author_association: CONTRIBUTOR
path: packages/core/src/interfaces/user.ts
user: login: reiji-h
body: 変更前に戻しました
created_at: 2025-03-06T02:32:04Z
author_association: CONTRIBUTOR
path: apps/app/src/server/models/user.js
user: login: reiji-h
body: 変更前に戻しました
created_at: 2025-03-06T02:32:43Z
author_association: CONTRIBUTOR
path: apps/app/src/server/routes/apiv3/personal-setting.js
user: login: reiji-h
body: 次のように変更しました。
- `router.get('/api-token', ...` はそのまま
- `routes/apiv3/personal-setting` フォルダーを作成
- `routes/apiv3/personal-setting.js` を `routes/apiv3/personal-setting/index.js` に移動
- 今回新設したエンドポイントを次のファイルに分離
  - `get-access-tokens.ts`
  - `generate-access-token.ts`
  - `delete-access-token.ts`
  - `delete-all-access-tokens.ts`

created_at: 2025-03-06T04:54:30Z
author_association: CONTRIBUTOR
path: apps/app/src/server/middlewares/access-token-parser/access-token-parser.ts
user: login: reiji-h
body: api token に対応するように戻しました
created_at: 2025-03-06T05:12:04Z
author_association: CONTRIBUTOR
path: apps/app/src/server/middlewares/access-token-parser/access-token-parser.integ.ts
user: login: reiji-h
body: apiToken のテストはそのまま、access token のテストを新設する形で修正しました
created_at: 2025-03-06T05:12:35Z
author_association: CONTRIBUTOR
path: apps/app/src/client/components/Me/ApiSettings.tsx
user: login: reiji-h
body: 同じタブ内にいれる形で修正しました。Access Token 設定は後続タスクで複数作成できるように ShareLink を参考に変更予定です。


![image](https://github.com/user-attachments/assets/6307f78b-d2c4-4745-99ed-551384580864)

created_at: 2025-03-06T06:43:14Z
author_association: CONTRIBUTOR
path: apps/app/src/client/components/Me/ApiSettings.tsx
user: login: yuki-takei
body: このまま進むとリファクタしたくなった時に旧コードに引っ張られるコスト増が見込まれる
(同じファイルに入っていると比較的新しいコードだけ綺麗に保って、旧コードは見捨てる、という選択ができなくなる)

後続ストーリーではファイルを分けてもらえると助かります。
created_at: 2025-03-07T11:58:20Z
author_association: CONTRIBUTOR
path: apps/app/src/server/middlewares/access-token-parser/access-token-parser.integ.ts
user: login: yuki-takei
body: せめて describe を分けてください
created_at: 2025-03-07T11:59:05Z
author_association: CONTRIBUTOR
path: apps/app/src/server/middlewares/access-token-parser/access-token-parser.integ.ts
user: login: reiji-h
body: 新しく追加したテストを describe を分ける形で修正しました
created_at: 2025-03-10T03:54:54Z
author_association: CONTRIBUTOR

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-03-04T07:23:13Z
body: ###  ⚠️  No Changeset found

Latest commit: 189839d38bc41e399d51e36b5354e8a872413c2c

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/weseek/growi/new/feat/85969-85966-access-token-parser-use-access-token-model?filename=.changeset/lucky-tips-tap.md&value=---%0A%22%40growi%2Fapp%22%3A%20patch%0A---%0A%0Afeat%3A%20Access%20token%20parser%20support%20access%20token%20model%0A)


author_association: NONE
user: login: mergify[bot]
created_at: 2025-03-07T12:13:00Z
body: This pull request has been removed from the queue for the following reason: <code>checks failed</code>.

The merge conditions cannot be satisfied due to failing checks:
- ❌ [test-prod-node20 / run-playwright (chromium, 2/2)](https://github.com/weseek/growi/actions/runs/13720257012/job/38374278655)
- ❌ [test-prod-node20 / run-playwright (firefox, 2/2)](https://github.com/weseek/growi/actions/runs/13720257012/job/38374279264)
- ❌ [test-prod-node20 / run-playwright (webkit, 2/2)](https://github.com/weseek/growi/actions/runs/13720257012/job/38374279896)
- ☑️ [approve-updating-patch-version](https://github.com/weseek/growi/actions/runs/13720257001/job/38373980112)
- ☑️ [test-prod-node18 / report-playwright](https://github.com/weseek/growi/actions/runs/13720257012/job/38374282678)
- ☑️ [test-prod-node18 / run-playwright](https://github.com/weseek/growi/actions/runs/13720257012/job/38374282342)


You may have to fix your CI before adding the pull request to the queue again.

If you want to requeue this pull request, you can post a `@mergifyio requeue` comment.

author_association: CONTRIBUTOR
user: login: mergify[bot]
created_at: 2025-03-10T04:09:05Z
body: This pull request has been removed from the queue for the following reason: <code>checks failed</code>.

The merge conditions cannot be satisfied due to failing checks:
- ❌ [test-prod-node20 / run-playwright (firefox, 2/2)](https://github.com/weseek/growi/actions/runs/13756532242/job/38464954120)
- ❌ [test-prod-node20 / run-playwright (webkit, 2/2)](https://github.com/weseek/growi/actions/runs/13756532242/job/38464954516)
- ☑️ [approve-updating-patch-version](https://github.com/weseek/growi/actions/runs/13756532247/job/38464788127)
- ☑️ [test-prod-node18 / report-playwright](https://github.com/weseek/growi/actions/runs/13756532242/job/38464964450)
- ☑️ [test-prod-node18 / run-playwright](https://github.com/weseek/growi/actions/runs/13756532242/job/38464964250)


You may have to fix your CI before adding the pull request to the queue again.

If you want to requeue this pull request, you can post a `@mergifyio requeue` comment.

author_association: CONTRIBUTOR
user: login: mergify[bot]
created_at: 2025-03-10T04:45:31Z
body: This pull request has been removed from the queue for the following reason: <code>checks failed</code>.

The merge conditions cannot be satisfied due to failing checks:
- ❌ [test-prod-node20 / run-playwright (firefox, 1/2)](https://github.com/weseek/growi/actions/runs/13756934482/job/38465914271)
- ☑️ [approve-updating-patch-version](https://github.com/weseek/growi/actions/runs/13756934469/job/38465773058)
- ☑️ [test-prod-node18 / report-playwright](https://github.com/weseek/growi/actions/runs/13756934482/job/38465915673)
- ☑️ [test-prod-node18 / run-playwright](https://github.com/weseek/growi/actions/runs/13756934482/job/38465915496)


You may have to fix your CI before adding the pull request to the queue again.

If you want to requeue this pull request, you can post a `@mergifyio requeue` comment.

author_association: CONTRIBUTOR