Compare commits

...

2 Commits

Author SHA1 Message Date
914b322805 KI-AGENT: Projekttermine in der Plantafel darstellen
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 44s
Build and Push Docker Images / build-frontend (push) Successful in 1m16s
Build and Push Docker Images / build-website (push) Successful in 23s
Build and Push Docker Images / build-central-services-api (push) Successful in 21s
Build and Push Docker Images / build-central-services-admin (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 22s
2026-09-07 19:32:28 +02:00
2f306ca8a2 KI-AGENT: Defekte Mailkonto-Verschlüsselung abfangen 2026-09-07 19:31:53 +02:00
6 changed files with 120 additions and 37 deletions

View File

@@ -23,27 +23,62 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
const encryptedValue = (value: unknown) => value ? decrypt(value as any) : null const encryptedValue = (value: unknown) => value ? decrypt(value as any) : null
const accountResponse = (row: any) => ({ const accountResponse = (row: any) => {
id: row.id, const invalidEncryptedFields: string[] = []
createdAt: row.createdAt, const safeEncryptedValue = (value: unknown, field: string) => {
updatedAt: row.updatedAt, if (!value) return null
userId: row.userId, try {
tenantId: row.tenantId, return encryptedValue(value)
type: row.type, } catch {
email: encryptedValue(row.emailEncrypted), invalidEncryptedFields.push(field)
smtpHost: encryptedValue(row.smtpHostEncrypted), return null
smtpPort: row.smtpPort ? Number(row.smtpPort) : null, }
smtpSsl: row.smtpSsl, }
imapHost: encryptedValue(row.imapHostEncrypted),
imapPort: row.imapPort ? Number(row.imapPort) : null,
imapSsl: row.imapSsl,
hasPassword: Boolean(row.passwordEncrypted),
})
const accountCredentials = (row: any) => ({ const email = safeEncryptedValue(row.emailEncrypted, "email")
...accountResponse(row), const smtpHost = safeEncryptedValue(row.smtpHostEncrypted, "smtpHost")
password: encryptedValue(row.passwordEncrypted), const imapHost = safeEncryptedValue(row.imapHostEncrypted, "imapHost")
}) safeEncryptedValue(row.passwordEncrypted, "password")
if (invalidEncryptedFields.length) {
server.log.warn({
accountId: row.id,
invalidEncryptedFields,
}, "E-Mail-Kontodaten können mit dem aktuellen ENCRYPTION_KEY nicht entschlüsselt werden")
}
return {
id: row.id,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
userId: row.userId,
tenantId: row.tenantId,
type: row.type,
email,
displayName: email || `Mailkonto ${String(row.id).slice(0, 8)} muss repariert werden`,
smtpHost,
smtpPort: row.smtpPort ? Number(row.smtpPort) : null,
smtpSsl: row.smtpSsl,
imapHost,
imapPort: row.imapPort ? Number(row.imapPort) : null,
imapSsl: row.imapSsl,
hasPassword: Boolean(row.passwordEncrypted),
credentialsReadable: invalidEncryptedFields.length === 0,
invalidEncryptedFields,
}
}
const accountCredentials = (row: any) => {
const account = accountResponse(row)
if (!account.credentialsReadable) {
throw new Error("Die verschlüsselten Kontodaten sind nicht lesbar. Bitte das E-Mail-Konto in den Einstellungen vollständig neu speichern.")
}
return {
...account,
password: encryptedValue(row.passwordEncrypted),
}
}
const bodyValue = (body: any, camelKey: string, snakeKey: string) => body[camelKey] ?? body[snakeKey] const bodyValue = (body: any, camelKey: string, snakeKey: string) => body[camelKey] ?? body[snakeKey]

View File

@@ -5,6 +5,8 @@ import { de as deLocale } from "date-fns/locale"
type EmailAccount = { type EmailAccount = {
id: string id: string
email: string email: string
displayName?: string
credentialsReadable?: boolean
imapHost?: string | null imapHost?: string | null
hasPassword?: boolean hasPassword?: boolean
} }
@@ -657,7 +659,7 @@ onMounted(loadAccounts)
v-else-if="accounts.length" v-else-if="accounts.length"
v-model="selectedAccountId" v-model="selectedAccountId"
:items="accounts" :items="accounts"
label-key="email" label-key="displayName"
value-key="id" value-key="id"
class="w-full" class="w-full"
/> />

View File

@@ -302,7 +302,7 @@ const sendEmail = async () => {
> >
<USelectMenu <USelectMenu
:items="emailAccounts" :items="emailAccounts"
label-key="email" label-key="displayName"
value-key="id" value-key="id"
v-model="emailData.account" v-model="emailData.account"
/> />

View File

@@ -36,6 +36,7 @@ const resources = ref([])
const events = ref([]) const events = ref([])
const profiles = ref([]) const profiles = ref([])
const inventoryitems = ref([]) const inventoryitems = ref([])
const projects = ref([])
const isDraftModeActive = ref(false) const isDraftModeActive = ref(false)
const isFinalizeDraftsModalOpen = ref(false) const isFinalizeDraftsModalOpen = ref(false)
const finalizingDrafts = ref(false) const finalizingDrafts = ref(false)
@@ -123,7 +124,8 @@ const resourceTypeOptions = [
{ label: "Alle Ressourcen", value: "all" }, { label: "Alle Ressourcen", value: "all" },
{ label: "Teams", value: "Team" }, { label: "Teams", value: "Team" },
{ label: "Profile", value: "Profile" }, { label: "Profile", value: "Profile" },
{ label: "Inventarartikel", value: "Inventarartikel" } { label: "Inventarartikel", value: "Inventarartikel" },
{ label: "Projekte", value: "Projekte" }
] ]
const calendarViewOptions = [ const calendarViewOptions = [
@@ -407,10 +409,17 @@ function resolveEventColor(eventType) {
function resolveEventTitle(event, projectsById) { function resolveEventTitle(event, projectsById) {
if (event.name) return event.name if (event.name) return event.name
if (event.project && projectsById.has(event.project)) return projectsById.get(event.project).name const projectId = getEventProjectId(event)
if (projectId && projectsById.has(projectId)) return projectsById.get(projectId).name
return event.quick ? activeQuickEntryConfig.value.name : "Planung" return event.quick ? activeQuickEntryConfig.value.name : "Planung"
} }
function getEventProjectId(event) {
return event?.project && typeof event.project === "object"
? event.project.id
: event?.project
}
function resolveRenderedEventColor(event) { function resolveRenderedEventColor(event) {
if (event?.quick) return event?.color || activeQuickEntryConfig.value.color if (event?.quick) return event?.color || activeQuickEntryConfig.value.color
return resolveEventColor(event.eventtype) return resolveEventColor(event.eventtype)
@@ -470,7 +479,7 @@ function normalizeSelectedResourceIds(resourceId) {
return [resourceId] return [resourceId]
} }
function buildResources({ profiles, inventoryitems }) { function buildResources({ profiles, inventoryitems, projects }) {
const branchResources = [] const branchResources = []
const teamResources = [] const teamResources = []
const profileResources = [] const profileResources = []
@@ -541,18 +550,28 @@ function buildResources({ profiles, inventoryitems }) {
title: item.name title: item.name
})) }))
return [...branchResources, ...teamResources, ...profileResources, ...inventoryResources] const projectResources = projects
.filter((project) => !project.archived)
.map((project) => ({
id: `PRJ-${project.id}`,
type: "Projekte",
title: project.name
}))
return [...branchResources, ...teamResources, ...profileResources, ...inventoryResources, ...projectResources]
} }
function buildEvents({ rawEvents, projectsById }) { function buildEvents({ rawEvents, projectsById }) {
const mappedEvents = rawEvents const mappedEvents = rawEvents
.filter((event) => !event.archived) .filter((event) => !event.archived)
.flatMap((event) => { .flatMap((event) => {
const projectId = getEventProjectId(event)
const resourceIds = [ const resourceIds = [
...(profiles.value ...(profiles.value
.filter((profile) => (event.profiles || []).includes(profile.id)) .filter((profile) => (event.profiles || []).includes(profile.id))
.flatMap((profile) => getProfileResourceIds(profile))), .flatMap((profile) => getProfileResourceIds(profile))),
...(event.inventoryitems || []).map((itemId) => `I-${itemId}`) ...(event.inventoryitems || []).map((itemId) => `I-${itemId}`),
...(projectId ? [`PRJ-${projectId}`] : [])
] ]
return expandRecurringEvent( return expandRecurringEvent(
@@ -807,7 +826,9 @@ async function createQuickEvent(info) {
vehicles: [], vehicles: [],
notes: "", notes: "",
link: "", link: "",
project: null, project: resourceIds
.filter((resourceId) => resourceId.startsWith("PRJ-"))
.map((resourceId) => Number(resourceId.replace("PRJ-", "")))[0] || null,
customer: null, customer: null,
vendor: null, vendor: null,
} }
@@ -938,7 +959,7 @@ async function loadPlanningBoard() {
loading.value = true loading.value = true
try { try {
const [rawEvents, projects, profileResponse, inventoryItemRows] = await Promise.all([ const [rawEvents, projectRows, profileResponse, inventoryItemRows] = await Promise.all([
useEntities("events").select(), useEntities("events").select(),
useEntities("projects").select(), useEntities("projects").select(),
useNuxtApp().$api("/api/tenant/profiles"), useNuxtApp().$api("/api/tenant/profiles"),
@@ -960,9 +981,10 @@ async function loadPlanningBoard() {
})) }))
) )
const projectsById = new Map((projects || []).map((project) => [project.id, project])) const projectsById = new Map((projectRows || []).map((project) => [project.id, project]))
profiles.value = profileRows || [] profiles.value = profileRows || []
inventoryitems.value = inventoryItemRows || [] inventoryitems.value = inventoryItemRows || []
projects.value = projectRows || []
if (!absenceForm.userId) { if (!absenceForm.userId) {
absenceForm.userId = profileOptions.value[0]?.value || "" absenceForm.userId = profileOptions.value[0]?.value || ""
@@ -970,7 +992,8 @@ async function loadPlanningBoard() {
resources.value = buildResources({ resources.value = buildResources({
profiles: profiles.value, profiles: profiles.value,
inventoryitems: inventoryitems.value inventoryitems: inventoryitems.value,
projects: projects.value
}) })
events.value = buildEvents({ events.value = buildEvents({
@@ -1109,7 +1132,7 @@ onMounted(() => {
v-else-if="visibleResources.length === 0" v-else-if="visibleResources.length === 0"
icon="i-heroicons-calendar-days" icon="i-heroicons-calendar-days"
title="Keine planbaren Ressourcen vorhanden" title="Keine planbaren Ressourcen vorhanden"
description="Lege Profile an oder aktiviere die Plantafel-Nutzung bei Inventarartikeln, damit hier Ressourcen erscheinen." description="Lege Profile oder Projekte an oder aktiviere die Plantafel-Nutzung bei Inventarartikeln, damit hier Ressourcen erscheinen."
/> />
<FullCalendar <FullCalendar

View File

@@ -5,6 +5,7 @@ const toast = useToast()
const mode = route.params.mode const mode = route.params.mode
const isCreate = computed(() => mode === "create") const isCreate = computed(() => mode === "create")
const loading = ref(false) const loading = ref(false)
const credentialsReadable = ref(true)
const itemInfo = ref({ const itemInfo = ref({
email: "", email: "",
@@ -20,6 +21,7 @@ const itemInfo = ref({
const setup = async () => { const setup = async () => {
if(!isCreate.value) { if(!isCreate.value) {
const account = await useNuxtApp().$api(`/api/email/accounts/${route.params.id}`) const account = await useNuxtApp().$api(`/api/email/accounts/${route.params.id}`)
credentialsReadable.value = account.credentialsReadable !== false
itemInfo.value = { itemInfo.value = {
...itemInfo.value, ...itemInfo.value,
...account, ...account,
@@ -36,6 +38,13 @@ const payload = () => ({
password: itemInfo.value.password || undefined, password: itemInfo.value.password || undefined,
}) })
const canSave = computed(() => Boolean(
itemInfo.value.email
&& itemInfo.value.imapHost
&& itemInfo.value.smtpHost
&& (isCreate.value || credentialsReadable.value || itemInfo.value.password)
))
const createAccount = async () => { const createAccount = async () => {
loading.value = true loading.value = true
const res = await useNuxtApp().$api(`/api/email/accounts`, { const res = await useNuxtApp().$api(`/api/email/accounts`, {
@@ -123,7 +132,7 @@ const syncAccount = async () => {
v-if="!isCreate" v-if="!isCreate"
icon="i-heroicons-check" icon="i-heroicons-check"
:loading="loading" :loading="loading"
:disabled="!itemInfo.email || !itemInfo.imapHost || !itemInfo.smtpHost" :disabled="!canSave"
@click="saveAccount" @click="saveAccount"
> >
Speichern Speichern
@@ -132,6 +141,16 @@ const syncAccount = async () => {
</UDashboardNavbar> </UDashboardNavbar>
<div class="mx-auto max-w-4xl p-4"> <div class="mx-auto max-w-4xl p-4">
<UAlert
v-if="!isCreate && !credentialsReadable"
class="mb-4"
color="error"
variant="soft"
icon="i-heroicons-exclamation-triangle"
title="Zugangsdaten müssen repariert werden"
description="Die Daten wurden mit einem anderen oder nicht mehr verfügbaren Verschlüsselungsschlüssel gespeichert. Trage E-Mail-Adresse, Passwort, IMAP-Host und SMTP-Host vollständig neu ein und speichere das Konto."
/>
<UAlert <UAlert
v-if="!isCreate" v-if="!isCreate"
class="mb-4" class="mb-4"
@@ -139,7 +158,7 @@ const syncAccount = async () => {
variant="soft" variant="soft"
icon="i-heroicons-lock-closed" icon="i-heroicons-lock-closed"
title="Passwort bleibt geschützt" title="Passwort bleibt geschützt"
description="Das gespeicherte Passwort wird nicht angezeigt. Trage nur dann ein neues Passwort ein, wenn du es ändern möchtest." :description="credentialsReadable ? 'Das gespeicherte Passwort wird nicht angezeigt. Trage nur dann ein neues Passwort ein, wenn du es ändern möchtest.' : 'Das bisherige Passwort kann nicht entschlüsselt werden und muss neu eingetragen werden.'"
/> />
<UForm class="grid gap-6"> <UForm class="grid gap-6">
@@ -158,11 +177,11 @@ const syncAccount = async () => {
/> />
</UFormField> </UFormField>
<UFormField :label="isCreate ? 'Passwort' : 'Neues Passwort'"> <UFormField :label="isCreate || !credentialsReadable ? 'Passwort' : 'Neues Passwort'">
<UInput <UInput
v-model="itemInfo.password" v-model="itemInfo.password"
type="password" type="password"
:placeholder="isCreate ? '' : 'Unverändert lassen'" :placeholder="isCreate || !credentialsReadable ? '' : 'Unverändert lassen'"
/> />
</UFormField> </UFormField>
</div> </div>

View File

@@ -73,7 +73,7 @@ setupPage()
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<UIcon name="i-heroicons-envelope" class="size-5 text-primary" /> <UIcon name="i-heroicons-envelope" class="size-5 text-primary" />
<p class="truncate font-medium"> <p class="truncate font-medium">
{{ account.email }} {{ account.displayName || account.email }}
</p> </p>
<UBadge <UBadge
size="xs" size="xs"
@@ -88,6 +88,9 @@ setupPage()
<span>SMTP: {{ account.smtpHost || "nicht gesetzt" }}:{{ account.smtpPort || "-" }}</span> <span>SMTP: {{ account.smtpHost || "nicht gesetzt" }}:{{ account.smtpPort || "-" }}</span>
<span>{{ account.hasPassword ? "Passwort hinterlegt" : "Passwort fehlt" }}</span> <span>{{ account.hasPassword ? "Passwort hinterlegt" : "Passwort fehlt" }}</span>
</div> </div>
<p v-if="account.credentialsReadable === false" class="mt-2 text-sm text-error">
Die verschlüsselten Zugangsdaten sind nicht lesbar. Öffne das Konto und trage alle Zugangsdaten neu ein.
</p>
</div> </div>
<div class="flex shrink-0 items-center gap-2"> <div class="flex shrink-0 items-center gap-2">
@@ -96,6 +99,7 @@ setupPage()
color="neutral" color="neutral"
variant="soft" variant="soft"
:loading="syncingAccount === account.id" :loading="syncingAccount === account.id"
:disabled="account.credentialsReadable === false"
@click.stop="syncAccount(account)" @click.stop="syncAccount(account)"
> >
Synchronisieren Synchronisieren