mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 18:36:30 +01:00 
			
		
		
		
	feat(react/settings): port etapi tokens
This commit is contained in:
		| @@ -40,6 +40,7 @@ import ImageSettings from "./options/images.jsx"; | ||||
| import AdvancedSettings from "./options/advanced.jsx"; | ||||
| import InternationalizationOptions from "./options/i18n.jsx"; | ||||
| import SyncOptions from "./options/sync.jsx"; | ||||
| import EtapiSettings from "./options/etapi.js"; | ||||
|  | ||||
| const TPL = /*html*/`<div class="note-detail-content-widget note-detail-printable"> | ||||
|     <style> | ||||
| @@ -96,9 +97,7 @@ const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", ((typeof NoteContextA | ||||
|         ProtectedSessionTimeoutOptions | ||||
|     ], | ||||
|     _optionsMFA: [MultiFactorAuthenticationOptions], | ||||
|     _optionsEtapi: [ | ||||
|         EtapiOptions | ||||
|     ], | ||||
|     _optionsEtapi: <EtapiSettings />, | ||||
|     _optionsBackup: [ | ||||
|         BackupOptions | ||||
|     ], | ||||
|   | ||||
| @@ -1,157 +0,0 @@ | ||||
| import { formatDateTime } from "../../../utils/formatters.js"; | ||||
| import { t } from "../../../services/i18n.js"; | ||||
| import dialogService from "../../../services/dialog.js"; | ||||
| import OptionsWidget from "./options_widget.js"; | ||||
| import server from "../../../services/server.js"; | ||||
| import toastService from "../../../services/toast.js"; | ||||
|  | ||||
| const TPL = /*html*/` | ||||
| <div class="etapi-options-section options-section"> | ||||
|     <h4>${t("etapi.title")}</h4> | ||||
|  | ||||
|     <p class="form-text">${t("etapi.description")} <br/> | ||||
|       ${t("etapi.see_more", { | ||||
|         link_to_wiki: `<a class="tn-link" href="https://triliumnext.github.io/Docs/Wiki/etapi.html">${t("etapi.wiki")}</a>`, | ||||
|         // TODO: We use window.open src/public/app/services/link.ts -> prevents regular click behavior on "a" element here because it's a relative path | ||||
|         link_to_openapi_spec: `<a class="tn-link" onclick="window.open('etapi/etapi.openapi.yaml')" href="etapi/etapi.openapi.yaml">${t("etapi.openapi_spec")}</a>`, | ||||
|         link_to_swagger_ui: `<a class="tn-link" href="#_help_f3xpgx6H01PW">${t("etapi.swagger_ui")}</a>` | ||||
|       })} | ||||
|     </p> | ||||
|  | ||||
|     <button type="button" class="create-etapi-token btn btn-sm"> | ||||
|         <span class="bx bx-plus"></span> | ||||
|         ${t("etapi.create_token")} | ||||
|     </button> | ||||
|  | ||||
|     <hr /> | ||||
|  | ||||
|     <h5>${t("etapi.existing_tokens")}</h5> | ||||
|  | ||||
|     <div class="no-tokens-yet">${t("etapi.no_tokens_yet")}</div> | ||||
|  | ||||
|     <div style="overflow: auto; height: 500px;"> | ||||
|         <table class="tokens-table table table-stripped"> | ||||
|         <thead> | ||||
|             <tr> | ||||
|                 <th>${t("etapi.token_name")}</th> | ||||
|                 <th>${t("etapi.created")}</th> | ||||
|                 <th>${t("etapi.actions")}</th> | ||||
|             </tr> | ||||
|         </thead> | ||||
|         <tbody></tbody> | ||||
|         </table> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <style> | ||||
|     .token-table-button { | ||||
|         display: inline-block; | ||||
|         cursor: pointer; | ||||
|         padding: 3px; | ||||
|         margin-right: 20px; | ||||
|         font-size: large; | ||||
|         border: 1px solid transparent; | ||||
|         border-radius: var(--button-border-radius); | ||||
|     } | ||||
|  | ||||
|     .token-table-button:hover { | ||||
|         border: 1px solid var(--button-border-color); | ||||
|     } | ||||
| </style>`; | ||||
|  | ||||
| // TODO: Deduplicate | ||||
| interface PostTokensResponse { | ||||
|     authToken: string; | ||||
| } | ||||
|  | ||||
| // TODO: Deduplicate | ||||
| interface Token { | ||||
|     name: string; | ||||
|     utcDateCreated: number; | ||||
|     etapiTokenId: string; | ||||
| } | ||||
|  | ||||
| export default class EtapiOptions extends OptionsWidget { | ||||
|  | ||||
|     doRender() { | ||||
|         this.$widget = $(TPL); | ||||
|  | ||||
|         this.$widget.find(".create-etapi-token").on("click", async () => { | ||||
|             const tokenName = await dialogService.prompt({ | ||||
|                 title: t("etapi.new_token_title"), | ||||
|                 message: t("etapi.new_token_message"), | ||||
|                 defaultValue: t("etapi.default_token_name") | ||||
|             }); | ||||
|  | ||||
|             if (!tokenName?.trim()) { | ||||
|                 toastService.showError(t("etapi.error_empty_name")); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             const { authToken } = await server.post<PostTokensResponse>("etapi-tokens", { tokenName }); | ||||
|  | ||||
|             await dialogService.prompt({ | ||||
|                 title: t("etapi.token_created_title"), | ||||
|                 message: t("etapi.token_created_message"), | ||||
|                 defaultValue: authToken | ||||
|             }); | ||||
|  | ||||
|             this.refreshTokens(); | ||||
|         }); | ||||
|  | ||||
|         this.refreshTokens(); | ||||
|     } | ||||
|  | ||||
|     async refreshTokens() { | ||||
|         const $noTokensYet = this.$widget.find(".no-tokens-yet"); | ||||
|         const $tokensTable = this.$widget.find(".tokens-table"); | ||||
|  | ||||
|         const tokens = await server.get<Token[]>("etapi-tokens"); | ||||
|  | ||||
|         $noTokensYet.toggle(tokens.length === 0); | ||||
|         $tokensTable.toggle(tokens.length > 0); | ||||
|  | ||||
|         const $tokensTableBody = $tokensTable.find("tbody"); | ||||
|         $tokensTableBody.empty(); | ||||
|  | ||||
|         for (const token of tokens) { | ||||
|             $tokensTableBody.append( | ||||
|                 $("<tr>") | ||||
|                     .append($("<td>").text(token.name)) | ||||
|                     .append($("<td>").text(formatDateTime(token.utcDateCreated))) | ||||
|                     .append( | ||||
|                         $("<td>").append( | ||||
|                             $(`<span class="bx bx-edit-alt token-table-button icon-action" title="${t("etapi.rename_token")}"></span>`).on("click", () => this.renameToken(token.etapiTokenId, token.name)), | ||||
|                             $(`<span class="bx bx-trash token-table-button icon-action" title="${t("etapi.delete_token")}"></span>`).on("click", () => this.deleteToken(token.etapiTokenId, token.name)) | ||||
|                         ) | ||||
|                     ) | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async renameToken(etapiTokenId: string, oldName: string) { | ||||
|         const tokenName = await dialogService.prompt({ | ||||
|             title: t("etapi.rename_token_title"), | ||||
|             message: t("etapi.rename_token_message"), | ||||
|             defaultValue: oldName | ||||
|         }); | ||||
|  | ||||
|         if (!tokenName?.trim()) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         await server.patch(`etapi-tokens/${etapiTokenId}`, { name: tokenName }); | ||||
|  | ||||
|         this.refreshTokens(); | ||||
|     } | ||||
|  | ||||
|     async deleteToken(etapiTokenId: string, name: string) { | ||||
|         if (!(await dialogService.confirm(t("etapi.delete_token_confirmation", { name })))) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         await server.remove(`etapi-tokens/${etapiTokenId}`); | ||||
|  | ||||
|         this.refreshTokens(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										139
									
								
								apps/client/src/widgets/type_widgets/options/etapi.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								apps/client/src/widgets/type_widgets/options/etapi.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,139 @@ | ||||
| import { useCallback, useEffect, useState } from "preact/hooks"; | ||||
| import { t } from "../../../services/i18n"; | ||||
| import Button from "../../react/Button"; | ||||
| import FormText from "../../react/FormText"; | ||||
| import RawHtml from "../../react/RawHtml"; | ||||
| import OptionsSection from "./components/OptionsSection"; | ||||
| import { EtapiToken, PostTokensResponse } from "@triliumnext/commons"; | ||||
| import server from "../../../services/server"; | ||||
| import toast from "../../../services/toast"; | ||||
| import dialog from "../../../services/dialog"; | ||||
| import { formatDateTime } from "../../../utils/formatters"; | ||||
| import ActionButton from "../../react/ActionButton"; | ||||
|  | ||||
| type RenameTokenCallback = (tokenId: string, oldName: string) => Promise<void>; | ||||
| type DeleteTokenCallback = (tokenId: string, name: string ) => Promise<void>; | ||||
|  | ||||
| export default function EtapiSettings() { | ||||
|     const [ tokens, setTokens ] = useState<EtapiToken[]>([]); | ||||
|  | ||||
|     function refreshTokens() { | ||||
|         server.get<EtapiToken[]>("etapi-tokens").then(setTokens); | ||||
|     } | ||||
|  | ||||
|     useEffect(refreshTokens, []); | ||||
|  | ||||
|     const createTokenCallback = useCallback(async () => { | ||||
|         const tokenName = await dialog.prompt({ | ||||
|             title: t("etapi.new_token_title"), | ||||
|             message: t("etapi.new_token_message"), | ||||
|             defaultValue: t("etapi.default_token_name") | ||||
|         }); | ||||
|  | ||||
|         if (!tokenName?.trim()) { | ||||
|             toast.showError(t("etapi.error_empty_name")); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const { authToken } = await server.post<PostTokensResponse>("etapi-tokens", { tokenName }); | ||||
|  | ||||
|         await dialog.prompt({ | ||||
|             title: t("etapi.token_created_title"), | ||||
|             message: t("etapi.token_created_message"), | ||||
|             defaultValue: authToken | ||||
|         }); | ||||
|  | ||||
|         refreshTokens(); | ||||
|     }, []); | ||||
|  | ||||
|     const renameTokenCallback = useCallback<RenameTokenCallback>(async (tokenId: string, oldName: string) => { | ||||
|         const tokenName = await dialog.prompt({ | ||||
|             title: t("etapi.rename_token_title"), | ||||
|             message: t("etapi.rename_token_message"), | ||||
|             defaultValue: oldName | ||||
|         }); | ||||
|  | ||||
|         if (!tokenName?.trim()) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         await server.patch(`etapi-tokens/${tokenId}`, { name: tokenName }); | ||||
|  | ||||
|         refreshTokens(); | ||||
|     }, []); | ||||
|  | ||||
|     const deleteTokenCallback = useCallback<DeleteTokenCallback>(async (tokenId: string, name: string) => { | ||||
|         if (!(await dialog.confirm(t("etapi.delete_token_confirmation", { name })))) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         await server.remove(`etapi-tokens/${tokenId}`); | ||||
|         refreshTokens(); | ||||
|     }, []); | ||||
|  | ||||
|     return ( | ||||
|         <OptionsSection title={t("etapi.title")}> | ||||
|             <FormText> | ||||
|                 {t("etapi.description")}<br /> | ||||
|                 <RawHtml | ||||
|                     html={t("etapi.see_more", { | ||||
|                         link_to_wiki: `<a class="tn-link" href="https://triliumnext.github.io/Docs/Wiki/etapi.html">${t("etapi.wiki")}</a>`, | ||||
|                         // TODO: We use window.open src/public/app/services/link.ts -> prevents regular click behavior on "a" element here because it's a relative path | ||||
|                         link_to_openapi_spec: `<a class="tn-link" onclick="window.open('etapi/etapi.openapi.yaml')" href="etapi/etapi.openapi.yaml">${t("etapi.openapi_spec")}</a>`, | ||||
|                         link_to_swagger_ui: `<a class="tn-link" href="#_help_f3xpgx6H01PW">${t("etapi.swagger_ui")}</a>` | ||||
|                     })} />                     | ||||
|             </FormText> | ||||
|  | ||||
|             <Button | ||||
|                 size="small" icon="bx bx-plus" | ||||
|                 text={t("etapi.create_token")} | ||||
|                 onClick={createTokenCallback} | ||||
|             /> | ||||
|             <hr /> | ||||
|  | ||||
|             <h5>{t("etapi.existing_tokens")}</h5> | ||||
|             <TokenList tokens={tokens} renameCallback={renameTokenCallback} deleteCallback={deleteTokenCallback} /> | ||||
|         </OptionsSection> | ||||
|     ) | ||||
| } | ||||
|  | ||||
| function TokenList({ tokens, renameCallback, deleteCallback }: { tokens: EtapiToken[], renameCallback: RenameTokenCallback, deleteCallback: DeleteTokenCallback }) { | ||||
|     if (!tokens.length) { | ||||
|         return <div>{t("etapi.no_tokens_yet")}</div>; | ||||
|     }     | ||||
|  | ||||
|     return ( | ||||
|         <div style={{ overflow: "auto", height: "500px"}}> | ||||
|             <table className="table table-stripped"> | ||||
|                 <thead> | ||||
|                     <tr> | ||||
|                         <th>{t("etapi.token_name")}</th> | ||||
|                         <th>{t("etapi.created")}</th> | ||||
|                         <th>{t("etapi.actions")}</th> | ||||
|                     </tr> | ||||
|                 </thead> | ||||
|                 <tbody> | ||||
|                     {tokens.map(({ etapiTokenId, name, utcDateCreated}) => ( | ||||
|                         <tr> | ||||
|                             <td>{name}</td> | ||||
|                             <td>{formatDateTime(utcDateCreated)}</td> | ||||
|                             <td> | ||||
|                                 <ActionButton | ||||
|                                     icon="bx bx-edit-alt" | ||||
|                                     text={t("etapi.rename_token")} | ||||
|                                     onClick={() => renameCallback(etapiTokenId!, name)} | ||||
|                                 /> | ||||
|  | ||||
|                                 <ActionButton | ||||
|                                     icon="bx bx-trash" | ||||
|                                     text={t("etapi.delete_token")} | ||||
|                                     onClick={() => deleteCallback(etapiTokenId!, name)} | ||||
|                                 /> | ||||
|                             </td> | ||||
|                         </tr> | ||||
|                     ))} | ||||
|                 </tbody> | ||||
|             </table> | ||||
|         </div> | ||||
|     ) | ||||
| } | ||||
| @@ -86,7 +86,7 @@ export function SyncTest() { | ||||
|                     if (result.success) { | ||||
|                         toast.showMessage(result.message); | ||||
|                     } else { | ||||
|                         toast.showError(t("sync_2.handshake_failed", { message: result.error })); | ||||
|                         toast.showError(t("sync_2.handshake_failed", { message: result.message })); | ||||
|                     } | ||||
|                 }} | ||||
|             /> | ||||
|   | ||||
| @@ -1,16 +1,17 @@ | ||||
| import type { Request } from "express"; | ||||
| import etapiTokenService from "../../services/etapi_tokens.js"; | ||||
| import { EtapiToken, PostTokensResponse } from "@triliumnext/commons"; | ||||
|  | ||||
| function getTokens() { | ||||
|     const tokens = etapiTokenService.getTokens(); | ||||
|  | ||||
|     tokens.sort((a, b) => (a.utcDateCreated < b.utcDateCreated ? -1 : 1)); | ||||
|  | ||||
|     return tokens; | ||||
|     return tokens satisfies EtapiToken[]; | ||||
| } | ||||
|  | ||||
| function createToken(req: Request) { | ||||
|     return etapiTokenService.createToken(req.body.tokenName); | ||||
|     return etapiTokenService.createToken(req.body.tokenName) satisfies PostTokensResponse; | ||||
| } | ||||
|  | ||||
| function patchToken(req: Request) { | ||||
|   | ||||
| @@ -35,7 +35,7 @@ async function testSync(): Promise<SyncTestResponse> { | ||||
|         const [errMessage] = safeExtractMessageAndStackFromError(e); | ||||
|         return { | ||||
|             success: false, | ||||
|             error: errMessage | ||||
|             message: errMessage | ||||
|         }; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,13 @@ | ||||
| import { AttributeRow, NoteType } from "./rows.js"; | ||||
|  | ||||
| type Response = { | ||||
|     success: true, | ||||
|     message: string; | ||||
| } | { | ||||
|     success: false; | ||||
|     message: string; | ||||
| } | ||||
|  | ||||
| export interface AppInfo { | ||||
|     appVersion: string; | ||||
|     dbVersion: number; | ||||
| @@ -78,10 +86,14 @@ export interface AnonymizedDbResponse { | ||||
|     fileName: string; | ||||
| } | ||||
|  | ||||
| export type SyncTestResponse = { | ||||
|     success: true; | ||||
|     message: string; | ||||
| } | { | ||||
|     success: false; | ||||
|     error: string; | ||||
| }; | ||||
| export type SyncTestResponse = Response; | ||||
|  | ||||
| export interface EtapiToken { | ||||
|     name: string; | ||||
|     utcDateCreated: string; | ||||
|     etapiTokenId?: string; | ||||
| } | ||||
|  | ||||
| export interface PostTokensResponse { | ||||
|     authToken: string; | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user