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

Merge branch 'master' into feat/update-cypress-10-install

Ryoji Shimizu 3 лет назад
Родитель
Сommit
3ccd8f8f91

+ 4 - 0
.github/workflows/reusable-app-prod.yml

@@ -237,6 +237,10 @@ jobs:
       run: |
       run: |
         npx lerna bootstrap -- --production
         npx lerna bootstrap -- --production
 
 
+    - name: lerna add packages needed for CI
+      run: |
+        npx lerna add yargs
+
     - name: Download production files artifact
     - name: Download production files artifact
       uses: actions/download-artifact@v3
       uses: actions/download-artifact@v3
       with:
       with:

+ 5 - 3
packages/app/src/components/InstallerForm.tsx

@@ -106,11 +106,13 @@ const InstallerForm = memo((): JSX.Element => {
       <div className="row">
       <div className="row">
         <form role="form" id="register-form" className="col-md-12" onSubmit={submitHandler}>
         <form role="form" id="register-form" className="col-md-12" onSubmit={submitHandler}>
           <div className="dropdown mb-3">
           <div className="dropdown mb-3">
-            <div className="d-flex dropdown-with-icon">
-              <i className="icon-bubbles border-0 rounded-0" />
+            <div className="input-group">
+              <div className="input-group-prepend dropdown-with-icon">
+                <i className="input-group-text icon-bubbles border-0 rounded-0" />
+              </div>
               <button
               <button
                 type="button"
                 type="button"
-                className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
+                className="btn btn-secondary dropdown-toggle form-control text-right rounded-right"
                 id="dropdownLanguage"
                 id="dropdownLanguage"
                 data-testid="dropdownLanguage"
                 data-testid="dropdownLanguage"
                 data-toggle="dropdown"
                 data-toggle="dropdown"

+ 0 - 2
packages/app/src/components/Layout/BasicLayout.tsx

@@ -22,7 +22,6 @@ const PageDeleteModal = dynamic(() => import('../PageDeleteModal'), { ssr: false
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal'), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal'), { ssr: false });
-const DrawioModal = dynamic(() => import('../PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
 // Fab
 // Fab
 const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false });
 const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false });
 
 
@@ -64,7 +63,6 @@ export const BasicLayout = ({
         <PageDeleteModal />
         <PageDeleteModal />
         <PageRenameModal />
         <PageRenameModal />
         <PageAccessoriesModal />
         <PageAccessoriesModal />
-        <DrawioModal />
       </DndProvider>
       </DndProvider>
 
 
       <PagePresentationModal />
       <PagePresentationModal />

+ 0 - 10
packages/app/src/components/Layout/NoLoginLayout.module.scss

@@ -63,16 +63,6 @@
     }
     }
   }
   }
 
 
-  .dropdown-with-icon {
-    .dropdown-toggle {
-      @extend .form-control;
-    }
-    i {
-      @extend .input-group-text;
-      margin-right: -1px;
-    }
-  }
-
   .input-group {
   .input-group {
     margin-bottom: 10px;
     margin-bottom: 10px;
 
 

+ 9 - 4
packages/app/src/components/LoginForm.tsx

@@ -7,8 +7,9 @@ import { useRouter } from 'next/router';
 import ReactCardFlip from 'react-card-flip';
 import ReactCardFlip from 'react-card-flip';
 
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
+import type { IExternalAccountLoginError } from '~/interfaces/errors/external-account-login-error';
 import { LoginErrorCode } from '~/interfaces/errors/login-error';
 import { LoginErrorCode } from '~/interfaces/errors/login-error';
-import { IErrorV3 } from '~/interfaces/errors/v3-error';
+import type { IErrorV3 } from '~/interfaces/errors/v3-error';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import { toArrayIfNot } from '~/utils/array-utils';
 import { toArrayIfNot } from '~/utils/array-utils';
 
 
@@ -26,7 +27,8 @@ type LoginFormProps = {
   isLdapStrategySetup: boolean,
   isLdapStrategySetup: boolean,
   isLdapSetupFailed: boolean,
   isLdapSetupFailed: boolean,
   objOfIsExternalAuthEnableds?: any,
   objOfIsExternalAuthEnableds?: any,
-  isMailerSetup?: boolean
+  isMailerSetup?: boolean,
+  externalAccountLoginError?: IExternalAccountLoginError,
 }
 }
 export const LoginForm = (props: LoginFormProps): JSX.Element => {
 export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -129,7 +131,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   }, [t]);
   }, [t]);
 
 
   // wrap error elements which do not use dangerouslySetInnerHtml
   // wrap error elements which do not use dangerouslySetInnerHtml
-  const generateSafelySetErrors = useCallback((errors: IErrorV3[]): JSX.Element => {
+  const generateSafelySetErrors = useCallback((errors: (IErrorV3 | IExternalAccountLoginError)[]): JSX.Element => {
     if (errors == null || errors.length === 0) return <></>;
     if (errors == null || errors.length === 0) return <></>;
     return (
     return (
       <ul className="alert alert-danger">
       <ul className="alert alert-danger">
@@ -151,7 +153,10 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     // Generate login error elements using dangerouslySetInnerHTML
     // Generate login error elements using dangerouslySetInnerHTML
     const loginErrorElementWithDangerouslySetInnerHTML = generateDangerouslySetErrors(loginErrorListForDangerouslySetInnerHTML);
     const loginErrorElementWithDangerouslySetInnerHTML = generateDangerouslySetErrors(loginErrorListForDangerouslySetInnerHTML);
     // Generate login error elements using <ul>, <li>
     // Generate login error elements using <ul>, <li>
-    const loginErrorElement = generateSafelySetErrors(loginErrorList);
+
+    const loginErrorElement = props.externalAccountLoginError != null
+      ? generateSafelySetErrors([...loginErrorList, props.externalAccountLoginError])
+      : generateSafelySetErrors(loginErrorList);
 
 
     return (
     return (
       <>
       <>

+ 6 - 3
packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -1,6 +1,7 @@
 import React, { FC } from 'react';
 import React, { FC } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 
 
 import { IRevision } from '~/interfaces/revision';
 import { IRevision } from '~/interfaces/revision';
 import { useSWRxPageByPath } from '~/stores/page';
 import { useSWRxPageByPath } from '~/stores/page';
@@ -19,9 +20,11 @@ const logger = loggerFactory('growi:cli:CustomSidebar');
 const SidebarNotFound = () => {
 const SidebarNotFound = () => {
   return (
   return (
     <div className="grw-sidebar-content-header h5 text-center p-3">
     <div className="grw-sidebar-content-header h5 text-center p-3">
-      <a href="/Sidebar#edit">
-        <i className="icon-magic-wand"></i> Create <strong>/Sidebar</strong> page
-      </a>
+      <Link href="/Sidebar#edit">
+        <a href="/Sidebar#edit">
+          <i className="icon-magic-wand"></i> Create <strong>/Sidebar</strong> page
+        </a>
+      </Link>
     </div>
     </div>
   );
   );
 };
 };

+ 8 - 0
packages/app/src/interfaces/errors/external-account-login-error.ts

@@ -0,0 +1,8 @@
+import { ExternalAccountLoginError } from '~/models/vo/external-account-login-error';
+
+export type IExternalAccountLoginError = ExternalAccountLoginError;
+
+// type guard
+export const isExternalAccountLoginError = (args: any): args is IExternalAccountLoginError => {
+  return (args as IExternalAccountLoginError).message != null;
+};

+ 11 - 0
packages/app/src/models/vo/external-account-login-error.ts

@@ -0,0 +1,11 @@
+export class ExternalAccountLoginError extends Error {
+
+  args?: any;
+
+  constructor(message = '', args = undefined) {
+    super();
+    this.message = message;
+    this.args = args;
+  }
+
+}

+ 2 - 0
packages/app/src/pages/[[...path]].page.tsx

@@ -86,6 +86,7 @@ const UnsavedAlertDialog = dynamic(() => import('../components/UnsavedAlertDialo
 const GrowiSubNavigationSwitcher = dynamic(() => import('../components/Navbar/GrowiSubNavigationSwitcher'), { ssr: false });
 const GrowiSubNavigationSwitcher = dynamic(() => import('../components/Navbar/GrowiSubNavigationSwitcher'), { ssr: false });
 const UsersHomePageFooter = dynamic<UsersHomePageFooterProps>(() => import('../components/UsersHomePageFooter')
 const UsersHomePageFooter = dynamic<UsersHomePageFooterProps>(() => import('../components/UsersHomePageFooter')
   .then(mod => mod.UsersHomePageFooter), { ssr: false });
   .then(mod => mod.UsersHomePageFooter), { ssr: false });
+const DrawioModal = dynamic(() => import('../components/PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
 const HandsontableModal = dynamic(() => import('../components/PageEditor/HandsontableModal').then(mod => mod.HandsontableModal), { ssr: false });
 const HandsontableModal = dynamic(() => import('../components/PageEditor/HandsontableModal').then(mod => mod.HandsontableModal), { ssr: false });
 
 
 const logger = loggerFactory('growi:pages:all');
 const logger = loggerFactory('growi:pages:all');
@@ -361,6 +362,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
 
           <UnsavedAlertDialog />
           <UnsavedAlertDialog />
           <DescendantsPageListModal />
           <DescendantsPageListModal />
+          <DrawioModal />
           <HandsontableModal />
           <HandsontableModal />
           {shouldRenderPutbackPageModal && <PutbackPageModal />}
           {shouldRenderPutbackPageModal && <PutbackPageModal />}
         </div>
         </div>

+ 10 - 0
packages/app/src/pages/login.page.tsx

@@ -8,6 +8,7 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
 import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
 import { LoginForm } from '~/components/LoginForm';
 import { LoginForm } from '~/components/LoginForm';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import { IExternalAccountLoginError, isExternalAccountLoginError } from '~/interfaces/errors/external-account-login-error';
 import type { RegistrationMode } from '~/interfaces/registration-mode';
 import type { RegistrationMode } from '~/interfaces/registration-mode';
 
 
 import {
 import {
@@ -30,6 +31,7 @@ type Props = CommonProps & {
   isLdapSetupFailed: boolean,
   isLdapSetupFailed: boolean,
   isPasswordResetEnabled: boolean,
   isPasswordResetEnabled: boolean,
   isEmailAuthenticationEnabled: boolean,
   isEmailAuthenticationEnabled: boolean,
+  externalAccountLoginError?: IExternalAccountLoginError,
 };
 };
 
 
 const LoginPage: NextPage<Props> = (props: Props) => {
 const LoginPage: NextPage<Props> = (props: Props) => {
@@ -54,6 +56,7 @@ const LoginPage: NextPage<Props> = (props: Props) => {
         isPasswordResetEnabled={props.isPasswordResetEnabled}
         isPasswordResetEnabled={props.isPasswordResetEnabled}
         isMailerSetup={props.isMailerSetup}
         isMailerSetup={props.isMailerSetup}
         registrationMode={props.registrationMode}
         registrationMode={props.registrationMode}
+        externalAccountLoginError={props.externalAccountLoginError}
       />
       />
     </NoLoginLayout>
     </NoLoginLayout>
   );
   );
@@ -120,6 +123,13 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
 
 
   const props: Props = result.props as Props;
   const props: Props = result.props as Props;
 
 
+  if (context.query.externalAccountLoginError != null) {
+    const externalAccountLoginError = context.query.externalAccountLoginError;
+    if (isExternalAccountLoginError(externalAccountLoginError)) {
+      props.externalAccountLoginError = { ...externalAccountLoginError as IExternalAccountLoginError };
+    }
+  }
+
   injectServerConfigurations(context, props);
   injectServerConfigurations(context, props);
   injectEnabledStrategies(context, props);
   injectEnabledStrategies(context, props);
   await injectNextI18NextConfigurations(context, props, ['translation']);
   await injectNextI18NextConfigurations(context, props, ['translation']);

+ 11 - 11
packages/app/src/server/routes/index.js

@@ -97,17 +97,17 @@ module.exports = function(crowi, app) {
   }
   }
 
 
   // OAuth
   // OAuth
-  app.get('/passport/google'                      , loginPassport.loginWithGoogle, loginPassport.loginFailure);
-  app.get('/passport/github'                      , loginPassport.loginWithGitHub, loginPassport.loginFailure);
-  app.get('/passport/twitter'                     , loginPassport.loginWithTwitter, loginPassport.loginFailure);
-  app.get('/passport/oidc'                        , loginPassport.loginWithOidc, loginPassport.loginFailure);
-  app.get('/passport/saml'                        , loginPassport.loginWithSaml, loginPassport.loginFailure);
-  app.get('/passport/basic'                       , loginPassport.loginWithBasic, loginPassport.loginFailure);
-  app.get('/passport/google/callback'             , loginPassport.loginPassportGoogleCallback   , loginPassport.loginFailure);
-  app.get('/passport/github/callback'             , loginPassport.loginPassportGitHubCallback   , loginPassport.loginFailure);
-  app.get('/passport/twitter/callback'            , loginPassport.loginPassportTwitterCallback  , loginPassport.loginFailure);
-  app.get('/passport/oidc/callback'               , loginPassport.loginPassportOidcCallback     , loginPassport.loginFailure);
-  app.post('/passport/saml/callback'              , addActivity, loginPassport.loginPassportSamlCallback, loginPassport.loginFailure);
+  app.get('/passport/google'                      , loginPassport.loginWithGoogle, loginPassport.loginFailureForExternalAccount);
+  app.get('/passport/github'                      , loginPassport.loginWithGitHub, loginPassport.loginFailureForExternalAccount);
+  app.get('/passport/twitter'                     , loginPassport.loginWithTwitter, loginPassport.loginFailureForExternalAccount);
+  app.get('/passport/oidc'                        , loginPassport.loginWithOidc, loginPassport.loginFailureForExternalAccount);
+  app.get('/passport/saml'                        , loginPassport.loginWithSaml, loginPassport.loginFailureForExternalAccount);
+  app.get('/passport/basic'                       , loginPassport.loginWithBasic, loginPassport.loginFailureForExternalAccount);
+  app.get('/passport/google/callback'             , loginPassport.loginPassportGoogleCallback   , loginPassport.loginFailureForExternalAccount);
+  app.get('/passport/github/callback'             , loginPassport.loginPassportGitHubCallback   , loginPassport.loginFailureForExternalAccount);
+  app.get('/passport/twitter/callback'            , loginPassport.loginPassportTwitterCallback  , loginPassport.loginFailureForExternalAccount);
+  app.get('/passport/oidc/callback'               , loginPassport.loginPassportOidcCallback     , loginPassport.loginFailureForExternalAccount);
+  app.post('/passport/saml/callback'              , addActivity, loginPassport.loginPassportSamlCallback, loginPassport.loginFailureForExternalAccount);
 
 
   app.post('/_api/login/testLdap'    , loginRequiredStrictly , loginFormValidator.loginRules() , loginFormValidator.loginValidation , loginPassport.testLdapCredentials);
   app.post('/_api/login/testLdap'    , loginRequiredStrictly , loginFormValidator.loginRules() , loginFormValidator.loginValidation , loginPassport.testLdapCredentials);
 
 

+ 48 - 45
packages/app/src/server/routes/login-passport.js

@@ -1,8 +1,10 @@
 
 
 import { ErrorV3 } from '@growi/core';
 import { ErrorV3 } from '@growi/core';
+import next from 'next';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
 import { LoginErrorCode } from '~/interfaces/errors/login-error';
 import { LoginErrorCode } from '~/interfaces/errors/login-error';
+import { ExternalAccountLoginError } from '~/models/vo/external-account-login-error';
 import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
 import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -131,20 +133,6 @@ module.exports = function(crowi, app) {
     return res.apiv3({ redirectTo });
     return res.apiv3({ redirectTo });
   };
   };
 
 
-  /**
-   * failure handler
-   * @param {*} req
-   * @param {*} res
-   */
-  const loginFailureHandler = async(req, res, message) => {
-    req.flash('errorMessage', message || req.t('message.sign_in_failure'));
-
-    const parameters = { action: SupportedAction.ACTION_USER_LOGIN_FAILURE };
-    activityEvent.emit('update', res.locals.activity._id, parameters);
-
-    return res.redirect('/login');
-  };
-
   const cannotLoginErrorHadnler = (req, res, next) => {
   const cannotLoginErrorHadnler = (req, res, next) => {
     // this is called when all login method is somehow failed without invoking 'return next(<any Error>)'
     // this is called when all login method is somehow failed without invoking 'return next(<any Error>)'
     const err = new ErrorV3('message.sign_in_failure');
     const err = new ErrorV3('message.sign_in_failure');
@@ -166,6 +154,20 @@ module.exports = function(crowi, app) {
     return res.apiv3Err(error);
     return res.apiv3Err(error);
   };
   };
 
 
+  const loginFailureForExternalAccount = async(error, req, res, next) => {
+    const parameters = {
+      ip:  req.ip,
+      endpoint: req.originalUrl,
+      action: SupportedAction.ACTION_USER_LOGIN_FAILURE,
+    };
+    await crowi.activityService.createActivity(parameters);
+
+    const { nextApp } = crowi;
+    req.crowi = crowi;
+    nextApp.render(req, res, '/login', { externalAccountLoginError: error });
+    return;
+  };
+
   /**
   /**
    * return true(valid) or false(invalid)
    * return true(valid) or false(invalid)
    *
    *
@@ -359,8 +361,8 @@ module.exports = function(crowi, app) {
   const loginWithGoogle = function(req, res, next) {
   const loginWithGoogle = function(req, res, next) {
     if (!passportService.isGoogleStrategySetup) {
     if (!passportService.isGoogleStrategySetup) {
       debug('GoogleStrategy has not been set up');
       debug('GoogleStrategy has not been set up');
-      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'GoogleStrategy' }));
-      return next();
+      const error = new ExternalAccountLoginError('message.strategy_has_not_been_set_up', { strategy: 'GoogleStrategy' });
+      return next(error);
     }
     }
 
 
     passport.authenticate('google', {
     passport.authenticate('google', {
@@ -379,7 +381,7 @@ module.exports = function(crowi, app) {
       response = await promisifiedPassportAuthentication(strategyName, req, res);
       response = await promisifiedPassportAuthentication(strategyName, req, res);
     }
     }
     catch (err) {
     catch (err) {
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError(err.message));
     }
     }
 
 
     let name;
     let name;
@@ -413,14 +415,14 @@ module.exports = function(crowi, app) {
 
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
     if (!externalAccount) {
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
     }
 
 
     const user = await externalAccount.getPopulatedUser();
     const user = await externalAccount.getPopulatedUser();
 
 
     // login
     // login
     req.logIn(user, async(err) => {
     req.logIn(user, async(err) => {
-      if (err) { debug(err.message); return next() }
+      if (err) { debug(err.message); return next(new ExternalAccountLoginError(err.message)) }
 
 
       return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_GOOGLE, true);
       return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_GOOGLE, true);
     });
     });
@@ -429,8 +431,8 @@ module.exports = function(crowi, app) {
   const loginWithGitHub = function(req, res, next) {
   const loginWithGitHub = function(req, res, next) {
     if (!passportService.isGitHubStrategySetup) {
     if (!passportService.isGitHubStrategySetup) {
       debug('GitHubStrategy has not been set up');
       debug('GitHubStrategy has not been set up');
-      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'GitHubStrategy' }));
-      return next();
+      const error = new ExternalAccountLoginError('message.strategy_has_not_been_set_up', { strategy: 'GitHubStrategy' });
+      return next(error);
     }
     }
 
 
     passport.authenticate('github')(req, res);
     passport.authenticate('github')(req, res);
@@ -445,7 +447,7 @@ module.exports = function(crowi, app) {
       response = await promisifiedPassportAuthentication(strategyName, req, res);
       response = await promisifiedPassportAuthentication(strategyName, req, res);
     }
     }
     catch (err) {
     catch (err) {
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError(err.message));
     }
     }
 
 
     const userInfo = {
     const userInfo = {
@@ -456,14 +458,14 @@ module.exports = function(crowi, app) {
 
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
     if (!externalAccount) {
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
     }
 
 
     const user = await externalAccount.getPopulatedUser();
     const user = await externalAccount.getPopulatedUser();
 
 
     // login
     // login
     req.logIn(user, async(err) => {
     req.logIn(user, async(err) => {
-      if (err) { debug(err.message); return next() }
+      if (err) { debug(err.message); return next(new ExternalAccountLoginError(err.message)) }
 
 
       return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_GITHUB, true);
       return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_GITHUB, true);
     });
     });
@@ -472,8 +474,8 @@ module.exports = function(crowi, app) {
   const loginWithTwitter = function(req, res, next) {
   const loginWithTwitter = function(req, res, next) {
     if (!passportService.isTwitterStrategySetup) {
     if (!passportService.isTwitterStrategySetup) {
       debug('TwitterStrategy has not been set up');
       debug('TwitterStrategy has not been set up');
-      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'TwitterStrategy' }));
-      return next();
+      const error = new ExternalAccountLoginError('message.strategy_has_not_been_set_up', { strategy: 'TwitterStrategy' });
+      return next(error);
     }
     }
 
 
     passport.authenticate('twitter')(req, res);
     passport.authenticate('twitter')(req, res);
@@ -488,7 +490,7 @@ module.exports = function(crowi, app) {
       response = await promisifiedPassportAuthentication(strategyName, req, res);
       response = await promisifiedPassportAuthentication(strategyName, req, res);
     }
     }
     catch (err) {
     catch (err) {
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError(err.message));
     }
     }
 
 
     const userInfo = {
     const userInfo = {
@@ -499,14 +501,14 @@ module.exports = function(crowi, app) {
 
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
     if (!externalAccount) {
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
     }
 
 
     const user = await externalAccount.getPopulatedUser();
     const user = await externalAccount.getPopulatedUser();
 
 
     // login
     // login
     req.logIn(user, async(err) => {
     req.logIn(user, async(err) => {
-      if (err) { debug(err.message); return next() }
+      if (err) { debug(err.message); return next(new ExternalAccountLoginError(err.message)) }
 
 
       return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_TWITTER, true);
       return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_TWITTER, true);
     });
     });
@@ -515,8 +517,8 @@ module.exports = function(crowi, app) {
   const loginWithOidc = function(req, res, next) {
   const loginWithOidc = function(req, res, next) {
     if (!passportService.isOidcStrategySetup) {
     if (!passportService.isOidcStrategySetup) {
       debug('OidcStrategy has not been set up');
       debug('OidcStrategy has not been set up');
-      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'OidcStrategy' }));
-      return next();
+      const error = new ExternalAccountLoginError('message.strategy_has_not_been_set_up', { strategy: 'OidcStrategy' });
+      return next(error);
     }
     }
 
 
     passport.authenticate('oidc')(req, res);
     passport.authenticate('oidc')(req, res);
@@ -536,7 +538,7 @@ module.exports = function(crowi, app) {
     }
     }
     catch (err) {
     catch (err) {
       debug(err);
       debug(err);
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError(err.message));
     }
     }
 
 
     const userInfo = {
     const userInfo = {
@@ -549,13 +551,13 @@ module.exports = function(crowi, app) {
 
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
     if (!externalAccount) {
-      return loginFailureHandler(req, res);
+      return new ExternalAccountLoginError('message.sign_in_failure');
     }
     }
 
 
     // login
     // login
     const user = await externalAccount.getPopulatedUser();
     const user = await externalAccount.getPopulatedUser();
     req.logIn(user, async(err) => {
     req.logIn(user, async(err) => {
-      if (err) { debug(err.message); return next() }
+      if (err) { debug(err.message); return next(new ExternalAccountLoginError(err.message)) }
 
 
       return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_OIDC, true);
       return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_OIDC, true);
     });
     });
@@ -564,8 +566,8 @@ module.exports = function(crowi, app) {
   const loginWithSaml = function(req, res, next) {
   const loginWithSaml = function(req, res, next) {
     if (!passportService.isSamlStrategySetup) {
     if (!passportService.isSamlStrategySetup) {
       debug('SamlStrategy has not been set up');
       debug('SamlStrategy has not been set up');
-      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'SamlStrategy' }));
-      return next();
+      const error = new ExternalAccountLoginError('message.strategy_has_not_been_set_up', { strategy: 'SamlStrategy' });
+      return next(error);
     }
     }
 
 
     passport.authenticate('saml')(req, res);
     passport.authenticate('saml')(req, res);
@@ -585,7 +587,7 @@ module.exports = function(crowi, app) {
       response = await promisifiedPassportAuthentication(strategyName, req, res);
       response = await promisifiedPassportAuthentication(strategyName, req, res);
     }
     }
     catch (err) {
     catch (err) {
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError(err.message));
     }
     }
 
 
     const userInfo = {
     const userInfo = {
@@ -603,12 +605,12 @@ module.exports = function(crowi, app) {
 
 
     // Attribute-based Login Control
     // Attribute-based Login Control
     if (!crowi.passportService.verifySAMLResponseByABLCRule(response)) {
     if (!crowi.passportService.verifySAMLResponseByABLCRule(response)) {
-      return loginFailureHandler(req, res, 'Sign in failure due to insufficient privileges.');
+      return next(new ExternalAccountLoginError('Sign in failure due to insufficient privileges.'));
     }
     }
 
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
     if (!externalAccount) {
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
     }
 
 
     const user = await externalAccount.getPopulatedUser();
     const user = await externalAccount.getPopulatedUser();
@@ -617,7 +619,7 @@ module.exports = function(crowi, app) {
     req.logIn(user, (err) => {
     req.logIn(user, (err) => {
       if (err != null) {
       if (err != null) {
         logger.error(err);
         logger.error(err);
-        return loginFailureHandler(req, res);
+        return next(new ExternalAccountLoginError(err.message));
       }
       }
 
 
       return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_SAML, true);
       return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_SAML, true);
@@ -633,8 +635,8 @@ module.exports = function(crowi, app) {
   const loginWithBasic = async(req, res, next) => {
   const loginWithBasic = async(req, res, next) => {
     if (!passportService.isBasicStrategySetup) {
     if (!passportService.isBasicStrategySetup) {
       debug('BasicStrategy has not been set up');
       debug('BasicStrategy has not been set up');
-      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'Basic' }));
-      return next();
+      const error = new ExternalAccountLoginError('message.strategy_has_not_been_set_up', { strategy: 'Basic' });
+      return next(error);
     }
     }
 
 
     const providerId = 'basic';
     const providerId = 'basic';
@@ -645,7 +647,7 @@ module.exports = function(crowi, app) {
       userId = await promisifiedPassportAuthentication(strategyName, req, res);
       userId = await promisifiedPassportAuthentication(strategyName, req, res);
     }
     }
     catch (err) {
     catch (err) {
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError(err.message));
     }
     }
 
 
     const userInfo = {
     const userInfo = {
@@ -656,12 +658,12 @@ module.exports = function(crowi, app) {
 
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
     if (!externalAccount) {
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
     }
 
 
     const user = await externalAccount.getPopulatedUser();
     const user = await externalAccount.getPopulatedUser();
     await req.logIn(user, (err) => {
     await req.logIn(user, (err) => {
-      if (err) { debug(err.message); return next() }
+      if (err) { debug(err.message); return next(new ExternalAccountLoginError(err.message)) }
 
 
       return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_BASIC, true);
       return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_BASIC, true);
     });
     });
@@ -670,6 +672,7 @@ module.exports = function(crowi, app) {
   return {
   return {
     cannotLoginErrorHadnler,
     cannotLoginErrorHadnler,
     loginFailure,
     loginFailure,
+    loginFailureForExternalAccount,
     loginWithLdap,
     loginWithLdap,
     testLdapCredentials,
     testLdapCredentials,
     loginWithLocal,
     loginWithLocal,

+ 1 - 1
packages/app/src/styles/organisms/_wiki-custom-sidebar.scss

@@ -39,7 +39,7 @@
     margin: 10px 0;
     margin: 10px 0;
   }
   }
 
 
-  .page-list .page-list-ul {
+  .lsx.page-list > .page-list-ul {
     padding-left: 0;
     padding-left: 0;
     margin: 0;
     margin: 0;
   }
   }

+ 27 - 12
packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/factory-attributes.js

@@ -82,13 +82,6 @@ export function factoryAttributes(
       return shortcutStart(code);
       return shortcutStart(code);
     }
     }
 
 
-    if (code === codes.colon || code === codes.underscore || code === codes.slash || asciiAlpha(code)) {
-      effects.enter(attributeType);
-      effects.enter(attributeNameType);
-      effects.consume(code);
-      return name;
-    }
-
     if (disallowEol && markdownSpace(code)) {
     if (disallowEol && markdownSpace(code)) {
       return factorySpace(effects, between, types.whitespace)(code);
       return factorySpace(effects, between, types.whitespace)(code);
     }
     }
@@ -97,6 +90,19 @@ export function factoryAttributes(
       return factoryAttributesDevider(effects, between)(code);
       return factoryAttributesDevider(effects, between)(code);
     }
     }
 
 
+    if (code !== codes.rightParenthesis
+      && code !== codes.eof
+      && code !== codes.carriageReturn
+      && code !== codes.lineFeed
+      && code !== codes.carriageReturnLineFeed
+      && code !== codes.ampersand
+    ) {
+      effects.enter(attributeType);
+      effects.enter(attributeNameType);
+      effects.consume(code);
+      return name;
+    }
+
     return end(code);
     return end(code);
   }
   }
 
 
@@ -166,11 +172,20 @@ export function factoryAttributes(
   /** @type {State} */
   /** @type {State} */
   function name(code) {
   function name(code) {
     if (
     if (
-      code === codes.dash
-      || code === codes.dot
-      || code === codes.colon
-      || code === codes.underscore
-      || asciiAlphanumeric(code)
+      code !== codes.eof
+        && code !== codes.carriageReturn
+        && code !== codes.lineFeed
+        && code !== codes.carriageReturnLineFeed
+        && code !== codes.quotationMark
+        && code !== codes.numberSign
+        && code !== codes.apostrophe
+        && code !== codes.dot
+        && code !== codes.lessThan
+        && code !== codes.equalsTo
+        && code !== codes.greaterThan
+        && code !== codes.graveAccent
+        && code !== codes.rightParenthesis
+        && code !== codes.space
     ) {
     ) {
       effects.consume(code);
       effects.consume(code);
       return name;
       return name;

+ 69 - 2
packages/remark-growi-plugin/test/micromark-extension-growi-plugin.test.js

@@ -113,7 +113,7 @@ test('micromark-extension-directive (syntax)', (t) => {
     t.equal(
     t.equal(
       micromark('$a(', options()),
       micromark('$a(', options()),
       '<p>(</p>',
       '<p>(</p>',
-      'should support a name followed by an unclosed `{`',
+      'should support a name followed by an unclosed `(`',
     );
     );
 
 
     t.equal(
     t.equal(
@@ -125,7 +125,7 @@ test('micromark-extension-directive (syntax)', (t) => {
     t.equal(
     t.equal(
       micromark('$a(b', options()),
       micromark('$a(b', options()),
       '<p>(b</p>',
       '<p>(b</p>',
-      'should support a name followed by an unclosed `{` w/ content',
+      'should support a name followed by an unclosed `(` w/ content',
     );
     );
 
 
     t.equal(
     t.equal(
@@ -326,6 +326,12 @@ test('micromark-extension-directive (syntax)', (t) => {
       'should not support a grave accent in an unquoted attribute value',
       'should not support a grave accent in an unquoted attribute value',
     );
     );
 
 
+    t.equal(
+      micromark('a $a(b💚=a💚b)', options()),
+      '<p>a </p>',
+      'should support most other characters in attribute keys',
+    );
+
     t.equal(
     t.equal(
       micromark('a $a(b=a💚b)', options()),
       micromark('a $a(b=a💚b)', options()),
       '<p>a </p>',
       '<p>a </p>',
@@ -547,6 +553,12 @@ test('micromark-extension-directive (syntax)', (t) => {
     //   'should not support `=` to start an unquoted attribute value',
     //   'should not support `=` to start an unquoted attribute value',
     // );
     // );
 
 
+    t.equal(
+      micromark('$a(b💚=a💚b)', options()),
+      '',
+      'should support most other characters in attribute keys',
+    );
+
     t.equal(
     t.equal(
       micromark('$a(b=a💚b)', options()),
       micromark('$a(b=a💚b)', options()),
       '',
       '',
@@ -772,6 +784,41 @@ test('micromark-extension-directive (compile)', (t) => {
     'should support directives (youtube)',
     'should support directives (youtube)',
   );
   );
 
 
+  t.equal(
+    micromark(
+      [
+        'Text:',
+        'a $lsx',
+        'a $lsx()',
+        'a $lsx(num=1)',
+        'a $lsx(/)',
+        'a $lsx(💚)',
+        'Leaf:',
+        '$lsx',
+        '$lsx()',
+        '$lsx(num=1)',
+        '$lsx(/)',
+        '$lsx(💚)',
+      ].join('\n\n'),
+      options({ lsx }),
+    ),
+    [
+      '<p>Text:</p>',
+      '<p>a <lsx ></lsx></p>',
+      '<p>a <lsx ></lsx></p>',
+      '<p>a <lsx num="1"></lsx></p>',
+      '<p>a <lsx prefix="/"></lsx></p>',
+      '<p>a <lsx prefix="💚"></lsx></p>',
+      '<p>Leaf:</p>',
+      '<lsx ></lsx>',
+      '<lsx ></lsx>',
+      '<lsx num="1"></lsx>',
+      '<lsx prefix="/"></lsx>',
+      '<lsx prefix="💚"></lsx>',
+    ].join('\n'),
+    'should support directives (lsx)',
+  );
+
   t.equal(
   t.equal(
     micromark('a $youtube[Cat in a box]\n$br a', options({ youtube, '*': h })),
     micromark('a $youtube[Cat in a box]\n$br a', options({ youtube, '*': h })),
     '<p>a <youtube>Cat in a box</youtube>\n<br> a</p>',
     '<p>a <youtube>Cat in a box</youtube>\n<br> a</p>',
@@ -1051,6 +1098,26 @@ function youtube(d) {
   this.tag('</iframe>');
   this.tag('</iframe>');
 }
 }
 
 
+/** @type {Handle} */
+function lsx(d) {
+  const attrs = d.attributes || {};
+
+  const props = [];
+
+  // eslint-disable-next-line no-restricted-syntax
+  for (const key in attrs) {
+    if (attrs[key].length === 0) {
+      props.push(`prefix="${key}"`);
+    }
+    else {
+      props.push(`${key}="${attrs[key]}"`);
+    }
+  }
+
+  this.tag(`<lsx ${props.join(' ')}>`);
+  this.tag('</lsx>');
+}
+
 /** @type {Handle} */
 /** @type {Handle} */
 function h(d) {
 function h(d) {
   const content = d.content || d.label;
   const content = d.content || d.label;