feat(17-01): move TenderEmailConfig ownership from tenant to user
Alert-Postfach gehoert jetzt dem einzelnen Nutzer (userId @unique) statt
dem Mandanten (D-01) — ein zweiter Kollege desselben Mandanten kann sein
eigenes Postfach anbinden. tenantId bleibt denormalisiert (SMTP-Aufloesung,
Herkunftsmarkierung), wird auf create UND update mitgeschrieben.
- Handgeschriebene Migration (prisma migrate dev verweigert die
nicht-interaktive Shell): befuellt Bestandszeilen mit dem aeltesten
aktiven Administrator ihres Mandanten, entfernt verwaiste Zeilen ohne
Administrator, ersetzt die tenantId-Eindeutigkeit durch userId.
Lokal getestet (0 Bestandszeilen lokal und auf alpha — Zaehlung im
Task-1-Checkpoint), Index-Ergebnis verifiziert.
- TenderEmailConfigService.getConfigForApi/saveConfig auf userId als
Schluessel umgestellt; saveConfig nimmt {userId, tenantId}.
- TendersController: email-config-Routen von @Roles(ADMIN,SUPER_ADMIN)
auf @UseModule('tender-radar') umgestellt (Postfach ist jetzt
Nutzereinstellung); Route-Reihenfolge vor @Get(':id') unveraendert.
- Neue Seite /modules/tender-radar/my-sources ("Meine Quellen") mit dem
unveraenderten EmailAlertConfigForm; Hinweistext benennt D-05 (Tender
bleibt plattform-global — nur wer Quellen einspeist aendert sich).
- tenders.controller.spec.ts an neue Service-Signatur angepasst (Rule 3,
nicht im Plan gelistet, aber zum Kompilieren/Bestehen erforderlich).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
-- Phase 17 (D-01): das Alert-Postfach ("TenderEmailConfig") gehoerte bisher
|
||||||
|
-- dem MANDANTEN (tenantId eindeutig) -- ein zweiter Kollege mit eigenem
|
||||||
|
-- Portal-Konto konnte sein Postfach nicht anbinden, weil es bereits eines
|
||||||
|
-- fuer den ganzen Mandanten gab. Ab dieser Migration gehoert das Postfach
|
||||||
|
-- dem NUTZER (userId eindeutig); tenantId bleibt als gewoehnliches,
|
||||||
|
-- denormalisiertes Feld erhalten (SMTP-Aufloesung, Herkunftsmarkierung der
|
||||||
|
-- eingelesenen Ausschreibungen -- gleiche Rolle wie in TenderMatch).
|
||||||
|
--
|
||||||
|
-- Eine bereits vorhandene Postfach-Zeile bekommt dabei den AELTESTEN
|
||||||
|
-- AKTIVEN Administrator (ADMIN oder SUPER_ADMIN) ihres Mandanten als
|
||||||
|
-- Besitzer zugeordnet, nicht geloescht: die Zeile enthaelt verschluesselte
|
||||||
|
-- Zugangsdaten und wurde seinerzeit von genau dieser Rolle angelegt: ein
|
||||||
|
-- Loeschen wuerde den laufenden Abruf ohne Vorwarnung stilllegen. Nur wenn
|
||||||
|
-- ein Mandant ueberhaupt keinen aktiven Administrator hat, wird die Zeile
|
||||||
|
-- entfernt -- sonst waere sie fuer niemanden mehr bearbeitbar, wuerde aber
|
||||||
|
-- im Hintergrund weiter abgefragt.
|
||||||
|
|
||||||
|
-- 1. Neue Spalte zunaechst ohne Pflicht, damit Bestandszeilen befuellt
|
||||||
|
-- werden koennen, bevor NOT NULL erzwungen wird.
|
||||||
|
ALTER TABLE "TenderEmailConfig" ADD COLUMN "userId" TEXT;
|
||||||
|
|
||||||
|
-- 2. Bestandszeilen: aeltester aktiver Administrator desselben Mandanten
|
||||||
|
-- (sortiert nach createdAt, bei Gleichstand nach id, genau einer).
|
||||||
|
UPDATE "TenderEmailConfig" AS tec
|
||||||
|
SET "userId" = (
|
||||||
|
SELECT u."id"
|
||||||
|
FROM "User" u
|
||||||
|
WHERE u."tenantId" = tec."tenantId"
|
||||||
|
AND u."isActive" = true
|
||||||
|
AND u."role" IN ('ADMIN', 'SUPER_ADMIN')
|
||||||
|
ORDER BY u."createdAt" ASC, u."id" ASC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE tec."userId" IS NULL;
|
||||||
|
|
||||||
|
-- 3. Zeilen ohne gefundenen Administrator entfernen (kein Mandanten-Admin
|
||||||
|
-- vorhanden -- siehe Begruendung oben).
|
||||||
|
DELETE FROM "TenderEmailConfig" WHERE "userId" IS NULL;
|
||||||
|
|
||||||
|
-- 4. Alte Eindeutigkeitsregel "ein Postfach pro Mandant" aufheben. Der
|
||||||
|
-- gewoehnliche Index auf tenantId (TenderEmailConfig_tenantId_idx)
|
||||||
|
-- bleibt bestehen -- tenantId wird weiterhin gefiltert (SMTP-Aufloesung).
|
||||||
|
DROP INDEX "TenderEmailConfig_tenantId_key";
|
||||||
|
|
||||||
|
-- 5. Neue Eindeutigkeitsregel "ein Postfach pro Nutzer" -- erst NOT NULL
|
||||||
|
-- setzen, nachdem jede Zeile einen Besitzer hat (Schritte 2/3).
|
||||||
|
ALTER TABLE "TenderEmailConfig" ALTER COLUMN "userId" SET NOT NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TenderEmailConfig_userId_key" ON "TenderEmailConfig"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TenderEmailConfig_userId_idx" ON "TenderEmailConfig"("userId");
|
||||||
@@ -275,9 +275,15 @@ model DkvModuleConfig {
|
|||||||
// DKV invoice inbox). Credentials are encrypted via CalendarCryptoService
|
// DKV invoice inbox). Credentials are encrypted via CalendarCryptoService
|
||||||
// (same AES-256-GCM iv:authTag:ciphertext format as DkvModuleConfig/
|
// (same AES-256-GCM iv:authTag:ciphertext format as DkvModuleConfig/
|
||||||
// SmtpConfig) and excluded from every API response (Safe-Select, T-07-12).
|
// SmtpConfig) and excluded from every API response (Safe-Select, T-07-12).
|
||||||
|
// Phase 17 (D-01): ownership lives at the USER, not the tenant — each user
|
||||||
|
// connects their own alert mailbox, so two colleagues at the same tenant can
|
||||||
|
// each run their own inbox in parallel. `tenantId` stays as a denormalized
|
||||||
|
// field (SMTP resolution, ownerTenantId tagging on ingested Tender rows) —
|
||||||
|
// same role as `tenantId` on TenderMatch, not the ownership key anymore.
|
||||||
model TenderEmailConfig {
|
model TenderEmailConfig {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
tenantId String @unique
|
userId String @unique
|
||||||
|
tenantId String
|
||||||
protocol String @default("imap") // 'imap' | 'exchange'
|
protocol String @default("imap") // 'imap' | 'exchange'
|
||||||
host String?
|
host String?
|
||||||
port Int?
|
port Int?
|
||||||
@@ -291,6 +297,7 @@ model TenderEmailConfig {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model DkvVehicleMaster {
|
model DkvVehicleMaster {
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import { TenderEmailConfigService } from './tender-email-config.service';
|
|||||||
* (deterministic reversible encode, NOT real AES) — same convention as
|
* (deterministic reversible encode, NOT real AES) — same convention as
|
||||||
* tender-dedup.service.spec.ts: no live DB/crypto dependency, just proving
|
* tender-dedup.service.spec.ts: no live DB/crypto dependency, just proving
|
||||||
* this service's own encrypt-preserve-empty / safe-select contract.
|
* this service's own encrypt-preserve-empty / safe-select contract.
|
||||||
|
*
|
||||||
|
* Phase 17, Plan 01 (D-01): ownership moved from tenantId to userId — the
|
||||||
|
* fake prisma below is now keyed by userId (matches the real
|
||||||
|
* `where: { userId }` upsert target), and two new cases prove the actual
|
||||||
|
* new capability: two users of the SAME tenant get two independent rows,
|
||||||
|
* and tenantId is written on create (denormalized, D-01).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function makeFakeCrypto() {
|
function makeFakeCrypto() {
|
||||||
@@ -24,7 +30,7 @@ function makeFakePrisma() {
|
|||||||
return {
|
return {
|
||||||
tenderEmailConfig: {
|
tenderEmailConfig: {
|
||||||
findUnique: vi.fn(async ({ where, select }: any) => {
|
findUnique: vi.fn(async ({ where, select }: any) => {
|
||||||
const row = configs.get(where.tenantId);
|
const row = configs.get(where.userId);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
if (!select) return row;
|
if (!select) return row;
|
||||||
const out: any = {};
|
const out: any = {};
|
||||||
@@ -32,9 +38,9 @@ function makeFakePrisma() {
|
|||||||
return out;
|
return out;
|
||||||
}),
|
}),
|
||||||
upsert: vi.fn(async ({ where, update, create, select }: any) => {
|
upsert: vi.fn(async ({ where, update, create, select }: any) => {
|
||||||
const existing = configs.get(where.tenantId);
|
const existing = configs.get(where.userId);
|
||||||
const row = existing ? { ...existing, ...update } : { id: 'cfg-1', ...create };
|
const row = existing ? { ...existing, ...update } : { id: `cfg-${configs.size + 1}`, ...create };
|
||||||
configs.set(where.tenantId, row);
|
configs.set(where.userId, row);
|
||||||
if (!select) return row;
|
if (!select) return row;
|
||||||
const out: any = {};
|
const out: any = {};
|
||||||
for (const k of Object.keys(select)) out[k] = row[k];
|
for (const k of Object.keys(select)) out[k] = row[k];
|
||||||
@@ -46,12 +52,12 @@ function makeFakePrisma() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('TenderEmailConfigService', () => {
|
describe('TenderEmailConfigService', () => {
|
||||||
it('getConfigForApi returns null when no config exists for the tenant', async () => {
|
it('getConfigForApi returns null when no config exists for the user', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
const crypto = makeFakeCrypto();
|
const crypto = makeFakeCrypto();
|
||||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||||
|
|
||||||
const result = await service.getConfigForApi('tenant-missing');
|
const result = await service.getConfigForApi('user-missing');
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -61,18 +67,21 @@ describe('TenderEmailConfigService', () => {
|
|||||||
const crypto = makeFakeCrypto();
|
const crypto = makeFakeCrypto();
|
||||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||||
|
|
||||||
await service.saveConfig('tenant-a', {
|
await service.saveConfig(
|
||||||
protocol: 'imap',
|
{ userId: 'user-a', tenantId: 'tenant-a' },
|
||||||
encryption: 'ssl-tls',
|
{
|
||||||
host: 'imap.example.test',
|
protocol: 'imap',
|
||||||
port: 993,
|
encryption: 'ssl-tls',
|
||||||
folder: 'INBOX',
|
host: 'imap.example.test',
|
||||||
username: 'alerts@example.test',
|
port: 993,
|
||||||
password: 'super-secret',
|
folder: 'INBOX',
|
||||||
isActive: true,
|
username: 'alerts@example.test',
|
||||||
} as any);
|
password: 'super-secret',
|
||||||
|
isActive: true,
|
||||||
|
} as any,
|
||||||
|
);
|
||||||
|
|
||||||
const apiResult = await service.getConfigForApi('tenant-a');
|
const apiResult = await service.getConfigForApi('user-a');
|
||||||
|
|
||||||
expect(apiResult).not.toBeNull();
|
expect(apiResult).not.toBeNull();
|
||||||
expect(apiResult).not.toHaveProperty('password');
|
expect(apiResult).not.toHaveProperty('password');
|
||||||
@@ -86,16 +95,19 @@ describe('TenderEmailConfigService', () => {
|
|||||||
const crypto = makeFakeCrypto();
|
const crypto = makeFakeCrypto();
|
||||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||||
|
|
||||||
await service.saveConfig('tenant-b', {
|
await service.saveConfig(
|
||||||
protocol: 'imap',
|
{ userId: 'user-b', tenantId: 'tenant-b' },
|
||||||
encryption: 'ssl-tls',
|
{
|
||||||
host: 'imap.example.test',
|
protocol: 'imap',
|
||||||
port: 993,
|
encryption: 'ssl-tls',
|
||||||
folder: 'INBOX',
|
host: 'imap.example.test',
|
||||||
isActive: false,
|
port: 993,
|
||||||
} as any);
|
folder: 'INBOX',
|
||||||
|
isActive: false,
|
||||||
|
} as any,
|
||||||
|
);
|
||||||
|
|
||||||
const apiResult = await service.getConfigForApi('tenant-b');
|
const apiResult = await service.getConfigForApi('user-b');
|
||||||
|
|
||||||
expect(apiResult!.hasPassword).toBe(false);
|
expect(apiResult!.hasPassword).toBe(false);
|
||||||
expect(apiResult!.username).toBeNull();
|
expect(apiResult!.username).toBeNull();
|
||||||
@@ -107,21 +119,27 @@ describe('TenderEmailConfigService', () => {
|
|||||||
const crypto = makeFakeCrypto();
|
const crypto = makeFakeCrypto();
|
||||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||||
|
|
||||||
await service.saveConfig('tenant-c', {
|
await service.saveConfig(
|
||||||
protocol: 'imap',
|
{ userId: 'user-c', tenantId: 'tenant-c' },
|
||||||
encryption: 'ssl-tls',
|
{
|
||||||
username: 'old@example.test',
|
protocol: 'imap',
|
||||||
password: 'original-secret',
|
encryption: 'ssl-tls',
|
||||||
} as any);
|
username: 'old@example.test',
|
||||||
|
password: 'original-secret',
|
||||||
|
} as any,
|
||||||
|
);
|
||||||
|
|
||||||
// Re-save with a new username, password left blank (T-07-12 UI convention)
|
// Re-save with a new username, password left blank (T-07-12 UI convention)
|
||||||
await service.saveConfig('tenant-c', {
|
await service.saveConfig(
|
||||||
protocol: 'imap',
|
{ userId: 'user-c', tenantId: 'tenant-c' },
|
||||||
encryption: 'ssl-tls',
|
{
|
||||||
username: 'new@example.test',
|
protocol: 'imap',
|
||||||
} as any);
|
encryption: 'ssl-tls',
|
||||||
|
username: 'new@example.test',
|
||||||
|
} as any,
|
||||||
|
);
|
||||||
|
|
||||||
const raw = prisma.__store.get('tenant-c');
|
const raw = prisma.__store.get('user-c');
|
||||||
const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds));
|
const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds));
|
||||||
expect(decrypted.username).toBe('new@example.test');
|
expect(decrypted.username).toBe('new@example.test');
|
||||||
expect(decrypted.password).toBe('original-secret');
|
expect(decrypted.password).toBe('original-secret');
|
||||||
@@ -132,20 +150,26 @@ describe('TenderEmailConfigService', () => {
|
|||||||
const crypto = makeFakeCrypto();
|
const crypto = makeFakeCrypto();
|
||||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||||
|
|
||||||
await service.saveConfig('tenant-d', {
|
await service.saveConfig(
|
||||||
protocol: 'imap',
|
{ userId: 'user-d', tenantId: 'tenant-d' },
|
||||||
encryption: 'ssl-tls',
|
{
|
||||||
username: 'stable@example.test',
|
protocol: 'imap',
|
||||||
password: 'first-secret',
|
encryption: 'ssl-tls',
|
||||||
} as any);
|
username: 'stable@example.test',
|
||||||
|
password: 'first-secret',
|
||||||
|
} as any,
|
||||||
|
);
|
||||||
|
|
||||||
await service.saveConfig('tenant-d', {
|
await service.saveConfig(
|
||||||
protocol: 'imap',
|
{ userId: 'user-d', tenantId: 'tenant-d' },
|
||||||
encryption: 'ssl-tls',
|
{
|
||||||
password: 'rotated-secret',
|
protocol: 'imap',
|
||||||
} as any);
|
encryption: 'ssl-tls',
|
||||||
|
password: 'rotated-secret',
|
||||||
|
} as any,
|
||||||
|
);
|
||||||
|
|
||||||
const raw = prisma.__store.get('tenant-d');
|
const raw = prisma.__store.get('user-d');
|
||||||
const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds));
|
const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds));
|
||||||
expect(decrypted.username).toBe('stable@example.test');
|
expect(decrypted.username).toBe('stable@example.test');
|
||||||
expect(decrypted.password).toBe('rotated-secret');
|
expect(decrypted.password).toBe('rotated-secret');
|
||||||
@@ -156,14 +180,54 @@ describe('TenderEmailConfigService', () => {
|
|||||||
const crypto = makeFakeCrypto();
|
const crypto = makeFakeCrypto();
|
||||||
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||||
|
|
||||||
const result = await service.saveConfig('tenant-e', {
|
const result = await service.saveConfig(
|
||||||
protocol: 'imap',
|
{ userId: 'user-e', tenantId: 'tenant-e' },
|
||||||
encryption: 'ssl-tls',
|
{
|
||||||
username: 'x@example.test',
|
protocol: 'imap',
|
||||||
password: 'y',
|
encryption: 'ssl-tls',
|
||||||
} as any);
|
username: 'x@example.test',
|
||||||
|
password: 'y',
|
||||||
|
} as any,
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).not.toHaveProperty('encryptedInboxCreds');
|
expect(result).not.toHaveProperty('encryptedInboxCreds');
|
||||||
expect(result).not.toHaveProperty('password');
|
expect(result).not.toHaveProperty('password');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('saveConfig writes BOTH userId and tenantId on create (Phase 17, D-01: tenantId stays denormalized)', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const crypto = makeFakeCrypto();
|
||||||
|
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||||
|
|
||||||
|
await service.saveConfig(
|
||||||
|
{ userId: 'user-f', tenantId: 'tenant-f' },
|
||||||
|
{ protocol: 'imap', encryption: 'ssl-tls' } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const raw = prisma.__store.get('user-f');
|
||||||
|
expect(raw.userId).toBe('user-f');
|
||||||
|
expect(raw.tenantId).toBe('tenant-f');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two users of the SAME tenant each get their own row — the second save never overwrites the first (Phase 17, D-01)', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const crypto = makeFakeCrypto();
|
||||||
|
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||||
|
|
||||||
|
await service.saveConfig(
|
||||||
|
{ userId: 'user-g1', tenantId: 'tenant-shared' },
|
||||||
|
{ protocol: 'imap', encryption: 'ssl-tls', host: 'imap.user-g1.test' } as any,
|
||||||
|
);
|
||||||
|
await service.saveConfig(
|
||||||
|
{ userId: 'user-g2', tenantId: 'tenant-shared' },
|
||||||
|
{ protocol: 'imap', encryption: 'ssl-tls', host: 'imap.user-g2.test' } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const configG1 = await service.getConfigForApi('user-g1');
|
||||||
|
const configG2 = await service.getConfigForApi('user-g2');
|
||||||
|
|
||||||
|
expect(configG1!.host).toBe('imap.user-g1.test');
|
||||||
|
expect(configG2!.host).toBe('imap.user-g2.test');
|
||||||
|
expect(prisma.__store.size).toBe(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { TenderEmailConfigDto } from './dto/tender-email-config.dto';
|
|||||||
*/
|
*/
|
||||||
const EMAIL_CONFIG_SAFE_SELECT = {
|
const EMAIL_CONFIG_SAFE_SELECT = {
|
||||||
id: true,
|
id: true,
|
||||||
|
userId: true,
|
||||||
tenantId: true,
|
tenantId: true,
|
||||||
protocol: true,
|
protocol: true,
|
||||||
host: true,
|
host: true,
|
||||||
@@ -25,25 +26,36 @@ const EMAIL_CONFIG_SAFE_SELECT = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TenderEmailConfigService — per-tenant admin CRUD for the portal-alert
|
* TenderEmailConfigService — per-USER CRUD for the portal-alert mailbox
|
||||||
* mailbox config (Phase 14, Plan 03, INGEST-05/CONFIG-02, D-06/D-07).
|
* config (Phase 14, Plan 03, INGEST-05/CONFIG-02, D-06/D-07; ownership
|
||||||
* Structural clone of DkvService's config half (safe-select + encrypt-
|
* moved from tenant to user in Phase 17, Plan 01, D-01). Structural clone
|
||||||
* preserve-empty semantics), mirroring the exact same pattern already
|
* of DkvService's config half (safe-select + encrypt-preserve-empty
|
||||||
* proven for DKV's own (separate, D-03) mailbox config.
|
* semantics), mirroring the exact same pattern already proven for DKV's
|
||||||
|
* own (separate, D-03) mailbox config.
|
||||||
|
*
|
||||||
|
* Ownership (Phase 17, D-01): a mailbox belongs to exactly one user
|
||||||
|
* (`userId @unique`) — two users at the same tenant each connect their own
|
||||||
|
* inbox independently, neither can read or overwrite the other's config.
|
||||||
|
* `tenantId` stays denormalized on every row (SMTP resolution, same role as
|
||||||
|
* `tenantId` on TenderMatch) and is written on both create and update,
|
||||||
|
* since a user's tenant can in principle change.
|
||||||
*
|
*
|
||||||
* Security:
|
* Security:
|
||||||
* - T-07-12: encryptedInboxCreds is excluded from every read-path select;
|
* - T-07-12: encryptedInboxCreds is excluded from every read-path select;
|
||||||
* getConfigForApi returns `hasPassword: boolean` instead of the password.
|
* getConfigForApi returns `hasPassword: boolean` instead of the password.
|
||||||
* - T-05-13: decrypted credentials only ever exist within a method's local
|
* - T-05-13: decrypted credentials only ever exist within a method's local
|
||||||
* scope — never logged.
|
* scope — never logged.
|
||||||
|
* - T-17-01: userId/tenantId are supplied by the caller from the auth
|
||||||
|
* context (TendersController.extractTriageContext) — this service never
|
||||||
|
* derives ownership from anything the DTO carries.
|
||||||
*
|
*
|
||||||
* This service is used ONLY by the admin GET/PUT /email-config routes
|
* This service is used ONLY by the GET/PUT /email-config routes
|
||||||
* (TendersController). EmailAlertAdapter's own per-tenant poll-time fan-out
|
* (TendersController). EmailAlertAdapter's own poll-time fan-out decrypts
|
||||||
* decrypts credentials independently via a direct CryptoService
|
* credentials independently via a direct CryptoService injection
|
||||||
* injection (RESEARCH.md Pattern 1) — it does NOT go through this service,
|
* (RESEARCH.md Pattern 1) — it does NOT go through this service, since the
|
||||||
* since the adapter's cross-tenant `findMany({where:{isActive:true}})` read
|
* adapter's cross-tenant `findMany({where:{isActive:true}})` read is a
|
||||||
* is a deliberate platform-scheduler exception (see EmailAlertAdapter's
|
* deliberate platform-scheduler exception (see EmailAlertAdapter's
|
||||||
* docstring), structurally different from this service's tenant-scoped CRUD.
|
* docstring), structurally different from this service's per-user CRUD.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TenderEmailConfigService {
|
export class TenderEmailConfigService {
|
||||||
@@ -54,11 +66,12 @@ export class TenderEmailConfigService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Load config for API response: safe fields + decrypted username +
|
* Load config for API response: safe fields + decrypted username +
|
||||||
* hasPassword flag. T-07-12: password is NEVER returned.
|
* hasPassword flag. T-07-12: password is NEVER returned. Scoped strictly
|
||||||
|
* by userId (T-17-01) — a user only ever reads their own mailbox.
|
||||||
*/
|
*/
|
||||||
async getConfigForApi(tenantId: string) {
|
async getConfigForApi(userId: string) {
|
||||||
const safe = await this.prisma.tenderEmailConfig.findUnique({
|
const safe = await this.prisma.tenderEmailConfig.findUnique({
|
||||||
where: { tenantId },
|
where: { userId },
|
||||||
select: EMAIL_CONFIG_SAFE_SELECT,
|
select: EMAIL_CONFIG_SAFE_SELECT,
|
||||||
});
|
});
|
||||||
if (!safe) return null;
|
if (!safe) return null;
|
||||||
@@ -66,7 +79,7 @@ export class TenderEmailConfigService {
|
|||||||
let username: string | null = null;
|
let username: string | null = null;
|
||||||
let hasPassword = false;
|
let hasPassword = false;
|
||||||
try {
|
try {
|
||||||
const raw = await this.prisma.tenderEmailConfig.findUnique({ where: { tenantId } });
|
const raw = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } });
|
||||||
if (raw?.encryptedInboxCreds) {
|
if (raw?.encryptedInboxCreds) {
|
||||||
const creds = JSON.parse(this.crypto.decrypt(raw.encryptedInboxCreds)) as {
|
const creds = JSON.parse(this.crypto.decrypt(raw.encryptedInboxCreds)) as {
|
||||||
username?: string;
|
username?: string;
|
||||||
@@ -83,7 +96,7 @@ export class TenderEmailConfigService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upsert TenderEmailConfig for a tenant.
|
* Upsert TenderEmailConfig for a user.
|
||||||
*
|
*
|
||||||
* Credential handling (identical semantics to DkvService.saveConfig):
|
* Credential handling (identical semantics to DkvService.saveConfig):
|
||||||
* - dto.password non-empty: re-encrypt {username, password} together.
|
* - dto.password non-empty: re-encrypt {username, password} together.
|
||||||
@@ -91,10 +104,15 @@ export class TenderEmailConfigService {
|
|||||||
* password, re-encrypt with the new username.
|
* password, re-encrypt with the new username.
|
||||||
* - both empty/undefined: preserve existing encryptedInboxCreds entirely.
|
* - both empty/undefined: preserve existing encryptedInboxCreds entirely.
|
||||||
*
|
*
|
||||||
|
* tenantId is written on both create AND update (Phase 17, D-01): it is
|
||||||
|
* denormalized, so if the resolved tenant for this user ever changes the
|
||||||
|
* stored value must follow.
|
||||||
|
*
|
||||||
* T-07-12: Returns safe select (no encryptedInboxCreds).
|
* T-07-12: Returns safe select (no encryptedInboxCreds).
|
||||||
* T-05-13: Never logs decrypted credentials.
|
* T-05-13: Never logs decrypted credentials.
|
||||||
*/
|
*/
|
||||||
async saveConfig(tenantId: string, dto: TenderEmailConfigDto) {
|
async saveConfig(ctx: { userId: string; tenantId: string }, dto: TenderEmailConfigDto) {
|
||||||
|
const { userId, tenantId } = ctx;
|
||||||
let encryptedInboxCreds: string | undefined;
|
let encryptedInboxCreds: string | undefined;
|
||||||
|
|
||||||
const credChanged =
|
const credChanged =
|
||||||
@@ -107,7 +125,7 @@ export class TenderEmailConfigService {
|
|||||||
|
|
||||||
if (!dto.password || !dto.username) {
|
if (!dto.password || !dto.username) {
|
||||||
try {
|
try {
|
||||||
const existing = await this.prisma.tenderEmailConfig.findUnique({ where: { tenantId } });
|
const existing = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } });
|
||||||
if (existing?.encryptedInboxCreds) {
|
if (existing?.encryptedInboxCreds) {
|
||||||
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
|
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
|
||||||
username?: string;
|
username?: string;
|
||||||
@@ -136,10 +154,13 @@ export class TenderEmailConfigService {
|
|||||||
...(encryptedInboxCreds !== undefined && { encryptedInboxCreds }),
|
...(encryptedInboxCreds !== undefined && { encryptedInboxCreds }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// tenantId is written on BOTH create and update — it is denormalized
|
||||||
|
// (Phase 17, D-01), so a user's resolved tenant must always overwrite
|
||||||
|
// whatever was stored previously, not just be set once on create.
|
||||||
return this.prisma.tenderEmailConfig.upsert({
|
return this.prisma.tenderEmailConfig.upsert({
|
||||||
where: { tenantId },
|
where: { userId },
|
||||||
create: { tenantId, ...data },
|
create: { userId, tenantId, ...data },
|
||||||
update: data,
|
update: { tenantId, ...data },
|
||||||
select: EMAIL_CONFIG_SAFE_SELECT,
|
select: EMAIL_CONFIG_SAFE_SELECT,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,13 +104,17 @@ function makeFakeRssFeedService() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fake TenderEmailConfigService for controller-level wiring tests (Plan
|
* Fake TenderEmailConfigService for controller-level wiring tests (Plan
|
||||||
* 14-03, D-06/D-07/CONFIG-02). Default stubs echo/return null; individual
|
* 14-03, D-06/D-07/CONFIG-02; per-user ownership since Phase 17, Plan 01,
|
||||||
* tests override via `.mockResolvedValueOnce`/reassigning the mock.
|
* D-01). Default stubs echo/return null; individual tests override via
|
||||||
|
* `.mockResolvedValueOnce`/reassigning the mock.
|
||||||
*/
|
*/
|
||||||
function makeFakeEmailConfigService() {
|
function makeFakeEmailConfigService() {
|
||||||
return {
|
return {
|
||||||
getConfigForApi: vi.fn(async (_tenantId: string) => null as any),
|
getConfigForApi: vi.fn(async (_userId: string) => null as any),
|
||||||
saveConfig: vi.fn(async (_tenantId: string, dto: any) => ({ id: 'ec-1', ...dto })),
|
saveConfig: vi.fn(async (_ctx: { userId: string; tenantId: string }, dto: any) => ({
|
||||||
|
id: 'ec-1',
|
||||||
|
...dto,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -959,12 +963,13 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', (
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('TendersController — email-config (Plan 14-03, per-tenant, D-06/D-07/T-14-03-05)', () => {
|
describe('TendersController — email-config (Plan 14-03, per-user since Phase 17 Plan 01 D-01, T-14-03-05/T-17-01)', () => {
|
||||||
it('GET /email-config resolves tenantId from the auth context and delegates to tenderEmailConfig.getConfigForApi(tenantId)', async () => {
|
it('GET /email-config resolves userId from the auth context and delegates to tenderEmailConfig.getConfigForApi(userId)', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
const emailConfigService = makeFakeEmailConfigService();
|
const emailConfigService = makeFakeEmailConfigService();
|
||||||
emailConfigService.getConfigForApi.mockResolvedValueOnce({
|
emailConfigService.getConfigForApi.mockResolvedValueOnce({
|
||||||
|
userId: 'u1',
|
||||||
tenantId: 'tenant1',
|
tenantId: 'tenant1',
|
||||||
protocol: 'imap',
|
protocol: 'imap',
|
||||||
hasPassword: true,
|
hasPassword: true,
|
||||||
@@ -981,11 +986,16 @@ describe('TendersController — email-config (Plan 14-03, per-tenant, D-06/D-07/
|
|||||||
|
|
||||||
const result = await controller.getEmailConfig(makeFakeRequest('u1', 'tenant1'));
|
const result = await controller.getEmailConfig(makeFakeRequest('u1', 'tenant1'));
|
||||||
|
|
||||||
expect(emailConfigService.getConfigForApi).toHaveBeenCalledWith('tenant1');
|
expect(emailConfigService.getConfigForApi).toHaveBeenCalledWith('u1');
|
||||||
expect(result).toEqual({ tenantId: 'tenant1', protocol: 'imap', hasPassword: true });
|
expect(result).toEqual({
|
||||||
|
userId: 'u1',
|
||||||
|
tenantId: 'tenant1',
|
||||||
|
protocol: 'imap',
|
||||||
|
hasPassword: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PUT /email-config delegates to tenderEmailConfig.saveConfig with tenantId from the auth context, never the body', async () => {
|
it('PUT /email-config delegates to tenderEmailConfig.saveConfig with {userId, tenantId} from the auth context, never the body', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
const emailConfigService = makeFakeEmailConfigService();
|
const emailConfigService = makeFakeEmailConfigService();
|
||||||
@@ -1002,7 +1012,31 @@ describe('TendersController — email-config (Plan 14-03, per-tenant, D-06/D-07/
|
|||||||
const dto = { protocol: 'imap', encryption: 'ssl-tls', host: 'imap.example.test' } as any;
|
const dto = { protocol: 'imap', encryption: 'ssl-tls', host: 'imap.example.test' } as any;
|
||||||
await controller.saveEmailConfig(dto, makeFakeRequest('u1', 'tenant1'));
|
await controller.saveEmailConfig(dto, makeFakeRequest('u1', 'tenant1'));
|
||||||
|
|
||||||
expect(emailConfigService.saveConfig).toHaveBeenCalledWith('tenant1', dto);
|
expect(emailConfigService.saveConfig).toHaveBeenCalledWith(
|
||||||
|
{ userId: 'u1', tenantId: 'tenant1' },
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two different users of the same tenant each resolve their own userId — never the other user\'s (T-17-01, IDOR)', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
|
const emailConfigService = makeFakeEmailConfigService();
|
||||||
|
const controller = new TendersController(
|
||||||
|
prisma as any,
|
||||||
|
scheduler,
|
||||||
|
makeFakeTriageService() as any,
|
||||||
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
|
emailConfigService as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
await controller.getEmailConfig(makeFakeRequest('user-a', 'tenant1'));
|
||||||
|
await controller.getEmailConfig(makeFakeRequest('user-b', 'tenant1'));
|
||||||
|
|
||||||
|
expect(emailConfigService.getConfigForApi).toHaveBeenNthCalledWith(1, 'user-a');
|
||||||
|
expect(emailConfigService.getConfigForApi).toHaveBeenNthCalledWith(2, 'user-b');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -59,15 +59,17 @@ const DOE_SOURCE_TYPE = 'doe-opendata';
|
|||||||
* shared platform-wide poll schedule is a platform-admin action, not a
|
* shared platform-wide poll schedule is a platform-admin action, not a
|
||||||
* per-tenant module feature.
|
* per-tenant module feature.
|
||||||
*
|
*
|
||||||
* Phase 14, Plan 03 (INGEST-05, D-13): `GET`/`PUT /email-config` are, like
|
* Phase 14, Plan 03 (INGEST-05, D-13) originally made `GET`/`PUT
|
||||||
* `source-config`/`rss-feeds`, per-handler `@Roles(ADMIN, SUPER_ADMIN)`-
|
* /email-config` per-handler `@Roles(ADMIN, SUPER_ADMIN)`-guarded and
|
||||||
* guarded — but UNLIKE those (platform-wide singletons/lists), the email
|
* per-TENANT. Phase 17, Plan 01 (D-01) changed this: the mailbox is now
|
||||||
* mailbox config is per-TENANT: tenantId is resolved from the auth context,
|
* per-USER — every user with module access connects their own inbox, so
|
||||||
* never the body (T-14-03-05/IDOR). This is also the plan that breaks the
|
* these two routes are `@UseModule('tender-radar')`-gated like the other
|
||||||
* "GET/GET :id are never row-scoped" invariant above, narrowly: `listTenders`/
|
* per-user routes below, and `userId` (not `tenantId`) is the ownership
|
||||||
* `getTender` now resolve the requesting tenant to apply the D-13 OR[global,
|
* key, resolved from the auth context, never the body (T-14-03-05/T-17-01/
|
||||||
* mine] visibility filter for PRIVATE (email-alert) tenders only — public
|
* IDOR). `listTenders`/`getTender` still resolve the requesting tenant to
|
||||||
* tenders (D-03) remain visible to every tenant exactly as before.
|
* apply the D-13 OR[global, mine] visibility filter for PRIVATE
|
||||||
|
* (email-alert) tenders — public tenders (D-03) remain visible to every
|
||||||
|
* tenant exactly as before; that part of D-13 is unaffected by D-01.
|
||||||
*/
|
*/
|
||||||
@Controller('modules/tender-radar')
|
@Controller('modules/tender-radar')
|
||||||
export class TendersController {
|
export class TendersController {
|
||||||
@@ -268,37 +270,40 @@ export class TendersController {
|
|||||||
return this.tenderRssFeedSource.remove(feedId);
|
return this.tenderRssFeedSource.remove(feedId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── E-Mail-Alerts config (Roles-guarded, PER-TENANT, D-06/D-07/D-13) ──────
|
// ─── E-Mail-Alerts config (ModuleGuard-gated, PER-USER, Phase 17 D-01) ─────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /modules/tender-radar/email-config — this tenant's portal-alert
|
* GET /modules/tender-radar/email-config — the requesting USER's own
|
||||||
* mailbox config (safe-select — never the password, T-07-12). Unlike
|
* portal-alert mailbox config (safe-select — never the password,
|
||||||
* `source-config`/`rss-feeds` (platform-wide), this is PER-TENANT:
|
* T-07-12). Phase 17 (D-01): ownership moved from tenant to user — every
|
||||||
* tenantId is resolved from the auth context, never a query/body field
|
* user with module access manages their own mailbox, so the Roles guard
|
||||||
* (T-14-03-05 / V4 — IDOR).
|
* (previously ADMIN/SUPER_ADMIN only) is replaced with `@UseModule`, the
|
||||||
|
* same access gate as every other per-user route below. `userId` comes
|
||||||
|
* exclusively from the auth context, never a query/body field
|
||||||
|
* (T-14-03-05 / T-17-01 / V4 — IDOR).
|
||||||
*
|
*
|
||||||
* MUST be declared before `@Get(':id')` below — same route-order pitfall
|
* MUST be declared before `@Get(':id')` below — same route-order pitfall
|
||||||
* as `source-config`/`coverage`/`triage`/`rss-feeds`/... above (Pitfall 5).
|
* as `source-config`/`coverage`/`triage`/`rss-feeds`/... above (Pitfall 5).
|
||||||
*/
|
*/
|
||||||
@Get('email-config')
|
@Get('email-config')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
@UseModule('tender-radar')
|
||||||
async getEmailConfig(@Req() req: Request) {
|
async getEmailConfig(@Req() req: Request) {
|
||||||
const { tenantId } = this.extractTriageContext(req);
|
const { userId } = this.extractTriageContext(req);
|
||||||
return this.tenderEmailConfig.getConfigForApi(tenantId);
|
return this.tenderEmailConfig.getConfigForApi(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /modules/tender-radar/email-config — upsert this tenant's mailbox
|
* PUT /modules/tender-radar/email-config — upsert the requesting USER's
|
||||||
* config. tenantId comes exclusively from the auth context — `dto` never
|
* own mailbox config. userId/tenantId come exclusively from the auth
|
||||||
* carries a tenantId field (T-14-03-05 / V4 — IDOR). Credential
|
* context — `dto` never carries either field (T-14-03-05 / T-17-01 / V4
|
||||||
* encrypt-preserve-empty semantics live in TenderEmailConfigService
|
* — IDOR). Credential encrypt-preserve-empty semantics live in
|
||||||
* (mirrors DkvService.saveConfig / T-07-12).
|
* TenderEmailConfigService (mirrors DkvService.saveConfig / T-07-12).
|
||||||
*/
|
*/
|
||||||
@Put('email-config')
|
@Put('email-config')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
@UseModule('tender-radar')
|
||||||
async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: Request) {
|
async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: Request) {
|
||||||
const { tenantId } = this.extractTriageContext(req);
|
const { userId, tenantId } = this.extractTriageContext(req);
|
||||||
return this.tenderEmailConfig.saveConfig(tenantId, dto);
|
return this.tenderEmailConfig.saveConfig({ userId, tenantId }, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { EmailAlertConfigForm } from '../settings/components/EmailAlertConfigForm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Meine Quellen" — the per-user Ausschreibungs-Radar module page (Phase
|
||||||
|
* 17, Plan 01, D-01/D-05).
|
||||||
|
*
|
||||||
|
* Until this plan, the alert mailbox lived on the admin-only
|
||||||
|
* `/modules/tender-radar/settings` page, one config per TENANT — a second
|
||||||
|
* colleague with their own portal account could not connect their own
|
||||||
|
* inbox. Phase 17 moves ownership to the USER (TenderEmailConfig.userId).
|
||||||
|
* This page is where every user with module access manages their own
|
||||||
|
* mailbox, reusing the existing `EmailAlertConfigForm` UNCHANGED — it
|
||||||
|
* already talks to the same `GET`/`PUT /modules/tender-radar/email-config`
|
||||||
|
* endpoints, which now resolve ownership by userId instead of tenantId
|
||||||
|
* (TendersController.getEmailConfig/saveEmailConfig).
|
||||||
|
*
|
||||||
|
* Deliberately a SEPARATE page from `/settings` (D-01 open point 4), not a
|
||||||
|
* new section on `/settings/general`: module-specific settings stay with
|
||||||
|
* the module rather than accumulating on a generic account page as more
|
||||||
|
* modules adopt the same per-user pattern (DKV-Fleet, future modules).
|
||||||
|
*
|
||||||
|
* D-05 (harte Grenze): `Tender` stays platform-global — connecting a
|
||||||
|
* mailbox here only changes WHO feeds sources in, not WHO sees hits. The
|
||||||
|
* intro text below says so explicitly, so nobody is surprised that a
|
||||||
|
* colleague's inbox produces results everyone at the tenant can see.
|
||||||
|
*
|
||||||
|
* No module-loader whitelist change needed — nested route under the
|
||||||
|
* already-whitelisted `tender-radar` module page (same reasoning as
|
||||||
|
* `/settings`, Plan 10-02).
|
||||||
|
*/
|
||||||
|
export default function TenderRadarMySourcesPage() {
|
||||||
|
const t = useTranslations('tenderRadar');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl p-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight mb-2">
|
||||||
|
{t('mySources.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="mb-6 text-sm text-muted-foreground">
|
||||||
|
{t('mySources.intro')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||||
|
{t('mySources.mailboxSectionTitle')}
|
||||||
|
</h2>
|
||||||
|
<EmailAlertConfigForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -933,6 +933,11 @@
|
|||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"saveSuccess": "Einstellungen gespeichert.",
|
"saveSuccess": "Einstellungen gespeichert.",
|
||||||
"errorSave": "Einstellungen konnten nicht gespeichert werden"
|
"errorSave": "Einstellungen konnten nicht gespeichert werden"
|
||||||
|
},
|
||||||
|
"mySources": {
|
||||||
|
"title": "Meine Quellen",
|
||||||
|
"intro": "Ausschreibungen, die aus Ihrem Postfach hereinkommen, erscheinen danach in der Trefferliste aller Kolleginnen und Kollegen Ihres Mandanten — es gibt keine getrennte Sichtbarkeit je Postfach.",
|
||||||
|
"mailboxSectionTitle": "Mein Postfach"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -933,6 +933,11 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saveSuccess": "Settings saved.",
|
"saveSuccess": "Settings saved.",
|
||||||
"errorSave": "Could not save settings"
|
"errorSave": "Could not save settings"
|
||||||
|
},
|
||||||
|
"mySources": {
|
||||||
|
"title": "My sources",
|
||||||
|
"intro": "Tenders that arrive through your mailbox will then appear in the results list for every colleague at your organization — there is no separate visibility per mailbox.",
|
||||||
|
"mailboxSectionTitle": "My mailbox"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user