Skip to content

Commit bbbc772

Browse files
committed
Merge branch 'main' of https://github.com/kittendevv/Invio
2 parents 05e8e2a + 823257b commit bbbc772

17 files changed

Lines changed: 1660 additions & 6 deletions

File tree

.env.example

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,35 @@ BACKEND_URL=http://localhost:3000
5555
# DEMO_DB_PATH=/app/data/invio-demo.db
5656
# DEMO_RESET_HOURS=0.5
5757
# DEMO_RESET_ON_START=true
58+
# ─── Email / SMTP ────────────────────────────────────────
59+
# Enables the "Send via Email" button on invoice pages.
60+
# Works with any SMTP server (Gmail, Outlook, SMTP2GO, Mailgun, self-hosted…).
61+
#
62+
# SMTP_HOST=mai-server
63+
# SMTP_PORT=smtp-port # 587 = STARTTLS (default), 465 = TLS, 25 = plain
64+
# SMTP_SECURE=true # set true only for port 465 (direct TLS)
65+
# SMTP_USER=username # leave blank if the server needs no auth
66+
# SMTP_PASS=secretpassword
67+
# EMAIL_FROM_ADDRESS=email-address
68+
# EMAIL_FROM_NAME=email-from-name # optional display name shown in email clients
69+
70+
# ─── Authentik OIDC / SSO (optional) ─────────────────────
71+
# Set OIDC_ENABLED=true and fill in the values below to add a
72+
# "Login with SSO" button to the login page.
73+
#
74+
# In Authentik: create an OAuth2/OIDC Provider + Application,
75+
# set the redirect URI to OIDC_REDIRECT_URI, and grant scopes:
76+
# openid email profile
77+
#
78+
# OIDC_ENABLED=false
79+
# OIDC_ISSUER_URL=https://authentik.example.com/application/o/<app-slug>
80+
# OIDC_CLIENT_ID=
81+
# OIDC_CLIENT_SECRET=
82+
# OIDC_REDIRECT_URI=https://invio.example.com/auth/callback
83+
#
84+
# Set to true to automatically create an Invio account on first SSO login.
85+
# When false (default), the user's email must already match an existing account.
86+
# OIDC_AUTO_PROVISION=false
87+
88+
89+

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@
1818
/Invio.wiki
1919
.DS_Store
2020
/backend/data/*
21-
/chrome-headless-shell
21+
/chrome-headless-shell
22+
.claude/settings.local.json

backend/src/database/init.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ function ensureUserColumns(database: DB): void {
196196
"INTEGER NOT NULL DEFAULT 0",
197197
);
198198
addColumnIfMissing(database, "users", "two_factor_recovery_codes", "TEXT");
199+
addColumnIfMissing(database, "users", "oidc_subject", "TEXT");
199200
}
200201

201202
function ensureStatusHistoryTable(database: DB): void {
@@ -474,6 +475,8 @@ function ensureSchemaUpgrades(database: DB): void {
474475
const BUILTIN_TEMPLATES = [
475476
{ id: "professional-modern", name: "Professional Modern", isDefault: false },
476477
{ id: "minimalist-clean", name: "Minimalist Clean", isDefault: true },
478+
{ id: "nova", name: "Nova", isDefault: false },
479+
{ id: "slate", name: "Slate", isDefault: false },
477480
] as const;
478481

479482
function loadTemplateHtml(id: string): string {

backend/src/database/migrations.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,9 @@ CREATE TABLE IF NOT EXISTS user_permissions (
221221
UNIQUE(user_id, resource, action)
222222
);
223223

224+
ALTER TABLE users ADD COLUMN oidc_subject TEXT;
225+
226+
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc_subject ON users(oidc_subject) WHERE oidc_subject IS NOT NULL;
224227
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
225228
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
226229
CREATE INDEX IF NOT EXISTS idx_user_permissions_user ON user_permissions(user_id);

backend/src/routes/admin.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import {
6666
updateUnit,
6767
} from "../controllers/productOptions.ts";
6868
import { buildInvoiceHTML, generatePDF } from "../utils/pdf.ts";
69+
import { isEmailConfigured, sendEmail } from "../utils/email.ts";
6970
import { generateUBLInvoiceXML } from "../utils/ubl.ts"; // legacy direct import
7071
import { generateInvoiceXML, listXMLProfiles } from "../utils/xmlProfiles.ts";
7172
import { availableInvoiceLocales } from "../i18n/translations.ts";
@@ -1654,6 +1655,183 @@ adminRoutes.get(
16541655
},
16551656
);
16561657

1658+
// Send invoice via email (SMTP2GO)
1659+
adminRoutes.post(
1660+
"/invoices/:id/send-email",
1661+
requirePermission("invoices", "export"),
1662+
async (c) => {
1663+
if (!isEmailConfigured()) {
1664+
return c.json(
1665+
{ error: "Email is not configured. Set SMTP2GO_API_KEY and EMAIL_FROM_ADDRESS." },
1666+
503,
1667+
);
1668+
}
1669+
1670+
const id = c.req.param("id");
1671+
const invoice = getInvoiceById(id);
1672+
if (!invoice) return c.json({ error: "Invoice not found" }, 404);
1673+
1674+
let to: string[] = [];
1675+
let subject = "";
1676+
let message = "";
1677+
try {
1678+
const body = await c.req.json();
1679+
to = Array.isArray(body.to) ? body.to.filter((e: unknown) => typeof e === "string" && e.includes("@")) : [];
1680+
subject = typeof body.subject === "string" ? body.subject.trim() : "";
1681+
message = typeof body.message === "string" ? body.message.trim() : "";
1682+
} catch {
1683+
return c.json({ error: "Invalid request body" }, 400);
1684+
}
1685+
1686+
if (to.length === 0) {
1687+
return c.json({ error: "At least one valid recipient email is required" }, 400);
1688+
}
1689+
if (!subject) {
1690+
return c.json({ error: "Subject is required" }, 400);
1691+
}
1692+
1693+
// Build settings map (same as /pdf route)
1694+
const settings = await getSettings();
1695+
const settingsMap = settings.reduce(
1696+
(acc: Record<string, string>, s) => { acc[s.key] = s.value as string; return acc; },
1697+
{} as Record<string, string>,
1698+
);
1699+
if (!settingsMap.postalCityFormat && settingsMap.postal_city_format) {
1700+
settingsMap.postalCityFormat = settingsMap.postal_city_format;
1701+
}
1702+
if (!settingsMap.logo && settingsMap.logoUrl) {
1703+
settingsMap.logo = settingsMap.logoUrl;
1704+
}
1705+
1706+
const businessSettings = {
1707+
companyName: settingsMap.companyName || "Your Company",
1708+
companyAddress: settingsMap.companyAddress || "",
1709+
companyCity: settingsMap.companyCity || "",
1710+
companyPostalCode: settingsMap.companyPostalCode || "",
1711+
companyCountryCode: settingsMap.companyCountryCode || "",
1712+
postalCityFormat: settingsMap.postalCityFormat || "auto",
1713+
companyEmail: settingsMap.companyEmail || "",
1714+
companyPhone: settingsMap.companyPhone || "",
1715+
companyTaxId: settingsMap.companyTaxId || "",
1716+
currency: settingsMap.currency || "USD",
1717+
taxLabel: settingsMap.taxLabel || undefined,
1718+
logo: settingsMap.logo,
1719+
paymentMethods: settingsMap.paymentMethods || "Bank Transfer",
1720+
bankAccount: settingsMap.bankAccount || "",
1721+
paymentTerms: settingsMap.paymentTerms || "Due in 30 days",
1722+
defaultNotes: settingsMap.defaultNotes || "",
1723+
locale: settingsMap.locale || undefined,
1724+
};
1725+
1726+
const highlight = settingsMap.highlight ?? undefined;
1727+
let selectedTemplateId: string | undefined = settingsMap.templateId?.toLowerCase();
1728+
if (selectedTemplateId === "professional" || selectedTemplateId === "professional-modern") {
1729+
selectedTemplateId = "professional-modern";
1730+
} else if (selectedTemplateId === "minimalist" || selectedTemplateId === "minimalist-clean") {
1731+
selectedTemplateId = "minimalist-clean";
1732+
}
1733+
1734+
// Generate PDF attachment
1735+
let pdfBuffer: Uint8Array;
1736+
try {
1737+
const customer = getCustomerById(invoice.customerId);
1738+
const renderLocale = resolveInvoiceRenderLocale(
1739+
invoice.locale,
1740+
customer?.countryCode,
1741+
settingsMap.locale,
1742+
);
1743+
pdfBuffer = await generatePDF(
1744+
invoice,
1745+
businessSettings,
1746+
selectedTemplateId,
1747+
highlight,
1748+
{
1749+
embedXml: false,
1750+
dateFormat: settingsMap.dateFormat,
1751+
numberFormat: settingsMap.numberFormat,
1752+
locale: renderLocale,
1753+
},
1754+
);
1755+
} catch (e) {
1756+
const msg = e instanceof Error ? e.message : String(e);
1757+
console.error("Email: PDF generation failed:", msg);
1758+
return c.json({ error: "Failed to generate PDF attachment", details: msg }, 500);
1759+
}
1760+
1761+
// Build email body
1762+
const companyName = businessSettings.companyName;
1763+
const invoiceNumber = invoice.invoiceNumber || invoice.id;
1764+
const total = `${Number(invoice.total || 0).toFixed(2)} ${invoice.currency || ""}`.trim();
1765+
const issueDate = invoice.issueDate
1766+
? new Date(invoice.issueDate).toISOString().slice(0, 10)
1767+
: "";
1768+
const dueDate = invoice.dueDate
1769+
? new Date(invoice.dueDate).toISOString().slice(0, 10)
1770+
: null;
1771+
const origin = c.req.header("origin") ||
1772+
c.req.header("referer")?.replace(/\/$/, "") || "";
1773+
const shareLink = invoice.shareToken && origin
1774+
? `${origin}/public/invoices/${invoice.shareToken}`
1775+
: null;
1776+
1777+
const messageHtml = message
1778+
? `<p style="white-space:pre-wrap;">${message.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</p>`
1779+
: "";
1780+
const shareLinkHtml = shareLink
1781+
? `<p><a href="${shareLink}" style="color:#2563eb;">View invoice online</a></p>`
1782+
: "";
1783+
const dueDateHtml = dueDate ? `<tr><td style="padding:4px 8px;color:#6b7280;">Due date</td><td style="padding:4px 8px;">${dueDate}</td></tr>` : "";
1784+
1785+
const htmlBody = `<!DOCTYPE html>
1786+
<html>
1787+
<head><meta charset="utf-8"></head>
1788+
<body style="font-family:sans-serif;color:#111;max-width:600px;margin:0 auto;padding:24px;">
1789+
<h2 style="margin-top:0;">${companyName}</h2>
1790+
${messageHtml}
1791+
<table style="border-collapse:collapse;margin:16px 0;width:auto;">
1792+
<tr><td style="padding:4px 8px;color:#6b7280;">Invoice</td><td style="padding:4px 8px;font-weight:600;">#${invoiceNumber}</td></tr>
1793+
<tr><td style="padding:4px 8px;color:#6b7280;">Issue date</td><td style="padding:4px 8px;">${issueDate}</td></tr>
1794+
${dueDateHtml}
1795+
<tr><td style="padding:4px 8px;color:#6b7280;">Total</td><td style="padding:4px 8px;font-weight:600;">${total}</td></tr>
1796+
</table>
1797+
${shareLinkHtml}
1798+
<p style="color:#6b7280;font-size:13px;">The invoice PDF is attached to this email.</p>
1799+
</body>
1800+
</html>`;
1801+
1802+
const textBody = [
1803+
companyName,
1804+
"",
1805+
message || "",
1806+
`Invoice: #${invoiceNumber}`,
1807+
`Issue date: ${issueDate}`,
1808+
dueDate ? `Due date: ${dueDate}` : "",
1809+
`Total: ${total}`,
1810+
shareLink ? `\nView online: ${shareLink}` : "",
1811+
].filter((l) => l !== undefined).join("\n").trim();
1812+
1813+
try {
1814+
await sendEmail({
1815+
to,
1816+
subject,
1817+
htmlBody,
1818+
textBody,
1819+
attachment: {
1820+
filename: `invoice-${invoiceNumber}.pdf`,
1821+
content: pdfBuffer,
1822+
mimeType: "application/pdf",
1823+
},
1824+
});
1825+
} catch (e) {
1826+
const msg = e instanceof Error ? e.message : String(e);
1827+
console.error("Email send failed:", msg);
1828+
return c.json({ error: "Failed to send email", details: msg }, 502);
1829+
}
1830+
1831+
return c.json({ sent: true, recipients: to.length });
1832+
},
1833+
);
1834+
16571835
// UBL (PEPPOL BIS Billing 3.0) XML for an invoice by ID
16581836
adminRoutes.get(
16591837
"/invoices/:id/ubl.xml",

backend/src/routes/auth.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ import {
2222
} from "../middleware/rateLimiter.ts";
2323
import { generateUUID } from "../utils/uuid.ts";
2424
import { isDemoMode } from "../utils/env.ts";
25+
import {
26+
buildAuthorizationUrl,
27+
exchangeAndVerify,
28+
getOidcConfig,
29+
type OidcClaims,
30+
} from "../utils/oidc.ts";
31+
import { getDatabase } from "../database/init.ts";
2532

2633
function getSessionTtlSeconds(): number {
2734
const parsed = parseInt(Deno.env.get("SESSION_TTL_SECONDS") || "3600", 10);
@@ -307,4 +314,122 @@ authRoutes.post("/auth/recover-2fa", async (c) => {
307314
return c.json(session);
308315
});
309316

317+
authRoutes.get("/auth/oidc/authorize", async (c) => {
318+
const oidc = getOidcConfig();
319+
if (!oidc.enabled) return c.json({ error: "OIDC not enabled" }, 404);
320+
try {
321+
const url = await buildAuthorizationUrl();
322+
return c.json({ url });
323+
} catch (err) {
324+
console.error("OIDC authorize error:", err);
325+
return c.json({ error: "Failed to build authorization URL" }, 500);
326+
}
327+
});
328+
329+
authRoutes.post("/auth/oidc/callback", async (c) => {
330+
const oidc = getOidcConfig();
331+
if (!oidc.enabled) return c.json({ error: "OIDC not enabled" }, 404);
332+
333+
let code: string | undefined;
334+
let state: string | undefined;
335+
try {
336+
const body = await c.req.json();
337+
code = typeof body.code === "string" ? body.code : undefined;
338+
state = typeof body.state === "string" ? body.state : undefined;
339+
} catch {
340+
return c.json({ error: "Invalid request body" }, 400);
341+
}
342+
343+
if (!code || !state) return c.json({ error: "Missing code or state" }, 400);
344+
345+
let claims: OidcClaims;
346+
try {
347+
claims = await exchangeAndVerify(code, state);
348+
} catch (err) {
349+
console.error("OIDC callback error:", err);
350+
return c.json({ error: "OIDC verification failed" }, 401);
351+
}
352+
353+
const db = getDatabase();
354+
355+
// 1. Look up by oidc_subject
356+
let rows = db.query(
357+
"SELECT id, username, is_admin, is_active FROM users WHERE oidc_subject = ?",
358+
[claims.sub],
359+
) as unknown[][];
360+
361+
// 2. Look up by email and bind oidc_subject
362+
if (rows.length === 0 && claims.email) {
363+
rows = db.query(
364+
"SELECT id, username, is_admin, is_active FROM users WHERE email = ?",
365+
[claims.email],
366+
) as unknown[][];
367+
if (rows.length > 0) {
368+
db.query(
369+
"UPDATE users SET oidc_subject = ?, updated_at = ? WHERE id = ?",
370+
[claims.sub, new Date().toISOString(), String((rows[0] as unknown[])[0])],
371+
);
372+
}
373+
}
374+
375+
// 3. Auto-provision a new user
376+
if (rows.length === 0) {
377+
if (!oidc.autoProvision) {
378+
return c.json(
379+
{ error: "No matching Invio account. Contact an administrator." },
380+
403,
381+
);
382+
}
383+
384+
const baseUsername = (
385+
claims.preferred_username || claims.name || claims.sub
386+
)
387+
.replace(/[^a-zA-Z0-9._-]/g, "_")
388+
.slice(0, 50);
389+
390+
let username = baseUsername;
391+
let attempt = 0;
392+
while (
393+
(db.query("SELECT id FROM users WHERE username = ?", [username]) as unknown[][]).length > 0
394+
) {
395+
attempt++;
396+
username = `${baseUsername}_${attempt}`;
397+
}
398+
399+
const id = generateUUID();
400+
const now = new Date().toISOString();
401+
db.query(
402+
`INSERT INTO users (id, username, email, display_name, password_hash, is_admin, is_active, oidc_subject, created_at, updated_at)
403+
VALUES (?, ?, ?, ?, 'oidc:no-password', 0, 1, ?, ?, ?)`,
404+
[
405+
id,
406+
username,
407+
claims.email || null,
408+
claims.name || null,
409+
claims.sub,
410+
now,
411+
now,
412+
],
413+
);
414+
415+
rows = db.query(
416+
"SELECT id, username, is_admin, is_active FROM users WHERE id = ?",
417+
[id],
418+
) as unknown[][];
419+
}
420+
421+
const row = rows[0] as unknown[];
422+
const userId = String(row[0]);
423+
const username = String(row[1]);
424+
const isAdmin = Boolean(row[2]);
425+
const isActive = Boolean(row[3]);
426+
427+
if (!isActive) {
428+
return c.json({ error: "Account is disabled" }, 403);
429+
}
430+
431+
const session = await issueSessionToken({ id: userId, username, isAdmin });
432+
return c.json(session);
433+
});
434+
310435
export { authRoutes };

0 commit comments

Comments
 (0)