فهرست منبع

add AccessTokenList and AccessTokenForm components for managing access tokens

reiji-h 1 سال پیش
والد
کامیت
15bcf7ab22

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

@@ -0,0 +1,103 @@
+import type { FormEventHandler } from 'react';
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+const MAX_DESCRIPTION_LENGTH = 200;
+
+type AccessTokenFormProps = {
+  submitHandler: (info: {
+    expiredAt: Date,
+    scope: string[],
+    description: string,
+  }) => Promise<void>;
+}
+
+export const AccessTokenForm = React.memo((props: AccessTokenFormProps): JSX.Element => {
+  const { submitHandler } = props;
+  const { t } = useTranslation();
+
+  const defaultExpiredAt = new Date();
+  defaultExpiredAt.setMonth(defaultExpiredAt.getMonth() + 1);
+
+  const handleSubmit: FormEventHandler<HTMLFormElement> = (e) => {
+    e.preventDefault();
+    const form = new FormData(e.currentTarget);
+    const expiredAtDate = new Date(form.get('expiredAt') as string);
+    const description = form.get('description') as string;
+    const scope = []; // form.getAll('scope') as string[];
+    console.log(expiredAtDate, description, scope);
+
+    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>
+            <div className="row">
+              <div className="col-16 col-sm-4 col-md-4 col-lg-3">
+                <div className="input-group">
+                  <input
+                    type="date"
+                    className="form-control"
+                    name="expiredAt"
+                    min={new Date().toISOString().split('T')[0]}
+                    required
+                    defaultValue={defaultExpiredAt.toISOString().split('T')[0]}
+                  />
+                </div>
+              </div>
+            </div>
+            <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"
+              name="description"
+              maxLength={MAX_DESCRIPTION_LENGTH}
+              rows={3}
+              required
+              defaultValue=""
+            />
+            <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">(TODO: Implement scope selection)</div>
+            <div className="form-check">
+              <input
+                className="form-check-input"
+                type="checkbox"
+                name="readScope"
+                disabled
+              />
+              <label className="form-check-label" htmlFor="readScope">
+                {t('Read')}
+              </label>
+            </div>
+          </div>
+
+          <button
+            type="submit"
+            className="btn btn-primary"
+            data-testid="create-access-token-button"
+          >
+            {t('Create Token')}
+          </button>
+        </form>
+      </div>
+    </div>
+  );
+});
+AccessTokenForm.displayName = 'AccessTokenForm';

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

@@ -0,0 +1,63 @@
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+
+// TODO: add types for accessTokens
+type AccessTokenListProps = {
+  accessTokens: any[];
+  deleteHandler: (tokenId: string) => void;
+}
+export const AccessTokenList = React.memo((props: AccessTokenListProps): JSX.Element => {
+
+  const { t } = useTranslation();
+  const { accessTokens, deleteHandler } = props;
+
+  return (
+
+    <div className="table-responsive">
+      <table className="table table-bordered">
+        <thead>
+          <tr>
+            <th>description</th>
+            <th>expiredAt</th>
+            <th>scope</th>
+            <th>action</th>
+          </tr>
+        </thead>
+        <tbody>
+          {(accessTokens.length === 0)
+            ? (
+              <tr>
+                <td colSpan={4} className="text-center">
+                  {t('No access tokens found')}
+                </td>
+              </tr>
+            )
+            : (
+              <>{
+                accessTokens.map(token => (
+                  <tr key={token._id}>
+                    <td>{token.description}</td>
+                    {/* <td>{token.expiredAt.toISOString().split('T')[0]}</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>
+  );
+});

+ 53 - 213
apps/app/src/client/components/Me/AccessTokenSettings.tsx

@@ -4,159 +4,81 @@ import { useTranslation } from 'next-i18next';
 import CopyToClipboard from 'react-copy-to-clipboard';
 
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { AccessToken } from '~/interfaces/access-token';
 import { useSWRxAccessToken } from '~/stores/personal-settings';
 
+import { AccessTokenForm } from './AccessTokenForm';
+import { AccessTokenList } from './AccessTokenList';
 
-type AccessTokenFormProps = {
-  submitHandler: (info: {
-    expiredAt: Date,
-    scope: string[],
-    description: string,
-  }) => Promise<void>;
-}
+const NewTokenDisplay = React.memo(({ newToken, closeNewTokenDisplay }: { newToken?: string, closeNewTokenDisplay: () => void }): JSX.Element => {
 
-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);
+  // Handle successful copy
+  const handleCopySuccess = useCallback(() => {
+    toastSuccess(t('Copied to clipboard'));
+  }, [t]);
 
-    // Call the parent's submitHandler
-    submitHandler({
-      expiredAt: expiredAtDate,
-      description,
-      scope,
-    });
-  };
+  if (newToken == null) {
+    return <></>;
+  }
 
   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>
-            <div className="row">
-              <div className="col-16 col-sm-4 col-md-4 col-lg-3">
-                <div className="input-group">
-                  <input
-                    type="date"
-                    className="form-control"
-                    id="expiredAt"
-                    value={expiredAt}
-                    min={today}
-                    onChange={e => setExpiredAt(e.target.value)}
-                    required
-                  />
-                </div>
-              </div>
-            </div>
-            <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>
+    <div className="alert alert-success mb-4" role="alert">
+      <div className="d-flex justify-content-between align-items-center mb-2">
+        <h5 className="mb-0">
+          {t('New Access Token')}
+        </h5>
+        <button
+          type="button"
+          className="btn-close"
+          onClick={closeNewTokenDisplay}
+          aria-label="Close"
+        >
+        </button>
+      </div>
 
+      <p className="fw-bold mb-2">{t('This token will only be displayed once. Please save it securely.')}</p>
+
+      <div className="input-group mb-2">
+        <input
+          type="text"
+          className="form-control font-monospace"
+          value={newToken}
+          readOnly
+          data-vrt-blackout
+        />
+        <CopyToClipboard text={newToken} onCopy={handleCopySuccess}>
           <button
-            type="submit"
-            className="btn btn-primary"
-            disabled={!isFormValid}
-            data-testid="create-access-token-button"
+            className="btn btn-outline-secondary"
+            type="button"
           >
-            {t('Create Token')}
+            <span className="material-symbols-outlined">content_copy</span>
           </button>
-        </form>
+        </CopyToClipboard>
       </div>
     </div>
   );
 });
 
-
 export const AccessTokenSettings = React.memo((): JSX.Element => {
 
   const { t } = useTranslation();
 
-  const [isOpen, setIsOpen] = React.useState<boolean>(false);
-  const toggleOpen = useCallback(() => {
-    setIsOpen(prev => !prev);
+  const [isFormOpen, setIsFormOpen] = React.useState<boolean>(false);
+  const toggleFormOpen = useCallback(() => {
+    setIsFormOpen(prev => !prev);
   }, []);
 
-  // State to store the newly generated token
-  const [newToken, setNewToken] = React.useState<string | null>(null);
+  const [newToken, setNewToken] = React.useState<string | undefined>(undefined);
 
   const {
-    data: accessTokens, mutate, generateAccessToken, deleteAccessToken, deleteAllAccessTokens,
+    data: accessTokens, mutate, generateAccessToken, deleteAccessToken,
   } = useSWRxAccessToken();
 
-  // Function to hide the token display
-  const closeTokenDisplay = useCallback(() => {
-    setNewToken(null);
+  const closeNewTokenDisplay = useCallback(() => {
+    setNewToken(undefined);
   }, []);
 
-  // Handle successful copy
-  const handleCopySuccess = useCallback(() => {
-    toastSuccess(t('Copied to clipboard'));
-  }, [t]);
 
   // TODO: model で共通化
   type GenerateAccessTokenInfo = {
@@ -168,7 +90,7 @@ export const AccessTokenSettings = React.memo((): JSX.Element => {
     try {
       const result = await generateAccessToken(info);
       mutate();
-      setIsOpen(false); // Close form after successful submission
+      setIsFormOpen(false);
 
       // Store the newly generated token to display to the user
       if (result?.token) {
@@ -180,7 +102,7 @@ export const AccessTokenSettings = React.memo((): JSX.Element => {
     catch (err) {
       toastError(err);
     }
-  }, [t, generateAccessToken, mutate, setIsOpen]);
+  }, [t, generateAccessToken, mutate, setIsFormOpen]);
 
   const deleteHandler = useCallback(async(tokenId: string) => {
     try {
@@ -197,101 +119,19 @@ export const AccessTokenSettings = React.memo((): JSX.Element => {
     <>
 
       <div className="container p-0">
-        {/* Token Display Area (non-modal) */}
-        {newToken && (
-          <div className="alert alert-warning mb-4" role="alert">
-            <div className="d-flex justify-content-between align-items-center mb-2">
-              <h5 className="mb-0">
-                <i className="fa fa-exclamation-triangle me-2" aria-hidden="true"></i>
-                {t('New Access Token')}
-              </h5>
-              <button
-                type="button"
-                className="btn-close"
-                onClick={closeTokenDisplay}
-                aria-label="Close"
-              >
-              </button>
-            </div>
 
-            <p className="fw-bold mb-2">{t('This token will only be displayed once. Please save it securely.')}</p>
-
-            <div className="input-group mb-2">
-              <input
-                type="text"
-                className="form-control font-monospace"
-                value={newToken}
-                readOnly
-                data-vrt-blackout
-              />
-              <CopyToClipboard text={newToken} onCopy={handleCopySuccess}>
-                <button
-                  className="btn btn-outline-secondary"
-                  type="button"
-                >
-                  <span className="material-symbols-outlined">content_copy</span>
-                </button>
-              </CopyToClipboard>
-            </div>
-          </div>
-        )}
-
-        <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>
+        <NewTokenDisplay newToken={newToken} closeNewTokenDisplay={closeNewTokenDisplay} />
+        <AccessTokenList accessTokens={accessTokens ?? []} deleteHandler={deleteHandler} />
 
         <button
           className="btn btn-outline-secondary d-block mx-auto px-5"
           type="button"
-          onClick={toggleOpen}
-          data-testid="btn-sharelink-toggleform"
+          onClick={toggleFormOpen}
+          data-testid="btn-accesstoken-toggleform"
         >
-          {isOpen ? t('Close') : t('New')}
+          {isFormOpen ? t('Close') : t('New')}
         </button>
-        {isOpen && <AccessTokenForm submitHandler={submitHandler} />}
-
+        {isFormOpen && <AccessTokenForm submitHandler={submitHandler} />}
       </div>
     </>
   );