Compare commits
4 Commits
34f537238e
...
4e49dd18a1
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e49dd18a1 | |||
| 252021acee | |||
| eae321b364 | |||
| 5a2682c835 |
@@ -2,16 +2,67 @@ import { FastifyInstance } from "fastify"
|
|||||||
import fp from "fastify-plugin"
|
import fp from "fastify-plugin"
|
||||||
import jwt from "jsonwebtoken"
|
import jwt from "jsonwebtoken"
|
||||||
import { secrets } from "../utils/secrets"
|
import { secrets } from "../utils/secrets"
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
authUserRoles,
|
authUserRoles,
|
||||||
authRolePermissions,
|
authRolePermissions,
|
||||||
authUsers,
|
authUsers,
|
||||||
|
m2mApiKeys,
|
||||||
} from "../../db/schema"
|
} from "../../db/schema"
|
||||||
|
|
||||||
import { eq, and, inArray } from "drizzle-orm"
|
import { eq, and, inArray } from "drizzle-orm"
|
||||||
|
|
||||||
export default fp(async (server: FastifyInstance) => {
|
export default fp(async (server: FastifyInstance) => {
|
||||||
|
const hashApiKey = (apiKey: string) =>
|
||||||
|
createHash("sha256").update(apiKey, "utf8").digest("hex")
|
||||||
|
|
||||||
|
const isMcpRoute = (url: string) =>
|
||||||
|
url === "/mcp" ||
|
||||||
|
url.startsWith("/mcp/") ||
|
||||||
|
url === "/api/mcp" ||
|
||||||
|
url.startsWith("/api/mcp/")
|
||||||
|
|
||||||
|
const authenticateMcpApiKey = async (apiKey: string) => {
|
||||||
|
if (!apiKey.startsWith("fedeo_mcp_")) return false
|
||||||
|
|
||||||
|
const keyHash = hashApiKey(apiKey)
|
||||||
|
|
||||||
|
const rows = await server.db
|
||||||
|
.select({
|
||||||
|
id: m2mApiKeys.id,
|
||||||
|
tenantId: m2mApiKeys.tenantId,
|
||||||
|
userId: m2mApiKeys.userId,
|
||||||
|
active: m2mApiKeys.active,
|
||||||
|
expiresAt: m2mApiKeys.expiresAt,
|
||||||
|
userEmail: authUsers.email,
|
||||||
|
isAdmin: authUsers.is_admin,
|
||||||
|
})
|
||||||
|
.from(m2mApiKeys)
|
||||||
|
.innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId))
|
||||||
|
.where(and(
|
||||||
|
eq(m2mApiKeys.keyHash, keyHash),
|
||||||
|
eq(m2mApiKeys.active, true)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const key = rows[0]
|
||||||
|
if (!key) return false
|
||||||
|
if (key.expiresAt && new Date(key.expiresAt).getTime() < Date.now()) return false
|
||||||
|
|
||||||
|
await server.db
|
||||||
|
.update(m2mApiKeys)
|
||||||
|
.set({ lastUsedAt: new Date(), updatedAt: new Date() })
|
||||||
|
.where(eq(m2mApiKeys.id, key.id))
|
||||||
|
|
||||||
|
return {
|
||||||
|
user_id: key.userId,
|
||||||
|
email: key.userEmail,
|
||||||
|
tenant_id: key.tenantId,
|
||||||
|
is_admin: Boolean(key.isAdmin),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
server.addHook("preHandler", async (req, reply) => {
|
server.addHook("preHandler", async (req, reply) => {
|
||||||
// 1️⃣ Token aus Header oder Cookie lesen
|
// 1️⃣ Token aus Header oder Cookie lesen
|
||||||
const cookieToken = req.cookies?.token
|
const cookieToken = req.cookies?.token
|
||||||
@@ -30,11 +81,31 @@ export default fp(async (server: FastifyInstance) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2️⃣ JWT verifizieren
|
// 2️⃣ JWT verifizieren oder für MCP dauerhaften Token akzeptieren
|
||||||
const payload = jwt.verify(token, secrets.JWT_SECRET!) as {
|
let payload: {
|
||||||
user_id: string
|
user_id: string
|
||||||
email: string
|
email: string
|
||||||
tenant_id: number | null
|
tenant_id: number | null
|
||||||
|
is_admin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
payload = jwt.verify(token, secrets.JWT_SECRET!) as {
|
||||||
|
user_id: string
|
||||||
|
email: string
|
||||||
|
tenant_id: number | null
|
||||||
|
}
|
||||||
|
} catch (jwtError) {
|
||||||
|
const mcpPayload = isMcpRoute(req.url)
|
||||||
|
? await authenticateMcpApiKey(token)
|
||||||
|
: false
|
||||||
|
|
||||||
|
if (!mcpPayload) {
|
||||||
|
throw jwtError
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = mcpPayload
|
||||||
|
;(req as any).mcpTokenAuth = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!payload?.user_id) {
|
if (!payload?.user_id) {
|
||||||
@@ -44,15 +115,19 @@ export default fp(async (server: FastifyInstance) => {
|
|||||||
// Payload an Request hängen
|
// Payload an Request hängen
|
||||||
req.user = payload
|
req.user = payload
|
||||||
|
|
||||||
const [currentUser] = await server.db
|
if (typeof payload.is_admin === "boolean") {
|
||||||
.select({
|
req.user.is_admin = payload.is_admin
|
||||||
is_admin: authUsers.is_admin,
|
} else {
|
||||||
})
|
const [currentUser] = await server.db
|
||||||
.from(authUsers)
|
.select({
|
||||||
.where(eq(authUsers.id, payload.user_id))
|
is_admin: authUsers.is_admin,
|
||||||
.limit(1)
|
})
|
||||||
|
.from(authUsers)
|
||||||
|
.where(eq(authUsers.id, payload.user_id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
req.user.is_admin = Boolean(currentUser?.is_admin)
|
req.user.is_admin = Boolean(currentUser?.is_admin)
|
||||||
|
}
|
||||||
|
|
||||||
// Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung
|
// Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung
|
||||||
if (!req.user.tenant_id) {
|
if (!req.user.tenant_id) {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { and, desc, eq } from "drizzle-orm"
|
||||||
|
import { createHash, randomBytes } from "node:crypto"
|
||||||
|
import { m2mApiKeys } from "../../db/schema"
|
||||||
import { assertToolPermission, createMcpContext } from "../mcp/authz"
|
import { assertToolPermission, createMcpContext } from "../mcp/authz"
|
||||||
import { mcpToolMap, mcpTools } from "../mcp/registry"
|
import { mcpToolMap, mcpTools } from "../mcp/registry"
|
||||||
import { asToolError, asToolResult } from "../mcp/result"
|
import { asToolError, asToolResult } from "../mcp/result"
|
||||||
@@ -38,7 +41,160 @@ function selectProtocolVersion(clientVersion?: string) {
|
|||||||
return SUPPORTED_PROTOCOL_VERSIONS[0]
|
return SUPPORTED_PROTOCOL_VERSIONS[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hashApiKey = (apiKey: string) =>
|
||||||
|
createHash("sha256").update(apiKey, "utf8").digest("hex")
|
||||||
|
|
||||||
|
const createMcpToken = () => `fedeo_mcp_${randomBytes(32).toString("base64url")}`
|
||||||
|
|
||||||
|
const requireMcpTokenManagementPermission = (req: any) => {
|
||||||
|
if (req.mcpTokenAuth) {
|
||||||
|
throw Object.assign(new Error("MCP Tokens können nur mit einer Benutzersitzung verwaltet werden"), { statusCode: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.user?.is_admin) return
|
||||||
|
if (typeof req.hasPermission === "function" && req.hasPermission("mcp.tokens.write")) return
|
||||||
|
|
||||||
|
throw Object.assign(new Error("Fehlende Berechtigung: mcp.tokens.write"), { statusCode: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
export default async function mcpRoutes(server: FastifyInstance) {
|
export default async function mcpRoutes(server: FastifyInstance) {
|
||||||
|
server.get("/mcp/tokens", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
requireMcpTokenManagementPermission(req)
|
||||||
|
|
||||||
|
if (!req.user?.tenant_id) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await server.db
|
||||||
|
.select({
|
||||||
|
id: m2mApiKeys.id,
|
||||||
|
createdAt: m2mApiKeys.createdAt,
|
||||||
|
updatedAt: m2mApiKeys.updatedAt,
|
||||||
|
name: m2mApiKeys.name,
|
||||||
|
keyPrefix: m2mApiKeys.keyPrefix,
|
||||||
|
active: m2mApiKeys.active,
|
||||||
|
lastUsedAt: m2mApiKeys.lastUsedAt,
|
||||||
|
expiresAt: m2mApiKeys.expiresAt,
|
||||||
|
userId: m2mApiKeys.userId,
|
||||||
|
createdBy: m2mApiKeys.createdBy,
|
||||||
|
})
|
||||||
|
.from(m2mApiKeys)
|
||||||
|
.where(and(
|
||||||
|
eq(m2mApiKeys.tenantId, req.user.tenant_id)
|
||||||
|
))
|
||||||
|
.orderBy(desc(m2mApiKeys.createdAt))
|
||||||
|
|
||||||
|
return { rows: rows.filter((row) => row.keyPrefix.startsWith("fedeo_mcp_")) }
|
||||||
|
} catch (error) {
|
||||||
|
const statusCode = (error as any)?.statusCode || 500
|
||||||
|
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Internal Server Error" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/mcp/tokens", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
requireMcpTokenManagementPermission(req)
|
||||||
|
|
||||||
|
if (!req.user?.tenant_id || !req.user?.user_id) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = req.body as {
|
||||||
|
name?: string
|
||||||
|
expiresAt?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = createMcpToken()
|
||||||
|
const expiresAt = body?.expiresAt ? new Date(body.expiresAt) : null
|
||||||
|
|
||||||
|
if (expiresAt && Number.isNaN(expiresAt.getTime())) {
|
||||||
|
return reply.code(400).send({ error: "expiresAt must be a valid date" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await server.db
|
||||||
|
.insert(m2mApiKeys)
|
||||||
|
.values({
|
||||||
|
tenantId: req.user.tenant_id,
|
||||||
|
userId: req.user.user_id,
|
||||||
|
createdBy: req.user.user_id,
|
||||||
|
name: body?.name?.trim() || "FEDEO MCP Token",
|
||||||
|
keyPrefix: token.slice(0, 20),
|
||||||
|
keyHash: hashApiKey(token),
|
||||||
|
active: true,
|
||||||
|
expiresAt,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: m2mApiKeys.id,
|
||||||
|
createdAt: m2mApiKeys.createdAt,
|
||||||
|
name: m2mApiKeys.name,
|
||||||
|
keyPrefix: m2mApiKeys.keyPrefix,
|
||||||
|
active: m2mApiKeys.active,
|
||||||
|
expiresAt: m2mApiKeys.expiresAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
tokenType: "Bearer",
|
||||||
|
note: "Token wird nur einmal angezeigt. Bitte direkt in Codex oder einem Secret Store hinterlegen.",
|
||||||
|
record: created,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const statusCode = (error as any)?.statusCode || 500
|
||||||
|
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Internal Server Error" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.delete("/mcp/tokens/:id", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
requireMcpTokenManagementPermission(req)
|
||||||
|
|
||||||
|
if (!req.user?.tenant_id || !req.user?.user_id) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
|
||||||
|
const [existing] = await server.db
|
||||||
|
.select({
|
||||||
|
id: m2mApiKeys.id,
|
||||||
|
keyPrefix: m2mApiKeys.keyPrefix,
|
||||||
|
})
|
||||||
|
.from(m2mApiKeys)
|
||||||
|
.where(and(
|
||||||
|
eq(m2mApiKeys.id, id),
|
||||||
|
eq(m2mApiKeys.tenantId, req.user.tenant_id)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing || !existing.keyPrefix.startsWith("fedeo_mcp_")) {
|
||||||
|
return reply.code(404).send({ error: "MCP token not found" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await server.db
|
||||||
|
.update(m2mApiKeys)
|
||||||
|
.set({
|
||||||
|
active: false,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(
|
||||||
|
eq(m2mApiKeys.id, id),
|
||||||
|
eq(m2mApiKeys.tenantId, req.user.tenant_id)
|
||||||
|
))
|
||||||
|
.returning({
|
||||||
|
id: m2mApiKeys.id,
|
||||||
|
name: m2mApiKeys.name,
|
||||||
|
keyPrefix: m2mApiKeys.keyPrefix,
|
||||||
|
active: m2mApiKeys.active,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { token: updated }
|
||||||
|
} catch (error) {
|
||||||
|
const statusCode = (error as any)?.statusCode || 500
|
||||||
|
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Internal Server Error" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
server.post("/mcp", async (req, reply) => {
|
server.post("/mcp", async (req, reply) => {
|
||||||
const body = req.body as JsonRpcRequest | JsonRpcRequest[]
|
const body = req.body as JsonRpcRequest | JsonRpcRequest[]
|
||||||
const requests = Array.isArray(body) ? body : [body]
|
const requests = Array.isArray(body) ? body : [body]
|
||||||
@@ -141,4 +297,3 @@ export default async function mcpRoutes(server: FastifyInstance) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ const displayCurrency = (input: number, onlyAbs = false) => {
|
|||||||
return (onlyAbs ? Math.abs(input) : input).toFixed(2).replace(".", ",");
|
return (onlyAbs ? Math.abs(input) : input).toFixed(2).replace(".", ",");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCreatedDocumentDatevTaxKey = (document: { taxType?: string | null }) => {
|
||||||
|
return document.taxType === "13b UStG" ? "46" : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCreatedDocumentPaymentDatevTaxKey = (document: { taxType?: string | null }) => {
|
||||||
|
return document.taxType === "13b UStG" ? "46" : "3";
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
// MAIN EXPORT FUNCTION
|
// MAIN EXPORT FUNCTION
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
@@ -305,8 +313,9 @@ export async function buildExportZip(
|
|||||||
|
|
||||||
let shSelector = Math.sign(total) === -1 ? "H" : "S";
|
let shSelector = Math.sign(total) === -1 ? "H" : "S";
|
||||||
const cust = cd.customer; // durch Mapping verfügbar
|
const cust = cd.customer; // durch Mapping verfügbar
|
||||||
|
const datevTaxKey = getCreatedDocumentDatevTaxKey(cd);
|
||||||
|
|
||||||
bookingLines.push(`${displayCurrency(total,true)};"${shSelector}";;;;;${cust?.customerNumber || ""};8400;"";${dayjs(cd.documentDate).format("DDMM")};"${cd.documentNumber}";;;"${`${typeString} ${cd.documentNumber} - ${cust?.name || ""}`.substring(0,59)}";;;;;;${file ? `"BEDI ""${file.id}"""` : ""};"Geschäftspartner";"${cust?.name || ""}";"Kundennummer";"${cust?.customerNumber || ""}";"Belegnummer";"${cd.documentNumber}";"Leistungsdatum";"${dayjs(cd.deliveryDate).format("DD.MM.YYYY")}";"Belegdatum";"${dayjs(cd.documentDate).format("DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
|
bookingLines.push(`${displayCurrency(total,true)};"${shSelector}";;;;;${cust?.customerNumber || ""};8400;"${datevTaxKey}";${dayjs(cd.documentDate).format("DDMM")};"${cd.documentNumber}";;;"${`${typeString} ${cd.documentNumber} - ${cust?.name || ""}`.substring(0,59)}";;;;;;${file ? `"BEDI ""${file.id}"""` : ""};"Geschäftspartner";"${cust?.name || ""}";"Kundennummer";"${cust?.customerNumber || ""}";"Belegnummer";"${cd.documentNumber}";"Leistungsdatum";"${dayjs(cd.deliveryDate).format("DD.MM.YYYY")}";"Belegdatum";"${dayjs(cd.documentDate).format("DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ER
|
// ER
|
||||||
@@ -383,7 +392,8 @@ export async function buildExportZip(
|
|||||||
if(alloc.createddocument && alloc.createddocument.customer) {
|
if(alloc.createddocument && alloc.createddocument.customer) {
|
||||||
const cd = alloc.createddocument;
|
const cd = alloc.createddocument;
|
||||||
const cust = cd.customer;
|
const cust = cd.customer;
|
||||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"H";;;;;${cust?.customerNumber};${datevKonto};"3";${dayjs(cd.documentDate).format("DDMM")};"${cd.documentNumber}";;;"${`ZE${alloc.description}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust?.name}";"Kundennummer";"${cust?.customerNumber}";"Belegnummer";"${cd.documentNumber}";"Leistungsdatum";"${dayjs(cd.deliveryDate).format("DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
const datevTaxKey = getCreatedDocumentPaymentDatevTaxKey(cd);
|
||||||
|
bookingLines.push(`${displayCurrency(alloc.amount,true)};"H";;;;;${cust?.customerNumber};${datevKonto};"${datevTaxKey}";${dayjs(cd.documentDate).format("DDMM")};"${cd.documentNumber}";;;"${`ZE${alloc.description}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust?.name}";"Kundennummer";"${cust?.customerNumber}";"Belegnummer";"${cd.documentNumber}";"Leistungsdatum";"${dayjs(cd.deliveryDate).format("DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
||||||
} else if(alloc.incominginvoice && alloc.incominginvoice.vendor) {
|
} else if(alloc.incominginvoice && alloc.incominginvoice.vendor) {
|
||||||
const ii = alloc.incominginvoice;
|
const ii = alloc.incominginvoice;
|
||||||
const vend = ii.vendor;
|
const vend = ii.vendor;
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ const hasCancellationInvoice = (row) => Boolean(getCancellationInvoice(row))
|
|||||||
const openUnpaidInvoicesFilter = {
|
const openUnpaidInvoicesFilter = {
|
||||||
name: 'Nur offene Belege',
|
name: 'Nur offene Belege',
|
||||||
filterFunction: (row) => {
|
filterFunction: (row) => {
|
||||||
return useSum().isOpenCreatedDocument(row, items.value)
|
return row.state === 'Entwurf' || useSum().isOpenCreatedDocument(row, items.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<UDropdown :items="rootMenuItems" :popper="{ placement: 'bottom-end' }">
|
<UDropdownMenu :items="rootMenuItems" :content="{ align: 'end' }">
|
||||||
<UButton color="gray" variant="ghost" icon="i-heroicons-plus" size="sm" />
|
<UButton color="gray" variant="ghost" icon="i-heroicons-plus" size="sm" />
|
||||||
</UDropdown>
|
</UDropdownMenu>
|
||||||
<UButton @click="isSidebarOpen = false" color="gray" variant="ghost" icon="i-heroicons-chevron-double-left" size="sm" />
|
<UButton @click="isSidebarOpen = false" color="gray" variant="ghost" icon="i-heroicons-chevron-double-left" size="sm" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,8 +200,8 @@ async function handleModalConfirm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rootMenuItems = [
|
const rootMenuItems = [
|
||||||
[{ label: 'Neue Seite', icon: 'i-heroicons-document-plus', click: () => openWikiAction('create', null, false) }],
|
[{ label: 'Neue Seite', icon: 'i-heroicons-document-plus', onSelect: () => openWikiAction('create', null, false) }],
|
||||||
[{ label: 'Neuer Ordner', icon: 'i-heroicons-folder-plus', click: () => openWikiAction('create', null, true) }]
|
[{ label: 'Neuer Ordner', icon: 'i-heroicons-folder-plus', onSelect: () => openWikiAction('create', null, true) }]
|
||||||
]
|
]
|
||||||
|
|
||||||
// --- EDITOR ACTIONS (Identisch wie vorher) ---
|
// --- EDITOR ACTIONS (Identisch wie vorher) ---
|
||||||
|
|||||||
Reference in New Issue
Block a user