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 <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-07-26 12:00:10 +03:30
co-authored by Cursor
parent cbffb23ec3
commit 2e40d5eb4c
11 changed files with 152 additions and 107 deletions
View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -11,6 +11,12 @@ import {
import tableStyles from '../pages/BusinessesPage.module.css' import tableStyles from '../pages/BusinessesPage.module.css'
import styles from '../pages/BusinessInvoicesPage.module.css' import styles from '../pages/BusinessInvoicesPage.module.css'
function focusKeyPointInput(index: number) {
const el = document.querySelector<HTMLInputElement>(`input[data-keypoint-index="${index}"]`)
el?.focus()
el?.select()
}
type Props = { type Props = {
itemTemplates: InvoiceItemTemplate[] itemTemplates: InvoiceItemTemplate[]
items: DraftLineItem[] items: DraftLineItem[]
@@ -192,9 +198,9 @@ export function InvoiceDraftFields({
<div className={styles.repeatStack}> <div className={styles.repeatStack}>
{keyPoints.map((point, index) => ( {keyPoints.map((point, index) => (
<div key={point.key} className={styles.repeatRow}> <div key={point.key} className={styles.repeatRow}>
<div className={`${tableStyles.field} ${styles.flexGrow}`}> <div className={`${tableStyles.field} ${styles.flexGrow} ${styles.fieldNoLabel}`}>
<label>Point {index + 1}</label>
<input <input
data-keypoint-index={index}
value={point.text} value={point.text}
onChange={(e) => onChange={(e) =>
onKeyPointsChange( 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" placeholder="e.g. Payment due within 7 days"
aria-label={`Key point ${index + 1}`}
/> />
</div> </div>
<button <button
@@ -237,75 +255,85 @@ export function InvoiceDraftFields({
<p className={styles.templateHint}>No bank accounts yet.</p> <p className={styles.templateHint}>No bank accounts yet.</p>
) : ( ) : (
<div className={styles.repeatStack}> <div className={styles.repeatStack}>
{accounts.map((acc) => ( {accounts.map((acc, index) => {
<div key={acc.key} className={styles.accountRow}> const showLabels = index === 0
<div className={`${tableStyles.field} ${styles.accountCol2}`}> return (
<label>Bank name</label> <div
<input key={acc.key}
value={acc.bankName} className={`${styles.accountRow} ${showLabels ? '' : styles.accountRowPlain}`}
onChange={(e) =>
onAccountsChange(
accounts.map((a) =>
a.key === acc.key ? { ...a, bankName: e.target.value } : a,
),
)
}
placeholder="Bank name"
/>
</div>
<div className={`${tableStyles.field} ${styles.accountCol2}`}>
<label>Account holder</label>
<input
value={acc.accountHolderName}
onChange={(e) =>
onAccountsChange(
accounts.map((a) =>
a.key === acc.key ? { ...a, accountHolderName: e.target.value } : a,
),
)
}
placeholder="Account holder name"
/>
</div>
<div className={`${tableStyles.field} ${styles.accountCol3}`}>
<label>Card number</label>
<input
value={acc.cardNumber}
onChange={(e) =>
onAccountsChange(
accounts.map((a) =>
a.key === acc.key ? { ...a, cardNumber: e.target.value } : a,
),
)
}
placeholder="Optional"
/>
</div>
<div className={`${tableStyles.field} ${styles.accountCol5}`}>
<label>IBAN</label>
<input
value={acc.iban}
onChange={(e) =>
onAccountsChange(
accounts.map((a) =>
a.key === acc.key ? { ...a, iban: e.target.value } : a,
),
)
}
placeholder="Optional"
/>
</div>
<button
type="button"
className={styles.removeFieldBtn}
onClick={() => onAccountsChange(accounts.filter((a) => a.key !== acc.key))}
aria-label="Remove account"
title="Remove"
> >
<X size={16} /> <div className={`${tableStyles.field} ${styles.accountCol2}`}>
</button> {showLabels ? <label>Bank name</label> : null}
</div> <input
))} value={acc.bankName}
onChange={(e) =>
onAccountsChange(
accounts.map((a) =>
a.key === acc.key ? { ...a, bankName: e.target.value } : a,
),
)
}
placeholder="Bank name"
aria-label="Bank name"
/>
</div>
<div className={`${tableStyles.field} ${styles.accountCol2}`}>
{showLabels ? <label>Account holder</label> : null}
<input
value={acc.accountHolderName}
onChange={(e) =>
onAccountsChange(
accounts.map((a) =>
a.key === acc.key ? { ...a, accountHolderName: e.target.value } : a,
),
)
}
placeholder="Account holder name"
aria-label="Account holder"
/>
</div>
<div className={`${tableStyles.field} ${styles.accountCol3}`}>
{showLabels ? <label>Card number</label> : null}
<input
value={acc.cardNumber}
onChange={(e) =>
onAccountsChange(
accounts.map((a) =>
a.key === acc.key ? { ...a, cardNumber: e.target.value } : a,
),
)
}
placeholder="Optional"
aria-label="Card number"
/>
</div>
<div className={`${tableStyles.field} ${styles.accountCol5}`}>
{showLabels ? <label>IBAN</label> : null}
<input
value={acc.iban}
onChange={(e) =>
onAccountsChange(
accounts.map((a) =>
a.key === acc.key ? { ...a, iban: e.target.value } : a,
),
)
}
placeholder="Optional"
aria-label="IBAN"
/>
</div>
<button
type="button"
className={styles.removeFieldBtn}
onClick={() => onAccountsChange(accounts.filter((a) => a.key !== acc.key))}
aria-label="Remove account"
title="Remove"
>
<X size={16} />
</button>
</div>
)
})}
</div> </div>
)} )}
</div> </div>
+6 -6
View File
@@ -11,20 +11,20 @@ export function isAllowedAdminHost(hostname = window.location.hostname): boolean
/** /**
* Public invoice URL for platform invoices. * Public invoice URL for platform invoices.
* Local/dev: current origin (`https://meshkee.app:5174/invoices/{id}`) so the show page is reachable. * Local/dev: current origin (`https://meshkee.app:5174/invoices/{publicId}`) so the show page is reachable.
* Production: `https://{VITE_INVOICE_PUBLIC_DOMAIN}/invoices/{id}` (default meshkee.com). * Production: `https://{VITE_INVOICE_PUBLIC_DOMAIN}/invoices/{publicId}` (default meshkee.com).
* Override either with `VITE_INVOICE_PUBLIC_BASE_URL` (full origin, optional path prefix). * 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() const baseOverride = import.meta.env.VITE_INVOICE_PUBLIC_BASE_URL?.trim()
if (baseOverride) { if (baseOverride) {
return `${baseOverride.replace(/\/$/, '')}/invoices/${invoiceId}` return `${baseOverride.replace(/\/$/, '')}/invoices/${publicId}`
} }
if (import.meta.env.DEV) { 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' 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). */ /** Marketing / main business site for platform invoices (default https://meshkee.com). */
@@ -138,7 +138,6 @@
background: rgba(239, 68, 68, 0.08); background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.18); border: 1px solid rgba(239, 68, 68, 0.18);
flex-shrink: 0; flex-shrink: 0;
align-self: flex-end;
} }
.removeFieldBtn:hover:not(:disabled) { .removeFieldBtn:hover:not(:disabled) {
@@ -147,7 +146,7 @@
.repeatRow { .repeatRow {
display: flex; display: flex;
align-items: flex-end; align-items: center;
gap: 8px; gap: 8px;
} }
@@ -156,6 +155,14 @@
min-width: 0; min-width: 0;
} }
.fieldNoLabel {
margin: 0;
}
.fieldNoLabel label {
display: none;
}
.itemGrid { .itemGrid {
display: grid; display: grid;
grid-template-columns: minmax(160px, 2fr) minmax(90px, 1fr) minmax(90px, 1fr) minmax(110px, 1.1fr) minmax(120px, 1.2fr); 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; gap: 8px;
} }
.accountRowPlain {
align-items: center;
}
.accountCol2, .accountCol2,
.accountCol3, .accountCol3,
.accountCol5 { .accountCol5 {
@@ -111,7 +111,7 @@ export function BusinessInvoicesPage() {
} }
async function copyPublicLink(invoice: Invoice) { async function copyPublicLink(invoice: Invoice) {
const url = invoice.publicUrl || getPlatformInvoicePublicUrl(invoice.id) const url = invoice.publicUrl || getPlatformInvoicePublicUrl(invoice.publicId)
try { try {
await navigator.clipboard.writeText(url) await navigator.clipboard.writeText(url)
showToast('Invoice link copied.', 'success') showToast('Invoice link copied.', 'success')
@@ -123,12 +123,12 @@ export function BusinessInvoicesPage() {
function invoicePublicUrl(invoice: Invoice) { function invoicePublicUrl(invoice: Invoice) {
// Prefer local/dev origin so the public show page is reachable while designing. // 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) { 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 ( return (
<main className={pageStyles.content}> <main className={pageStyles.content}>
@@ -59,7 +59,7 @@ export function IssueInvoicePage() {
const [formError, setFormError] = useState('') const [formError, setFormError] = useState('')
const listPath = `/businesses/${businessId}/invoices` const listPath = `/businesses/${businessId}/invoices`
const businessName = business?.nameFa || business?.name || 'Business' const businessName = business?.name || business?.nameFa || 'Business'
const createTotal = useMemo(() => { const createTotal = useMemo(() => {
return draftItems.reduce((sum, item) => { return draftItems.reduce((sum, item) => {
@@ -46,7 +46,7 @@ export function PublicInvoicePage() {
useEffect(() => { useEffect(() => {
if (!invoice) return if (!invoice) return
const previous = document.title const previous = document.title
document.title = invoice.name?.trim() || `Invoice #${invoice.id}` document.title = invoice.name?.trim() || `Invoice ${invoice.publicId}`
return () => { return () => {
document.title = previous document.title = previous
} }
@@ -65,7 +65,7 @@ export function PublicInvoicePage() {
{invoice ? ( {invoice ? (
<> <>
<header className={styles.header}> <header className={styles.header}>
<h1 className={styles.title}>{invoice.name || `Invoice #${invoice.id}`}</h1> <h1 className={styles.title}>{invoice.name || `Invoice ${invoice.publicId}`}</h1>
<p className={styles.meta}> <p className={styles.meta}>
Issued {formatDate(invoice.issuedAt)} Issued {formatDate(invoice.issuedAt)}
{invoice.business?.name ? ` · ${invoice.business.name}` : ''} {invoice.business?.name ? ` · ${invoice.business.name}` : ''}
@@ -141,9 +141,9 @@ export function deleteBusinessInvoice(businessId: string, invoiceId: string) {
}) })
} }
/** Public show-page payload (no auth). */ /** Public show-page payload (no auth). Lookup by opaque publicId. */
export function getPublicInvoice(invoiceId: string, signal?: AbortSignal) { export function getPublicInvoice(publicId: string, signal?: AbortSignal) {
return apiRequest<PublicInvoice>(`/public/invoices/${invoiceId}`, { return apiRequest<PublicInvoice>(`/public/invoices/${publicId}`, {
auth: false, auth: false,
signal, signal,
}) })
+16 -16
View File
@@ -46,6 +46,7 @@ export interface InvoiceAccount {
export interface Invoice { export interface Invoice {
id: string id: string
publicId: string
businessId: string businessId: string
ownerScope: 'platform' | 'business' ownerScope: 'platform' | 'business'
issuerBusinessId: string | null issuerBusinessId: string | null
@@ -76,22 +77,21 @@ export interface Invoice {
total?: number total?: number
} }
/** Public viewer payload (no notes / issuer). */ /** Public viewer payload (no notes / issuer / sequential id). */
export type PublicInvoice = Pick< export type PublicInvoice = {
Invoice, publicId: string
| 'id' status: InvoiceStatus
| 'status' name: string | null
| 'name' topText: string | null
| 'topText' issuedAt: string
| 'issuedAt' business?: Invoice['business']
| 'business' items?: InvoiceItem[]
| 'items' keyPoints?: InvoiceKeyPoint[]
| 'keyPoints' accounts?: InvoiceAccount[]
| 'accounts' subtotal?: number
| 'subtotal' total?: number
| 'total' publicUrl: string | null
| 'publicUrl' }
>
export interface InvoiceItemInput { export interface InvoiceItemInput {
templateId?: string templateId?: string
+5 -1
View File
@@ -33,9 +33,13 @@ rsync -az --delete \
ssh root@45.149.76.52 'cd /opt/meshkee/dashboards && npm ci && npm run build && \ 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/super-admin/dist/ /var/www/meshkee/super-admin/ && \
rsync -a --delete apps/business/dist/ /var/www/meshkee/business/ && \ 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`): Build env on server (`apps/*/.env`):
- All: `VITE_API_BASE_URL=https://api.meshkee.com/api/v1` - All: `VITE_API_BASE_URL=https://api.meshkee.com/api/v1`
+4 -2
View File
@@ -318,6 +318,7 @@ Run in order from `MeshkeeApp Backend/database/migrations/`:
| `037_invoice_name.sql` | Optional invoice name | | `037_invoice_name.sql` | Optional invoice name |
| `038_invoice_templates.sql` | Full invoice templates + key points / accounts | | `038_invoice_templates.sql` | Full invoice templates + key points / accounts |
| `039_invoice_account_holder.sql` | Account holder name on bank 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. After schema changes: `npx prisma generate` and restart the backend.
@@ -345,11 +346,12 @@ Two template layers + issued invoices:
**Public invoice viewer (platform):** **Public invoice viewer (platform):**
- Route: super-admin SPA `/invoices/:id` (`PublicInvoicePage`) — glass layout, print-to-PDF, “Issued by” Meshkee footer - 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` - 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) - 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:** **Super Admin UI:**
- `/settings` — Invoice templates list + item templates (top text preview = one-line ellipsis) - `/settings` — Invoice templates list + item templates (top text preview = one-line ellipsis)