Files
linumiq-invoice/app/routes/app.settings.tsx
T
Gerhard Scheikl 01b4734477 security hardening
2026-05-31 09:35:31 +02:00

586 lines
23 KiB
TypeScript

import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { Form, useActionData, useLoaderData, useNavigation } from "react-router";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import {
isValidAtVatId,
isValidBic,
isValidIban,
normaliseIban,
} from "../services/invoice/validation";
import { STORED_LOGO_SENTINEL } from "../services/invoice/logoCache.constants";
import { validateMerchantHttpsUrl } from "../services/invoice/safeFetch.server";
import {
deleteStoredLogo,
storeUploadedLogo,
} from "../services/invoice/logoCache.server";
import { encryptField } from "../services/crypto/fieldCrypto.server";
import { RichTextEditor } from "../components/RichTextEditor";
import {
DEFAULT_EMAIL_BODY_DE,
DEFAULT_EMAIL_BODY_EN,
DEFAULT_EMAIL_SUBJECT_DE,
DEFAULT_EMAIL_SUBJECT_EN,
} from "../services/invoice/emailTemplates";
interface SettingsFieldErrors {
vatId?: string;
iban?: string;
bic?: string;
smtpPort?: string;
paymentTermDays?: string;
invoiceSeed?: string;
logo?: string;
}
/**
* Sentinel value used in the SMTP password input. The real password is
* never sent to the client; if the form posts back this exact value the
* action treats it as "unchanged" and keeps whatever is already in the DB.
*/
const SMTP_PASSWORD_SENTINEL = "__unchanged__";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const settings = await db.shopSettings.upsert({
where: { shopDomain: session.shop },
update: {},
create: { shopDomain: session.shop },
});
let logoPreviewDataUrl: string | null = null;
if (settings.logoUrl === STORED_LOGO_SENTINEL) {
const cached = await db.logoCache.findUnique({ where: { shopDomain: session.shop } });
if (cached) {
logoPreviewDataUrl = `data:${cached.contentType};base64,${Buffer.from(cached.bytes).toString("base64")}`;
}
} else if (settings.logoUrl) {
// External HTTPS URL — fine to display directly in the editor.
logoPreviewDataUrl = settings.logoUrl;
}
// Never expose the SMTP password to the browser. We replace it with a
// sentinel and the form action interprets that as "keep existing value".
const safeSettings = {
...settings,
smtpPassword: settings.smtpPassword ? SMTP_PASSWORD_SENTINEL : "",
};
return { settings: safeSettings, logoPreviewDataUrl, smtpPasswordSentinel: SMTP_PASSWORD_SENTINEL };
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const form = await request.formData();
const errors: SettingsFieldErrors = {};
const str = (k: string, fallback = "") => (form.get(k) ?? fallback).toString().trim();
const bool = (k: string) => form.get(k) === "on";
const intOrNull = (k: string): number | null => {
const raw = form.get(k);
if (raw === null || raw === "") return null;
const n = parseInt(raw.toString(), 10);
return Number.isFinite(n) ? n : null;
};
const vatId = str("vatId").toUpperCase();
if (vatId && !isValidAtVatId(vatId)) {
errors.vatId = "Expected format: ATU followed by 8 digits (e.g. ATU12345678).";
}
const iban = normaliseIban(str("iban"));
if (iban && !isValidIban(iban)) {
errors.iban = "Invalid IBAN (failed checksum or unknown country length).";
}
const bic = str("bic").toUpperCase();
if (bic && !isValidBic(bic)) {
errors.bic = "Invalid BIC (8 or 11 alphanumeric characters).";
}
const smtpPort = intOrNull("smtpPort");
if (smtpPort !== null && (smtpPort < 1 || smtpPort > 65535)) {
errors.smtpPort = "Port must be between 1 and 65535.";
}
const paymentTermDays = intOrNull("paymentTermDays");
if (paymentTermDays !== null && (paymentTermDays < 0 || paymentTermDays > 365)) {
errors.paymentTermDays = "Must be between 0 and 365.";
}
const invoiceSeed = intOrNull("invoiceSeed");
if (invoiceSeed !== null && invoiceSeed < 0) {
errors.invoiceSeed = "Must be a non-negative number.";
}
// --- Logo handling --------------------------------------------------------
// The settings page allows three actions on the logo:
// 1. Upload a new file (multipart `logoFile`) — stored in `LogoCache`.
// 2. Remove the current logo (`removeLogo=on`).
// 3. Provide an external URL via the `logoUrl` field.
// If a file is uploaded it wins over a manually-entered URL.
// Look up the existing logoUrl so we don't accidentally clear it when
// the user just edited unrelated fields (the visible URL field is hidden
// for stored uploads, so it submits empty in that case).
const existing = await db.shopSettings.findUnique({
where: { shopDomain: session.shop },
select: { logoUrl: true },
});
const submittedLogoUrl = str("logoUrl");
// Validate any merchant-supplied external logo URL at the trust boundary:
// require a syntactically valid https URL whose host is a domain name, not
// an IP literal (SSRF defence-in-depth; safeFetch is the runtime backstop).
if (submittedLogoUrl && submittedLogoUrl !== STORED_LOGO_SENTINEL) {
const urlError = validateMerchantHttpsUrl(submittedLogoUrl);
if (urlError) errors.logo = urlError;
}
const removeLogo = bool("removeLogo");
const logoFile = form.get("logoFile");
const hasUpload =
logoFile && typeof logoFile === "object" && "size" in logoFile && (logoFile as File).size > 0;
let resolvedLogoUrl = submittedLogoUrl || existing?.logoUrl || "";
if (removeLogo && !hasUpload) {
await deleteStoredLogo(session.shop);
resolvedLogoUrl = "";
} else if (hasUpload) {
const file = logoFile as File;
const buf = Buffer.from(await file.arrayBuffer());
const stored = await storeUploadedLogo(session.shop, buf, file.type);
if (!stored.ok) {
errors.logo = stored.error ?? "Failed to store uploaded logo.";
} else {
resolvedLogoUrl = STORED_LOGO_SENTINEL;
}
}
if (Object.keys(errors).length > 0) {
return { ok: false, errors, savedAt: null as string | null };
}
// Resolve SMTP password: the loader sends a sentinel instead of the real
// value. If the form posts that sentinel back unchanged, keep whatever is
// already in the DB; otherwise persist the new value (including the empty
// string, which means "clear the password").
const submittedSmtpPassword = str("smtpPassword");
let nextSmtpPassword: string;
if (submittedSmtpPassword === SMTP_PASSWORD_SENTINEL) {
// Unchanged: keep the stored value as-is (already encrypted at rest).
const current = await db.shopSettings.findUnique({
where: { shopDomain: session.shop },
select: { smtpPassword: true },
});
nextSmtpPassword = current?.smtpPassword ?? "";
} else {
// New password (including "" to clear). Encrypt non-empty values at rest.
nextSmtpPassword = submittedSmtpPassword
? encryptField(submittedSmtpPassword)
: "";
}
const data = {
companyName: str("companyName"),
legalForm: str("legalForm"),
ownerName: str("ownerName"),
addressLine1: str("addressLine1"),
addressLine2: str("addressLine2"),
postalCode: str("postalCode"),
city: str("city"),
countryCode: str("countryCode", "AT").toUpperCase(),
phone: str("phone"),
email: str("email"),
website: str("website"),
vatId,
taxNumber: str("taxNumber"),
registrationNo: str("registrationNo"),
registrationCourt: str("registrationCourt"),
bankName: str("bankName"),
iban,
bic,
giroCodeEnabled: bool("giroCodeEnabled"),
numberingMode: str("numberingMode", "shopify_order_number"),
invoicePrefix: str("invoicePrefix"),
invoiceSeed: invoiceSeed ?? 1000,
defaultLanguage: str("defaultLanguage", "de") === "en" ? "en" : "de",
paymentTermDays: paymentTermDays ?? 14,
footerNote: str("footerNote"),
footerNoteEn: str("footerNoteEn"),
kleinunternehmer: bool("kleinunternehmer"),
logoUrl: resolvedLogoUrl,
smtpHost: str("smtpHost"),
smtpPort: smtpPort ?? 587,
smtpSecure: bool("smtpSecure"),
smtpUser: str("smtpUser"),
smtpPassword: nextSmtpPassword,
smtpFromName: str("smtpFromName"),
smtpFromEmail: str("smtpFromEmail"),
smtpReplyTo: str("smtpReplyTo"),
emailSubjectDe: str("emailSubjectDe"),
emailBodyHtmlDe: str("emailBodyHtmlDe"),
emailSubjectEn: str("emailSubjectEn"),
emailBodyHtmlEn: str("emailBodyHtmlEn"),
autoEmailOnWireTransferPlaced: bool("autoEmailOnWireTransferPlaced"),
autoEmailOnFulfilledNonWireTransfer: bool("autoEmailOnFulfilledNonWireTransfer"),
};
await db.shopSettings.upsert({
where: { shopDomain: session.shop },
update: data,
create: { shopDomain: session.shop, ...data },
});
return { ok: true, errors: {}, savedAt: new Date().toISOString() };
};
export default function SettingsRoute() {
const { settings, logoPreviewDataUrl } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const nav = useNavigation();
const isSaving = nav.state === "submitting";
const errors = actionData?.errors ?? {};
const hasStoredLogo = settings.logoUrl === STORED_LOGO_SENTINEL;
// Show the URL field only when not using a stored upload — keeps the UI
// simpler and avoids the sentinel value leaking into the input.
const visibleLogoUrl = hasStoredLogo ? "" : settings.logoUrl;
return (
<s-page heading="Invoice settings">
<s-section>
<s-paragraph>
Configure issuer details, bank, numbering, language and SMTP. These
values are written into every PDF invoice this app generates and must
match your legal records.
</s-paragraph>
</s-section>
<Form method="post" encType="multipart/form-data">
<s-section heading="Company">
<s-stack direction="block" gap="base">
<Field label="Company name" name="companyName" defaultValue={settings.companyName} />
<Field label="Legal form (e.g. e.U., GmbH)" name="legalForm" defaultValue={settings.legalForm} />
<Field label="Owner / managing director" name="ownerName" defaultValue={settings.ownerName} />
<Field label="Address line 1" name="addressLine1" defaultValue={settings.addressLine1} />
<Field label="Address line 2" name="addressLine2" defaultValue={settings.addressLine2} />
<s-stack direction="inline" gap="base">
<Field label="Postal code" name="postalCode" defaultValue={settings.postalCode} />
<Field label="City" name="city" defaultValue={settings.city} />
<Field label="Country (ISO)" name="countryCode" defaultValue={settings.countryCode} />
</s-stack>
<s-stack direction="inline" gap="base">
<Field label="Phone" name="phone" defaultValue={settings.phone} />
<Field label="E-mail" name="email" defaultValue={settings.email} />
<Field label="Website" name="website" defaultValue={settings.website} />
</s-stack>
</s-stack>
</s-section>
<s-section heading="Legal identifiers">
<s-stack direction="block" gap="base">
<Field
label="VAT ID (UID)"
name="vatId"
defaultValue={settings.vatId}
error={errors.vatId}
helpText="Austrian format: ATU followed by 8 digits."
/>
<Field label="Tax number (Steuernummer)" name="taxNumber" defaultValue={settings.taxNumber} />
<Field label="Commercial register no. (FN)" name="registrationNo" defaultValue={settings.registrationNo} />
<Field label="Commercial register court" name="registrationCourt" defaultValue={settings.registrationCourt} />
<Toggle
label="I am a Kleinunternehmer (no VAT charged, § 6 Abs. 1 Z 27 UStG)"
name="kleinunternehmer"
checked={settings.kleinunternehmer}
/>
</s-stack>
</s-section>
<s-section heading="Bank">
<s-stack direction="block" gap="base">
<Field label="Bank name" name="bankName" defaultValue={settings.bankName} />
<Field
label="IBAN"
name="iban"
defaultValue={settings.iban}
error={errors.iban}
/>
<Field
label="BIC"
name="bic"
defaultValue={settings.bic}
error={errors.bic}
/>
<Toggle
label="Render GiroCode (EPC QR) on unpaid invoices"
name="giroCodeEnabled"
checked={settings.giroCodeEnabled}
/>
</s-stack>
</s-section>
<s-section heading="Invoice numbering">
<s-stack direction="block" gap="base">
<s-paragraph>
<strong>shopify_order_number</strong> reuses the Shopify order
number (e.g. <code>RE-1004</code>). <strong>prefix_sequential</strong>
{" "}allocates a strictly gapless number from an internal counter
(legally safest under § 11 UStG).
</s-paragraph>
<Select
label="Numbering mode"
name="numberingMode"
defaultValue={settings.numberingMode}
options={[
{ value: "shopify_order_number", label: "Use Shopify order number" },
{ value: "prefix_sequential", label: "Sequential (gapless, app-managed)" },
]}
/>
<Field label="Invoice number prefix" name="invoicePrefix" defaultValue={settings.invoicePrefix} />
<Field
label="Sequential start (last issued; next = this + 1)"
name="invoiceSeed"
type="number"
defaultValue={String(settings.invoiceSeed)}
error={errors.invoiceSeed}
/>
<Field
label="Payment term (days)"
name="paymentTermDays"
type="number"
defaultValue={String(settings.paymentTermDays)}
error={errors.paymentTermDays}
/>
<Select
label="Default language"
name="defaultLanguage"
defaultValue={settings.defaultLanguage}
options={[
{ value: "de", label: "German (de)" },
{ value: "en", label: "English (en)" },
]}
/>
<Field label="Footer note (German)" name="footerNote" defaultValue={settings.footerNote} helpText="Shown at the bottom of every German PDF invoice (e.g. „Vielen Dank für Ihren Auftrag.“)." />
<Field label="Footer note (English)" name="footerNoteEn" defaultValue={settings.footerNoteEn} helpText="Shown on English PDF invoices. Falls back to the German note when empty." />
</s-stack>
</s-section>
<s-section heading="Logo">
<s-stack direction="block" gap="base">
<s-paragraph>
The logo is rendered in the top-right corner of every invoice
PDF. Upload an image (PNG, JPEG, WebP or GIF, max 5 MB) or
provide a publicly reachable HTTPS URL uploads take precedence.
</s-paragraph>
{errors.logo && <s-banner tone="critical">{errors.logo}</s-banner>}
{logoPreviewDataUrl && (
<s-stack direction="block" gap="small">
<s-text>Current logo:</s-text>
<img
src={logoPreviewDataUrl}
alt="Current invoice logo"
style={{ maxWidth: "240px", maxHeight: "120px", border: "1px solid #ddd", padding: "8px", background: "#fff" }}
/>
<s-checkbox name="removeLogo" label="Remove current logo on save" />
</s-stack>
)}
<s-text>Upload a new logo:</s-text>
<input type="file" name="logoFile" accept="image/png,image/jpeg,image/webp,image/gif" />
<Field
label="Or external logo URL (PNG/JPG/WebP, served from Shopify Files or any HTTPS URL)"
name="logoUrl"
defaultValue={visibleLogoUrl}
/>
</s-stack>
</s-section>
<s-section heading="SMTP (used by the email Flow action)">
<s-stack direction="block" gap="base">
<s-stack direction="inline" gap="base">
<Field label="Host" name="smtpHost" defaultValue={settings.smtpHost} />
<Field
label="Port"
name="smtpPort"
type="number"
defaultValue={String(settings.smtpPort)}
error={errors.smtpPort}
/>
</s-stack>
<Toggle
label="Use TLS (SMTPS) — typically required for port 465"
name="smtpSecure"
checked={settings.smtpSecure}
/>
<Field label="Username" name="smtpUser" defaultValue={settings.smtpUser} />
<Field label="Password" name="smtpPassword" type="password" defaultValue={settings.smtpPassword} />
<s-stack direction="inline" gap="base">
<Field label="From name" name="smtpFromName" defaultValue={settings.smtpFromName} />
<Field label="From e-mail" name="smtpFromEmail" defaultValue={settings.smtpFromEmail} />
<Field label="Reply-to" name="smtpReplyTo" defaultValue={settings.smtpReplyTo} />
</s-stack>
</s-stack>
</s-section>
<s-section heading="Email templates">
<s-stack direction="block" gap="base">
<s-paragraph>
These templates are used when sending the invoice PDF by email.
Leave a field empty to fall back to the built-in default.
</s-paragraph>
<Field
label="Subject (German)"
name="emailSubjectDe"
defaultValue={settings.emailSubjectDe || DEFAULT_EMAIL_SUBJECT_DE}
helpText="Variables like {{invoiceNumber}} are substituted at send time."
/>
<RichTextEditor
name="emailBodyHtmlDe"
label="Body (German)"
defaultValue={settings.emailBodyHtmlDe || DEFAULT_EMAIL_BODY_DE}
variables={EMAIL_VARS}
minHeight={220}
logoDataUrl={logoPreviewDataUrl}
/>
<Field
label="Subject (English)"
name="emailSubjectEn"
defaultValue={settings.emailSubjectEn || DEFAULT_EMAIL_SUBJECT_EN}
helpText="Variables like {{invoiceNumber}} are substituted at send time."
/>
<RichTextEditor
name="emailBodyHtmlEn"
label="Body (English)"
defaultValue={settings.emailBodyHtmlEn || DEFAULT_EMAIL_BODY_EN}
variables={EMAIL_VARS}
minHeight={220}
logoDataUrl={logoPreviewDataUrl}
/>
</s-stack>
</s-section>
<s-section heading="Automations">
<s-stack direction="block" gap="base">
<s-paragraph>
These trigger directly from Shopify order webhooks no Shopify
Flow required (Flow is gated to Plus stores for custom apps).
When an automation fires, the invoice is generated (if it doesn't
already exist) and emailed to the customer using the SMTP and
email-template settings above. "Wire-transfer" is detected via
Shopify's <code>OrderTransaction.manualPaymentGateway</code> flag,
so any merchant-defined manual payment method (Überweisung, Cash
on Delivery, Money Order, ) qualifies.
</s-paragraph>
<Toggle
label='Auto-email the invoice when a wire-transfer order is placed (so the customer gets the bank details + GiroCode immediately).'
name="autoEmailOnWireTransferPlaced"
checked={settings.autoEmailOnWireTransferPlaced}
/>
<Toggle
label='Auto-email the invoice when an order is fulfilled and is NOT a wire-transfer order (e.g. the customer paid by card and we send the invoice with the shipment).'
name="autoEmailOnFulfilledNonWireTransfer"
checked={settings.autoEmailOnFulfilledNonWireTransfer}
/>
</s-stack>
</s-section>
<s-section>
<s-stack direction="block" gap="base">
{actionData?.ok && (
<s-banner tone="success" heading="Settings saved">
Your changes are now live and will be used for the next invoice.
</s-banner>
)}
{actionData && !actionData.ok && (
<s-banner tone="critical" heading="Please fix the highlighted errors">
Some fields below need attention before settings can be saved.
</s-banner>
)}
<s-stack direction="inline" gap="base" justifyContent="end" alignItems="center">
{isSaving ? <s-text tone="neutral">Saving</s-text> : null}
<s-button type="submit" variant="primary" {...(isSaving ? { loading: true } : {})}>
Save settings
</s-button>
</s-stack>
</s-stack>
</s-section>
</Form>
</s-page>
);
}
const EMAIL_VARS = [
{ token: "{{invoiceNumber}}" },
{ token: "{{customerName}}" },
{ token: "{{customerFirstName}}" },
{ token: "{{orderName}}" },
{ token: "{{totalGross}}" },
{ token: "{{dueDate}}" },
{ token: "{{companyName}}" },
{ token: "{{ownerName}}" },
{ token: "{{shopEmail}}" },
{ token: "{{shopWebsite}}" },
];
interface FieldProps {
label: string;
name: string;
defaultValue?: string;
type?: string;
error?: string;
helpText?: string;
}
function Field({ label, name, defaultValue = "", type = "text", error, helpText }: FieldProps) {
// Polaris web-components don't expose a generic html `type` prop; we use
// distinct tags only when needed (e.g. password). The form action parses
// numeric strings server-side.
if (type === "password") {
return (
// s-password-field renders an obscured input
<s-password-field
label={label}
name={name}
value={defaultValue}
{...(error ? { error } : {})}
{...(helpText ? { details: helpText } : {})}
/>
);
}
return (
<s-text-field
label={label}
name={name}
value={defaultValue}
{...(error ? { error } : {})}
{...(helpText ? { details: helpText } : {})}
/>
);
}
interface ToggleProps { label: string; name: string; checked: boolean }
function Toggle({ label, name, checked }: ToggleProps) {
return (
<s-checkbox
name={name}
label={label}
{...(checked ? { checked: true } : {})}
/>
);
}
interface SelectProps {
label: string;
name: string;
defaultValue: string;
options: { value: string; label: string }[];
}
function Select({ label, name, defaultValue, options }: SelectProps) {
return (
<s-select label={label} name={name} value={defaultValue}>
{options.map((o) => (
<s-option key={o.value} value={o.value}>
{o.label}
</s-option>
))}
</s-select>
);
}