diff --git a/.env.example b/.env.example index 971f5bd..28aad20 100644 --- a/.env.example +++ b/.env.example @@ -55,3 +55,35 @@ BACKEND_URL=http://localhost:3000 # DEMO_DB_PATH=/app/data/invio-demo.db # DEMO_RESET_HOURS=0.5 # DEMO_RESET_ON_START=true +# ─── Email / SMTP ──────────────────────────────────────── +# Enables the "Send via Email" button on invoice pages. +# Works with any SMTP server (Gmail, Outlook, SMTP2GO, Mailgun, self-hosted…). +# +# SMTP_HOST=mai-server +# SMTP_PORT=smtp-port # 587 = STARTTLS (default), 465 = TLS, 25 = plain +# SMTP_SECURE=true # set true only for port 465 (direct TLS) +# SMTP_USER=username # leave blank if the server needs no auth +# SMTP_PASS=secretpassword +# EMAIL_FROM_ADDRESS=email-address +# EMAIL_FROM_NAME=email-from-name # optional display name shown in email clients + +# ─── Authentik OIDC / SSO (optional) ───────────────────── +# Set OIDC_ENABLED=true and fill in the values below to add a +# "Login with SSO" button to the login page. +# +# In Authentik: create an OAuth2/OIDC Provider + Application, +# set the redirect URI to OIDC_REDIRECT_URI, and grant scopes: +# openid email profile +# +# OIDC_ENABLED=false +# OIDC_ISSUER_URL=https://authentik.example.com/application/o/ +# OIDC_CLIENT_ID= +# OIDC_CLIENT_SECRET= +# OIDC_REDIRECT_URI=https://invio.example.com/auth/callback +# +# Set to true to automatically create an Invio account on first SSO login. +# When false (default), the user's email must already match an existing account. +# OIDC_AUTO_PROVISION=false + + + diff --git a/.gitignore b/.gitignore index 5bf7092..dd861fc 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ /Invio.wiki .DS_Store /backend/data/* -/chrome-headless-shell \ No newline at end of file +/chrome-headless-shell +.claude/settings.local.json diff --git a/backend/src/database/init.ts b/backend/src/database/init.ts index 0f80437..27ea74b 100644 --- a/backend/src/database/init.ts +++ b/backend/src/database/init.ts @@ -196,6 +196,7 @@ function ensureUserColumns(database: DB): void { "INTEGER NOT NULL DEFAULT 0", ); addColumnIfMissing(database, "users", "two_factor_recovery_codes", "TEXT"); + addColumnIfMissing(database, "users", "oidc_subject", "TEXT"); } function ensureStatusHistoryTable(database: DB): void { @@ -474,6 +475,8 @@ function ensureSchemaUpgrades(database: DB): void { const BUILTIN_TEMPLATES = [ { id: "professional-modern", name: "Professional Modern", isDefault: false }, { id: "minimalist-clean", name: "Minimalist Clean", isDefault: true }, + { id: "nova", name: "Nova", isDefault: false }, + { id: "slate", name: "Slate", isDefault: false }, ] as const; function loadTemplateHtml(id: string): string { diff --git a/backend/src/database/migrations.sql b/backend/src/database/migrations.sql index 2277e4f..81a5ad8 100644 --- a/backend/src/database/migrations.sql +++ b/backend/src/database/migrations.sql @@ -221,6 +221,9 @@ CREATE TABLE IF NOT EXISTS user_permissions ( UNIQUE(user_id, resource, action) ); +ALTER TABLE users ADD COLUMN oidc_subject TEXT; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc_subject ON users(oidc_subject) WHERE oidc_subject IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active); CREATE INDEX IF NOT EXISTS idx_user_permissions_user ON user_permissions(user_id); diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 9dda7e6..ee8185f 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -66,6 +66,7 @@ import { updateUnit, } from "../controllers/productOptions.ts"; import { buildInvoiceHTML, generatePDF } from "../utils/pdf.ts"; +import { isEmailConfigured, sendEmail } from "../utils/email.ts"; import { generateUBLInvoiceXML } from "../utils/ubl.ts"; // legacy direct import import { generateInvoiceXML, listXMLProfiles } from "../utils/xmlProfiles.ts"; import { availableInvoiceLocales } from "../i18n/translations.ts"; @@ -1627,6 +1628,183 @@ adminRoutes.get( }, ); +// Send invoice via email (SMTP2GO) +adminRoutes.post( + "/invoices/:id/send-email", + requirePermission("invoices", "export"), + async (c) => { + if (!isEmailConfigured()) { + return c.json( + { error: "Email is not configured. Set SMTP2GO_API_KEY and EMAIL_FROM_ADDRESS." }, + 503, + ); + } + + const id = c.req.param("id"); + const invoice = getInvoiceById(id); + if (!invoice) return c.json({ error: "Invoice not found" }, 404); + + let to: string[] = []; + let subject = ""; + let message = ""; + try { + const body = await c.req.json(); + to = Array.isArray(body.to) ? body.to.filter((e: unknown) => typeof e === "string" && e.includes("@")) : []; + subject = typeof body.subject === "string" ? body.subject.trim() : ""; + message = typeof body.message === "string" ? body.message.trim() : ""; + } catch { + return c.json({ error: "Invalid request body" }, 400); + } + + if (to.length === 0) { + return c.json({ error: "At least one valid recipient email is required" }, 400); + } + if (!subject) { + return c.json({ error: "Subject is required" }, 400); + } + + // Build settings map (same as /pdf route) + const settings = await getSettings(); + const settingsMap = settings.reduce( + (acc: Record, s) => { acc[s.key] = s.value as string; return acc; }, + {} as Record, + ); + if (!settingsMap.postalCityFormat && settingsMap.postal_city_format) { + settingsMap.postalCityFormat = settingsMap.postal_city_format; + } + if (!settingsMap.logo && settingsMap.logoUrl) { + settingsMap.logo = settingsMap.logoUrl; + } + + const businessSettings = { + companyName: settingsMap.companyName || "Your Company", + companyAddress: settingsMap.companyAddress || "", + companyCity: settingsMap.companyCity || "", + companyPostalCode: settingsMap.companyPostalCode || "", + companyCountryCode: settingsMap.companyCountryCode || "", + postalCityFormat: settingsMap.postalCityFormat || "auto", + companyEmail: settingsMap.companyEmail || "", + companyPhone: settingsMap.companyPhone || "", + companyTaxId: settingsMap.companyTaxId || "", + currency: settingsMap.currency || "USD", + taxLabel: settingsMap.taxLabel || undefined, + logo: settingsMap.logo, + paymentMethods: settingsMap.paymentMethods || "Bank Transfer", + bankAccount: settingsMap.bankAccount || "", + paymentTerms: settingsMap.paymentTerms || "Due in 30 days", + defaultNotes: settingsMap.defaultNotes || "", + locale: settingsMap.locale || undefined, + }; + + const highlight = settingsMap.highlight ?? undefined; + let selectedTemplateId: string | undefined = settingsMap.templateId?.toLowerCase(); + if (selectedTemplateId === "professional" || selectedTemplateId === "professional-modern") { + selectedTemplateId = "professional-modern"; + } else if (selectedTemplateId === "minimalist" || selectedTemplateId === "minimalist-clean") { + selectedTemplateId = "minimalist-clean"; + } + + // Generate PDF attachment + let pdfBuffer: Uint8Array; + try { + const customer = getCustomerById(invoice.customerId); + const renderLocale = resolveInvoiceRenderLocale( + invoice.locale, + customer?.countryCode, + settingsMap.locale, + ); + pdfBuffer = await generatePDF( + invoice, + businessSettings, + selectedTemplateId, + highlight, + { + embedXml: false, + dateFormat: settingsMap.dateFormat, + numberFormat: settingsMap.numberFormat, + locale: renderLocale, + }, + ); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("Email: PDF generation failed:", msg); + return c.json({ error: "Failed to generate PDF attachment", details: msg }, 500); + } + + // Build email body + const companyName = businessSettings.companyName; + const invoiceNumber = invoice.invoiceNumber || invoice.id; + const total = `${Number(invoice.total || 0).toFixed(2)} ${invoice.currency || ""}`.trim(); + const issueDate = invoice.issueDate + ? new Date(invoice.issueDate).toISOString().slice(0, 10) + : ""; + const dueDate = invoice.dueDate + ? new Date(invoice.dueDate).toISOString().slice(0, 10) + : null; + const origin = c.req.header("origin") || + c.req.header("referer")?.replace(/\/$/, "") || ""; + const shareLink = invoice.shareToken && origin + ? `${origin}/public/invoices/${invoice.shareToken}` + : null; + + const messageHtml = message + ? `

${message.replace(/&/g, "&").replace(//g, ">")}

` + : ""; + const shareLinkHtml = shareLink + ? `

View invoice online

` + : ""; + const dueDateHtml = dueDate ? `Due date${dueDate}` : ""; + + const htmlBody = ` + + + +

${companyName}

+ ${messageHtml} + + + + ${dueDateHtml} + +
Invoice#${invoiceNumber}
Issue date${issueDate}
Total${total}
+ ${shareLinkHtml} +

The invoice PDF is attached to this email.

+ +`; + + const textBody = [ + companyName, + "", + message || "", + `Invoice: #${invoiceNumber}`, + `Issue date: ${issueDate}`, + dueDate ? `Due date: ${dueDate}` : "", + `Total: ${total}`, + shareLink ? `\nView online: ${shareLink}` : "", + ].filter((l) => l !== undefined).join("\n").trim(); + + try { + await sendEmail({ + to, + subject, + htmlBody, + textBody, + attachment: { + filename: `invoice-${invoiceNumber}.pdf`, + content: pdfBuffer, + mimeType: "application/pdf", + }, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("Email send failed:", msg); + return c.json({ error: "Failed to send email", details: msg }, 502); + } + + return c.json({ sent: true, recipients: to.length }); + }, +); + // UBL (PEPPOL BIS Billing 3.0) XML for an invoice by ID adminRoutes.get( "/invoices/:id/ubl.xml", diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index acb6875..0fc31b1 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -22,6 +22,13 @@ import { } from "../middleware/rateLimiter.ts"; import { generateUUID } from "../utils/uuid.ts"; import { isDemoMode } from "../utils/env.ts"; +import { + buildAuthorizationUrl, + exchangeAndVerify, + getOidcConfig, + type OidcClaims, +} from "../utils/oidc.ts"; +import { getDatabase } from "../database/init.ts"; function getSessionTtlSeconds(): number { const parsed = parseInt(Deno.env.get("SESSION_TTL_SECONDS") || "3600", 10); @@ -307,4 +314,122 @@ authRoutes.post("/auth/recover-2fa", async (c) => { return c.json(session); }); +authRoutes.get("/auth/oidc/authorize", async (c) => { + const oidc = getOidcConfig(); + if (!oidc.enabled) return c.json({ error: "OIDC not enabled" }, 404); + try { + const url = await buildAuthorizationUrl(); + return c.json({ url }); + } catch (err) { + console.error("OIDC authorize error:", err); + return c.json({ error: "Failed to build authorization URL" }, 500); + } +}); + +authRoutes.post("/auth/oidc/callback", async (c) => { + const oidc = getOidcConfig(); + if (!oidc.enabled) return c.json({ error: "OIDC not enabled" }, 404); + + let code: string | undefined; + let state: string | undefined; + try { + const body = await c.req.json(); + code = typeof body.code === "string" ? body.code : undefined; + state = typeof body.state === "string" ? body.state : undefined; + } catch { + return c.json({ error: "Invalid request body" }, 400); + } + + if (!code || !state) return c.json({ error: "Missing code or state" }, 400); + + let claims: OidcClaims; + try { + claims = await exchangeAndVerify(code, state); + } catch (err) { + console.error("OIDC callback error:", err); + return c.json({ error: "OIDC verification failed" }, 401); + } + + const db = getDatabase(); + + // 1. Look up by oidc_subject + let rows = db.query( + "SELECT id, username, is_admin, is_active FROM users WHERE oidc_subject = ?", + [claims.sub], + ) as unknown[][]; + + // 2. Look up by email and bind oidc_subject + if (rows.length === 0 && claims.email) { + rows = db.query( + "SELECT id, username, is_admin, is_active FROM users WHERE email = ?", + [claims.email], + ) as unknown[][]; + if (rows.length > 0) { + db.query( + "UPDATE users SET oidc_subject = ?, updated_at = ? WHERE id = ?", + [claims.sub, new Date().toISOString(), String((rows[0] as unknown[])[0])], + ); + } + } + + // 3. Auto-provision a new user + if (rows.length === 0) { + if (!oidc.autoProvision) { + return c.json( + { error: "No matching Invio account. Contact an administrator." }, + 403, + ); + } + + const baseUsername = ( + claims.preferred_username || claims.name || claims.sub + ) + .replace(/[^a-zA-Z0-9._-]/g, "_") + .slice(0, 50); + + let username = baseUsername; + let attempt = 0; + while ( + (db.query("SELECT id FROM users WHERE username = ?", [username]) as unknown[][]).length > 0 + ) { + attempt++; + username = `${baseUsername}_${attempt}`; + } + + const id = generateUUID(); + const now = new Date().toISOString(); + db.query( + `INSERT INTO users (id, username, email, display_name, password_hash, is_admin, is_active, oidc_subject, created_at, updated_at) + VALUES (?, ?, ?, ?, 'oidc:no-password', 0, 1, ?, ?, ?)`, + [ + id, + username, + claims.email || null, + claims.name || null, + claims.sub, + now, + now, + ], + ); + + rows = db.query( + "SELECT id, username, is_admin, is_active FROM users WHERE id = ?", + [id], + ) as unknown[][]; + } + + const row = rows[0] as unknown[]; + const userId = String(row[0]); + const username = String(row[1]); + const isAdmin = Boolean(row[2]); + const isActive = Boolean(row[3]); + + if (!isActive) { + return c.json({ error: "Account is disabled" }, 403); + } + + const session = await issueSessionToken({ id: userId, username, isAdmin }); + return c.json(session); +}); + export { authRoutes }; diff --git a/backend/src/utils/email.ts b/backend/src/utils/email.ts new file mode 100644 index 0000000..cafd7b0 --- /dev/null +++ b/backend/src/utils/email.ts @@ -0,0 +1,75 @@ +import { getEnv } from "./env.ts"; +import nodemailer from "npm:nodemailer"; +import { Buffer } from "node:buffer"; + +export interface EmailAttachment { + filename: string; + content: Uint8Array; + mimeType: string; +} + +export interface SendEmailOptions { + to: string[]; + subject: string; + htmlBody: string; + textBody: string; + attachment?: EmailAttachment; +} + +export function isEmailConfigured(): boolean { + return Boolean( + getEnv("SMTP_HOST", "") && + getEnv("EMAIL_FROM_ADDRESS", ""), + ); +} + +function getSmtpConfig() { + const host = getEnv("SMTP_HOST"); + const port = parseInt(getEnv("SMTP_PORT", "587") || "587", 10); + const secure = (getEnv("SMTP_SECURE", "false") || "false").toLowerCase() === "true"; + const user = getEnv("SMTP_USER", ""); + const pass = getEnv("SMTP_PASS", ""); + const fromAddress = getEnv("EMAIL_FROM_ADDRESS"); + const fromName = getEnv("EMAIL_FROM_NAME", "") || ""; + + if (!host) throw new Error("SMTP_HOST is not configured."); + if (!fromAddress) throw new Error("EMAIL_FROM_ADDRESS is not configured."); + + return { host, port, secure, user, pass, fromAddress, fromName }; +} + +export async function sendEmail(opts: SendEmailOptions): Promise { + const cfg = getSmtpConfig(); + + const transporter = nodemailer.createTransport({ + host: cfg.host, + port: cfg.port, + secure: cfg.secure, + ...(cfg.user && cfg.pass + ? { auth: { user: cfg.user, pass: cfg.pass } } + : {}), + }); + + const from = cfg.fromName + ? `"${cfg.fromName}" <${cfg.fromAddress}>` + : cfg.fromAddress; + + const attachments = opts.attachment + ? [ + { + filename: opts.attachment.filename, + content: Buffer.from(opts.attachment.content), + contentType: opts.attachment.mimeType, + }, + ] + : []; + + await transporter.sendMail({ + from, + to: opts.to.join(", "), + subject: opts.subject, + html: opts.htmlBody, + text: opts.textBody, + attachments, + }); +} diff --git a/backend/src/utils/oidc.ts b/backend/src/utils/oidc.ts new file mode 100644 index 0000000..169cc1b --- /dev/null +++ b/backend/src/utils/oidc.ts @@ -0,0 +1,246 @@ +import { getEnv } from "./env.ts"; + +export interface OidcConfig { + enabled: boolean; + issuerUrl: string; + clientId: string; + clientSecret: string; + redirectUri: string; + autoProvision: boolean; +} + +export function getOidcConfig(): OidcConfig { + return { + enabled: (getEnv("OIDC_ENABLED", "false") || "false").toLowerCase() === "true", + issuerUrl: (getEnv("OIDC_ISSUER_URL", "") || "").replace(/\/$/, ""), + clientId: getEnv("OIDC_CLIENT_ID", "") || "", + clientSecret: getEnv("OIDC_CLIENT_SECRET", "") || "", + redirectUri: getEnv("OIDC_REDIRECT_URI", "") || "", + autoProvision: (getEnv("OIDC_AUTO_PROVISION", "false") || "false").toLowerCase() === "true", + }; +} + +export interface OidcClaims { + sub: string; + email?: string; + preferred_username?: string; + name?: string; +} + +// ── Discovery document ───────────────────────────────────────────────────── + +interface DiscoveryDocument { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + jwks_uri: string; +} + +let discoveryCache: DiscoveryDocument | null = null; +let discoveryCacheTime = 0; +const DISCOVERY_TTL_MS = 60 * 60 * 1000; + +async function getDiscovery(): Promise { + const now = Date.now(); + if (discoveryCache && now - discoveryCacheTime < DISCOVERY_TTL_MS) { + return discoveryCache; + } + const { issuerUrl } = getOidcConfig(); + if (!issuerUrl) throw new Error("OIDC_ISSUER_URL is not configured"); + const resp = await fetch(`${issuerUrl}/.well-known/openid-configuration`); + if (!resp.ok) throw new Error(`OIDC discovery failed: ${resp.status}`); + discoveryCache = (await resp.json()) as DiscoveryDocument; + discoveryCacheTime = now; + return discoveryCache; +} + +// ── JWKS cache ───────────────────────────────────────────────────────────── + +let jwksCache: JsonWebKey[] | null = null; +let jwksCacheTime = 0; +const JWKS_TTL_MS = 60 * 60 * 1000; + +async function getJwks(jwksUri: string): Promise { + const now = Date.now(); + if (jwksCache && now - jwksCacheTime < JWKS_TTL_MS) return jwksCache; + const resp = await fetch(jwksUri); + if (!resp.ok) throw new Error(`JWKS fetch failed: ${resp.status}`); + const body = (await resp.json()) as { keys: JsonWebKey[] }; + jwksCache = body.keys; + jwksCacheTime = now; + return jwksCache; +} + +// ── State / nonce store ──────────────────────────────────────────────────── + +interface OidcState { + nonce: string; + createdAt: number; +} + +const stateStore = new Map(); +const STATE_TTL_MS = 10 * 60 * 1000; + +setInterval(() => { + const cutoff = Date.now() - STATE_TTL_MS; + for (const [key, val] of stateStore.entries()) { + if (val.createdAt < cutoff) stateStore.delete(key); + } +}, 5 * 60 * 1000); + +function randomHex(bytes: number): string { + const arr = new Uint8Array(bytes); + crypto.getRandomValues(arr); + return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +// ── Public: build authorization URL ─────────────────────────────────────── + +export async function buildAuthorizationUrl(): Promise { + const config = getOidcConfig(); + if (!config.clientId) throw new Error("OIDC_CLIENT_ID is not configured"); + if (!config.redirectUri) throw new Error("OIDC_REDIRECT_URI is not configured"); + + const discovery = await getDiscovery(); + const state = randomHex(16); + const nonce = randomHex(16); + stateStore.set(state, { nonce, createdAt: Date.now() }); + + const params = new URLSearchParams({ + response_type: "code", + client_id: config.clientId, + redirect_uri: config.redirectUri, + scope: "openid email profile", + state, + nonce, + }); + + return `${discovery.authorization_endpoint}?${params}`; +} + +// ── Public: exchange code + verify ID token ──────────────────────────────── + +export async function exchangeAndVerify( + code: string, + state: string, +): Promise { + const stateEntry = stateStore.get(state); + if (!stateEntry || Date.now() - stateEntry.createdAt > STATE_TTL_MS) { + stateStore.delete(state); + throw new Error("Invalid or expired OIDC state"); + } + const { nonce } = stateEntry; + stateStore.delete(state); + + const config = getOidcConfig(); + const discovery = await getDiscovery(); + + const tokenResp = await fetch(discovery.token_endpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: config.redirectUri, + client_id: config.clientId, + client_secret: config.clientSecret, + }), + }); + + if (!tokenResp.ok) { + const body = await tokenResp.text(); + throw new Error(`Token exchange failed: ${tokenResp.status} ${body}`); + } + + const tokens = (await tokenResp.json()) as { id_token?: string }; + if (!tokens.id_token) throw new Error("No id_token in token response"); + + return verifyIdToken(tokens.id_token, discovery.jwks_uri, config.clientId, discovery.issuer, nonce); +} + +// ── ID token verification (RS256) ───────────────────────────────────────── + +function b64urlToBytes(b64url: string): Uint8Array { + const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/"); + const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); + const bin = atob(padded); + return Uint8Array.from(bin, (c) => c.charCodeAt(0)); +} + +function b64urlDecode(b64url: string): string { + return new TextDecoder().decode(b64urlToBytes(b64url)); +} + +async function verifyIdToken( + idToken: string, + jwksUri: string, + clientId: string, + expectedIssuer: string, + expectedNonce: string, +): Promise { + const parts = idToken.split("."); + if (parts.length !== 3) throw new Error("Malformed ID token"); + const [headerB64, payloadB64, sigB64] = parts; + + const header = JSON.parse(b64urlDecode(headerB64)) as { + alg?: string; + kid?: string; + }; + if (header.alg !== "RS256") { + throw new Error(`Unsupported ID token algorithm: ${header.alg}`); + } + + const jwks = await getJwks(jwksUri); + const jwk = jwks.find( + (k) => + k.kid === header.kid && + (k as Record).use !== "enc" && + ((k as Record).alg === "RS256" || + (k as Record).kty === "RSA"), + ); + if (!jwk) throw new Error(`No matching JWK for kid=${header.kid}`); + + const cryptoKey = await crypto.subtle.importKey( + "jwk", + jwk, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], + ); + + const signedData = new TextEncoder().encode(`${headerB64}.${payloadB64}`); + const signature = b64urlToBytes(sigB64); + const valid = await crypto.subtle.verify( + "RSASSA-PKCS1-v1_5", + cryptoKey, + signature, + signedData, + ); + if (!valid) throw new Error("ID token signature verification failed"); + + const payload = JSON.parse(b64urlDecode(payloadB64)) as Record; + + const now = Math.floor(Date.now() / 1000); + if (typeof payload.exp === "number" && payload.exp < now) { + throw new Error("ID token expired"); + } + if (payload.iss !== expectedIssuer) { + throw new Error(`Issuer mismatch: got ${payload.iss}`); + } + const aud = payload.aud; + const audOk = + aud === clientId || (Array.isArray(aud) && aud.includes(clientId)); + if (!audOk) throw new Error("ID token audience mismatch"); + if (payload.nonce !== expectedNonce) { + throw new Error("ID token nonce mismatch"); + } + + return { + sub: String(payload.sub), + email: payload.email ? String(payload.email) : undefined, + preferred_username: payload.preferred_username + ? String(payload.preferred_username) + : undefined, + name: payload.name ? String(payload.name) : undefined, + }; +} diff --git a/backend/static/templates/nova.html b/backend/static/templates/nova.html new file mode 100644 index 0000000..29dcdc3 --- /dev/null +++ b/backend/static/templates/nova.html @@ -0,0 +1,352 @@ + + + + + {{labels.invoiceTitle}} {{invoiceNumber}} + + + + +
+ +
+ + +
+
+ {{#logoUrl}}{{/logoUrl}} +
{{companyName}}
+
+ {{#companyAddress}}{{{companyAddress}}}{{/companyAddress}} + {{#companyPostalCity}}{{companyPostalCity}}{{/companyPostalCity}} + {{#companyEmail}}{{companyEmail}}{{/companyEmail}} + {{#companyPhone}}{{companyPhone}}{{/companyPhone}} + {{#companyTaxId}}{{labels.taxIdLabel}}: {{companyTaxId}}{{/companyTaxId}} +
+
+ +
+
{{labels.invoiceTitle}}
+
+
+ {{labels.invoiceNumberShortLabel}} + {{invoiceNumber}} +
+
+ {{labels.invoiceDateLabel}} + {{issueDate}} +
+ {{#dueDate}} +
+ {{labels.dueDateLabel}} + {{dueDate}} +
+ {{/dueDate}} +
+
{{status}}
+
+
+ + +
+
+
{{labels.billToHeading}}
+
{{customerName}}
+
+ {{#customerContactName}}{{customerContactName}}{{/customerContactName}} + {{#customerEmail}}{{customerEmail}}{{/customerEmail}} + {{#customerPhone}}{{customerPhone}}{{/customerPhone}} + {{#customerAddress}}{{{customerAddress}}}{{/customerAddress}} + {{#customerPostalCity}}{{customerPostalCity}}{{/customerPostalCity}} + {{#customerCountryCode}}{{customerCountryCode}}{{/customerCountryCode}} + {{#customerTaxId}}{{labels.taxIdLabel}}: {{customerTaxId}}{{/customerTaxId}} +
+
+
+ + + + + + + + {{#hasItemUnits}}{{/hasItemUnits}} + + + + + + {{#items}} + + + + {{#hasItemUnits}}{{/hasItemUnits}} + + + + {{/items}} + +
{{labels.itemHeaderDescription}}{{labels.itemHeaderQuantityShort}}{{labels.itemHeaderUnit}}{{labels.itemHeaderUnitPrice}}{{labels.itemHeaderAmount}}
+
{{description}}
+ {{#notes}}
{{notes}}
{{/notes}} +
{{quantity}}{{unit}}{{unitPrice}}{{lineTotal}}
+ + +
+
+
+
+
+
+ {{labels.subtotalLabel}} + {{subtotal}} +
+
+ {{#hasDiscount}} +
+
+ {{labels.discountLabel}}{{#discountPercentage}} ({{discountPercentage}}%){{/discountPercentage}} + −{{discountAmount}} +
+
+ {{/hasDiscount}} + {{#hasTax}} +
+
+ {{labels.taxLabel}} ({{taxRate}}%) + {{taxAmount}} +
+
+ {{/hasTax}} + {{#hasTaxSummary}} +
+
{{labels.taxSummaryHeading}}
+ + + + + + + + {{#taxSummary}} + + {{/taxSummary}} + +
{{labels.taxLabel}}{{labels.taxableLabel}}{{labels.taxAmountLabel}}
{{label}}{{taxable}}{{amount}}
+
+ {{/hasTaxSummary}} +
+
+ {{labels.totalLabel}} + {{total}} +
+
+
+
+
+ + + {{#notes}} +
+
{{labels.notesHeading}}
+
{{notes}}
+
+ {{/notes}} + + + + +
+ + diff --git a/backend/static/templates/slate.html b/backend/static/templates/slate.html new file mode 100644 index 0000000..5721bf2 --- /dev/null +++ b/backend/static/templates/slate.html @@ -0,0 +1,359 @@ + + + + + {{labels.invoiceTitle}} {{invoiceNumber}} + + + + + +
+
+
+ {{#logoUrl}}{{/logoUrl}} +
{{companyName}}
+
+ {{#companyAddress}}{{{companyAddress}}}{{/companyAddress}} + {{#companyPostalCity}}{{companyPostalCity}}{{/companyPostalCity}} + {{#companyEmail}}{{companyEmail}}{{/companyEmail}} + {{#companyPhone}}{{companyPhone}}{{/companyPhone}} + {{#companyTaxId}}{{labels.taxIdLabel}}: {{companyTaxId}}{{/companyTaxId}} +
+
+ +
+
{{labels.invoiceTitle}}
+
{{invoiceNumber}}
+
+
+ {{labels.invoiceDateLabel}} + {{issueDate}} +
+ {{#dueDate}} +
+ {{labels.dueDateLabel}} + {{dueDate}} +
+ {{/dueDate}} +
+
{{status}}
+
+
+
+
+ + +
+ + +
+
+
{{labels.billToHeading}}
+
{{customerName}}
+
+ {{#customerContactName}}{{customerContactName}}{{/customerContactName}} + {{#customerEmail}}{{customerEmail}}{{/customerEmail}} + {{#customerPhone}}{{customerPhone}}{{/customerPhone}} + {{#customerAddress}}{{{customerAddress}}}{{/customerAddress}} + {{#customerPostalCity}}{{customerPostalCity}}{{/customerPostalCity}} + {{#customerCountryCode}}{{customerCountryCode}}{{/customerCountryCode}} + {{#customerTaxId}}{{labels.taxIdLabel}}: {{customerTaxId}}{{/customerTaxId}} +
+
+
+ + + + + + + + {{#hasItemUnits}}{{/hasItemUnits}} + + + + + + {{#items}} + + + + {{#hasItemUnits}}{{/hasItemUnits}} + + + + {{/items}} + +
{{labels.itemHeaderDescription}}{{labels.itemHeaderQuantityShort}}{{labels.itemHeaderUnit}}{{labels.itemHeaderUnitPrice}}{{labels.itemHeaderAmount}}
+
{{description}}
+ {{#notes}}
{{notes}}
{{/notes}} +
{{quantity}}{{unit}}{{unitPrice}}{{lineTotal}}
+ + +
+
+
+
+
+
+ {{labels.subtotalLabel}} + {{subtotal}} +
+
+ {{#hasDiscount}} +
+
+ {{labels.discountLabel}}{{#discountPercentage}} ({{discountPercentage}}%){{/discountPercentage}} + −{{discountAmount}} +
+
+ {{/hasDiscount}} + {{#hasTax}} +
+
+ {{labels.taxLabel}} ({{taxRate}}%) + {{taxAmount}} +
+
+ {{/hasTax}} + {{#hasTaxSummary}} +
+
{{labels.taxSummaryHeading}}
+ + + + + + + + {{#taxSummary}} + + {{/taxSummary}} + +
{{labels.taxLabel}}{{labels.taxableLabel}}{{labels.taxAmountLabel}}
{{label}}{{taxable}}{{amount}}
+
+ {{/hasTaxSummary}} +
+
+ {{labels.totalLabel}} + {{total}} +
+
+
+
+
+ + + {{#notes}} +
+
{{labels.notesHeading}}
+
{{notes}}
+
+ {{/notes}} + + + + +
+ + diff --git a/frontend/src/routes/auth/callback/+server.ts b/frontend/src/routes/auth/callback/+server.ts new file mode 100644 index 0000000..b872776 --- /dev/null +++ b/frontend/src/routes/auth/callback/+server.ts @@ -0,0 +1,47 @@ +import type { RequestHandler } from "./$types"; +import { redirect } from "@sveltejs/kit"; +import { BACKEND_URL, SESSION_COOKIE, DEFAULT_SESSION_MAX_AGE } from "$lib/backend"; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const error = url.searchParams.get("error"); + if (error) { + throw redirect(303, `/login?error=oidc_${encodeURIComponent(error)}`); + } + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + + if (!code || !state) { + throw redirect(303, "/login?error=oidc_missing_params"); + } + + let resp: Response; + try { + resp = await fetch(`${BACKEND_URL}/api/v1/auth/oidc/callback`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code, state }), + }); + } catch { + throw redirect(303, "/login?error=oidc_server_error"); + } + + if (!resp.ok) { + throw redirect(303, "/login?error=oidc_failed"); + } + + const data = await resp.json(); + if (!data?.token) { + throw redirect(303, "/login?error=oidc_failed"); + } + + cookies.set(SESSION_COOKIE, data.token, { + path: "/", + httpOnly: true, + sameSite: "lax", + secure: false, + maxAge: data.expiresIn ?? DEFAULT_SESSION_MAX_AGE, + }); + + throw redirect(303, "/dashboard"); +}; diff --git a/frontend/src/routes/invoices/[id]/+page.server.ts b/frontend/src/routes/invoices/[id]/+page.server.ts index f5df8dd..d35521e 100644 --- a/frontend/src/routes/invoices/[id]/+page.server.ts +++ b/frontend/src/routes/invoices/[id]/+page.server.ts @@ -6,6 +6,7 @@ import { } from "$lib/backend"; import { error, redirect, fail } from "@sveltejs/kit"; import type { PageServerLoad, Actions } from "./$types"; +import { env } from "$env/dynamic/private"; export const load: PageServerLoad = async ({ params, locals, url }) => { if (!locals.authHeader) { @@ -32,6 +33,7 @@ export const load: PageServerLoad = async ({ params, locals, url }) => { invoice: invoiceRes.value, showPublishedBanner, allowProtectedInvoiceChanges, + emailEnabled: Boolean(env.SMTP_HOST && env.EMAIL_FROM_ADDRESS), }; } catch (err: any) { throw error(404, "Invoice not found"); @@ -102,6 +104,34 @@ export const actions: Actions = { await backendPost(`/api/v1/invoices/${id}/void`, locals.authHeader, {}); throw redirect(303, `/invoices/${id}`); } + if (intent === "send-email") { + const toRaw = String(data.get("emailTo") ?? "").trim(); + const subject = String(data.get("emailSubject") ?? "").trim(); + const message = String(data.get("emailMessage") ?? "").trim(); + + const to = toRaw + .split(",") + .map((e) => e.trim()) + .filter((e) => e.includes("@")); + + if (to.length === 0) { + return fail(400, { emailError: "Enter at least one valid recipient email address." }); + } + if (!subject) { + return fail(400, { emailError: "Subject is required." }); + } + + try { + await backendPost(`/api/v1/invoices/${id}/send-email`, locals.authHeader, { + to, + subject, + message, + }); + return { emailSent: true, emailRecipients: to }; + } catch (e) { + return fail(502, { emailError: `Failed to send: ${String(e)}` }); + } + } } catch (e) { if (e && typeof e === "object" && "status" in e && "location" in e) { // it's a redirect, rethrow it diff --git a/frontend/src/routes/invoices/[id]/+page.svelte b/frontend/src/routes/invoices/[id]/+page.svelte index e92448a..28d8f47 100644 --- a/frontend/src/routes/invoices/[id]/+page.svelte +++ b/frontend/src/routes/invoices/[id]/+page.svelte @@ -1,6 +1,6 @@ + + + + + +
{#if form?.error}
@@ -92,6 +209,16 @@
{/if} + {#if (form as any)?.emailSent} +
+ +
+
{t("Invoice sent successfully")}
+
{t("Sent to")} {(form as any).emailRecipients?.join(", ")}
+
+
+ {/if} + {#if showPublishedBanner && invoice?.shareToken}
@@ -249,6 +376,13 @@
{/if} + {#if emailEnabled && canExport && invoice.status !== "voided"} + + {/if} +