Compare commits
2 Commits
49b4c10e10
...
914b322805
| Author | SHA1 | Date | |
|---|---|---|---|
| 914b322805 | |||
| 2f306ca8a2 |
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user