feat(admin): centralize tool & module settings in Werkzeuge tab with per-tool permissions, DB-backed config, connection tests, and cog-button navigation

This commit is contained in:
Matthias Hochmeister
2026-04-17 08:37:29 +02:00
parent 6ead698294
commit 6614fbaa68
28 changed files with 2472 additions and 1426 deletions

View File

@@ -1,6 +1,6 @@
import axios from 'axios';
import httpClient from '../config/httpClient';
import environment from '../config/environment';
import toolConfigService, { BookstackConfig } from './toolConfig.service';
import logger from '../utils/logger';
export interface BookStackPage {
@@ -74,10 +74,9 @@ function isValidServiceUrl(raw: string): boolean {
return true;
}
function buildHeaders(): Record<string, string> {
const { bookstack } = environment;
function buildHeaders(config: BookstackConfig): Record<string, string> {
return {
'Authorization': `Token ${bookstack.tokenId}:${bookstack.tokenSecret}`,
'Authorization': `Token ${config.tokenId}:${config.tokenSecret}`,
'Content-Type': 'application/json',
};
}
@@ -95,11 +94,11 @@ async function getBookSlugMap(): Promise<Map<number, string>> {
if (bookSlugMapCache && Date.now() < bookSlugMapCache.expiresAt) {
return bookSlugMapCache.map;
}
const { bookstack } = environment;
const config = await toolConfigService.getBookstackConfig();
try {
const response = await httpClient.get(
`${bookstack.url}/api/books`,
{ params: { count: 500 }, headers: buildHeaders() },
`${config.url}/api/books`,
{ params: { count: 500 }, headers: buildHeaders(config) },
);
const books: Array<{ id: number; slug: string }> = response.data?.data ?? [];
const map = new Map(books.map((b) => [b.id, b.slug]));
@@ -111,18 +110,18 @@ async function getBookSlugMap(): Promise<Map<number, string>> {
}
async function getRecentPages(): Promise<BookStackPage[]> {
const { bookstack } = environment;
if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
const config = await toolConfigService.getBookstackConfig();
if (!config.url || !isValidServiceUrl(config.url)) {
throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL');
}
try {
const [response, bookSlugMap] = await Promise.all([
httpClient.get(
`${bookstack.url}/api/pages`,
`${config.url}/api/pages`,
{
params: { sort: '-updated_at', count: 20 },
headers: buildHeaders(),
headers: buildHeaders(config),
},
),
getBookSlugMap(),
@@ -130,7 +129,7 @@ async function getRecentPages(): Promise<BookStackPage[]> {
const pages: BookStackPage[] = response.data?.data ?? [];
return pages.map((p) => ({
...p,
url: `${bookstack.url}/books/${bookSlugMap.get(p.book_id) || p.book_slug || p.book_id}/page/${p.slug}`,
url: `${config.url}/books/${bookSlugMap.get(p.book_id) || p.book_slug || p.book_id}/page/${p.slug}`,
}));
} catch (error) {
if (axios.isAxiosError(error)) {
@@ -145,17 +144,17 @@ async function getRecentPages(): Promise<BookStackPage[]> {
}
async function searchPages(query: string): Promise<BookStackSearchResult[]> {
const { bookstack } = environment;
if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
const config = await toolConfigService.getBookstackConfig();
if (!config.url || !isValidServiceUrl(config.url)) {
throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL');
}
try {
const response = await httpClient.get(
`${bookstack.url}/api/search`,
`${config.url}/api/search`,
{
params: { query, count: 50 },
headers: buildHeaders(),
headers: buildHeaders(config),
},
);
const bookSlugMap = await getBookSlugMap();
@@ -167,7 +166,7 @@ async function searchPages(query: string): Promise<BookStackSearchResult[]> {
slug: item.slug,
book_id: item.book_id ?? 0,
book_slug: item.book_slug ?? '',
url: `${bookstack.url}/books/${bookSlugMap.get(item.book_id) || item.book_slug || item.book_id}/page/${item.slug}`,
url: `${config.url}/books/${bookSlugMap.get(item.book_id) || item.book_slug || item.book_id}/page/${item.slug}`,
preview_html: item.preview_html ?? { content: '' },
tags: item.tags ?? [],
}));
@@ -201,16 +200,16 @@ export interface BookStackPageDetail {
}
async function getPageById(id: number): Promise<BookStackPageDetail> {
const { bookstack } = environment;
if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
const config = await toolConfigService.getBookstackConfig();
if (!config.url || !isValidServiceUrl(config.url)) {
throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL');
}
try {
const [response, bookSlugMap] = await Promise.all([
httpClient.get(
`${bookstack.url}/api/pages/${id}`,
{ headers: buildHeaders() },
`${config.url}/api/pages/${id}`,
{ headers: buildHeaders(config) },
),
getBookSlugMap(),
]);
@@ -226,7 +225,7 @@ async function getPageById(id: number): Promise<BookStackPageDetail> {
html: page.html ?? '',
created_at: page.created_at,
updated_at: page.updated_at,
url: `${bookstack.url}/books/${bookSlug}/page/${page.slug}`,
url: `${config.url}/books/${bookSlug}/page/${page.slug}`,
book: page.book,
createdBy: page.created_by,
updatedBy: page.updated_by,

View File

@@ -1,6 +1,6 @@
import axios from 'axios';
import httpClient from '../config/httpClient';
import environment from '../config/environment';
import toolConfigService from './toolConfig.service';
import logger from '../utils/logger';
interface NextcloudLastMessage {
@@ -77,7 +77,7 @@ function isValidServiceUrl(raw: string): boolean {
}
async function initiateLoginFlow(): Promise<LoginFlowResult> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -145,7 +145,7 @@ interface NextcloudChatMessage {
}
async function getAllConversations(loginName: string, appPassword: string): Promise<NextcloudConversation[]> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -206,7 +206,7 @@ interface GetMessagesOptions {
}
async function getMessages(token: string, loginName: string, appPassword: string, options?: GetMessagesOptions): Promise<NextcloudChatMessage[]> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -294,7 +294,7 @@ async function getMessages(token: string, loginName: string, appPassword: string
}
async function sendMessage(token: string, message: string, loginName: string, appPassword: string, replyTo?: number): Promise<void> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -331,7 +331,7 @@ async function sendMessage(token: string, message: string, loginName: string, ap
}
async function markAsRead(token: string, loginName: string, appPassword: string): Promise<void> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -368,7 +368,7 @@ async function markAsRead(token: string, loginName: string, appPassword: string)
}
async function getConversations(loginName: string, appPassword: string): Promise<ConversationsResult> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -441,7 +441,7 @@ async function uploadFileToTalk(
loginName: string,
appPassword: string,
): Promise<void> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -503,7 +503,7 @@ async function downloadFile(
loginName: string,
appPassword: string,
): Promise<any> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -544,7 +544,7 @@ async function getFilePreview(
loginName: string,
appPassword: string,
): Promise<any> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -580,7 +580,7 @@ async function getFilePreview(
}
async function searchUsers(query: string, loginName: string, appPassword: string): Promise<any[]> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -616,7 +616,7 @@ async function searchUsers(query: string, loginName: string, appPassword: string
}
async function createRoom(roomType: number, invite: string, roomName: string | undefined, loginName: string, appPassword: string): Promise<{ token: string }> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -654,7 +654,7 @@ async function createRoom(roomType: number, invite: string, roomName: string | u
}
async function addReaction(token: string, messageId: number, reaction: string, loginName: string, appPassword: string): Promise<void> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -691,7 +691,7 @@ async function addReaction(token: string, messageId: number, reaction: string, l
}
async function removeReaction(token: string, messageId: number, reaction: string, loginName: string, appPassword: string): Promise<void> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -727,7 +727,7 @@ async function removeReaction(token: string, messageId: number, reaction: string
}
async function getReactions(token: string, messageId: number, loginName: string, appPassword: string): Promise<Record<string, any>> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -763,7 +763,7 @@ async function getReactions(token: string, messageId: number, loginName: string,
}
async function getPollDetails(token: string, pollId: number, loginName: string, appPassword: string): Promise<Record<string, any>> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}

View File

@@ -0,0 +1,92 @@
import settingsService from './settings.service';
import environment from '../config/environment';
export interface BookstackConfig {
url: string;
tokenId: string;
tokenSecret: string;
}
export interface VikunjaConfig {
url: string;
apiToken: string;
}
export interface NextcloudConfig {
url: string;
}
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
const CACHE_TTL_MS = 60_000; // 60 seconds
let bookstackCache: CacheEntry<BookstackConfig> | null = null;
let vikunjaCache: CacheEntry<VikunjaConfig> | null = null;
let nextcloudCache: CacheEntry<NextcloudConfig> | null = null;
async function getDbConfig(key: string): Promise<Record<string, string>> {
const setting = await settingsService.get(key);
if (!setting?.value || typeof setting.value !== 'object') return {};
return setting.value as Record<string, string>;
}
async function getBookstackConfig(): Promise<BookstackConfig> {
if (bookstackCache && Date.now() < bookstackCache.expiresAt) {
return bookstackCache.data;
}
const db = await getDbConfig('tool_config_bookstack');
const config: BookstackConfig = {
url: db.url || environment.bookstack.url,
tokenId: db.tokenId || environment.bookstack.tokenId,
tokenSecret: db.tokenSecret || environment.bookstack.tokenSecret,
};
bookstackCache = { data: config, expiresAt: Date.now() + CACHE_TTL_MS };
return config;
}
async function getVikunjaConfig(): Promise<VikunjaConfig> {
if (vikunjaCache && Date.now() < vikunjaCache.expiresAt) {
return vikunjaCache.data;
}
const db = await getDbConfig('tool_config_vikunja');
const config: VikunjaConfig = {
url: db.url || environment.vikunja.url,
apiToken: db.apiToken || environment.vikunja.apiToken,
};
vikunjaCache = { data: config, expiresAt: Date.now() + CACHE_TTL_MS };
return config;
}
async function getNextcloudConfig(): Promise<NextcloudConfig> {
if (nextcloudCache && Date.now() < nextcloudCache.expiresAt) {
return nextcloudCache.data;
}
const db = await getDbConfig('tool_config_nextcloud');
const config: NextcloudConfig = {
url: db.url || environment.nextcloudUrl,
};
nextcloudCache = { data: config, expiresAt: Date.now() + CACHE_TTL_MS };
return config;
}
function clearCache(): void {
bookstackCache = null;
vikunjaCache = null;
nextcloudCache = null;
}
export default {
getBookstackConfig,
getVikunjaConfig,
getNextcloudConfig,
clearCache,
};

View File

@@ -1,6 +1,6 @@
import axios from 'axios';
import httpClient from '../config/httpClient';
import environment from '../config/environment';
import toolConfigService, { VikunjaConfig } from './toolConfig.service';
import logger from '../utils/logger';
export interface VikunjaTask {
@@ -58,23 +58,23 @@ function isValidServiceUrl(raw: string): boolean {
return true;
}
function buildHeaders(): Record<string, string> {
function buildHeaders(config: VikunjaConfig): Record<string, string> {
return {
'Authorization': `Bearer ${environment.vikunja.apiToken}`,
'Authorization': `Bearer ${config.apiToken}`,
'Content-Type': 'application/json',
};
}
async function getMyTasks(): Promise<VikunjaTask[]> {
const { vikunja } = environment;
if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
const config = await toolConfigService.getVikunjaConfig();
if (!config.url || !isValidServiceUrl(config.url)) {
throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
}
try {
const response = await httpClient.get<VikunjaTask[]>(
`${vikunja.url}/api/v1/tasks/all`,
{ headers: buildHeaders() },
`${config.url}/api/v1/tasks/all`,
{ headers: buildHeaders(config) },
);
return (response.data ?? []).filter((t) => !t.done);
} catch (error) {
@@ -99,15 +99,15 @@ async function getOverdueTasks(): Promise<VikunjaTask[]> {
}
async function getProjects(): Promise<VikunjaProject[]> {
const { vikunja } = environment;
if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
const config = await toolConfigService.getVikunjaConfig();
if (!config.url || !isValidServiceUrl(config.url)) {
throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
}
try {
const response = await httpClient.get<VikunjaProject[]>(
`${vikunja.url}/api/v1/projects`,
{ headers: buildHeaders() },
`${config.url}/api/v1/projects`,
{ headers: buildHeaders(config) },
);
return response.data ?? [];
} catch (error) {
@@ -123,8 +123,8 @@ async function getProjects(): Promise<VikunjaProject[]> {
}
async function createTask(projectId: number, title: string, dueDate?: string): Promise<VikunjaTask> {
const { vikunja } = environment;
if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
const config = await toolConfigService.getVikunjaConfig();
if (!config.url || !isValidServiceUrl(config.url)) {
throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
}
@@ -134,9 +134,9 @@ async function createTask(projectId: number, title: string, dueDate?: string): P
body.due_date = dueDate;
}
const response = await httpClient.put<VikunjaTask>(
`${vikunja.url}/api/v1/projects/${projectId}/tasks`,
`${config.url}/api/v1/projects/${projectId}/tasks`,
body,
{ headers: buildHeaders() },
{ headers: buildHeaders(config) },
);
return response.data;
} catch (error) {