Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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/<app-slug>
# 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



3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@
/Invio.wiki
.DS_Store
/backend/data/*
/chrome-headless-shell
/chrome-headless-shell
.claude/settings.local.json
3 changes: 3 additions & 0 deletions backend/src/database/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions backend/src/database/migrations.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
178 changes: 178 additions & 0 deletions backend/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, string>, s) => { acc[s.key] = s.value as string; return acc; },
{} as Record<string, string>,
);
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
? `<p style="white-space:pre-wrap;">${message.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</p>`
: "";
const shareLinkHtml = shareLink
? `<p><a href="${shareLink}" style="color:#2563eb;">View invoice online</a></p>`
: "";
const dueDateHtml = dueDate ? `<tr><td style="padding:4px 8px;color:#6b7280;">Due date</td><td style="padding:4px 8px;">${dueDate}</td></tr>` : "";

const htmlBody = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family:sans-serif;color:#111;max-width:600px;margin:0 auto;padding:24px;">
<h2 style="margin-top:0;">${companyName}</h2>
${messageHtml}
<table style="border-collapse:collapse;margin:16px 0;width:auto;">
<tr><td style="padding:4px 8px;color:#6b7280;">Invoice</td><td style="padding:4px 8px;font-weight:600;">#${invoiceNumber}</td></tr>
<tr><td style="padding:4px 8px;color:#6b7280;">Issue date</td><td style="padding:4px 8px;">${issueDate}</td></tr>
${dueDateHtml}
<tr><td style="padding:4px 8px;color:#6b7280;">Total</td><td style="padding:4px 8px;font-weight:600;">${total}</td></tr>
</table>
${shareLinkHtml}
<p style="color:#6b7280;font-size:13px;">The invoice PDF is attached to this email.</p>
</body>
</html>`;

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",
Expand Down
125 changes: 125 additions & 0 deletions backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 };
Loading