feat(react/settings): port etapi tokens

This commit is contained in:
Elian Doran 2025-08-15 12:00:11 +03:00
parent 68086ec3f1
commit c9dcbef014
No known key found for this signature in database
7 changed files with 165 additions and 171 deletions

View File

@ -40,6 +40,7 @@ import ImageSettings from "./options/images.jsx";
import AdvancedSettings from "./options/advanced.jsx"; import AdvancedSettings from "./options/advanced.jsx";
import InternationalizationOptions from "./options/i18n.jsx"; import InternationalizationOptions from "./options/i18n.jsx";
import SyncOptions from "./options/sync.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"> const TPL = /*html*/`<div class="note-detail-content-widget note-detail-printable">
<style> <style>
@ -96,9 +97,7 @@ const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", ((typeof NoteContextA
ProtectedSessionTimeoutOptions ProtectedSessionTimeoutOptions
], ],
_optionsMFA: [MultiFactorAuthenticationOptions], _optionsMFA: [MultiFactorAuthenticationOptions],
_optionsEtapi: [ _optionsEtapi: <EtapiSettings />,
EtapiOptions
],
_optionsBackup: [ _optionsBackup: [
BackupOptions BackupOptions
], ],

View File

@ -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();
}
}

View 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>
)
}

View File

@ -86,7 +86,7 @@ export function SyncTest() {
if (result.success) { if (result.success) {
toast.showMessage(result.message); toast.showMessage(result.message);
} else { } else {
toast.showError(t("sync_2.handshake_failed", { message: result.error })); toast.showError(t("sync_2.handshake_failed", { message: result.message }));
} }
}} }}
/> />

View File

@ -1,16 +1,17 @@
import type { Request } from "express"; import type { Request } from "express";
import etapiTokenService from "../../services/etapi_tokens.js"; import etapiTokenService from "../../services/etapi_tokens.js";
import { EtapiToken, PostTokensResponse } from "@triliumnext/commons";
function getTokens() { function getTokens() {
const tokens = etapiTokenService.getTokens(); const tokens = etapiTokenService.getTokens();
tokens.sort((a, b) => (a.utcDateCreated < b.utcDateCreated ? -1 : 1)); tokens.sort((a, b) => (a.utcDateCreated < b.utcDateCreated ? -1 : 1));
return tokens; return tokens satisfies EtapiToken[];
} }
function createToken(req: Request) { function createToken(req: Request) {
return etapiTokenService.createToken(req.body.tokenName); return etapiTokenService.createToken(req.body.tokenName) satisfies PostTokensResponse;
} }
function patchToken(req: Request) { function patchToken(req: Request) {

View File

@ -35,7 +35,7 @@ async function testSync(): Promise<SyncTestResponse> {
const [errMessage] = safeExtractMessageAndStackFromError(e); const [errMessage] = safeExtractMessageAndStackFromError(e);
return { return {
success: false, success: false,
error: errMessage message: errMessage
}; };
} }
} }

View File

@ -1,5 +1,13 @@
import { AttributeRow, NoteType } from "./rows.js"; import { AttributeRow, NoteType } from "./rows.js";
type Response = {
success: true,
message: string;
} | {
success: false;
message: string;
}
export interface AppInfo { export interface AppInfo {
appVersion: string; appVersion: string;
dbVersion: number; dbVersion: number;
@ -78,10 +86,14 @@ export interface AnonymizedDbResponse {
fileName: string; fileName: string;
} }
export type SyncTestResponse = { export type SyncTestResponse = Response;
success: true;
message: string; export interface EtapiToken {
} | { name: string;
success: false; utcDateCreated: string;
error: string; etapiTokenId?: string;
}; }
export interface PostTokensResponse {
authToken: string;
}