Przeglądaj źródła

add access token management functionality with form and API integration

reiji-h 1 rok temu
rodzic
commit
6b4ee25593

+ 201 - 65
apps/app/src/client/components/Me/ApiSettings.tsx

@@ -6,7 +6,7 @@ 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 { usePersonalSettings, useSWRxPersonalSettings, useSWRxAccessToken } from '~/stores/personal-settings';
 
 
 const ApiTokenSettings = React.memo((): JSX.Element => {
@@ -86,6 +86,122 @@ const ApiTokenSettings = React.memo((): JSX.Element => {
 
 });
 
+type AccessTokenFormProps = {
+  submitHandler: (info: {
+    expiredAt: Date,
+    scope: string[],
+    description: string,
+  }) => Promise<void>;
+}
+
+const AccessTokenForm = React.memo((props: AccessTokenFormProps): JSX.Element => {
+  const { submitHandler } = props;
+  const { t } = useTranslation();
+  const [expiredAt, setExpiredAt] = React.useState<string>('');
+  const [description, setDescription] = React.useState<string>('');
+  const [scope, setScope] = React.useState<string[]>(['read']); // Default scope
+
+  const descriptionCharsLeft = 200 - description.length;
+  const isDescriptionValid = description.length > 0 && description.length <= 200;
+  const isFormValid = expiredAt && isDescriptionValid;
+
+  // Get current date in YYYY-MM-DD format for min attribute
+  const today = new Date().toISOString().split('T')[0];
+
+  // Calculate date 1 year from now for default expiration
+  const oneYearFromNow = new Date();
+  oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1);
+  const defaultExpiry = oneYearFromNow.toISOString().split('T')[0];
+
+  // Set default expiry date when component mounts
+  React.useEffect(() => {
+    setExpiredAt(defaultExpiry);
+  }, [defaultExpiry]);
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+
+    // Convert the date string to a Date object
+    const expiredAtDate = new Date(expiredAt);
+
+    // Call the parent's submitHandler
+    submitHandler({
+      expiredAt: expiredAtDate,
+      description,
+      scope,
+    });
+  };
+
+  return (
+    <div className="card mt-3 mb-4">
+      <div className="card-header">{t('Create New Access Token')}</div>
+      <div className="card-body">
+        <form onSubmit={handleSubmit}>
+          <div className="mb-3">
+            <label htmlFor="expiredAt" className="form-label">{t('Expiration Date')}</label>
+            <input
+              type="date"
+              className="form-control"
+              id="expiredAt"
+              value={expiredAt}
+              min={today}
+              onChange={e => setExpiredAt(e.target.value)}
+              required
+            />
+            <div className="form-text">{t('Select when this access token should expire')}</div>
+          </div>
+
+          <div className="mb-3">
+            <label htmlFor="description" className="form-label">{t('Description')}</label>
+            <textarea
+              className={`form-control ${!isDescriptionValid && description.length > 0 ? 'is-invalid' : ''}`}
+              id="description"
+              value={description}
+              onChange={e => setDescription(e.target.value)}
+              maxLength={200}
+              rows={3}
+              required
+            />
+            <div className={`form-text d-flex justify-content-end ${descriptionCharsLeft < 20 ? 'text-warning' : ''}`}>
+              {descriptionCharsLeft} {t('characters left')}
+            </div>
+            <div className="form-text">{t('Provide a description to help you identify this token later')}</div>
+          </div>
+
+          <div className="mb-3">
+            <label htmlFor="scope" className="form-label">{t('Scope')}</label>
+            <div className="form-text mb-2">{t('(TODO: Implement scope selection)')}</div>
+            <div className="form-check">
+              <input
+                className="form-check-input"
+                type="checkbox"
+                id="readScope"
+                checked={scope.includes('read')}
+                onChange={() => {
+                  // Placeholder for future implementation
+                  // This would toggle the 'read' scope
+                }}
+                disabled
+              />
+              <label className="form-check-label" htmlFor="readScope">
+                {t('Read')}
+              </label>
+            </div>
+          </div>
+
+          <button
+            type="submit"
+            className="btn btn-primary"
+            disabled={!isFormValid}
+            data-testid="create-access-token-button"
+          >
+            {t('Create Token')}
+          </button>
+        </form>
+      </div>
+    </div>
+  );
+});
 
 /**
  * TODO: support managing multiple access tokens.
@@ -93,86 +209,106 @@ const ApiTokenSettings = React.memo((): JSX.Element => {
 const AccessTokenSettings = React.memo((): JSX.Element => {
 
   const { t } = useTranslation();
-  const [accessToken, setAccessToken] = React.useState<string | null>(null);
 
-  const submitHandler = useCallback(async() => {
+  const [isOpen, setIsOpen] = React.useState<boolean>(false);
+  const toggleOpen = useCallback(() => {
+    setIsOpen(prev => !prev);
+  }, []);
 
+  const {
+    data: accessTokens, mutate, generateAccessToken, deleteAccessToken, deleteAllAccessTokens,
+  } = useSWRxAccessToken();
+
+
+  // TODO: model で共通化
+  type GenerateAccessTokenInfo = {
+    expiredAt: Date,
+    scope: string[],
+    description: string,
+  }
+  const submitHandler = useCallback(async(info: GenerateAccessTokenInfo) => {
     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);
+      await generateAccessToken(info);
+      mutate();
+      setIsOpen(false); // Close form after successful submission
 
       toastSuccess(t('toaster.update_successed', { target: t('page_me_access_token.access_token'), ns: 'commons' }));
     }
     catch (err) {
       toastError(err);
     }
+  }, [t, generateAccessToken, mutate, setIsOpen]);
 
-  }, [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();
-  }, []);
+  const deleteHandler = useCallback(async(tokenId: string) => {
+    try {
+      await deleteAccessToken(tokenId);
+      mutate();
+      toastSuccess(t('toaster.remove_access_token_success', { ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [deleteAccessToken, mutate, t]);
 
   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 className="container p-0">
+        <div className="table-responsive">
+          <table className="table table-bordered">
+            <thead>
+              <tr>
+                <th></th>
+                <th>description</th>
+                <th>expiredAt</th>
+                <th>scope</th>
+                <th>action</th>
+              </tr>
+            </thead>
+            <tbody>
+              {(accessTokens == null || accessTokens.length === 0)
+                ? (
+                  <tr>
+                    <td colSpan={5} className="text-center">
+                      {t('No access tokens found')}
+                    </td>
+                  </tr>
+                )
+                : (
+                  <>{
+                    accessTokens.map(token => (
+                      <tr key={token._id}>
+                        <td>{token._id.substring(0, 10)}</td>
+                        <td>{token.description}</td>
+                        <td>{token.expiredAt.toString()}</td>
+                        <td>{token.scope.join(', ')}</td>
+                        <td>
+                          <button
+                            className="btn btn-danger"
+                            type="button"
+                            onClick={() => deleteHandler(token._id)}
+                          >
+                            {t('Delete')}
+                          </button>
+                        </td>
+                      </tr>
+                    ))
+                  }
+                  </>
+                )}
+            </tbody>
+          </table>
         </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>
+        <button
+          className="btn btn-outline-secondary d-block mx-auto px-5"
+          type="button"
+          onClick={toggleOpen}
+          data-testid="btn-sharelink-toggleform"
+        >
+          {isOpen ? t('Close') : t('New')}
+        </button>
+        {isOpen && <AccessTokenForm submitHandler={submitHandler} />}
 
-        </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>
     </>
   );

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

@@ -7,7 +7,9 @@ import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provi
 import { useIsGuestUser } from '~/stores-universal/context';
 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';
 
@@ -110,3 +112,49 @@ export const useSWRxPersonalExternalAccounts = (): SWRResponse<(IExternalAccount
     endpoint => apiv3Get(endpoint).then(response => response.data.externalAccounts),
   );
 };
+
+
+type AccessTokenInfo = {
+  expiredAt: Date,
+  scope: string[],
+  description: string,
+}
+
+type AccessTokenResult = AccessTokenInfo & {
+  _id: string,
+}
+
+type GeneratedAccessToken = AccessTokenResult &{
+  token: string,
+}
+interface IAccessTokenOption {
+  generateAccessToken: (info: AccessTokenInfo) => Promise<GeneratedAccessToken>,
+  deleteAccessToken: (tokenId: string) => Promise<void>,
+  deleteAllAccessTokens: (userId: string) => Promise<void>,
+}
+
+export const useSWRxAccessToken = (): SWRResponse< AccessTokenResult[] | null, Error> & IAccessTokenOption => {
+  const generateAccessToken = async(info) => {
+    const res = await apiv3Post('/personal-setting/access-token', info);
+    return res.data;
+  };
+  const deleteAccessToken = async(tokenId: string) => {
+    await apiv3Delete('/personal-setting/access-token', { tokenId });
+  };
+  const deleteAllAccessTokens = 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,
+  };
+
+};