From a845590c70660e647b3fbe2c33d5c7b096226b11 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Thu, 27 Aug 2026 19:23:07 +0200 Subject: [PATCH 1/3] notification system with email --- bun.lock | 6 + package.json | 4 +- .../server/infra/notifications/config.test.ts | 123 +++++++++++++++++ src/lib/server/infra/notifications/config.ts | 73 ++++++++++ src/lib/server/infra/notifications/index.ts | 12 ++ .../notifications/mail-notification.test.ts | 74 ++++++++++ .../infra/notifications/mail-notification.ts | 41 ++++++ .../notifications/mail.transport.test.ts | 113 ++++++++++++++++ .../infra/notifications/mail.transport.ts | 46 +++++++ .../infra/notifications/notification.ts | 8 ++ .../server/infra/notifications/notify.test.ts | 89 +++++++++++++ src/lib/server/infra/notifications/notify.ts | 38 ++++++ .../notifications/slack-notification.test.ts | 18 +++ .../infra/notifications/slack-notification.ts | 27 ++++ .../infra/notifications/slack.transport.ts | 8 ++ .../infra/notifications/template.test.ts | 63 +++++++++ .../server/infra/notifications/template.ts | 55 ++++++++ .../server/infra/notifications/transport.ts | 5 + src/notifications/email/invite.html | 126 ++++++++++++++++++ 19 files changed, 928 insertions(+), 1 deletion(-) create mode 100644 src/lib/server/infra/notifications/config.test.ts create mode 100644 src/lib/server/infra/notifications/config.ts create mode 100644 src/lib/server/infra/notifications/index.ts create mode 100644 src/lib/server/infra/notifications/mail-notification.test.ts create mode 100644 src/lib/server/infra/notifications/mail-notification.ts create mode 100644 src/lib/server/infra/notifications/mail.transport.test.ts create mode 100644 src/lib/server/infra/notifications/mail.transport.ts create mode 100644 src/lib/server/infra/notifications/notification.ts create mode 100644 src/lib/server/infra/notifications/notify.test.ts create mode 100644 src/lib/server/infra/notifications/notify.ts create mode 100644 src/lib/server/infra/notifications/slack-notification.test.ts create mode 100644 src/lib/server/infra/notifications/slack-notification.ts create mode 100644 src/lib/server/infra/notifications/slack.transport.ts create mode 100644 src/lib/server/infra/notifications/template.test.ts create mode 100644 src/lib/server/infra/notifications/template.ts create mode 100644 src/lib/server/infra/notifications/transport.ts create mode 100644 src/notifications/email/invite.html diff --git a/bun.lock b/bun.lock index ea57514..c37927e 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@google-cloud/storage": "^8.0.1", "@lucide/svelte": "^1.34.0", "chart.js": "^4.5.1", + "nodemailer": "^9.0.6", }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -19,6 +20,7 @@ "@tailwindcss/postcss": "^4.3.3", "@types/better-sqlite3": "^9.6.0", "@types/node": "^26.4.0", + "@types/nodemailer": "^8.0.1", "@typescript-eslint/eslint-plugin": "^8.68.0", "@typescript-eslint/parser": "^8.68.0", "@vitest/coverage-v8": "^4.1.11", @@ -294,6 +296,8 @@ "@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="], + "@types/nodemailer": ["@types/nodemailer@8.0.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.68.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/type-utils": "8.68.0", "@typescript-eslint/utils": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q=="], @@ -652,6 +656,8 @@ "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], + "nodemailer": ["nodemailer@9.0.6", "", {}, "sha512-IQUGFdhdGwI9+AWX+FpUt4DLmvFaOjTMEoneTIWX/RXxuy1TdenPwWrvFMSfLkPKl+HQEXWuSAxEMMbPYXtBmg=="], + "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], diff --git a/package.json b/package.json index d0d765d..6aedd0b 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@tailwindcss/postcss": "^4.3.3", "@types/better-sqlite3": "^9.6.0", "@types/node": "^26.4.0", + "@types/nodemailer": "^8.0.1", "@typescript-eslint/eslint-plugin": "^8.68.0", "@typescript-eslint/parser": "^8.68.0", "@vitest/coverage-v8": "^4.1.11", @@ -48,6 +49,7 @@ "@getgitops/gitdb": "^0.8.0", "@google-cloud/storage": "^8.0.1", "@lucide/svelte": "^1.34.0", - "chart.js": "^4.5.1" + "chart.js": "^4.5.1", + "nodemailer": "^9.0.6" } } diff --git a/src/lib/server/infra/notifications/config.test.ts b/src/lib/server/infra/notifications/config.test.ts new file mode 100644 index 0000000..0ac5e01 --- /dev/null +++ b/src/lib/server/infra/notifications/config.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { formatSender, isSmtpConfigured, readSmtpConfig, requireSmtpConfig } from './config'; + +const KEYS = [ + 'NOTIFICATION_SMTP_HOST', + 'NOTIFICATION_SMTP_PORT', + 'NOTIFICATION_SMTP_SECURE', + 'NOTIFICATION_SMTP_USER', + 'NOTIFICATION_SMTP_PASSWORD', + 'NOTIFICATION_SMTP_FROM', + 'NOTIFICATION_SMTP_FROM_NAME', + 'NOTIFICATION_SMTP_REJECT_UNAUTHORIZED', +]; + +const original = new Map(); + +beforeEach(() => { + for (const key of KEYS) { + original.set(key, process.env[key]); + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of KEYS) { + const value = original.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +function setMinimalConfig() { + process.env.NOTIFICATION_SMTP_HOST = 'smtp.example.com'; + process.env.NOTIFICATION_SMTP_FROM = 'noreply@example.com'; +} + +describe('readSmtpConfig', () => { + it('returns null when the host or the sender are missing', () => { + expect(readSmtpConfig()).toBeNull(); + expect(isSmtpConfigured()).toBe(false); + + process.env.NOTIFICATION_SMTP_HOST = 'smtp.example.com'; + expect(readSmtpConfig()).toBeNull(); + }); + + it('applies defaults for the optional variables', () => { + setMinimalConfig(); + + expect(readSmtpConfig()).toEqual({ + host: 'smtp.example.com', + port: 587, + secure: false, + user: null, + password: null, + from: 'noreply@example.com', + fromName: null, + rejectUnauthorized: true, + }); + expect(isSmtpConfigured()).toBe(true); + }); + + it('enables TLS by default on port 465', () => { + setMinimalConfig(); + process.env.NOTIFICATION_SMTP_PORT = '465'; + + expect(readSmtpConfig()?.secure).toBe(true); + }); + + it('lets the secure flag be overridden explicitly', () => { + setMinimalConfig(); + process.env.NOTIFICATION_SMTP_PORT = '465'; + process.env.NOTIFICATION_SMTP_SECURE = 'false'; + + expect(readSmtpConfig()?.secure).toBe(false); + }); + + it('reads credentials when both are provided', () => { + setMinimalConfig(); + process.env.NOTIFICATION_SMTP_USER = 'mailer'; + process.env.NOTIFICATION_SMTP_PASSWORD = 's3cret'; + + const config = requireSmtpConfig(); + expect(config.user).toBe('mailer'); + expect(config.password).toBe('s3cret'); + }); + + it('rejects a partial credentials pair', () => { + setMinimalConfig(); + process.env.NOTIFICATION_SMTP_USER = 'mailer'; + + expect(() => readSmtpConfig()).toThrow(/must be set together/); + }); + + it('rejects an invalid port', () => { + setMinimalConfig(); + process.env.NOTIFICATION_SMTP_PORT = 'not-a-port'; + + expect(() => readSmtpConfig()).toThrow(/valid port number/); + }); + + it('keeps certificate validation on unless explicitly disabled', () => { + setMinimalConfig(); + process.env.NOTIFICATION_SMTP_REJECT_UNAUTHORIZED = 'false'; + + expect(readSmtpConfig()?.rejectUnauthorized).toBe(false); + }); +}); + +describe('requireSmtpConfig', () => { + it('throws when SMTP is not configured', () => { + expect(() => requireSmtpConfig()).toThrow(/SMTP is not configured/); + }); +}); + +describe('formatSender', () => { + it('includes the display name when present', () => { + setMinimalConfig(); + expect(formatSender(requireSmtpConfig())).toBe('noreply@example.com'); + + process.env.NOTIFICATION_SMTP_FROM_NAME = 'GitOps'; + expect(formatSender(requireSmtpConfig())).toBe('GitOps '); + }); +}); diff --git a/src/lib/server/infra/notifications/config.ts b/src/lib/server/infra/notifications/config.ts new file mode 100644 index 0000000..78842b4 --- /dev/null +++ b/src/lib/server/infra/notifications/config.ts @@ -0,0 +1,73 @@ +export type SmtpConfig = { + host: string; + port: number; + secure: boolean; + user: string | null; + password: string | null; + from: string; + fromName: string | null; + rejectUnauthorized: boolean; +}; + +const DEFAULT_PORT = 587; + +function value(name: string): string | null { + return process.env[name]?.trim() || null; +} + +function flag(name: string, fallback: boolean): boolean { + const raw = value(name); + if (raw === null) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw.toLowerCase()); +} + +/** Reads NOTIFICATION_SMTP_* on every call so tests and config reloads see fresh values. */ +export function readSmtpConfig(): SmtpConfig | null { + const host = value('NOTIFICATION_SMTP_HOST'); + const from = value('NOTIFICATION_SMTP_FROM'); + if (!host || !from) { + return null; + } + + const rawPort = value('NOTIFICATION_SMTP_PORT'); + const port = rawPort ? Number(rawPort) : DEFAULT_PORT; + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error('NOTIFICATION_SMTP_PORT must be a valid port number'); + } + + const user = value('NOTIFICATION_SMTP_USER'); + const password = value('NOTIFICATION_SMTP_PASSWORD'); + if (Boolean(user) !== Boolean(password)) { + throw new Error('NOTIFICATION_SMTP_USER and NOTIFICATION_SMTP_PASSWORD must be set together'); + } + + return { + host, + port, + secure: flag('NOTIFICATION_SMTP_SECURE', port === 465), + user, + password, + from, + fromName: value('NOTIFICATION_SMTP_FROM_NAME'), + // opt-in only: disabling certificate validation exposes the SMTP session to MITM + rejectUnauthorized: flag('NOTIFICATION_SMTP_REJECT_UNAUTHORIZED', true), + }; +} + +export function requireSmtpConfig(): SmtpConfig { + const config = readSmtpConfig(); + if (!config) { + throw new Error( + 'SMTP is not configured. Set NOTIFICATION_SMTP_HOST and NOTIFICATION_SMTP_FROM.', + ); + } + return config; +} + +export function isSmtpConfigured(): boolean { + return readSmtpConfig() !== null; +} + +export function formatSender(config: SmtpConfig): string { + return config.fromName ? `${config.fromName} <${config.from}>` : config.from; +} diff --git a/src/lib/server/infra/notifications/index.ts b/src/lib/server/infra/notifications/index.ts new file mode 100644 index 0000000..a0e51eb --- /dev/null +++ b/src/lib/server/infra/notifications/index.ts @@ -0,0 +1,12 @@ +export { Notification, type NotificationChannel } from './notification'; +export { MailNotification, type MailNotificationInput } from './mail-notification'; +export { SlackNotification, type SlackNotificationInput } from './slack-notification'; +export { notify, registerTransport, resetTransports } from './notify'; +export type { NotificationTransport } from './transport'; +export { + renderTemplate, + renderTemplateString, + clearTemplateCache, + type TemplateVariables, +} from './template'; +export { isSmtpConfigured, readSmtpConfig, requireSmtpConfig, type SmtpConfig } from './config'; diff --git a/src/lib/server/infra/notifications/mail-notification.test.ts b/src/lib/server/infra/notifications/mail-notification.test.ts new file mode 100644 index 0000000..d5632a0 --- /dev/null +++ b/src/lib/server/infra/notifications/mail-notification.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { MailNotification } from './mail-notification'; + +describe('MailNotification', () => { + it('normalizes a single recipient into a list', () => { + const notification = new MailNotification({ + to: ' user@example.com ', + subject: ' Welcome ', + content: '

Hi

', + }); + + expect(notification.channel).toBe('mail'); + expect(notification.to).toEqual(['user@example.com']); + expect(notification.subject).toBe('Welcome'); + }); + + it('keeps multiple recipients and drops empty entries', () => { + const notification = new MailNotification({ + to: ['a@example.com', ' ', 'b@example.com'], + subject: 'Welcome', + content: '

Hi

', + }); + + expect(notification.to).toEqual(['a@example.com', 'b@example.com']); + }); + + describe('validate', () => { + it('requires at least one recipient', () => { + expect(() => + new MailNotification({ to: [], subject: 'Welcome', content: '

Hi

' }).validate(), + ).toThrow(/at least one recipient/); + }); + + it('rejects malformed recipients', () => { + expect(() => + new MailNotification({ + to: 'not-an-email', + subject: 'Welcome', + content: '

Hi

', + }).validate(), + ).toThrow(/not a valid email/); + }); + + it('requires a subject', () => { + expect(() => + new MailNotification({ + to: 'user@example.com', + subject: ' ', + content: '

Hi

', + }).validate(), + ).toThrow(/requires a subject/); + }); + + it('requires content', () => { + expect(() => + new MailNotification({ + to: 'user@example.com', + subject: 'Welcome', + content: ' ', + }).validate(), + ).toThrow(/requires a content/); + }); + + it('passes for a complete notification', () => { + expect(() => + new MailNotification({ + to: 'user@example.com', + subject: 'Welcome', + content: '

Hi

', + }).validate(), + ).not.toThrow(); + }); + }); +}); diff --git a/src/lib/server/infra/notifications/mail-notification.ts b/src/lib/server/infra/notifications/mail-notification.ts new file mode 100644 index 0000000..4180296 --- /dev/null +++ b/src/lib/server/infra/notifications/mail-notification.ts @@ -0,0 +1,41 @@ +import { Notification, type NotificationChannel } from './notification'; + +export type MailNotificationInput = { + to: string | string[]; + subject: string; + /** HTML body, usually produced by `renderTemplate()`. */ + content: string; +}; + +export class MailNotification extends Notification { + readonly channel: NotificationChannel = 'mail'; + readonly to: string[]; + readonly subject: string; + readonly content: string; + + constructor(input: MailNotificationInput) { + super(); + this.to = (Array.isArray(input.to) ? input.to : [input.to]) + .map((address) => address.trim()) + .filter(Boolean); + this.subject = input.subject?.trim() ?? ''; + this.content = input.content ?? ''; + } + + validate(): void { + if (this.to.length === 0) { + throw new Error('MailNotification requires at least one recipient'); + } + for (const address of this.to) { + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)) { + throw new Error(`MailNotification recipient is not a valid email: ${address}`); + } + } + if (!this.subject) { + throw new Error('MailNotification requires a subject'); + } + if (!this.content.trim()) { + throw new Error('MailNotification requires a content'); + } + } +} diff --git a/src/lib/server/infra/notifications/mail.transport.test.ts b/src/lib/server/infra/notifications/mail.transport.test.ts new file mode 100644 index 0000000..09c81eb --- /dev/null +++ b/src/lib/server/infra/notifications/mail.transport.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const sendMail = vi.fn(async () => ({ messageId: 'id' })); +const createTransport = vi.fn(() => ({ sendMail })); + +vi.mock('nodemailer', () => ({ + default: { createTransport: (...args: unknown[]) => createTransport(...(args as [])) }, + createTransport: (...args: unknown[]) => createTransport(...(args as [])), +})); + +const { mailTransport, resetMailTransport } = await import('./mail.transport'); +const { MailNotification } = await import('./mail-notification'); + +const KEYS = [ + 'NOTIFICATION_SMTP_HOST', + 'NOTIFICATION_SMTP_PORT', + 'NOTIFICATION_SMTP_SECURE', + 'NOTIFICATION_SMTP_USER', + 'NOTIFICATION_SMTP_PASSWORD', + 'NOTIFICATION_SMTP_FROM', + 'NOTIFICATION_SMTP_FROM_NAME', + 'NOTIFICATION_SMTP_REJECT_UNAUTHORIZED', +]; + +const original = new Map(); + +beforeEach(() => { + vi.clearAllMocks(); + resetMailTransport(); + for (const key of KEYS) { + original.set(key, process.env[key]); + delete process.env[key]; + } + process.env.NOTIFICATION_SMTP_HOST = 'smtp.example.com'; + process.env.NOTIFICATION_SMTP_FROM = 'noreply@example.com'; +}); + +afterEach(() => { + for (const key of KEYS) { + const value = original.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +function notification() { + return new MailNotification({ + to: ['user@example.com'], + subject: 'Welcome', + content: '

Hi

', + }); +} + +describe('mailTransport', () => { + it('builds the transporter from the NOTIFICATION_SMTP_* variables', async () => { + process.env.NOTIFICATION_SMTP_PORT = '465'; + process.env.NOTIFICATION_SMTP_USER = 'mailer'; + process.env.NOTIFICATION_SMTP_PASSWORD = 's3cret'; + + await mailTransport.send(notification()); + + expect(createTransport).toHaveBeenCalledWith({ + host: 'smtp.example.com', + port: 465, + secure: true, + auth: { user: 'mailer', pass: 's3cret' }, + tls: { rejectUnauthorized: true }, + }); + }); + + it('omits auth when no credentials are configured', async () => { + await mailTransport.send(notification()); + + expect(createTransport).toHaveBeenCalledWith( + expect.objectContaining({ auth: undefined, port: 587, secure: false }), + ); + }); + + it('sends the notification as an HTML message', async () => { + process.env.NOTIFICATION_SMTP_FROM_NAME = 'GitOps'; + + await mailTransport.send(notification()); + + expect(sendMail).toHaveBeenCalledWith({ + from: 'GitOps ', + to: ['user@example.com'], + subject: 'Welcome', + html: '

Hi

', + }); + }); + + it('reuses the transporter while the configuration does not change', async () => { + await mailTransport.send(notification()); + await mailTransport.send(notification()); + + expect(createTransport).toHaveBeenCalledTimes(1); + }); + + it('rebuilds the transporter when the configuration changes', async () => { + await mailTransport.send(notification()); + process.env.NOTIFICATION_SMTP_HOST = 'smtp2.example.com'; + await mailTransport.send(notification()); + + expect(createTransport).toHaveBeenCalledTimes(2); + }); + + it('fails when SMTP is not configured', async () => { + delete process.env.NOTIFICATION_SMTP_HOST; + + await expect(mailTransport.send(notification())).rejects.toThrow(/SMTP is not configured/); + expect(sendMail).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/server/infra/notifications/mail.transport.ts b/src/lib/server/infra/notifications/mail.transport.ts new file mode 100644 index 0000000..dc933e0 --- /dev/null +++ b/src/lib/server/infra/notifications/mail.transport.ts @@ -0,0 +1,46 @@ +import nodemailer, { type Transporter } from 'nodemailer'; +import { formatSender, requireSmtpConfig, type SmtpConfig } from './config'; +import type { MailNotification } from './mail-notification'; +import type { NotificationTransport } from './transport'; + +let transporter: Transporter | null = null; +let signature: string | null = null; + +function configSignature(config: SmtpConfig): string { + return [config.host, config.port, config.secure, config.user, config.rejectUnauthorized].join( + '|', + ); +} + +function getTransporter(config: SmtpConfig): Transporter { + const current = configSignature(config); + if (!transporter || signature !== current) { + transporter = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, + auth: + config.user && config.password ? { user: config.user, pass: config.password } : undefined, + tls: { rejectUnauthorized: config.rejectUnauthorized }, + }); + signature = current; + } + return transporter; +} + +export function resetMailTransport(): void { + transporter = null; + signature = null; +} + +export const mailTransport: NotificationTransport = { + async send(notification) { + const config = requireSmtpConfig(); + await getTransporter(config).sendMail({ + from: formatSender(config), + to: notification.to, + subject: notification.subject, + html: notification.content, + }); + }, +}; diff --git a/src/lib/server/infra/notifications/notification.ts b/src/lib/server/infra/notifications/notification.ts new file mode 100644 index 0000000..1244b06 --- /dev/null +++ b/src/lib/server/infra/notifications/notification.ts @@ -0,0 +1,8 @@ +export type NotificationChannel = 'mail' | 'slack'; + +export abstract class Notification { + abstract readonly channel: NotificationChannel; + + /** Throws when the notification is missing data required by its channel. */ + abstract validate(): void; +} diff --git a/src/lib/server/infra/notifications/notify.test.ts b/src/lib/server/infra/notifications/notify.test.ts new file mode 100644 index 0000000..6caec24 --- /dev/null +++ b/src/lib/server/infra/notifications/notify.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MailNotification } from './mail-notification'; +import { Notification, type NotificationChannel } from './notification'; +import { notify, registerTransport, resetTransports } from './notify'; +import { SlackNotification } from './slack-notification'; + +class UnknownNotification extends Notification { + readonly channel = 'webhook' as NotificationChannel; + validate(): void {} +} + +function mailNotification() { + return new MailNotification({ + to: 'user@example.com', + subject: 'Welcome', + content: '

Hi

', + }); +} + +describe('notify', () => { + beforeEach(() => { + resetTransports(); + }); + + afterEach(() => { + resetTransports(); + }); + + it('delegates to the transport registered for the channel', async () => { + const send = vi.fn(async () => {}); + registerTransport('mail', { send } as never); + + const notification = mailNotification(); + await notify(notification); + + expect(send).toHaveBeenCalledWith(notification); + }); + + it('validates the notification before sending it', async () => { + const send = vi.fn(async () => {}); + registerTransport('mail', { send } as never); + + await expect( + notify(new MailNotification({ to: '', subject: 'Welcome', content: '

Hi

' })), + ).rejects.toThrow(/at least one recipient/); + expect(send).not.toHaveBeenCalled(); + }); + + it('routes each notification type to its own transport', async () => { + const mailSend = vi.fn(async () => {}); + const slackSend = vi.fn(async () => {}); + registerTransport('mail', { send: mailSend } as never); + registerTransport('slack', { send: slackSend } as never); + + await notify(new SlackNotification({ channelName: '#alerts', text: 'Deploy done' })); + + expect(slackSend).toHaveBeenCalledTimes(1); + expect(mailSend).not.toHaveBeenCalled(); + }); + + it('throws when no transport handles the channel', async () => { + await expect(notify(new UnknownNotification())).rejects.toThrow(/No transport registered/); + }); + + it('rejects values that are not notifications', async () => { + await expect(notify({ channel: 'mail' } as never)).rejects.toThrow( + /expects a Notification instance/, + ); + }); + + it('propagates transport failures', async () => { + registerTransport('mail', { + send: async () => { + throw new Error('smtp down'); + }, + } as never); + + await expect(notify(mailNotification())).rejects.toThrow('smtp down'); + }); + + it('restores the built-in transports on reset', async () => { + registerTransport('slack', { send: async () => {} } as never); + resetTransports(); + + await expect( + notify(new SlackNotification({ channelName: '#alerts', text: 'Deploy done' })), + ).rejects.toThrow(/not implemented yet/); + }); +}); diff --git a/src/lib/server/infra/notifications/notify.ts b/src/lib/server/infra/notifications/notify.ts new file mode 100644 index 0000000..e1315d5 --- /dev/null +++ b/src/lib/server/infra/notifications/notify.ts @@ -0,0 +1,38 @@ +import { mailTransport } from './mail.transport'; +import { Notification, type NotificationChannel } from './notification'; +import { slackTransport } from './slack.transport'; +import type { NotificationTransport } from './transport'; + +const defaults = (): Map> => + new Map>([ + ['mail', mailTransport as NotificationTransport], + ['slack', slackTransport as NotificationTransport], + ]); + +let transports = defaults(); + +export function registerTransport( + channel: NotificationChannel, + transport: NotificationTransport, +): void { + transports.set(channel, transport); +} + +export function resetTransports(): void { + transports = defaults(); +} + +export async function notify(notification: Notification): Promise { + if (!(notification instanceof Notification)) { + throw new Error('notify() expects a Notification instance'); + } + + notification.validate(); + + const transport = transports.get(notification.channel); + if (!transport) { + throw new Error(`No transport registered for notification channel: ${notification.channel}`); + } + + await transport.send(notification as never); +} diff --git a/src/lib/server/infra/notifications/slack-notification.test.ts b/src/lib/server/infra/notifications/slack-notification.test.ts new file mode 100644 index 0000000..4625bf9 --- /dev/null +++ b/src/lib/server/infra/notifications/slack-notification.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { SlackNotification } from './slack-notification'; + +describe('SlackNotification', () => { + it('exposes the slack channel', () => { + const notification = new SlackNotification({ channelName: '#alerts', text: 'Deploy done' }); + expect(notification.channel).toBe('slack'); + }); + + it('requires a channel and a text', () => { + expect(() => new SlackNotification({ channelName: ' ', text: 'x' }).validate()).toThrow( + /requires a channel/, + ); + expect(() => new SlackNotification({ channelName: '#alerts', text: ' ' }).validate()).toThrow( + /requires a text/, + ); + }); +}); diff --git a/src/lib/server/infra/notifications/slack-notification.ts b/src/lib/server/infra/notifications/slack-notification.ts new file mode 100644 index 0000000..1e97993 --- /dev/null +++ b/src/lib/server/infra/notifications/slack-notification.ts @@ -0,0 +1,27 @@ +import { Notification, type NotificationChannel } from './notification'; + +export type SlackNotificationInput = { + channelName: string; + text: string; +}; + +export class SlackNotification extends Notification { + readonly channel: NotificationChannel = 'slack'; + readonly channelName: string; + readonly text: string; + + constructor(input: SlackNotificationInput) { + super(); + this.channelName = input.channelName?.trim() ?? ''; + this.text = input.text ?? ''; + } + + validate(): void { + if (!this.channelName) { + throw new Error('SlackNotification requires a channel'); + } + if (!this.text.trim()) { + throw new Error('SlackNotification requires a text'); + } + } +} diff --git a/src/lib/server/infra/notifications/slack.transport.ts b/src/lib/server/infra/notifications/slack.transport.ts new file mode 100644 index 0000000..f08e160 --- /dev/null +++ b/src/lib/server/infra/notifications/slack.transport.ts @@ -0,0 +1,8 @@ +import type { SlackNotification } from './slack-notification'; +import type { NotificationTransport } from './transport'; + +export const slackTransport: NotificationTransport = { + async send() { + throw new Error('Slack notifications are not implemented yet'); + }, +}; diff --git a/src/lib/server/infra/notifications/template.test.ts b/src/lib/server/infra/notifications/template.test.ts new file mode 100644 index 0000000..0738af7 --- /dev/null +++ b/src/lib/server/infra/notifications/template.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { clearTemplateCache, renderTemplate, renderTemplateString } from './template'; + +describe('renderTemplateString', () => { + it('replaces placeholders with the provided values', () => { + expect(renderTemplateString('Hi {{ name }}!', { name: 'Ada' })).toBe('Hi Ada!'); + expect(renderTemplateString('Hi {{name}}!', { name: 'Ada' })).toBe('Hi Ada!'); + }); + + it('escapes HTML in interpolated values', () => { + expect(renderTemplateString('

{{ name }}

', { name: '' })).toBe( + '

<script>x</script>

', + ); + }); + + it('supports raw interpolation with triple braces', () => { + expect(renderTemplateString('
{{{ body }}}
', { body: 'hi' })).toBe( + '
hi
', + ); + }); + + it('renders unknown or nullish variables as an empty string', () => { + expect(renderTemplateString('[{{ missing }}][{{ empty }}]', { empty: null })).toBe('[][]'); + }); + + it('stringifies non-string values', () => { + expect(renderTemplateString('{{ count }}/{{ flag }}', { count: 3, flag: false })).toBe( + '3/false', + ); + }); +}); + +describe('renderTemplate', () => { + beforeEach(() => { + clearTemplateCache(); + }); + + it('renders the invite template with its variables', async () => { + const html = await renderTemplate('invite', { + subject: 'You are invited', + productName: 'GitOps', + inviterName: 'Ada', + organizationName: 'Kettu', + recipientName: 'Grace', + roleName: 'Developer', + inviteUrl: 'https://gitops.local/invite/abc', + expiresAt: '2026-01-01', + }); + + expect(html).toContain('Ada has invited you to join Kettu'); + expect(html).toContain('https://gitops.local/invite/abc'); + expect(html).toContain('Developer'); + expect(html).not.toContain('{{'); + }); + + it('rejects template names that could escape the templates folder', async () => { + await expect(renderTemplate('../../secret')).rejects.toThrow(/Invalid email template name/); + }); + + it('fails when the template does not exist', async () => { + await expect(renderTemplate('missing-template')).rejects.toThrow(); + }); +}); diff --git a/src/lib/server/infra/notifications/template.ts b/src/lib/server/infra/notifications/template.ts new file mode 100644 index 0000000..6897209 --- /dev/null +++ b/src/lib/server/infra/notifications/template.ts @@ -0,0 +1,55 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +export type TemplateVariables = Record; + +const TEMPLATES_ROOT = path.resolve(process.cwd(), 'src/notifications/email'); +const TEMPLATE_NAME = /^[a-zA-Z0-9_-]+$/; + +const cache = new Map(); + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function toText(value: TemplateVariables[string]): string { + return value === null || value === undefined ? '' : String(value); +} + +/** + * Interpolates `{{ name }}` (HTML-escaped) and `{{{ name }}}` (raw) placeholders. + * Unknown placeholders resolve to an empty string. + */ +export function renderTemplateString(template: string, variables: TemplateVariables = {}): string { + return template + .replace(/\{\{\{\s*([\w.]+)\s*\}\}\}/g, (_match, key: string) => toText(variables[key])) + .replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_match, key: string) => + escapeHtml(toText(variables[key])), + ); +} + +export async function renderTemplate( + name: string, + variables: TemplateVariables = {}, +): Promise { + if (!TEMPLATE_NAME.test(name)) { + throw new Error(`Invalid email template name: ${name}`); + } + + let template = cache.get(name); + if (template === undefined) { + template = await readFile(path.join(TEMPLATES_ROOT, `${name}.html`), 'utf8'); + cache.set(name, template); + } + + return renderTemplateString(template, variables); +} + +export function clearTemplateCache(): void { + cache.clear(); +} diff --git a/src/lib/server/infra/notifications/transport.ts b/src/lib/server/infra/notifications/transport.ts new file mode 100644 index 0000000..88775fc --- /dev/null +++ b/src/lib/server/infra/notifications/transport.ts @@ -0,0 +1,5 @@ +import type { Notification } from './notification'; + +export interface NotificationTransport { + send(notification: T): Promise; +} diff --git a/src/notifications/email/invite.html b/src/notifications/email/invite.html new file mode 100644 index 0000000..1b545db --- /dev/null +++ b/src/notifications/email/invite.html @@ -0,0 +1,126 @@ + + + + + + {{ subject }} + + + + + + +
+ + + + + + + + + + + + + + + + +
+

+ {{ productName }} +

+

+ {{ inviterName }} has invited you to join {{ organizationName }} +

+
+

Hi {{ recipientName }},

+

+ You have been invited to join {{ organizationName }} as + {{ roleName }}. Accept the invitation to set up your account and + get started. +

+
+ + Accept invitation + +
+

+ This invitation expires on {{ expiresAt }}. If the button does not work, copy and + paste this link into your browser: +

+

+ {{ inviteUrl }} +

+
+ If you were not expecting this invitation you can safely ignore this email. +
+
+ + From 34fe9113a91aac13dcb89efaaaf3573f828f3310 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Thu, 27 Aug 2026 20:20:35 +0200 Subject: [PATCH 2/3] invitation mails working --- src/hooks.server.ts | 8 +- src/lib/components/Users.svelte | 142 ++++++++++-- src/lib/database/schemas.ts | 2 + src/lib/server/infra/notifications/config.ts | 4 +- .../application/invitation.service.test.ts | 192 ++++++++++++++++ .../auth/application/invitation.service.ts | 80 +++++++ .../application/user-access.service.test.ts | 215 +++++++++++++++++- .../auth/application/user-access.service.ts | 150 +++++++++++- src/modules/auth/domain/entities.ts | 1 + src/modules/auth/domain/user.domain.ts | 2 + src/modules/auth/index.ts | 10 + .../notifications/invitation.notifier.test.ts | 56 +++++ .../notifications/invitation.notifier.ts | 36 +++ .../repositories/user.repository.ts | 49 +++- src/routes/auth/invitation/+page.server.ts | 56 +++++ src/routes/auth/invitation/+page.svelte | 122 ++++++++++ .../org/[org]/settings/users/+page.server.ts | 26 ++- test/env-dynamic-private.ts | 2 + vitest.config.ts | 2 + 19 files changed, 1125 insertions(+), 30 deletions(-) create mode 100644 src/modules/auth/application/invitation.service.test.ts create mode 100644 src/modules/auth/application/invitation.service.ts create mode 100644 src/modules/auth/infrastructure/notifications/invitation.notifier.test.ts create mode 100644 src/modules/auth/infrastructure/notifications/invitation.notifier.ts create mode 100644 src/routes/auth/invitation/+page.server.ts create mode 100644 src/routes/auth/invitation/+page.svelte create mode 100644 test/env-dynamic-private.ts diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 983b494..cc2bb17 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -58,8 +58,12 @@ export const handle: Handle = async ({ event, resolve }) => { return new Response(null, { status: 302, headers: { location: '/bootstrap' } }); } - // sign-in and sign-out must work without (or with a broken) session - if (pathname === '/auth/login' || pathname === '/auth/logout') { + // sign-in, sign-out and invitation acceptance must work without (or with a broken) session + if ( + pathname === '/auth/login' || + pathname === '/auth/logout' || + pathname === '/auth/invitation' + ) { return resolve(event); } diff --git a/src/lib/components/Users.svelte b/src/lib/components/Users.svelte index 20f83b6..61707c7 100644 --- a/src/lib/components/Users.svelte +++ b/src/lib/components/Users.svelte @@ -4,6 +4,7 @@ import { ChevronDown, CheckCircle, + Mail, Plus, Search, Send, @@ -51,6 +52,11 @@ let success = ''; let addModalOpen = false; let adding = false; + let addError = ''; + let inviteModalOpen = false; + let inviting = false; + let inviteEmail = ''; + let inviteError = ''; let savingAccessId: string | null = null; let removingAccessId: string | null = null; let resendingAccessId: string | null = null; @@ -85,7 +91,7 @@ ); function openAddModal() { - error = ''; + addError = ''; newUsername = ''; newEmail = ''; newPassword = ''; @@ -94,6 +100,12 @@ addModalOpen = true; } + function openInviteModal() { + inviteError = ''; + inviteEmail = ''; + inviteModalOpen = true; + } + function flashSuccess(message: string) { success = message; setTimeout(() => { @@ -115,21 +127,21 @@ } async function addUser() { - error = ''; + addError = ''; success = ''; if (!selectedRoleId) { - error = 'Role is required.'; + addError = 'Role is required.'; return; } if (scope !== 'project' && (!newUsername.trim() || !newPassword.trim())) { - error = 'Username and password are required.'; + addError = 'Username and password are required.'; return; } if (scope === 'project' && !selectedUserId) { - error = 'User is required.'; + addError = 'User is required.'; return; } @@ -145,12 +157,33 @@ addModalOpen = false; flashSuccess(scope === 'project' ? 'User assigned.' : 'User created.'); } catch (err: unknown) { - error = err instanceof Error ? err.message : 'Failed to add user.'; + addError = err instanceof Error ? err.message : 'Failed to add user.'; } finally { adding = false; } } + async function inviteUser() { + inviteError = ''; + success = ''; + + if (!inviteEmail.trim()) { + inviteError = 'Email is required.'; + return; + } + + inviting = true; + try { + await submitAction('inviteUser', { email: inviteEmail.trim() }); + inviteModalOpen = false; + flashSuccess('Invitation sent.'); + } catch (err: unknown) { + inviteError = err instanceof Error ? err.message : 'Failed to invite user.'; + } finally { + inviting = false; + } + } + async function selectRole(user: AccessUserRow, role: RoleRow) { if (user.role?.id === role.id) { openRoleMenuId = null; @@ -243,14 +276,26 @@

{title}

{description}

- +
+ {#if scope === 'organization'} + + {/if} + +
@@ -439,6 +484,12 @@
+ {#if addError} +
+ {addError} +
+ {/if} + {#if scope !== 'project'}
@@ -531,6 +582,69 @@
{/if} +{#if inviteModalOpen} + +
+ +
+{/if} + {#if removeModalUser} + + {/if} +
+
diff --git a/src/routes/org/[org]/settings/users/+page.server.ts b/src/routes/org/[org]/settings/users/+page.server.ts index 48b40f7..a78a974 100644 --- a/src/routes/org/[org]/settings/users/+page.server.ts +++ b/src/routes/org/[org]/settings/users/+page.server.ts @@ -37,6 +37,27 @@ export const actions = { } }, + async inviteUser({ request, locals, params, url }) { + const organization = await organizationService.findBySlug(params.org); + if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + return fail(403, { error: 'Forbidden' }); + } + + try { + const form = await request.formData(); + const user = await userAccessService.inviteOrganizationUser({ + organizationId: organization.id, + organizationName: organization.name, + email: String(form.get('email') ?? ''), + inviteUrl: `${url.origin}/auth/invitation`, + invitedBy: locals.user?.username ?? null, + }); + return { success: true, user }; + } catch (error: unknown) { + return errorResponse(error); + } + }, + async updateUserAccess({ request, locals, params }) { const organization = await organizationService.findBySlug(params.org); if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { @@ -77,7 +98,7 @@ export const actions = { } }, - async resendInvitation({ request, locals, params }) { + async resendInvitation({ request, locals, params, url }) { const organization = await organizationService.findBySlug(params.org); if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { return fail(403, { error: 'Forbidden' }); @@ -89,6 +110,9 @@ export const actions = { accessId: String(form.get('accessId') ?? ''), scope: 'organization', scopeId: organization.id, + organizationName: organization.name, + inviteUrl: `${url.origin}/auth/invitation`, + invitedBy: locals.user?.username ?? null, }); return { success: true, user }; } catch (error: unknown) { diff --git a/test/env-dynamic-private.ts b/test/env-dynamic-private.ts new file mode 100644 index 0000000..b8b4aad --- /dev/null +++ b/test/env-dynamic-private.ts @@ -0,0 +1,2 @@ +/** Stand-in for SvelteKit's $env/dynamic/private, which is only available through the Vite plugin. */ +export const env = process.env as Record; diff --git a/vitest.config.ts b/vitest.config.ts index aea1a28..d5206db 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,8 @@ export default defineConfig({ resolve: { alias: { $lib: path.resolve(import.meta.dirname, 'src/lib'), + $modules: path.resolve(import.meta.dirname, 'src/modules'), + '$env/dynamic/private': path.resolve(import.meta.dirname, 'test/env-dynamic-private.ts'), }, }, test: { From 057bd981dd57a66d64cf25758120b0162afc12d5 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Thu, 27 Aug 2026 20:58:45 +0200 Subject: [PATCH 3/3] remove users working --- src/hooks.server.ts | 1 + .../auth/application/auth.service.test.ts | 38 ++++++++- src/modules/auth/application/auth.service.ts | 4 +- .../application/user-access.service.test.ts | 85 ++++++++++++++++++- .../auth/application/user-access.service.ts | 17 +++- src/routes/auth/login/+page.server.ts | 12 +-- src/routes/auth/login/+page.svelte | 16 ++-- src/routes/login/+page.svelte | 16 ++-- 8 files changed, 158 insertions(+), 31 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index cc2bb17..9da25bc 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,6 +1,7 @@ import type { Handle } from '@sveltejs/kit'; import { authService, cancanService, ensureAuthReady } from '$modules/auth'; import { organizationService } from '$modules/organization'; +import { projectService } from '$modules/projects'; import { isBootstrapCompleted, refreshBootstrapState } from '$lib/server/bootstrap'; import { startGitDb } from '$lib/server/gitdb'; import { isServerReady, markServerFailed, markServerReady } from '$lib/server/server-ready'; diff --git a/src/modules/auth/application/auth.service.test.ts b/src/modules/auth/application/auth.service.test.ts index 38d540f..e47d2a0 100644 --- a/src/modules/auth/application/auth.service.test.ts +++ b/src/modules/auth/application/auth.service.test.ts @@ -15,11 +15,11 @@ function role(input: { id: string; slug: string; permissions?: string[] }) { }); } -function user(input: { id: string; username: string; role: RoleDomain | null }) { +function user(input: { id: string; username: string; role: RoleDomain | null; email?: string | null }) { return new UserDomain({ id: input.id, username: input.username, - email: null, + email: input.email ?? null, password: 'hashed', role: input.role, createdAt: '2024-01-01T00:00:00.000Z', @@ -35,6 +35,10 @@ class FakeUserRepository { return [...this.rows.values()].find((entry) => entry.username === username) ?? null; } + async findByEmail(email: string) { + return [...this.rows.values()].find((entry) => entry.email === email) ?? null; + } + async findById(id: string) { return this.rows.get(id) ?? null; } @@ -134,3 +138,33 @@ describe('AuthService bootstrapDefaults', () => { expect(userRepository.updatedRoleIds).toEqual([]); }); }); + +describe('AuthService authentication', () => { + it('authenticates with a case-insensitive email address and password', async () => { + const userRepository = new FakeUserRepository(); + const passwordService = { + ensureEncryptionKey: vi.fn(), + verifyPassword: vi.fn(() => true), + }; + userRepository.rows.set( + 'carlos-id', + user({ + id: 'carlos-id', + username: 'carlos', + email: 'carlos@kettu.studio', + role: role({ id: 'role-id', slug: 'cluster-user' }), + }), + ); + const service = new AuthService( + userRepository as any, + new FakeRoleRepository() as any, + passwordService as any, + { createToken: vi.fn(), parseAndVerifyToken: vi.fn() } as any, + ); + + const authenticated = await service.authenticate('Carlos@Kettu.Studio', 'secret'); + + expect(authenticated?.id).toBe('carlos-id'); + expect(passwordService.verifyPassword).toHaveBeenCalledWith('secret', 'hashed'); + }); +}); diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index 415856e..2a6849e 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -93,8 +93,8 @@ export class AuthService { return created; } - async authenticate(username: string, password: string): Promise { - const user = await this.userRepository.findByUsername(username); + async authenticate(email: string, password: string): Promise { + const user = await this.userRepository.findByEmail(email.trim().toLowerCase()); if (!user) { return null; } diff --git a/src/modules/auth/application/user-access.service.test.ts b/src/modules/auth/application/user-access.service.test.ts index 5fdf359..bf2cf4a 100644 --- a/src/modules/auth/application/user-access.service.test.ts +++ b/src/modules/auth/application/user-access.service.test.ts @@ -70,6 +70,10 @@ class FakeUserRepository { return [...this.rows]; } + async deleteById(id: string) { + this.rows = this.rows.filter((entry) => entry.id !== id); + } + async createUser(input: { id: string; username: string; @@ -115,6 +119,10 @@ class FakeUserAccessRepository { userRepository!: FakeUserRepository; roleRepository!: FakeRoleRepository; + async findByUserId(userId: string) { + return this.rows.filter((entry) => entry.userId === userId); + } + async findByScope(scope: 'cluster' | 'organization' | 'project', scopeId?: string) { return this.rows.filter((entry) => { if (entry.scope !== scope) return false; @@ -440,10 +448,11 @@ describe('UserAccessService', () => { expect(updated.status).toBe('invited'); }); - it('removes an access row', async () => { + it('removes only the selected project access', async () => { userRepository.rows.push(user({ id: 'jose-id', username: 'jose', role: null })); roleRepository.rows.push( role({ id: 'project-admin-id', slug: 'project-admin', scope: 'project', projectId: 'kettu' }), + role({ id: 'project-developer-id', slug: 'developer', scope: 'project', projectId: 'other' }), ); await userAccessRepository.create({ id: 'access-id', @@ -452,10 +461,84 @@ describe('UserAccessService', () => { scope: 'project', projectId: 'kettu', }); + await userAccessRepository.create({ + id: 'other-access-id', + userId: 'jose-id', + roleId: 'project-developer-id', + scope: 'project', + projectId: 'other', + }); await service.removeAccess({ accessId: 'access-id', scope: 'project', scopeId: 'kettu' }); + expect(userAccessRepository.rows.map((entry) => entry.id)).toEqual(['other-access-id']); + expect(await userRepository.findById('jose-id')).not.toBeNull(); + }); + + it('removes all user access and the user when removed from an organization', async () => { + userRepository.rows.push(user({ id: 'jose-id', username: 'jose', role: null })); + roleRepository.rows.push( + role({ id: 'org-admin-id', slug: 'org-admin', scope: 'organization', organizationId: 'gitops' }), + role({ id: 'project-admin-id', slug: 'project-admin', scope: 'project', projectId: 'kettu' }), + role({ id: 'project-developer-id', slug: 'developer', scope: 'project', projectId: 'other' }), + ); + await userAccessRepository.create({ + id: 'organization-access-id', + userId: 'jose-id', + roleId: 'org-admin-id', + scope: 'organization', + organizationId: 'gitops', + }); + await userAccessRepository.create({ + id: 'project-access-id', + userId: 'jose-id', + roleId: 'project-admin-id', + scope: 'project', + projectId: 'kettu', + }); + await userAccessRepository.create({ + id: 'other-project-access-id', + userId: 'jose-id', + roleId: 'project-developer-id', + scope: 'project', + projectId: 'other', + }); + + await service.removeAccess({ + accessId: 'organization-access-id', + scope: 'organization', + scopeId: 'gitops', + }); + + expect(userAccessRepository.rows).toEqual([]); + expect(await userRepository.findById('jose-id')).toBeNull(); + }); + + it('removes all user access and the user when removed from cluster settings', async () => { + userRepository.rows.push(user({ id: 'jose-id', username: 'jose', role: null })); + roleRepository.rows.push( + role({ id: 'org-admin-id', slug: 'org-admin', scope: 'organization', organizationId: 'gitops' }), + role({ id: 'project-admin-id', slug: 'project-admin', scope: 'project', projectId: 'kettu' }), + ); + await userAccessRepository.create({ + id: 'organization-access-id', + userId: 'jose-id', + roleId: 'org-admin-id', + scope: 'organization', + organizationId: 'gitops', + }); + await userAccessRepository.create({ + id: 'project-access-id', + userId: 'jose-id', + roleId: 'project-admin-id', + scope: 'project', + projectId: 'kettu', + }); + + await service.removeAccess({ accessId: 'jose-id', scope: 'cluster' }); + expect(userAccessRepository.rows).toEqual([]); + expect(await userRepository.findById('jose-id')).toBeNull(); }); it('resends invitations only for invited access rows', async () => { diff --git a/src/modules/auth/application/user-access.service.ts b/src/modules/auth/application/user-access.service.ts index 5e6c51b..b892d75 100644 --- a/src/modules/auth/application/user-access.service.ts +++ b/src/modules/auth/application/user-access.service.ts @@ -49,11 +49,18 @@ export class UserAccessService { | 'createUser' | 'updateRoleId' | 'updateStatus' + | 'deleteById' >, private readonly roleRepository: Pick, private readonly userAccessRepository: Pick< UserAccessRepository, - 'findByScope' | 'findOne' | 'findById' | 'create' | 'update' | 'deleteById' + | 'findByScope' + | 'findByUserId' + | 'findOne' + | 'findById' + | 'create' + | 'update' + | 'deleteById' >, private readonly passwordService: Pick, private readonly invitationNotifier: InvitationNotifierPort, @@ -262,9 +269,11 @@ export class UserAccessService { scopeId?: string; }): Promise { const access = await this.findAccessInScope(input.accessId, input.scope, input.scopeId); - if (input.scope === 'cluster') { - const clusterUserRole = await this.ensureClusterUserRole(); - await this.userRepository.updateRoleId(access.id, clusterUserRole.id); + if (input.scope === 'cluster' || input.scope === 'organization') { + const userId = input.scope === 'cluster' ? access.id : access.userId; + const userAccess = await this.userAccessRepository.findByUserId(access.userId); + await Promise.all(userAccess.map((entry) => this.userAccessRepository.deleteById(entry.id))); + await this.userRepository.deleteById(userId); return; } diff --git a/src/routes/auth/login/+page.server.ts b/src/routes/auth/login/+page.server.ts index 93d1af5..ed5beac 100644 --- a/src/routes/auth/login/+page.server.ts +++ b/src/routes/auth/login/+page.server.ts @@ -15,17 +15,17 @@ export async function load({ cookies }) { export const actions = { async login({ request, cookies }) { const form = await request.formData(); - const username = String(form.get('username') ?? '').trim(); + const email = String(form.get('email') ?? '').trim().toLowerCase(); const password = String(form.get('password') ?? ''); - if (!username || !password) { - return fail(400, { username, error: 'Username and password are required.' }); + if (!email || !password) { + return fail(400, { email, error: 'Email and password are required.' }); } - const user = await authService.authenticate(username, password); + const user = await authService.authenticate(email, password); if (!user) { - // same message for unknown user and wrong password, to avoid leaking valid usernames - return fail(401, { username, error: 'Invalid username or password.' }); + // same message for unknown email and wrong password, to avoid leaking valid emails + return fail(401, { email, error: 'Invalid email or password.' }); } cookies.set(SESSION_COOKIE, authService.createSessionToken(user.id), { diff --git a/src/routes/auth/login/+page.svelte b/src/routes/auth/login/+page.svelte index 163f7b3..96faad4 100644 --- a/src/routes/auth/login/+page.svelte +++ b/src/routes/auth/login/+page.svelte @@ -8,7 +8,7 @@ let isSubmitting = false; - $: username = form?.username ?? ''; + $: email = form?.email ?? ''; $: loggedOut = $page.url.searchParams.has('loggedOut'); @@ -65,15 +65,15 @@ class="space-y-4" >
- Email
diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 163f7b3..96faad4 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -8,7 +8,7 @@ let isSubmitting = false; - $: username = form?.username ?? ''; + $: email = form?.email ?? ''; $: loggedOut = $page.url.searchParams.has('loggedOut'); @@ -65,15 +65,15 @@ class="space-y-4" >
- Email