├── VERSION ├── frontend ├── utils │ ├── toast.ts │ ├── format.ts │ └── backend.ts ├── _fresh │ ├── chunk-GI7LLYGM.js │ ├── deserializer.js │ ├── island-breadcrumbs.js │ ├── snapshot.json │ └── signals.js ├── .dockerignore ├── static │ ├── input.css │ ├── app-init.js │ └── favicon.svg ├── main.ts ├── dev.ts ├── fresh.config.ts ├── components │ ├── InvoiceEditor.tsx │ ├── Breadcrumbs.tsx │ └── Layout.tsx ├── package.json ├── tailwind.config.js ├── routes │ ├── logout.ts │ ├── index.tsx │ ├── public │ │ └── invoices │ │ │ └── [share_token] │ │ │ ├── pdf.ts │ │ │ ├── html.ts │ │ │ ├── ubl.xml.ts │ │ │ ├── xml.ts │ │ │ └── index.tsx │ ├── templates │ │ └── index.tsx │ ├── invoices │ │ └── [id] │ │ │ ├── xml.ts │ │ │ ├── html.ts │ │ │ ├── pdf.ts │ │ │ └── edit.tsx │ ├── api │ │ ├── auth │ │ │ └── login.ts │ │ ├── templates │ │ │ └── install.ts │ │ └── admin │ │ │ └── export │ │ │ └── full.ts │ ├── _app.tsx │ ├── customers │ │ ├── [id] │ │ │ ├── cannot-delete.tsx │ │ │ └── edit.tsx │ │ ├── index.tsx │ │ ├── [id].tsx │ │ └── new.tsx │ ├── _middleware.ts │ └── login.tsx ├── islands │ ├── DemoModeDisabler.tsx │ ├── ConfirmOnSubmit.tsx │ ├── CopyPublicLink.tsx │ ├── SettingsNav.tsx │ ├── InvoiceFormButton.tsx │ ├── Breadcrumbs.tsx │ ├── ThemeToggle.tsx │ ├── InstallTemplateForm.tsx │ └── ExportAll.tsx ├── i18n │ ├── context.tsx │ └── mod.ts ├── TAILWIND_SETUP.md ├── Dockerfile ├── deno.json ├── README.md └── fresh.gen.ts ├── backend ├── src │ ├── middleware │ │ ├── cors.ts │ │ └── auth.ts │ ├── models │ │ ├── invoice.ts │ │ ├── setting.ts │ │ ├── template.ts │ │ └── customer.ts │ ├── assets │ │ ├── AdobeCompat-v2.icc │ │ └── PDFA_def.ps │ ├── utils │ │ ├── uuid.ts │ │ ├── xmp.ts │ │ ├── pdf.test.ts │ │ ├── jwt.ts │ │ ├── env.ts │ │ ├── chromium.ts │ │ └── xmlProfiles.ts │ ├── i18n │ │ ├── locales │ │ │ ├── en.json │ │ │ ├── de.json │ │ │ └── nl.json │ │ └── translations.ts │ ├── routes │ │ └── auth.ts │ ├── controllers │ │ ├── templates_new.ts │ │ ├── settings_verification.test.ts │ │ ├── settings.ts │ │ └── invoices_clean.ts │ ├── database │ │ ├── migrations_clean.sql │ │ └── migrations.sql │ ├── app.ts │ └── types │ │ └── index.ts ├── invio-demo.db ├── Dockerfile └── deno.json ├── assets ├── banner-pride.png ├── banner-default.png └── inviodashboard.webp ├── .vscode └── mcp.json ├── .devcontainer └── devcontainer.json ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── docker.yml ├── docker-compose.yml ├── docker-compose-dev.yml ├── LICENSE ├── .env.example ├── README.md └── CODE_OF_CONDUCT.md /VERSION: -------------------------------------------------------------------------------- 1 | 1.9.1 -------------------------------------------------------------------------------- /frontend/utils/toast.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/src/middleware/cors.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/src/models/invoice.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/src/models/setting.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/src/models/template.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/_fresh/chunk-GI7LLYGM.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .deno 3 | _fresh 4 | *.log -------------------------------------------------------------------------------- /assets/banner-pride.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kittendevv/Invio/HEAD/assets/banner-pride.png -------------------------------------------------------------------------------- /backend/invio-demo.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kittendevv/Invio/HEAD/backend/invio-demo.db -------------------------------------------------------------------------------- /assets/banner-default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kittendevv/Invio/HEAD/assets/banner-default.png -------------------------------------------------------------------------------- /assets/inviodashboard.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kittendevv/Invio/HEAD/assets/inviodashboard.webp -------------------------------------------------------------------------------- /backend/src/assets/AdobeCompat-v2.icc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kittendevv/Invio/HEAD/backend/src/assets/AdobeCompat-v2.icc -------------------------------------------------------------------------------- /frontend/static/input.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* Custom styles */ 6 | -------------------------------------------------------------------------------- /frontend/main.ts: -------------------------------------------------------------------------------- 1 | import { start } from "$fresh/server.ts"; 2 | import manifest from "./fresh.gen.ts"; 3 | 4 | await start(manifest); 5 | -------------------------------------------------------------------------------- /frontend/dev.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import dev from "$fresh/dev.ts"; 3 | await dev(import.meta.url, "./main.ts"); 4 | -------------------------------------------------------------------------------- /frontend/fresh.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "$fresh/server.ts"; 2 | 3 | export default defineConfig({ 4 | plugins: [], 5 | }); 6 | -------------------------------------------------------------------------------- /.vscode/mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "servers": { 3 | "daisyUI": { 4 | "type": "sse", 5 | "url": "https://gitmcp.io/saadeghi/daisyui" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/devcontainers/universal:2", 3 | "features": { 4 | "ghcr.io/devcontainers-community/features/deno:1": {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SQLite DB (any folder) 2 | **/invio.db 3 | **/invio.db-journal 4 | **/invio.db-wal 5 | **/invio.db-shm 6 | 7 | # node_modules in any folder 8 | **/node_modules 9 | 10 | # fresh build 11 | **/_fresh 12 | 13 | .env 14 | 15 | /frontend/static/dev 16 | /frontend/VERSION 17 | 18 | /Invio.wiki 19 | .DS_Store 20 | -------------------------------------------------------------------------------- /frontend/components/InvoiceEditor.tsx: -------------------------------------------------------------------------------- 1 | import type { InvoiceEditorProps } from "../islands/InvoiceEditorIsland.tsx"; 2 | import InvoiceEditorIsland from "../islands/InvoiceEditorIsland.tsx"; 3 | 4 | export type { InvoiceEditorProps } from "../islands/InvoiceEditorIsland.tsx"; 5 | 6 | export function InvoiceEditor(props: InvoiceEditorProps) { 7 | return ; 8 | } -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "invio-frontend", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "build:css": "npx tailwindcss -i ./static/input.css -o ./static/styles.css --minify", 6 | "watch:css": "npx tailwindcss -i ./static/input.css -o ./static/styles.css --watch" 7 | }, 8 | "devDependencies": { 9 | "tailwindcss": "^3.4.0", 10 | "daisyui": "^4.12.10" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./routes/**/*.{ts,tsx}", 5 | "./islands/**/*.{ts,tsx}", 6 | "./components/**/*.{ts,tsx}", 7 | ], 8 | theme: { 9 | extend: {}, 10 | }, 11 | plugins: [require("daisyui")], 12 | daisyui: { 13 | themes: ["light", "dark"], 14 | darkTheme: "dark", 15 | base: true, 16 | styled: true, 17 | utils: true, 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /frontend/routes/logout.ts: -------------------------------------------------------------------------------- 1 | import { Handlers } from "$fresh/server.ts"; 2 | import { clearAuthCookieHeaders } from "../utils/backend.ts"; 3 | 4 | export const handler: Handlers = { 5 | GET() { 6 | const headers = new Headers({ ...clearAuthCookieHeaders(), Location: "/" }); 7 | return new Response(null, { status: 303, headers }); 8 | }, 9 | POST() { 10 | const headers = new Headers({ ...clearAuthCookieHeaders(), Location: "/" }); 11 | return new Response(null, { status: 303, headers }); 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /frontend/islands/DemoModeDisabler.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "preact/hooks"; 2 | 3 | export default function DemoModeDisabler() { 4 | useEffect(() => { 5 | try { 6 | const sel = '[data-writable]'; 7 | document.querySelectorAll(sel).forEach((el) => { 8 | if (el instanceof HTMLElement) { 9 | el.setAttribute('disabled', 'true'); 10 | el.classList.add('opacity-50', 'cursor-not-allowed'); 11 | } 12 | }); 13 | } catch (_e) {/* ignore */} 14 | }, []); 15 | return null; 16 | } 17 | -------------------------------------------------------------------------------- /frontend/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Handlers, PageProps } from "$fresh/server.ts"; 2 | import { getAuthHeaderFromCookie } from "../utils/backend.ts"; 3 | 4 | export const handler: Handlers = { 5 | GET(req) { 6 | const auth = getAuthHeaderFromCookie( 7 | req.headers.get("cookie") || undefined, 8 | ); 9 | const Location = auth ? "/dashboard" : "/login"; 10 | return new Response(null, { status: 303, headers: { Location } }); 11 | }, 12 | }; 13 | 14 | export default function RedirectPage(_props: PageProps) { 15 | return null; 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/utils/uuid.ts: -------------------------------------------------------------------------------- 1 | // Default UUID for IDs 2 | export function generateUUID(): string { 3 | return crypto.randomUUID(); 4 | } 5 | 6 | // Generate a long random token suitable for share links (base64url, 32 bytes => 43 chars) 7 | export function generateShareToken(bytes: number = 32): string { 8 | const arr = new Uint8Array(bytes); 9 | crypto.getRandomValues(arr); 10 | // Convert to base64url without padding 11 | const b64 = btoa(String.fromCharCode(...arr as unknown as number[])) 12 | .replace(/\+/g, "-") 13 | .replace(/\//g, "_") 14 | .replace(/=+$/g, ""); 15 | return b64; 16 | } 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /frontend/i18n/context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from "preact"; 2 | import { ComponentChildren } from "preact"; 3 | import { useContext } from "preact/hooks"; 4 | import { LocalizationConfig, DEFAULT_LOCALIZATION } from "./mod.ts"; 5 | 6 | export const LocalizationContext = createContext( 7 | DEFAULT_LOCALIZATION, 8 | ); 9 | 10 | export function LocalizationProvider( 11 | props: { value: LocalizationConfig; children: ComponentChildren }, 12 | ) { 13 | return ( 14 | 15 | {props.children} 16 | 17 | ); 18 | } 19 | 20 | export function useTranslations() { 21 | return useContext(LocalizationContext); 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/assets/PDFA_def.ps: -------------------------------------------------------------------------------- 1 | %! 2 | % This is a sample prefix file for creating a PDF/A document. 3 | % You should modify it to suit your needs. 4 | 5 | % Define an ICC profile : 6 | /ICCProfile ({{ICC_PROFILE_PATH}}) def 7 | 8 | % Define the OutputIntent dictionary : 9 | /OutputIntentDict [ 10 | /Type /OutputIntent % Must be so (the default is the empty string) 11 | /S /GTS_PDFA1 % Must be so (the default is the empty string) 12 | /DestOutputProfile {ICCProfile} % Must be so (see above) 13 | /OutputConditionIdentifier (sRGB) % Customize 14 | /RegistryName (http://www.color.org) % Must be so (the default is the empty string) 15 | ] /ColorConversionStrategy /UseDeviceIndependentColor def % Define the color conversion strategy 16 | -------------------------------------------------------------------------------- /frontend/_fresh/deserializer.js: -------------------------------------------------------------------------------- 1 | var i="_f";function y(t){let s=atob(t),r=s.length,c=new Uint8Array(r);for(let a=0;a 10 | 11 | 12 | 13 | INVOICE 14 | ${fileName} 15 | ${version} 16 | ${conformanceLevel} 17 | 18 | 19 | 20 | `; 21 | } 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | name: invio 2 | 3 | services: 4 | backend: 5 | image: ghcr.io/kittendevv/invio-backend:latest 6 | env_file: 7 | - .env 8 | environment: 9 | ADMIN_USER: ${ADMIN_USER} 10 | ADMIN_PASS: ${ADMIN_PASS} 11 | JWT_SECRET: ${JWT_SECRET} 12 | DATABASE_PATH: ${DATABASE_PATH:-/app/data/invio.db} 13 | volumes: 14 | - invio_data:/app/data 15 | ports: 16 | - "${BACKEND_PORT:-3000}:3000" 17 | restart: unless-stopped 18 | 19 | frontend: 20 | image: ghcr.io/kittendevv/invio-frontend:latest 21 | env_file: 22 | - .env 23 | environment: 24 | PORT: ${FRONTEND_PORT_INTERNAL:-8000} 25 | BACKEND_URL: ${BACKEND_URL:-http://backend:3000} 26 | depends_on: 27 | - backend 28 | ports: 29 | - "${FRONTEND_PORT:-8000}:${FRONTEND_PORT_INTERNAL:-8000}" 30 | restart: unless-stopped 31 | 32 | volumes: 33 | invio_data: 34 | driver: local 35 | -------------------------------------------------------------------------------- /frontend/_fresh/island-breadcrumbs.js: -------------------------------------------------------------------------------- 1 | import{a as s}from"./chunk-BYRZ2NRM.js";import{a as o,b as u,d as c}from"./chunk-G4CPWP5O.js";import"./chunk-JP5XG2OT.js";function p(n){return n.replace(/[-_]+/g," ").split(" ").map(t=>t&&t[0].toUpperCase()+t.slice(1)).join(" ")}var b={dashboard:"Dashboard",invoices:"Invoices",customers:"Customers",settings:"Settings",new:"New",edit:"Edit",html:"HTML",pdf:"PDF"};function d(){let[n,t]=o("/");u(()=>{t(globalThis.location?.pathname||"/")},[]);let a=c(()=>{try{let e=n.replace(/(^\/+|\/+?$)/g,"").split("/").filter(Boolean),r=[];r.push({label:"Home",href:"/"});let i="";return e.forEach((l,h)=>{i+="/"+l;let m=h===e.length-1,f=b[l]||p(l);r.push({label:f,href:m?void 0:i})}),r}catch{return[{label:"Home",href:"/"}]}},[n]);return a.length?s("div",{class:"breadcrumbs text-sm mb-4",children:s("ul",{children:a.map((e,r)=>s("li",{children:e.href?s("a",{href:e.href,children:e.label}):s("span",{class:"font-medium",children:e.label})},r))})}):null}export{d as default}; 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /frontend/routes/templates/index.tsx: -------------------------------------------------------------------------------- 1 | import { Handlers } from "$fresh/server.ts"; 2 | import { getAuthHeaderFromCookie } from "../../utils/backend.ts"; 3 | 4 | export const handler: Handlers = { 5 | GET(req) { 6 | const auth = getAuthHeaderFromCookie( 7 | req.headers.get("cookie") || undefined, 8 | ); 9 | if (!auth) { 10 | return new Response(null, { 11 | status: 303, 12 | headers: { Location: "/login" }, 13 | }); 14 | } 15 | return new Response(null, { 16 | status: 303, 17 | headers: { Location: "/settings" }, 18 | }); 19 | }, 20 | POST(req) { 21 | const auth = getAuthHeaderFromCookie( 22 | req.headers.get("cookie") || undefined, 23 | ); 24 | if (!auth) { 25 | return new Response(null, { 26 | status: 303, 27 | headers: { Location: "/login" }, 28 | }); 29 | } 30 | return new Response(null, { 31 | status: 303, 32 | headers: { Location: "/settings" }, 33 | }); 34 | }, 35 | }; 36 | 37 | export default function Redirect() { 38 | return null; 39 | } 40 | -------------------------------------------------------------------------------- /frontend/routes/invoices/[id]/xml.ts: -------------------------------------------------------------------------------- 1 | import { Handlers } from "$fresh/server.ts"; 2 | import { BACKEND_URL, getAuthHeaderFromCookie } from "../../../utils/backend.ts"; 3 | 4 | export const handler: Handlers = { 5 | async GET(req, ctx) { 6 | const auth = getAuthHeaderFromCookie(req.headers.get("cookie") || undefined); 7 | if (!auth) { 8 | return new Response(null, { status: 303, headers: { Location: "/login" } }); 9 | } 10 | const { id } = ctx.params as { id: string }; 11 | const url = new URL(req.url); 12 | const profile = url.searchParams.get("profile"); 13 | const backendUrl = `${BACKEND_URL}/api/v1/invoices/${id}/xml${profile ? `?profile=${encodeURIComponent(profile)}` : ""}`; 14 | const res = await fetch(backendUrl, { headers: { Authorization: auth } }); 15 | if (!res.ok) return new Response(`Upstream error: ${res.status} ${res.statusText}`, { status: res.status }); 16 | const headers = new Headers(res.headers); 17 | headers.set("Cache-Control", "no-store"); 18 | return new Response(res.body, { status: 200, headers }); 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /backend/src/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import type { Context, Next } from "hono"; 2 | import { verifyJWT } from "../utils/jwt.ts"; 3 | import { getAdminCredentials } from "../utils/env.ts"; 4 | 5 | function unauthorized(): Response { 6 | return new Response("Unauthorized", { 7 | status: 401, 8 | headers: { 9 | "WWW-Authenticate": "Bearer realm=\"Invio Admin\"", 10 | }, 11 | }); 12 | } 13 | 14 | export async function requireAdminAuth(c: Context, next: Next) { 15 | const auth = c.req.header("authorization"); 16 | if (!auth || !auth.startsWith("Bearer ")) return unauthorized(); 17 | 18 | const token = auth.slice("Bearer ".length).trim(); 19 | if (!token) return unauthorized(); 20 | 21 | const payload = await verifyJWT(token); 22 | if (!payload || typeof payload !== "object") return unauthorized(); 23 | 24 | const subject = (payload as { username?: string }).username || (payload as { user?: string }).user; 25 | const { username: adminUser } = getAdminCredentials(); 26 | if (!subject || subject !== adminUser) return unauthorized(); 27 | 28 | return await next(); 29 | } 30 | -------------------------------------------------------------------------------- /frontend/islands/ConfirmOnSubmit.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "preact/hooks"; 2 | import { useTranslations } from "../i18n/context.tsx"; 3 | 4 | export default function ConfirmOnSubmit() { 5 | const { t } = useTranslations(); 6 | useEffect(() => { 7 | const fallback = t("Are you sure?"); 8 | const onSubmit = (e: Event) => { 9 | const target = e.target as Element | null; 10 | if (!target) return; 11 | const el = target as HTMLElement; 12 | // Guard: ensure matches exists on the element 13 | // deno-lint-ignore no-explicit-any 14 | const matches: ((sel: string) => boolean) | undefined = (el as any).matches; 15 | if (typeof matches === "function" && matches.call(el, "form[data-confirm]")) { 16 | const msg = el.getAttribute("data-confirm") || fallback; 17 | if (!globalThis.confirm(msg)) { 18 | e.preventDefault(); 19 | } 20 | } 21 | }; 22 | document.addEventListener("submit", onSubmit, true); 23 | return () => document.removeEventListener("submit", onSubmit, true); 24 | }, [t]); 25 | return null; 26 | } 27 | -------------------------------------------------------------------------------- /frontend/TAILWIND_SETUP.md: -------------------------------------------------------------------------------- 1 | # Tailwind CSS Setup 2 | 3 | This project uses Tailwind CSS CLI with DaisyUI. 4 | 5 | ## Setup 6 | 7 | 1. Install dependencies: 8 | ```bash 9 | npm install 10 | ``` 11 | 12 | 2. Build CSS (one-time): 13 | ```bash 14 | npm run build:css 15 | ``` 16 | Or using Deno: 17 | ```bash 18 | deno task css:build 19 | ``` 20 | 21 | 3. Watch CSS (development): 22 | ```bash 23 | npm run watch:css 24 | ``` 25 | Or using Deno: 26 | ```bash 27 | deno task css:watch 28 | ``` 29 | 30 | ## Development Workflow 31 | 32 | Run both the CSS watcher and the Deno dev server in separate terminals: 33 | 34 | Terminal 1: 35 | ```bash 36 | npm run watch:css 37 | ``` 38 | 39 | Terminal 2: 40 | ```bash 41 | deno task start 42 | ``` 43 | 44 | ## How it works 45 | 46 | - `static/input.css` - Source CSS file with Tailwind directives 47 | - `static/styles.css` - Generated output file (do not edit manually) 48 | - `tailwind.config.js` - Tailwind configuration with DaisyUI plugin 49 | - The build process scans all `.tsx` files in `routes/`, `islands/`, and `components/` for Tailwind classes 50 | -------------------------------------------------------------------------------- /frontend/utils/format.ts: -------------------------------------------------------------------------------- 1 | // Utility functions for formatting numbers and currency 2 | export function formatMoney( 3 | value: number | undefined, 4 | currency: string = "USD", 5 | numberFormat: "comma" | "period" = "comma" 6 | ): string { 7 | if (typeof value !== "number") return ""; 8 | 9 | // Create a custom locale based on the number format preference 10 | let locale: string; 11 | let options: Intl.NumberFormatOptions; 12 | 13 | if (numberFormat === "period") { 14 | // European style: 1.000,00 15 | locale = "de-DE"; // German locale uses period as thousands separator and comma as decimal 16 | options = { style: "currency", currency }; 17 | } else { 18 | // US style: 1,000.00 19 | locale = "en-US"; 20 | options = { style: "currency", currency }; 21 | } 22 | 23 | return new Intl.NumberFormat(locale, options).format(value); 24 | } 25 | 26 | // Helper function to get number format from settings 27 | export function getNumberFormat(settings?: Record): "comma" | "period" { 28 | const format = (settings?.numberFormat as string) || "comma"; 29 | return format === "period" ? "period" : "comma"; 30 | } -------------------------------------------------------------------------------- /backend/src/utils/pdf.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertExists } from "https://deno.land/std@0.224.0/assert/mod.ts"; 2 | import { PDFDocument, PDFName } from "pdf-lib"; 3 | 4 | 5 | import { embedXmlAttachment } from "./pdf.ts"; 6 | import { getXMLProfile } from "./xmlProfiles.ts"; 7 | 8 | Deno.test("embedXmlAttachment injects ZUGFeRD XML and XMP metadata", async () => { 9 | // Create a dummy PDF 10 | const doc = await PDFDocument.create(); 11 | doc.addPage(); 12 | const pdfBytes = await doc.save(); 13 | 14 | const profile = getXMLProfile("facturx22"); 15 | const xmlBytes = new TextEncoder().encode("dummy"); 16 | 17 | const resultBytes = await embedXmlAttachment( 18 | pdfBytes, 19 | xmlBytes, 20 | "invoice.xml", 21 | profile.mediaType, 22 | "ZUGFeRD Invoice", 23 | "en-US", 24 | profile 25 | ); 26 | 27 | assertExists(resultBytes); 28 | const pdfDoc = await PDFDocument.load(resultBytes); 29 | 30 | // Check for EmbeddedFiles 31 | const names = pdfDoc.catalog.get(PDFName.of("Names")); 32 | assertExists(names); 33 | 34 | // Check for Metadata 35 | const metadata = pdfDoc.catalog.get(PDFName.of("Metadata")); 36 | assertExists(metadata); 37 | }); 38 | -------------------------------------------------------------------------------- /frontend/islands/CopyPublicLink.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "preact/hooks"; 2 | import { useTranslations } from "../i18n/context.tsx"; 3 | 4 | export default function CopyPublicLink() { 5 | const { t } = useTranslations(); 6 | useEffect(() => { 7 | const btn = document.getElementById("copy-public-link"); 8 | const urlEl = document.getElementById("public-link-url"); 9 | if (!btn || !urlEl) return; 10 | const fallbackLabel = t("Copy link"); 11 | const successLabel = t("Copied!"); 12 | if (!btn.dataset.originalLabel) { 13 | btn.dataset.originalLabel = btn.textContent?.trim() || fallbackLabel; 14 | } 15 | const onClick = async () => { 16 | try { 17 | const text = urlEl.textContent || (urlEl as HTMLAnchorElement).href || ""; 18 | await navigator.clipboard.writeText(text); 19 | const original = btn.dataset.originalLabel || fallbackLabel; 20 | btn.textContent = successLabel; 21 | setTimeout(() => { btn.textContent = original; }, 1200); 22 | } catch (_e) {/* ignore */} 23 | }; 24 | btn.addEventListener("click", onClick); 25 | return () => btn.removeEventListener("click", onClick); 26 | }, [t]); 27 | return null; 28 | } 29 | -------------------------------------------------------------------------------- /docker-compose-dev.yml: -------------------------------------------------------------------------------- 1 | name: invio-dev 2 | 3 | services: 4 | backend: 5 | build: ./backend # Build from local backend/ directory 6 | env_file: 7 | - .env 8 | environment: 9 | ADMIN_USER: ${ADMIN_USER} 10 | ADMIN_PASS: ${ADMIN_PASS} 11 | JWT_SECRET: ${JWT_SECRET} 12 | DATABASE_PATH: ${DATABASE_PATH:-/app/data/invio.db} 13 | volumes: 14 | - invio_data:/app/data 15 | - ./backend:/app # Mount source for live changes (if supported by your backend setup) 16 | ports: 17 | - "${BACKEND_PORT:-3000}:3000" 18 | restart: unless-stopped 19 | 20 | frontend: 21 | build: ./frontend # Build from local frontend/ directory 22 | env_file: 23 | - .env 24 | environment: 25 | PORT: ${FRONTEND_PORT_INTERNAL:-8000} 26 | BACKEND_URL: ${BACKEND_URL:-http://backend:3000} 27 | depends_on: 28 | - backend 29 | volumes: 30 | - ./frontend:/app # Mount source for live changes 31 | - ./VERSION:/app/VERSION # Mount VERSION file into the container at /app/VERSION 32 | ports: 33 | - "${FRONTEND_PORT:-8000}:${FRONTEND_PORT_INTERNAL:-8000}" 34 | restart: unless-stopped 35 | 36 | volumes: 37 | invio_data: 38 | driver: local -------------------------------------------------------------------------------- /frontend/routes/invoices/[id]/html.ts: -------------------------------------------------------------------------------- 1 | import { Handlers } from "$fresh/server.ts"; 2 | import { 3 | BACKEND_URL, 4 | getAuthHeaderFromCookie, 5 | } from "../../../utils/backend.ts"; 6 | 7 | export const handler: Handlers = { 8 | async GET(req, ctx) { 9 | const auth = getAuthHeaderFromCookie( 10 | req.headers.get("cookie") || undefined, 11 | ); 12 | if (!auth) { 13 | return new Response(null, { 14 | status: 303, 15 | headers: { Location: "/login" }, 16 | }); 17 | } 18 | 19 | const { id } = ctx.params as { id: string }; 20 | const backendUrl = `${BACKEND_URL}/api/v1/invoices/${id}/html`; 21 | 22 | const res = await fetch(backendUrl, { headers: { Authorization: auth } }); 23 | if (!res.ok) { 24 | return new Response(`Upstream error: ${res.status} ${res.statusText}`, { 25 | status: res.status, 26 | }); 27 | } 28 | 29 | const headers = new Headers(); 30 | const ct = res.headers.get("content-type"); 31 | const cc = res.headers.get("cache-control"); 32 | if (ct) headers.set("content-type", ct); 33 | headers.set("cache-control", cc ?? "no-store"); 34 | return new Response(res.body, { status: 200, headers }); 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Runtime with Chromium (headless) for Puppeteer PDF rendering 2 | FROM debian:12-slim 3 | 4 | ENV DEBIAN_FRONTEND=noninteractive 5 | 6 | # Install Chromium and fonts (works across architectures in buildx) 7 | RUN apt-get update && apt-get install -y --no-install-recommends \ 8 | ca-certificates \ 9 | curl \ 10 | fontconfig \ 11 | tar \ 12 | chromium \ 13 | ghostscript \ 14 | fonts-dejavu \ 15 | fonts-liberation \ 16 | fonts-noto \ 17 | fonts-noto-cjk \ 18 | fonts-noto-color-emoji \ 19 | git \ 20 | golang \ 21 | && GOBIN=/usr/local/bin go install github.com/pdfcpu/pdfcpu/cmd/pdfcpu@v0.4.1 || \ 22 | GOBIN=/usr/local/bin go install github.com/pdfcpu/pdfcpu/cmd/pdfcpu@latest \ 23 | && apt-get purge -y --auto-remove git golang \ 24 | && rm -rf /var/lib/apt/lists/* /root/go 25 | 26 | # Install a Deno version that supports lockfile v5 27 | COPY --from=denoland/deno:bin-2.4.5 /deno /usr/local/bin/deno 28 | 29 | WORKDIR /app 30 | COPY . . 31 | RUN mkdir -p /app/data 32 | 33 | ENV PORT=3000 \ 34 | DATABASE_PATH=/app/data/invio.db \ 35 | PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \ 36 | GHOSTSCRIPT_BIN=/usr/bin/gs 37 | 38 | EXPOSE 3000 39 | CMD ["deno", "task", "start"] -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Frontend (Deno Fresh) production image 2 | FROM denoland/deno:debian 3 | 4 | WORKDIR /app 5 | 6 | # Copy frontend source 7 | COPY . /app 8 | 9 | # Ensure VERSION is available in the runtime image. Prefer copying the file from the 10 | # build context (expected to be synced from the repository root); fall back to the 11 | # APP_VERSION build arg when the file is absent. 12 | ARG APP_VERSION=unknown 13 | RUN if [ -f /app/VERSION ]; then \ 14 | printf "%s" "$(sed -e 's/\r$//' /app/VERSION)" > /app/VERSION; \ 15 | else \ 16 | printf "%s" "${APP_VERSION}" > /app/VERSION; \ 17 | fi 18 | 19 | # Cache imports 20 | RUN deno cache main.ts dev.ts 21 | 22 | # Build with platform-specific handling 23 | # Skip build for ARM64 in emulation (causes QEMU issues) 24 | # Fresh can run without pre-build in production mode 25 | RUN if [ "$(uname -m)" = "x86_64" ]; then \ 26 | deno task build; \ 27 | else \ 28 | echo "Skipping build on ARM64 - will build on first request"; \ 29 | fi 30 | 31 | # Default env; BACKEND_URL should point to backend service in compose 32 | ENV PORT=8000 \ 33 | BACKEND_URL=http://backend:3000 34 | 35 | EXPOSE 8000 36 | 37 | # Run the production preview server 38 | CMD ["deno", "run", "-A", "main.ts"] 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /frontend/static/app-init.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | // Set data-theme ASAP from localStorage or prefers-color-scheme 3 | try { 4 | const root = document.documentElement; 5 | const KEY = "theme"; 6 | let stored = null; 7 | try { stored = globalThis.localStorage ? localStorage.getItem(KEY) : null; } catch(_) { stored = null; } 8 | let prefersDark = false; 9 | try { prefersDark = !!(globalThis.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches); } catch(_) { prefersDark = false; } 10 | const theme = (stored === 'light' || stored === 'dark') ? stored : (prefersDark ? 'dark' : 'light'); 11 | root.setAttribute('data-theme', theme); 12 | } catch (_err) { 13 | // ignore theme init errors 14 | } 15 | })(); 16 | 17 | (function () { 18 | function init() { 19 | try { 20 | const lucide = globalThis.lucide; 21 | if (lucide && typeof lucide.createIcons === "function") { 22 | lucide.createIcons(); 23 | } else { 24 | // Retry if lucide isn't loaded yet 25 | setTimeout(init, 100); 26 | } 27 | } catch (_err) { 28 | // ignore lucide init errors 29 | } 30 | } 31 | if (document.readyState === "loading") { 32 | document.addEventListener("DOMContentLoaded", init); 33 | } else { 34 | init(); 35 | } 36 | })(); 37 | -------------------------------------------------------------------------------- /frontend/islands/SettingsNav.tsx: -------------------------------------------------------------------------------- 1 | import { LuChevronDown } from "../components/icons.tsx"; 2 | import { ComponentType } from "preact"; 3 | 4 | interface SettingsNavProps { 5 | currentSection: string; 6 | currentLabel: string; 7 | sections: Array<{ value: string; label: string; icon: ComponentType<{ size?: number }>; show?: boolean }>; 8 | } 9 | 10 | export default function SettingsNav({ currentSection, currentLabel, sections }: SettingsNavProps) { 11 | return ( 12 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /frontend/routes/api/auth/login.ts: -------------------------------------------------------------------------------- 1 | import { Handlers } from "$fresh/server.ts"; 2 | import { BACKEND_URL } from "../../../utils/backend.ts"; 3 | 4 | export const handler: Handlers = { 5 | async POST(req) { 6 | let body: { username?: unknown; password?: unknown } = {}; 7 | try { 8 | body = await req.json(); 9 | } catch { 10 | return new Response(JSON.stringify({ error: "Invalid JSON body" }), { 11 | status: 400, 12 | headers: { "Content-Type": "application/json" }, 13 | }); 14 | } 15 | const username = typeof body.username === "string" ? body.username : ""; 16 | const password = typeof body.password === "string" ? body.password : ""; 17 | if (!username || !password) { 18 | return new Response(JSON.stringify({ error: "Missing credentials" }), { 19 | status: 400, 20 | headers: { "Content-Type": "application/json" }, 21 | }); 22 | } 23 | 24 | const resp = await fetch(`${BACKEND_URL}/api/v1/auth/login`, { 25 | method: "POST", 26 | headers: { "Content-Type": "application/json" }, 27 | body: JSON.stringify({ username, password }), 28 | }); 29 | const text = await resp.text(); 30 | const downstreamHeaders = { "Content-Type": resp.headers.get("content-type") || "application/json" }; 31 | return new Response(text || "{}", { status: resp.status, headers: downstreamHeaders }); 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /frontend/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "nodeModulesDir": "auto", 3 | "lock": false, 4 | "tasks": { 5 | "start": "deno run -A --watch=static/,routes/ dev.ts", 6 | "build": "deno run -A dev.ts build", 7 | "preview": "deno run -A main.ts", 8 | "css:build": "deno run --allow-read --allow-write --allow-env --allow-run npm:tailwindcss -i ./static/input.css -o ./static/styles.css --minify", 9 | "css:watch": "deno run --allow-read --allow-write --allow-env --allow-run npm:tailwindcss -i ./static/input.css -o ./static/styles.css --watch" 10 | }, 11 | "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" }, 12 | "imports": { 13 | "$std/": "https://deno.land/std@0.224.0/", 14 | "@preact-icons/lu": "jsr:@preact-icons/lu@^1.0.13", 15 | "preact": "https://esm.sh/preact@10.22.0", 16 | "preact/hooks": "https://esm.sh/preact@10.22.0/hooks", 17 | "preact/jsx-runtime": "https://esm.sh/preact@10.22.0/jsx-runtime", 18 | "preact/debug": "https://esm.sh/preact@10.22.0/debug", 19 | "$fresh/": "https://deno.land/x/fresh@1.7.3/", 20 | "fresh": "https://deno.land/x/fresh@1.6.5/mod.ts", 21 | "preact/": "https://esm.sh/preact@10.22.0/", 22 | "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", 23 | "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1" 24 | }, 25 | "lint": { "rules": { "tags": ["fresh", "recommended"] } }, 26 | "exclude": ["**/_fresh/*"] 27 | } 28 | -------------------------------------------------------------------------------- /backend/src/i18n/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "invoiceTitle": "Invoice", 3 | "invoiceNumberLabel": "Invoice Number", 4 | "invoiceNumberShortLabel": "Invoice #", 5 | "invoiceDateLabel": "Invoice Date", 6 | "dateLabel": "Date", 7 | "dueDateLabel": "Due Date", 8 | "dueShortLabel": "Due", 9 | "referenceLabel": "Reference", 10 | "billToHeading": "Bill To", 11 | "itemsHeading": "Invoice Details", 12 | "itemHeaderDescription": "Description", 13 | "itemHeaderQuantity": "Quantity", 14 | "itemHeaderQuantityShort": "Qty", 15 | "itemHeaderUnitPrice": "Unit Price", 16 | "itemHeaderUnitPriceShort": "Rate", 17 | "itemHeaderAmount": "Amount", 18 | "itemHeaderTax": "Tax", 19 | "summaryHeading": "Invoice Summary", 20 | "subtotalLabel": "Subtotal", 21 | "discountLabel": "Discount", 22 | "taxLabel": "Tax", 23 | "totalLabel": "Total", 24 | "statusLabel": "Status", 25 | "taxSummaryHeading": "Tax Summary", 26 | "taxableLabel": "Taxable", 27 | "taxAmountLabel": "Tax Amount", 28 | "taxIdLabel": "Tax ID", 29 | "outstandingBalanceLabel": "Outstanding Balance", 30 | "paymentInformationHeading": "Payment Information", 31 | "paymentMethodsLabel": "Payment Methods", 32 | "paymentMethodsPrefix": "Methods:", 33 | "bankAccountLabel": "Bank Account", 34 | "bankAccountPrefix": "Bank:", 35 | "paymentTermsLabel": "Payment Terms", 36 | "notesHeading": "Notes", 37 | "thankYouNote": "Thank you for your business!" 38 | } 39 | -------------------------------------------------------------------------------- /backend/src/i18n/locales/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "invoiceTitle": "Rechnung", 3 | "invoiceNumberLabel": "Rechnungsnummer", 4 | "invoiceNumberShortLabel": "Rechnung #", 5 | "invoiceDateLabel": "Rechnungsdatum", 6 | "dateLabel": "Datum", 7 | "dueDateLabel": "Fällig am", 8 | "dueShortLabel": "Fällig", 9 | "referenceLabel": "Referenz", 10 | "billToHeading": "Rechnung an", 11 | "itemsHeading": "Rechnungsdetails", 12 | "itemHeaderDescription": "Beschreibung", 13 | "itemHeaderQuantity": "Menge", 14 | "itemHeaderQuantityShort": "Menge", 15 | "itemHeaderUnitPrice": "Einzelpreis", 16 | "itemHeaderUnitPriceShort": "Preis", 17 | "itemHeaderAmount": "Betrag", 18 | "itemHeaderTax": "Steuer", 19 | "summaryHeading": "Rechnungsübersicht", 20 | "subtotalLabel": "Zwischensumme", 21 | "discountLabel": "Rabatt", 22 | "taxLabel": "Steuer", 23 | "totalLabel": "Gesamt", 24 | "statusLabel": "Status", 25 | "taxSummaryHeading": "Steuerübersicht", 26 | "taxableLabel": "Steuerpflichtig", 27 | "taxAmountLabel": "Steuerbetrag", 28 | "taxIdLabel": "Steuer-ID", 29 | "outstandingBalanceLabel": "Offener Saldo", 30 | "paymentInformationHeading": "Zahlungsinformationen", 31 | "paymentMethodsLabel": "Zahlungsmethoden", 32 | "paymentMethodsPrefix": "Methoden:", 33 | "bankAccountLabel": "Bankkonto", 34 | "bankAccountPrefix": "Bank:", 35 | "paymentTermsLabel": "Zahlungsbedingungen", 36 | "notesHeading": "Notizen", 37 | "thankYouNote": "Vielen Dank für Ihr Vertrauen!" 38 | } 39 | -------------------------------------------------------------------------------- /backend/src/i18n/locales/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "invoiceTitle": "Factuur", 3 | "invoiceNumberLabel": "Factuurnummer", 4 | "invoiceNumberShortLabel": "Factuur #", 5 | "invoiceDateLabel": "Factuurdatum", 6 | "dateLabel": "Datum", 7 | "dueDateLabel": "Vervaldatum", 8 | "dueShortLabel": "Vervaldatum", 9 | "referenceLabel": "Referentie", 10 | "billToHeading": "Factureren aan", 11 | "itemsHeading": "Factuurdetails", 12 | "itemHeaderDescription": "Omschrijving", 13 | "itemHeaderQuantity": "Aantal", 14 | "itemHeaderQuantityShort": "Qty", 15 | "itemHeaderUnitPrice": "Eenheidsprijs", 16 | "itemHeaderUnitPriceShort": "Tarief", 17 | "itemHeaderAmount": "Bedrag", 18 | "itemHeaderTax": "Belasting", 19 | "summaryHeading": "Factuuroverzicht", 20 | "subtotalLabel": "Subtotaal", 21 | "discountLabel": "Korting", 22 | "taxLabel": "Belasting", 23 | "totalLabel": "Totaal", 24 | "statusLabel": "Status", 25 | "taxSummaryHeading": "Belastingoverzicht", 26 | "taxableLabel": "Belastbaar", 27 | "taxAmountLabel": "Belastingbedrag", 28 | "taxIdLabel": "BTW-ID", 29 | "outstandingBalanceLabel": "Openstaand Saldo", 30 | "paymentInformationHeading": "Betalingsinformatie", 31 | "paymentMethodsLabel": "Betalingsmethoden", 32 | "paymentMethodsPrefix": "Methoden:", 33 | "bankAccountLabel": "Bankrekening", 34 | "bankAccountPrefix": "Bank:", 35 | "paymentTermsLabel": "Betalingsvoorwaarden", 36 | "notesHeading": "Notities", 37 | "thankYouNote": "Bedankt voor uw bedrijf!" 38 | } 39 | -------------------------------------------------------------------------------- /frontend/islands/InvoiceFormButton.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "preact/hooks"; 2 | import { LuSave } from "../components/icons.tsx"; 3 | 4 | export default function InvoiceFormButton( 5 | { formId, label }: { formId: string; label: string }, 6 | ) { 7 | const [isValid, setIsValid] = useState(true); 8 | 9 | useEffect(() => { 10 | const form = document.getElementById(formId) as HTMLFormElement | null; 11 | if (!form) return; 12 | 13 | const checkValidity = () => { 14 | const validAttr = form.getAttribute("data-valid"); 15 | setIsValid(validAttr === "true"); 16 | }; 17 | 18 | // Initial check 19 | checkValidity(); 20 | 21 | // Watch for changes using MutationObserver 22 | const observer = new MutationObserver((mutations) => { 23 | mutations.forEach((mutation) => { 24 | if ( 25 | mutation.type === "attributes" && 26 | mutation.attributeName === "data-valid" 27 | ) { 28 | checkValidity(); 29 | } 30 | }); 31 | }); 32 | 33 | observer.observe(form, { 34 | attributes: true, 35 | attributeFilter: ["data-valid"], 36 | }); 37 | 38 | return () => observer.disconnect(); 39 | }, [formId]); 40 | 41 | return ( 42 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /frontend/routes/invoices/[id]/pdf.ts: -------------------------------------------------------------------------------- 1 | import { Handlers } from "$fresh/server.ts"; 2 | import { 3 | BACKEND_URL, 4 | getAuthHeaderFromCookie, 5 | } from "../../../utils/backend.ts"; 6 | 7 | export const handler: Handlers = { 8 | async GET(req, ctx) { 9 | const auth = getAuthHeaderFromCookie( 10 | req.headers.get("cookie") || undefined, 11 | ); 12 | if (!auth) { 13 | return new Response(null, { 14 | status: 303, 15 | headers: { Location: "/login" }, 16 | }); 17 | } 18 | 19 | const { id } = ctx.params as { id: string }; 20 | const backendUrl = `${BACKEND_URL}/api/v1/invoices/${id}/pdf`; 21 | 22 | const res = await fetch(backendUrl, { headers: { Authorization: auth } }); 23 | if (!res.ok) { 24 | return new Response(`Upstream error: ${res.status} ${res.statusText}`, { 25 | status: res.status, 26 | }); 27 | } 28 | 29 | const headers = new Headers(); 30 | for (const [k, v] of res.headers.entries()) { 31 | if (k.toLowerCase() === "set-cookie") continue; 32 | headers.set(k, v); 33 | } 34 | // Ensure proper content type/disposition for PDF 35 | headers.set( 36 | "content-type", 37 | res.headers.get("content-type") ?? "application/pdf", 38 | ); 39 | if (!headers.has("content-disposition")) { 40 | headers.set( 41 | "content-disposition", 42 | `attachment; filename=invoice-${id}.pdf`, 43 | ); 44 | } 45 | return new Response(res.body, { status: 200, headers }); 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /backend/src/routes/auth.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { createJWT } from "../utils/jwt.ts"; 3 | import { getAdminCredentials } from "../utils/env.ts"; 4 | 5 | function getSessionTtlSeconds(): number { 6 | const parsed = parseInt(Deno.env.get("SESSION_TTL_SECONDS") || "3600", 10); 7 | const candidate = Number.isFinite(parsed) ? parsed : 3600; 8 | return Math.max(300, Math.min(60 * 60 * 12, candidate)); 9 | } 10 | 11 | const authRoutes = new Hono(); 12 | 13 | authRoutes.post("/auth/login", async (c) => { 14 | let username: string | undefined; 15 | let password: string | undefined; 16 | 17 | try { 18 | const body = await c.req.json(); 19 | if (body && typeof body.username === "string" && typeof body.password === "string") { 20 | username = body.username; 21 | password = body.password; 22 | } 23 | } catch { 24 | // ignore parse errors; fall through to missing credentials handling 25 | } 26 | 27 | if (!username || !password) { 28 | return c.json({ error: "Missing credentials" }, 400); 29 | } 30 | 31 | const { username: adminUser, password: adminPass } = getAdminCredentials(); 32 | if (username !== adminUser || password !== adminPass) { 33 | return c.json({ error: "Invalid credentials" }, 401); 34 | } 35 | 36 | const sessionTtl = getSessionTtlSeconds(); 37 | const now = Math.floor(Date.now() / 1000); 38 | const token = await createJWT({ username: adminUser, iat: now, exp: now + sessionTtl }); 39 | return c.json({ token, expiresIn: sessionTtl }); 40 | }); 41 | 42 | export { authRoutes }; 43 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Invio Frontend (Deno Fresh) 2 | 3 | Modern, minimalist admin UI for Invio backend. 4 | 5 | - Framework: Fresh (SSR + islands) 6 | - Auth: JWT bearer sessions (stored as HttpOnly cookie; proxied to backend) 7 | - Features: 8 | - Login/logout 9 | - Dashboard summary 10 | - Invoices: list, filter (server-rendered), view, edit, duplicate, 11 | publish/unpublish, status updates, download PDF, public link 12 | - Customers: list, view, create, edit, delete 13 | - Settings: company details, logo, default template, highlight color 14 | - Templates UI integrated into Settings 15 | 16 | ## Dev 17 | 18 | Requires Deno 1.42+. 19 | 20 | Environment: 21 | 22 | - `BACKEND_URL` — backend base URL (default http://localhost:3000) 23 | - `FRONTEND_SECURE_HEADERS_DISABLED` — set to `true` to disable hardened headers in development 24 | - `FRONTEND_CONTENT_SECURITY_POLICY` — override default CSP if custom assets are required 25 | - `ENABLE_HSTS` — set to `true` to emit Strict-Transport-Security (only when served over HTTPS) 26 | 27 | Run: 28 | 29 | ```bash 30 | deno task start 31 | ``` 32 | 33 | Docker build (ensure the dashboard shows the correct version): 34 | 35 | ```bash 36 | cp ../VERSION ./VERSION # or pass --build-arg APP_VERSION=$(cat ../VERSION) 37 | docker build -t invio-frontend . 38 | ``` 39 | 40 | ## Notes 41 | 42 | - PDF/HTML generation links no longer take query parameters; output uses the 43 | saved Settings template and highlight. 44 | - UI uses DaisyUI components and aims for good accessibility (contrast, lang 45 | attribute, no client-side JS for exports). 46 | -------------------------------------------------------------------------------- /frontend/routes/api/templates/install.ts: -------------------------------------------------------------------------------- 1 | import { Handlers } from "$fresh/server.ts"; 2 | import { getAuthHeaderFromCookie } from "../../../utils/backend.ts"; 3 | 4 | export const handler: Handlers = { 5 | async POST(req) { 6 | try { 7 | const auth = getAuthHeaderFromCookie(req.headers.get("cookie") || undefined); 8 | if (!auth) return new Response("Unauthorized", { status: 401 }); 9 | const { url } = await req.json().catch(() => ({})); 10 | if (!url || typeof url !== "string") { 11 | return new Response(JSON.stringify({ error: "Missing 'url'" }), { 12 | status: 400, 13 | headers: { "Content-Type": "application/json" }, 14 | }); 15 | } 16 | // Proxy the backend response including status codes to avoid masking errors as 500s 17 | const res = await fetch(`${Deno.env.get("BACKEND_URL") || "http://localhost:3000"}/api/v1/templates/install-from-manifest`, { 18 | method: "POST", 19 | headers: { Authorization: auth, "Content-Type": "application/json" }, 20 | body: JSON.stringify({ url }), 21 | }); 22 | const text = await res.text(); 23 | const body = text && text.trim().startsWith("{") ? text : JSON.stringify({ ok: res.ok, status: res.status, body: text }); 24 | return new Response(body, { 25 | status: res.status, 26 | headers: { "Content-Type": "application/json" }, 27 | }); 28 | } catch (e) { 29 | return new Response(JSON.stringify({ error: String(e) }), { 30 | status: 500, 31 | headers: { "Content-Type": "application/json" }, 32 | }); 33 | } 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /frontend/_fresh/snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "build_id": "f27d0c4b2a31b97504b3fd780db4883ec2f5dcac", 3 | "files": { 4 | "island-installtemplateform.js": [ 5 | "chunk-BYRZ2NRM.js", 6 | "chunk-G4CPWP5O.js", 7 | "chunk-JP5XG2OT.js" 8 | ], 9 | "island-invoiceeditorisland.js": [ 10 | "chunk-G4CPWP5O.js", 11 | "chunk-JP5XG2OT.js" 12 | ], 13 | "island-settingsenhancements.js": [ 14 | "chunk-G4CPWP5O.js", 15 | "chunk-JP5XG2OT.js" 16 | ], 17 | "island-themetoggle.js": [ 18 | "chunk-BYRZ2NRM.js", 19 | "chunk-G4CPWP5O.js", 20 | "chunk-JP5XG2OT.js" 21 | ], 22 | "main.js": [ 23 | "chunk-GI7LLYGM.js", 24 | "chunk-JP5XG2OT.js" 25 | ], 26 | "deserializer.js": [], 27 | "signals.js": [ 28 | "chunk-GI7LLYGM.js", 29 | "chunk-G4CPWP5O.js", 30 | "chunk-JP5XG2OT.js" 31 | ], 32 | "chunk-GI7LLYGM.js": [], 33 | "island-breadcrumbs.js": [ 34 | "chunk-BYRZ2NRM.js", 35 | "chunk-G4CPWP5O.js", 36 | "chunk-JP5XG2OT.js" 37 | ], 38 | "island-confirmonsubmit.js": [ 39 | "chunk-G4CPWP5O.js", 40 | "chunk-JP5XG2OT.js" 41 | ], 42 | "island-copypubliclink.js": [ 43 | "chunk-G4CPWP5O.js", 44 | "chunk-JP5XG2OT.js" 45 | ], 46 | "island-demomodedisabler.js": [ 47 | "chunk-G4CPWP5O.js", 48 | "chunk-JP5XG2OT.js" 49 | ], 50 | "island-exportall.js": [ 51 | "chunk-BYRZ2NRM.js", 52 | "chunk-G4CPWP5O.js", 53 | "chunk-JP5XG2OT.js" 54 | ], 55 | "chunk-BYRZ2NRM.js": [ 56 | "chunk-JP5XG2OT.js" 57 | ], 58 | "chunk-G4CPWP5O.js": [ 59 | "chunk-JP5XG2OT.js" 60 | ], 61 | "chunk-JP5XG2OT.js": [], 62 | "metafile.json": [] 63 | } 64 | } -------------------------------------------------------------------------------- /frontend/components/Breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | type Crumb = { label: string; href?: string }; 2 | 3 | import { useTranslations } from "../i18n/context.tsx"; 4 | 5 | function titleize(slug: string) { 6 | return slug 7 | .replace(/[-_]+/g, " ") 8 | .split(" ") 9 | .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w)) 10 | .join(" "); 11 | } 12 | 13 | const LABEL_MAP: Record = { 14 | dashboard: "Dashboard", 15 | invoices: "Invoices", 16 | customers: "Customers", 17 | templates: "Templates", 18 | settings: "Settings", 19 | new: "New", 20 | edit: "Edit", 21 | html: "HTML", 22 | pdf: "PDF", 23 | }; 24 | 25 | export function Breadcrumbs(props: { path?: string }) { 26 | const { t } = useTranslations(); 27 | const path = props.path || "/"; 28 | const segments = path.replace(/(^\/+|\/+?$)/g, "").split("/").filter(Boolean); 29 | // Only show breadcrumbs on subpages (e.g., /section/subpage), not on top-level pages like /dashboard or /invoices 30 | if (segments.length < 2) return null as unknown as never; 31 | 32 | const crumbs: Crumb[] = []; 33 | let hrefAcc = ""; 34 | segments.forEach((seg, idx) => { 35 | hrefAcc += "/" + seg; 36 | const isLast = idx === segments.length - 1; 37 | const english = LABEL_MAP[seg] || titleize(seg); 38 | crumbs.push({ 39 | label: t(english), 40 | href: isLast ? undefined : hrefAcc, 41 | }); 42 | }); 43 | 44 | return ( 45 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /backend/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["deno.ns", "deno.window"], 4 | "strict": true, 5 | "skipLibCheck": true 6 | }, 7 | "tasks": { 8 | "dev": { 9 | "command": "deno run --allow-read --allow-write --allow-net --allow-env --allow-run --watch src/app.ts", 10 | "desc": "Run the application in development mode" 11 | }, 12 | "start": { 13 | "command": "deno run --allow-read --allow-write --allow-net --allow-env --allow-run src/app.ts", 14 | "desc": "Run the application" 15 | } 16 | }, 17 | "fmt": { 18 | "options": { 19 | "lineWidth": 80 20 | } 21 | }, 22 | "lint": { 23 | "rules": { 24 | "tags": ["recommended"] 25 | } 26 | }, 27 | "imports": { 28 | "@pakornv/fresh-plugin-tailwindcss": "jsr:@pakornv/fresh-plugin-tailwindcss@^1.0.3", 29 | "daisyui": "npm:daisyui@^5.0.54", 30 | "hono": "https://deno.land/x/hono@v4.0.10/mod.ts", 31 | "hono/cors": "https://deno.land/x/hono@v4.0.10/middleware/cors/index.ts", 32 | "hono/basic-auth": "https://deno.land/x/hono@v4.0.10/middleware/basic-auth/index.ts", 33 | "hono/jwt": "https://deno.land/x/hono@v4.0.10/middleware/jwt/index.ts", 34 | "sqlite": "https://deno.land/x/sqlite@v3.9.1/mod.ts", 35 | "djwt": "https://deno.land/x/djwt@v3.0.2/mod.ts", 36 | "tailwindcss": "npm:tailwindcss@^4.1.12", 37 | "uuid": "https://deno.land/std@0.224.0/uuid/mod.ts", 38 | "pdf-lib": "npm:pdf-lib@^1.17.1", 39 | "puppeteer-core": "npm:puppeteer-core@22.13.1", 40 | "crypto": "https://deno.land/std@0.224.0/crypto/mod.ts", 41 | "dotenv": "https://deno.land/std@0.224.0/dotenv/mod.ts", 42 | "yaml": "https://deno.land/std@0.224.0/yaml/mod.ts", 43 | "std/path": "https://deno.land/std@0.224.0/path/mod.ts" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /frontend/routes/api/admin/export/full.ts: -------------------------------------------------------------------------------- 1 | import { Handlers } from "$fresh/server.ts"; 2 | import { BACKEND_URL, getAuthHeaderFromCookie } from "../../../../utils/backend.ts"; 3 | 4 | // Proxy export download to the backend while allowing a re-auth prompt. 5 | export const handler: Handlers = { 6 | async GET(req) { 7 | const url = new URL(req.url); 8 | const includeDb = url.searchParams.get("includeDb") ?? "true"; 9 | const includeJson = url.searchParams.get("includeJson") ?? "true"; 10 | const includeAssets = url.searchParams.get("includeAssets") ?? "true"; 11 | 12 | // Prefer Authorization header from the client, else cookie 13 | const authFromHeader = req.headers.get("authorization") || undefined; 14 | const authFromCookie = getAuthHeaderFromCookie(req.headers.get("cookie") || undefined) || undefined; 15 | const auth = authFromHeader || authFromCookie; 16 | if (!auth) { 17 | return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "content-type": "application/json" } }); 18 | } 19 | const backendUrl = `${BACKEND_URL}/api/v1/export/full?includeDb=${includeDb}&includeJson=${includeJson}&includeAssets=${includeAssets}`; 20 | const resp = await fetch(backendUrl, { headers: { Authorization: auth } }); 21 | if (!resp.ok) { 22 | const body = await resp.text().catch(() => ""); 23 | return new Response(body || `Backend error ${resp.status}`, { status: resp.status, headers: { "content-type": resp.headers.get("content-type") || "text/plain" } }); 24 | } 25 | // Stream through headers and body 26 | const headers = new Headers(); 27 | const contentType = resp.headers.get("content-type") || "application/gzip"; 28 | const cd = resp.headers.get("content-disposition") || "attachment; filename=\"invio-export.tar.gz\""; 29 | headers.set("content-type", contentType); 30 | headers.set("content-disposition", cd); 31 | headers.set("cache-control", "no-store"); 32 | return new Response(resp.body, { status: 200, headers }); 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /backend/src/utils/jwt.ts: -------------------------------------------------------------------------------- 1 | import { create, decode, verify } from "djwt"; 2 | import { getJwtSecret, getAdminCredentials } from "./env.ts"; 3 | 4 | function validateSecret(secretKey: string) { 5 | if (!secretKey || secretKey.trim().length === 0) { 6 | throw new Error("JWT_SECRET must not be empty"); 7 | } 8 | 9 | const trimmed = secretKey.trim(); 10 | if (trimmed.length < 16) { 11 | console.warn("Warning: JWT_SECRET is shorter than 16 characters. Consider using a longer secret for better security."); 12 | } 13 | } 14 | 15 | function validateAdminCredentials() { 16 | const { username, password } = getAdminCredentials(); 17 | if (!username || username.trim().length === 0) { 18 | throw new Error("ADMIN_USER must not be empty"); 19 | } 20 | if (!password || password.trim().length === 0) { 21 | throw new Error("ADMIN_PASS must not be empty"); 22 | } 23 | } 24 | 25 | async function getKey(): Promise { 26 | validateAdminCredentials(); 27 | const secretKey = getJwtSecret(); 28 | validateSecret(secretKey); 29 | const secretBytes = new TextEncoder().encode(secretKey.trim()); 30 | const key = await crypto.subtle.importKey( 31 | "raw", 32 | secretBytes, 33 | { name: "HMAC", hash: "SHA-256" }, 34 | false, 35 | ["sign", "verify"], 36 | ); 37 | return key; 38 | } 39 | 40 | export async function createJWT(payload: Record) { 41 | const key = await getKey(); 42 | return await create({ alg: "HS256", typ: "JWT" }, payload, key); 43 | } 44 | 45 | export async function generateJWT(adminUser: string) { 46 | const payload = { user: adminUser }; 47 | const key = await getKey(); 48 | return await create({ alg: "HS256", typ: "JWT" }, payload, key); 49 | } 50 | 51 | export async function verifyJWT(token: string) { 52 | try { 53 | const key = await getKey(); 54 | const payload = await verify(token, key); 55 | return payload; 56 | } catch (error) { 57 | console.error("JWT verification failed:", error); 58 | return null; 59 | } 60 | } 61 | 62 | export function decodeJWT(token: string) { 63 | return decode(token); 64 | } 65 | -------------------------------------------------------------------------------- /frontend/islands/Breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "preact/hooks"; 2 | import { useTranslations } from "../i18n/context.tsx"; 3 | 4 | type Crumb = { label: string; href?: string }; 5 | 6 | // Title-case a slug: "invoice-items" -> "Invoice Items" 7 | function titleize(slug: string) { 8 | return slug 9 | .replace(/[-_]+/g, " ") 10 | .split(" ") 11 | .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w)) 12 | .join(" "); 13 | } 14 | 15 | const LABEL_MAP: Record = { 16 | dashboard: "Dashboard", 17 | invoices: "Invoices", 18 | customers: "Customers", 19 | settings: "Settings", 20 | new: "New", 21 | edit: "Edit", 22 | html: "HTML", 23 | pdf: "PDF", 24 | }; 25 | 26 | export default function Breadcrumbs() { 27 | const [path, setPath] = useState("/"); 28 | const { t } = useTranslations(); 29 | 30 | useEffect(() => { 31 | setPath(globalThis.location?.pathname || "/"); 32 | }, []); 33 | 34 | const crumbs = useMemo(() => { 35 | try { 36 | const segments = path.replace(/(^\/+|\/+?$)/g, "").split("/").filter( 37 | Boolean, 38 | ); 39 | const parts: Crumb[] = []; 40 | 41 | // Home always present 42 | parts.push({ label: t("Home"), href: "/" }); 43 | 44 | let hrefAcc = ""; 45 | segments.forEach((seg, idx) => { 46 | hrefAcc += "/" + seg; 47 | const isLast = idx === segments.length - 1; 48 | const labelKey = LABEL_MAP[seg]; 49 | const label = labelKey ? t(labelKey) : titleize(seg); 50 | parts.push({ label, href: isLast ? undefined : hrefAcc }); 51 | }); 52 | 53 | return parts; 54 | } catch { 55 | return [{ label: t("Home"), href: "/" }]; 56 | } 57 | }, [path, t]); 58 | 59 | if (!crumbs.length) return null; 60 | 61 | return ( 62 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /backend/src/controllers/templates_new.ts: -------------------------------------------------------------------------------- 1 | import { getDatabase } from "../database/init.ts"; 2 | import { Template } from "../types/index.ts"; 3 | import { generateUUID } from "../utils/uuid.ts"; 4 | 5 | export const getTemplates = () => { 6 | const db = getDatabase(); 7 | const results = db.query( 8 | "SELECT id, name, html, is_default, created_at FROM templates", 9 | ); 10 | return results.map((row: unknown[]) => ({ 11 | id: row[0] as string, 12 | name: row[1] as string, 13 | html: row[2] as string, 14 | isDefault: Boolean(row[3]), 15 | createdAt: new Date(row[4] as string), 16 | })); 17 | }; 18 | 19 | export const createTemplate = (data: Partial