|
@@ -6,7 +6,7 @@ import {
|
|
|
apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
|
|
apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
|
|
|
} from '~/client/util/apiv3-client';
|
|
} from '~/client/util/apiv3-client';
|
|
|
import { toastSuccess, toastError } from '~/client/util/toastr';
|
|
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 => {
|
|
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.
|
|
* TODO: support managing multiple access tokens.
|
|
@@ -93,86 +209,106 @@ const ApiTokenSettings = React.memo((): JSX.Element => {
|
|
|
const AccessTokenSettings = React.memo((): JSX.Element => {
|
|
const AccessTokenSettings = React.memo((): JSX.Element => {
|
|
|
|
|
|
|
|
const { t } = useTranslation();
|
|
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 {
|
|
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' }));
|
|
toastSuccess(t('toaster.update_successed', { target: t('page_me_access_token.access_token'), ns: 'commons' }));
|
|
|
}
|
|
}
|
|
|
catch (err) {
|
|
catch (err) {
|
|
|
toastError(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 (
|
|
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>
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
- <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>
|
|
</div>
|
|
|
</>
|
|
</>
|
|
|
);
|
|
);
|