diff --git a/.env.example b/.env.example index 8dda2c7..787a36e 100644 --- a/.env.example +++ b/.env.example @@ -74,6 +74,29 @@ GRAPH_TENANT_ID=organizations GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= +# --- Benachrichtigungen ---------------------------------------------------- +# Beides ist freiwillig. Fehlt es, laeuft der Planer ohne Benachrichtigungen +# weiter und sagt es in den Einstellungen. + +# Web-Push. Schluesselpaar einmalig erzeugen mit: +# npx web-push generate-vapid-keys +# Der oeffentliche Schluessel geht an den Browser, der private bleibt hier. +# Wird das Paar getauscht, muessen sich alle Geraete neu anmelden. +VAPID_OEFFENTLICH= +VAPID_PRIVAT= +# Kontakt fuer den Push-Dienst, als mailto: oder https:-Adresse. +VAPID_KONTAKT= + +# E-Mail. Push braucht SMTP nicht und umgekehrt. +SMTP_HOST= +SMTP_PORT=587 +SMTP_BENUTZER= +SMTP_PASSWORT= +# Absender, z. B. "Planer ". +SMTP_ABSENDER= +# true bei Port 465 (SMTPS), false bei 587 mit STARTTLS. +SMTP_TLS=false + # --- Sonstiges ------------------------------------------------------------- # Zeitzone fuer Anzeigen und Tagesgrenzen. TZ=Europe/Berlin diff --git a/package-lock.json b/package-lock.json index 843ba97..539baa2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "server-only": "0.0.1", "sonner": "2.0.8", "tailwind-merge": "3.6.0", + "web-push": "3.6.7", "zod": "4.6.1" }, "devDependencies": { @@ -49,6 +50,7 @@ "@types/pg": "8.23.1", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", + "@types/web-push": "3.6.4", "@vitejs/plugin-react": "6.1.1", "@vitest/coverage-v8": "5.0.0", "dotenv": "17.4.2", @@ -4545,6 +4547,16 @@ "@types/react": "^19.3.0" } }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.70.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", @@ -5446,6 +5458,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -5678,6 +5699,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/asn1js": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", @@ -5832,6 +5865,12 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -5892,6 +5931,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/c12": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", @@ -6413,7 +6458,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -6597,6 +6641,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/effect": { "version": "3.20.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", @@ -8085,6 +8138,28 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -8139,6 +8214,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -8804,6 +8885,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9280,6 +9382,12 @@ "node": ">=8.6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -9300,7 +9408,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9310,7 +9417,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mysql2": { @@ -10731,6 +10837,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -10793,7 +10919,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true, "license": "MIT" }, "node_modules/saxes": { @@ -12398,6 +12523,25 @@ "node": ">=18" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", diff --git a/package.json b/package.json index 7568b03..6a96d73 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "server-only": "0.0.1", "sonner": "2.0.8", "tailwind-merge": "3.6.0", + "web-push": "3.6.7", "zod": "4.6.1" }, "devDependencies": { @@ -79,6 +80,7 @@ "@types/pg": "8.23.1", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", + "@types/web-push": "3.6.4", "@vitejs/plugin-react": "6.1.1", "@vitest/coverage-v8": "5.0.0", "dotenv": "17.4.2", diff --git a/prisma/migrations/20260911044207_benachrichtigungen/migration.sql b/prisma/migrations/20260911044207_benachrichtigungen/migration.sql new file mode 100644 index 0000000..963d288 --- /dev/null +++ b/prisma/migrations/20260911044207_benachrichtigungen/migration.sql @@ -0,0 +1,66 @@ +-- CreateTable +CREATE TABLE "benachrichtigungseinstellung" ( + "id" TEXT NOT NULL, + "benutzerId" TEXT NOT NULL, + "anlaesse" TEXT NOT NULL DEFAULT 'tagesueberblick,faelligkeit', + "perPush" BOOLEAN NOT NULL DEFAULT false, + "perMail" BOOLEAN NOT NULL DEFAULT false, + "ueberblickUm" INTEGER NOT NULL DEFAULT 420, + "ruheVon" INTEGER NOT NULL DEFAULT 1200, + "ruheBis" INTEGER NOT NULL DEFAULT 420, + "ruheAmWochenende" BOOLEAN NOT NULL DEFAULT false, + "zeitblockVorlaufMinuten" INTEGER NOT NULL DEFAULT 10, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "aktualisiertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "benachrichtigungseinstellung_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "push_geraet" ( + "id" TEXT NOT NULL, + "benutzerId" TEXT NOT NULL, + "endpunkt" TEXT NOT NULL, + "p256dh" TEXT NOT NULL, + "auth" TEXT NOT NULL, + "bezeichnung" TEXT, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "zuletztBenutzt" TIMESTAMP(3), + "fehlversuche" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "push_geraet_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "meldungsnachweis" ( + "id" TEXT NOT NULL, + "benutzerId" TEXT NOT NULL, + "schluessel" TEXT NOT NULL, + "gesendetAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "meldungsnachweis_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "benachrichtigungseinstellung_benutzerId_key" ON "benachrichtigungseinstellung"("benutzerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "push_geraet_endpunkt_key" ON "push_geraet"("endpunkt"); + +-- CreateIndex +CREATE INDEX "push_geraet_benutzerId_idx" ON "push_geraet"("benutzerId"); + +-- CreateIndex +CREATE INDEX "meldungsnachweis_gesendetAm_idx" ON "meldungsnachweis"("gesendetAm"); + +-- CreateIndex +CREATE UNIQUE INDEX "meldungsnachweis_benutzerId_schluessel_key" ON "meldungsnachweis"("benutzerId", "schluessel"); + +-- AddForeignKey +ALTER TABLE "benachrichtigungseinstellung" ADD CONSTRAINT "benachrichtigungseinstellung_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "push_geraet" ADD CONSTRAINT "push_geraet_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "meldungsnachweis" ADD CONSTRAINT "meldungsnachweis_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260911045857_abgleich_fehlversuche/migration.sql b/prisma/migrations/20260911045857_abgleich_fehlversuche/migration.sql new file mode 100644 index 0000000..ab8a762 --- /dev/null +++ b/prisma/migrations/20260911045857_abgleich_fehlversuche/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "kalenderkonto" ADD COLUMN "fehlversuche" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 70c018f..dd7b924 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,10 +31,13 @@ model Benutzer { erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt - konten Konto[] - passkeys Passkey[] - audit AuditEintrag[] - kalenderkonten Kalenderkonto[] + konten Konto[] + passkeys Passkey[] + audit AuditEintrag[] + kalenderkonten Kalenderkonto[] + benachrichtigung Benachrichtigungseinstellung? + pushGeraete PushGeraet[] + meldungen Meldungsnachweis[] @@index([aktiv]) @@map("benutzer") @@ -300,6 +303,9 @@ model Kalenderkonto { letzterAbgleich DateTime? /// Klartextmeldung des letzten Fehlschlags, ohne Tokens. letzterFehler String? + /// Wie oft der Abgleich hintereinander scheiterte. Ein Aussetzer ist keine + /// Nachricht wert; gemeldet wird erst ab mehreren. + fehlversuche Int @default(0) aktiv Boolean @default(true) @@ -378,3 +384,67 @@ model Zeitblock { @@index([aufgabeId]) @@map("zeitblock") } + +/// Wie und worueber ein Benutzer benachrichtigt werden will. +model Benachrichtigungseinstellung { + id String @id @default(cuid()) + benutzerId String @unique + + /// Kommagetrennte Anlaesse, siehe src/lib/benachrichtigung.ts. + anlaesse String @default("tagesueberblick,faelligkeit") + perPush Boolean @default(false) + perMail Boolean @default(false) + + /// Alles als Minuten seit Mitternacht, Europe/Berlin. + ueberblickUm Int @default(420) + ruheVon Int @default(1200) + ruheBis Int @default(420) + + ruheAmWochenende Boolean @default(false) + zeitblockVorlaufMinuten Int @default(10) + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + + benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) + + @@map("benachrichtigungseinstellung") +} + +/// Ein Browser, der Push-Nachrichten empfangen darf. +model PushGeraet { + id String @id @default(cuid()) + benutzerId String + + /// Vom Browser vergebene Adresse; eindeutig je Geraet. + endpunkt String @unique + /// Schluessel aus der Push-Anmeldung, verschluesselt abgelegt. + p256dh String + auth String + + bezeichnung String? + erstelltAm DateTime @default(now()) + zuletztBenutzt DateTime? + /// Wie oft der Versand hintereinander scheiterte; ab einer Grenze weg. + fehlversuche Int @default(0) + + benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) + + @@index([benutzerId]) + @@map("push_geraet") +} + +/// Nachweis, dass eine Meldung schon hinausging – verhindert Wiederholungen. +model Meldungsnachweis { + id String @id @default(cuid()) + benutzerId String + /// anlass:sache:tag, siehe meldungsSchluessel(). + schluessel String + gesendetAm DateTime @default(now()) + + benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) + + @@unique([benutzerId, schluessel]) + @@index([gesendetAm]) + @@map("meldungsnachweis") +} diff --git a/src/server/umgebung.ts b/src/server/umgebung.ts index 533c9a4..832ae18 100644 --- a/src/server/umgebung.ts +++ b/src/server/umgebung.ts @@ -41,6 +41,22 @@ const schema = z.object({ GOOGLE_CLIENT_ID: z.string().trim().default(''), GOOGLE_CLIENT_SECRET: z.string().trim().default(''), + // --- Benachrichtigungen (M5) --- + // VAPID-Schluessel fuer Web-Push. Erzeugen mit: + // npx web-push generate-vapid-keys + VAPID_OEFFENTLICH: z.string().trim().default(''), + VAPID_PRIVAT: z.string().trim().default(''), + /// Kontaktadresse fuer den Push-Dienst, als mailto:. + VAPID_KONTAKT: z.string().trim().default(''), + + SMTP_HOST: z.string().trim().default(''), + SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587), + SMTP_BENUTZER: z.string().trim().default(''), + SMTP_PASSWORT: z.string().default(''), + SMTP_ABSENDER: z.string().trim().default(''), + /// Implizites TLS (Port 465). Sonst wird STARTTLS versucht. + SMTP_TLS: jaNein.default(false), + DEMO_MODUS: jaNein.default(false), }) @@ -51,6 +67,9 @@ export type Umgebung = z.infer & { /** Graph laesst sich verbinden, sobald Kennung und Geheimnis stehen. */ graphAktiv: boolean googleAktiv: boolean + /** Web-Push ist erst mit vollstaendigem VAPID-Satz moeglich. */ + pushAktiv: boolean + mailAktiv: boolean } let zwischengespeichert: Umgebung | null = null @@ -72,6 +91,10 @@ function lesen(): Umgebung { ), graphAktiv: Boolean(werte.GRAPH_CLIENT_ID && werte.GRAPH_CLIENT_SECRET), googleAktiv: Boolean(werte.GOOGLE_CLIENT_ID && werte.GOOGLE_CLIENT_SECRET), + pushAktiv: Boolean( + werte.VAPID_OEFFENTLICH && werte.VAPID_PRIVAT && werte.VAPID_KONTAKT, + ), + mailAktiv: Boolean(werte.SMTP_HOST && werte.SMTP_ABSENDER), } }