From 2e40d5eb4cb8cf808848787df5fa6116e1124b9e Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sun, 26 Jul 2026 12:00:10 +0330 Subject: [PATCH] Harden invoice public links and tighten draft UI. Use publicId in links, compact key-point/account labels, English business names, and readable favicon deploy perms. Co-authored-by: Cursor --- apps/super-admin/public/favicon.png | Bin .../src/components/InvoiceDraftFields.tsx | 168 ++++++++++-------- apps/super-admin/src/lib/config.ts | 12 +- .../src/pages/BusinessInvoicesPage.module.css | 15 +- .../src/pages/BusinessInvoicesPage.tsx | 8 +- .../src/pages/IssueInvoicePage.tsx | 2 +- .../src/pages/PublicInvoicePage.tsx | 4 +- .../src/services/invoiceService.ts | 6 +- apps/super-admin/src/types/invoice.ts | 32 ++-- docs/DEPLOY.md | 6 +- docs/PROJECT_CONTEXT.md | 6 +- 11 files changed, 152 insertions(+), 107 deletions(-) mode change 100755 => 100644 apps/super-admin/public/favicon.png diff --git a/apps/super-admin/public/favicon.png b/apps/super-admin/public/favicon.png old mode 100755 new mode 100644 diff --git a/apps/super-admin/src/components/InvoiceDraftFields.tsx b/apps/super-admin/src/components/InvoiceDraftFields.tsx index 3b66610..7082efe 100644 --- a/apps/super-admin/src/components/InvoiceDraftFields.tsx +++ b/apps/super-admin/src/components/InvoiceDraftFields.tsx @@ -11,6 +11,12 @@ import { import tableStyles from '../pages/BusinessesPage.module.css' import styles from '../pages/BusinessInvoicesPage.module.css' +function focusKeyPointInput(index: number) { + const el = document.querySelector(`input[data-keypoint-index="${index}"]`) + el?.focus() + el?.select() +} + type Props = { itemTemplates: InvoiceItemTemplate[] items: DraftLineItem[] @@ -192,9 +198,9 @@ export function InvoiceDraftFields({
{keyPoints.map((point, index) => (
-
- +
onKeyPointsChange( @@ -203,7 +209,19 @@ export function InvoiceDraftFields({ ), ) } + onKeyDown={(e) => { + if (e.key !== 'Enter') return + e.preventDefault() + const next = index + 1 + if (next < keyPoints.length) { + focusKeyPointInput(next) + return + } + onKeyPointsChange([...keyPoints, emptyDraftKeyPoint()]) + window.setTimeout(() => focusKeyPointInput(next), 0) + }} placeholder="e.g. Payment due within 7 days" + aria-label={`Key point ${index + 1}`} />
-
- ))} +
+ {showLabels ? : null} + + onAccountsChange( + accounts.map((a) => + a.key === acc.key ? { ...a, bankName: e.target.value } : a, + ), + ) + } + placeholder="Bank name" + aria-label="Bank name" + /> +
+
+ {showLabels ? : null} + + onAccountsChange( + accounts.map((a) => + a.key === acc.key ? { ...a, accountHolderName: e.target.value } : a, + ), + ) + } + placeholder="Account holder name" + aria-label="Account holder" + /> +
+
+ {showLabels ? : null} + + onAccountsChange( + accounts.map((a) => + a.key === acc.key ? { ...a, cardNumber: e.target.value } : a, + ), + ) + } + placeholder="Optional" + aria-label="Card number" + /> +
+
+ {showLabels ? : null} + + onAccountsChange( + accounts.map((a) => + a.key === acc.key ? { ...a, iban: e.target.value } : a, + ), + ) + } + placeholder="Optional" + aria-label="IBAN" + /> +
+ +
+ ) + })}
)} diff --git a/apps/super-admin/src/lib/config.ts b/apps/super-admin/src/lib/config.ts index 4c33ea5..57c24ae 100644 --- a/apps/super-admin/src/lib/config.ts +++ b/apps/super-admin/src/lib/config.ts @@ -11,20 +11,20 @@ export function isAllowedAdminHost(hostname = window.location.hostname): boolean /** * Public invoice URL for platform invoices. - * Local/dev: current origin (`https://meshkee.app:5174/invoices/{id}`) so the show page is reachable. - * Production: `https://{VITE_INVOICE_PUBLIC_DOMAIN}/invoices/{id}` (default meshkee.com). + * Local/dev: current origin (`https://meshkee.app:5174/invoices/{publicId}`) so the show page is reachable. + * Production: `https://{VITE_INVOICE_PUBLIC_DOMAIN}/invoices/{publicId}` (default meshkee.com). * Override either with `VITE_INVOICE_PUBLIC_BASE_URL` (full origin, optional path prefix). */ -export function getPlatformInvoicePublicUrl(invoiceId: string): string { +export function getPlatformInvoicePublicUrl(publicId: string): string { const baseOverride = import.meta.env.VITE_INVOICE_PUBLIC_BASE_URL?.trim() if (baseOverride) { - return `${baseOverride.replace(/\/$/, '')}/invoices/${invoiceId}` + return `${baseOverride.replace(/\/$/, '')}/invoices/${publicId}` } if (import.meta.env.DEV) { - return `${window.location.origin}/invoices/${invoiceId}` + return `${window.location.origin}/invoices/${publicId}` } const domain = import.meta.env.VITE_INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com' - return `https://${domain}/invoices/${invoiceId}` + return `https://${domain}/invoices/${publicId}` } /** Marketing / main business site for platform invoices (default https://meshkee.com). */ diff --git a/apps/super-admin/src/pages/BusinessInvoicesPage.module.css b/apps/super-admin/src/pages/BusinessInvoicesPage.module.css index f669aee..fca460a 100644 --- a/apps/super-admin/src/pages/BusinessInvoicesPage.module.css +++ b/apps/super-admin/src/pages/BusinessInvoicesPage.module.css @@ -138,7 +138,6 @@ background: rgba(239, 68, 68, 0.08); border: 1px solid rgba(239, 68, 68, 0.18); flex-shrink: 0; - align-self: flex-end; } .removeFieldBtn:hover:not(:disabled) { @@ -147,7 +146,7 @@ .repeatRow { display: flex; - align-items: flex-end; + align-items: center; gap: 8px; } @@ -156,6 +155,14 @@ min-width: 0; } +.fieldNoLabel { + margin: 0; +} + +.fieldNoLabel label { + display: none; +} + .itemGrid { display: grid; grid-template-columns: minmax(160px, 2fr) minmax(90px, 1fr) minmax(90px, 1fr) minmax(110px, 1.1fr) minmax(120px, 1.2fr); @@ -406,6 +413,10 @@ gap: 8px; } +.accountRowPlain { + align-items: center; +} + .accountCol2, .accountCol3, .accountCol5 { diff --git a/apps/super-admin/src/pages/BusinessInvoicesPage.tsx b/apps/super-admin/src/pages/BusinessInvoicesPage.tsx index 1748b4d..85d8f21 100644 --- a/apps/super-admin/src/pages/BusinessInvoicesPage.tsx +++ b/apps/super-admin/src/pages/BusinessInvoicesPage.tsx @@ -111,7 +111,7 @@ export function BusinessInvoicesPage() { } async function copyPublicLink(invoice: Invoice) { - const url = invoice.publicUrl || getPlatformInvoicePublicUrl(invoice.id) + const url = invoice.publicUrl || getPlatformInvoicePublicUrl(invoice.publicId) try { await navigator.clipboard.writeText(url) showToast('Invoice link copied.', 'success') @@ -123,12 +123,12 @@ export function BusinessInvoicesPage() { function invoicePublicUrl(invoice: Invoice) { // Prefer local/dev origin so the public show page is reachable while designing. if (import.meta.env.DEV || import.meta.env.VITE_INVOICE_PUBLIC_BASE_URL) { - return getPlatformInvoicePublicUrl(invoice.id) + return getPlatformInvoicePublicUrl(invoice.publicId) } - return invoice.publicUrl || getPlatformInvoicePublicUrl(invoice.id) + return invoice.publicUrl || getPlatformInvoicePublicUrl(invoice.publicId) } - const businessName = business?.nameFa || business?.name || 'Business' + const businessName = business?.name || business?.nameFa || 'Business' return (
diff --git a/apps/super-admin/src/pages/IssueInvoicePage.tsx b/apps/super-admin/src/pages/IssueInvoicePage.tsx index d3031e6..ef10beb 100644 --- a/apps/super-admin/src/pages/IssueInvoicePage.tsx +++ b/apps/super-admin/src/pages/IssueInvoicePage.tsx @@ -59,7 +59,7 @@ export function IssueInvoicePage() { const [formError, setFormError] = useState('') const listPath = `/businesses/${businessId}/invoices` - const businessName = business?.nameFa || business?.name || 'Business' + const businessName = business?.name || business?.nameFa || 'Business' const createTotal = useMemo(() => { return draftItems.reduce((sum, item) => { diff --git a/apps/super-admin/src/pages/PublicInvoicePage.tsx b/apps/super-admin/src/pages/PublicInvoicePage.tsx index 8a9179f..aa41478 100644 --- a/apps/super-admin/src/pages/PublicInvoicePage.tsx +++ b/apps/super-admin/src/pages/PublicInvoicePage.tsx @@ -46,7 +46,7 @@ export function PublicInvoicePage() { useEffect(() => { if (!invoice) return const previous = document.title - document.title = invoice.name?.trim() || `Invoice #${invoice.id}` + document.title = invoice.name?.trim() || `Invoice ${invoice.publicId}` return () => { document.title = previous } @@ -65,7 +65,7 @@ export function PublicInvoicePage() { {invoice ? ( <>
-

{invoice.name || `Invoice #${invoice.id}`}

+

{invoice.name || `Invoice ${invoice.publicId}`}

Issued {formatDate(invoice.issuedAt)} {invoice.business?.name ? ` · ${invoice.business.name}` : ''} diff --git a/apps/super-admin/src/services/invoiceService.ts b/apps/super-admin/src/services/invoiceService.ts index b1ae627..a595cef 100644 --- a/apps/super-admin/src/services/invoiceService.ts +++ b/apps/super-admin/src/services/invoiceService.ts @@ -141,9 +141,9 @@ export function deleteBusinessInvoice(businessId: string, invoiceId: string) { }) } -/** Public show-page payload (no auth). */ -export function getPublicInvoice(invoiceId: string, signal?: AbortSignal) { - return apiRequest(`/public/invoices/${invoiceId}`, { +/** Public show-page payload (no auth). Lookup by opaque publicId. */ +export function getPublicInvoice(publicId: string, signal?: AbortSignal) { + return apiRequest(`/public/invoices/${publicId}`, { auth: false, signal, }) diff --git a/apps/super-admin/src/types/invoice.ts b/apps/super-admin/src/types/invoice.ts index 5ed23b7..d85a919 100644 --- a/apps/super-admin/src/types/invoice.ts +++ b/apps/super-admin/src/types/invoice.ts @@ -46,6 +46,7 @@ export interface InvoiceAccount { export interface Invoice { id: string + publicId: string businessId: string ownerScope: 'platform' | 'business' issuerBusinessId: string | null @@ -76,22 +77,21 @@ export interface Invoice { total?: number } -/** Public viewer payload (no notes / issuer). */ -export type PublicInvoice = Pick< - Invoice, - | 'id' - | 'status' - | 'name' - | 'topText' - | 'issuedAt' - | 'business' - | 'items' - | 'keyPoints' - | 'accounts' - | 'subtotal' - | 'total' - | 'publicUrl' -> +/** Public viewer payload (no notes / issuer / sequential id). */ +export type PublicInvoice = { + publicId: string + status: InvoiceStatus + name: string | null + topText: string | null + issuedAt: string + business?: Invoice['business'] + items?: InvoiceItem[] + keyPoints?: InvoiceKeyPoint[] + accounts?: InvoiceAccount[] + subtotal?: number + total?: number + publicUrl: string | null +} export interface InvoiceItemInput { templateId?: string diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index b7af136..89149dd 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -33,9 +33,13 @@ rsync -az --delete \ ssh root@45.149.76.52 'cd /opt/meshkee/dashboards && npm ci && npm run build && \ rsync -a --delete apps/super-admin/dist/ /var/www/meshkee/super-admin/ && \ rsync -a --delete apps/business/dist/ /var/www/meshkee/business/ && \ - rsync -a --delete apps/customer/dist/ /var/www/meshkee/customer/' + rsync -a --delete apps/customer/dist/ /var/www/meshkee/customer/ && \ + find /var/www/meshkee -type f -exec chmod a+r {} + && \ + find /var/www/meshkee -type d -exec chmod a+rx {} +' ``` +> **Note:** Source files like `favicon.png` must be world-readable (`644`). If they are `700`, Nginx returns **403** and browsers fall back to a default icon (often the Vite lightning bolt). + Build env on server (`apps/*/.env`): - All: `VITE_API_BASE_URL=https://api.meshkee.com/api/v1` diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 63b06ac..81f0067 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -318,6 +318,7 @@ Run in order from `MeshkeeApp Backend/database/migrations/`: | `037_invoice_name.sql` | Optional invoice name | | `038_invoice_templates.sql` | Full invoice templates + key points / accounts | | `039_invoice_account_holder.sql` | Account holder name on bank accounts | +| `040_invoice_public_id.sql` | Opaque 12-digit `public_id` for public links | After schema changes: `npx prisma generate` and restart the backend. @@ -345,11 +346,12 @@ Two template layers + issued invoices: **Public invoice viewer (platform):** - Route: super-admin SPA `/invoices/:id` (`PublicInvoicePage`) — glass layout, print-to-PDF, “Issued by” Meshkee footer -- Local/dev link: current Vite origin (e.g. `https://meshkee.app:5174/invoices/{id}`) +- Links use opaque **12-digit `publicId`** (not sequential PK) — `GET /public/invoices/:publicId` +- Local/dev link: current Vite origin (e.g. `https://meshkee.app:5174/invoices/{publicId}`) - Production link domain: `VITE_INVOICE_PUBLIC_DOMAIN` / `INVOICE_PUBLIC_DOMAIN` (default `meshkee.com`) — optional full origin override via `*_PUBLIC_BASE_URL` - Until `meshkee.com` proxies or hosts `/invoices/*`, production links may need that DNS/nginx wiring (viewer code ships with super-admin build) -**Migrations:** `036_invoices.sql`, `037_invoice_name.sql`, `038_invoice_templates.sql`, `039_invoice_account_holder.sql` +**Migrations:** `036_invoices.sql` … `040_invoice_public_id.sql` **Super Admin UI:** - `/settings` — Invoice templates list + item templates (top text preview = one-line ellipsis)