diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1d72ec..5b51f3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,9 +16,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: "3.12" diff --git a/src/server/generate_form.py b/src/server/generate_form.py index 6b2b8b3..3cdbc67 100644 --- a/src/server/generate_form.py +++ b/src/server/generate_form.py @@ -58,6 +58,8 @@ def generate_form( use_google_auth = bool(google_client_id) auth_mode = "google" if use_google_auth else "local" + # apiKey is intentionally embedded in the generated HTML and must be treated + # as a shared client token, not a secret or user-authentication credential. form_config = { "authMode": auth_mode, "meals": menu["meals"], @@ -112,7 +114,9 @@ def main(): "--api-url", default="", help="API Gateway base URL (cloud mode)" ) parser.add_argument( - "--api-key", default="", help="API key for order submission (cloud mode)" + "--api-key", + default="", + help="Shared client token embedded in the generated form (cloud mode)", ) parser.add_argument( "--bulk-discount", diff --git a/src/server/templates/admin.js b/src/server/templates/admin.js index b835ad6..85839ce 100644 --- a/src/server/templates/admin.js +++ b/src/server/templates/admin.js @@ -123,7 +123,21 @@ function buildViewActions(idx, email, name) { return wrap; } +function validateAdminData(data) { + if (!data || !Array.isArray(data.orders)) { + throw new Error('Invalid orders response'); + } + if ( + data.orders.length > 0 + && (!Number.isFinite(data.total_employees) || !Number.isFinite(data.grand_total)) + ) { + throw new Error('Invalid order aggregates'); + } + return data; +} + function renderAdminTable(data) { + validateAdminData(data); lastAdminData = data; const hasOrders = !!(data.orders && data.orders.length > 0); const dlBtn = document.getElementById('admin-download-btn'); @@ -186,7 +200,12 @@ function renderAdminTable(data) { MEALS.forEach(m => { menuPrices[m.name] = m.price; }); const newTotal = editNames.reduce((sum, name) => { const price = menuPrices[name] || 0; - return sum + price * (editQuantities[name] || 0); + const employeePrice = window.FormUtils.employeePrice( + price, + window.CONFIG.bulkDiscount, + window.CONFIG.companySubsidy + ); + return sum + employeePrice * (editQuantities[name] || 0); }, 0); totalHtml = '$' + newTotal.toFixed(2); tr.appendChild(empTd); @@ -262,7 +281,8 @@ function downloadOrderList() { })); const names = Object.keys(totals).sort((a, b) => a.localeCompare(b)); const esc = v => { - const s = String(v); + const value = String(v); + const s = /^[=+\-@]/.test(value) ? "'" + value : value; return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; const rows = [['Item', 'Quantity']].concat(names.map(n => [n, totals[n]])); @@ -305,15 +325,25 @@ function downloadSummaryPdf() { function adminStartEdit(idx, email) { fetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek), { headers: adminHeaders() }) - .then(r => r.json()) + .then(r => { + if (!r.ok) throw new Error('Request failed with status ' + r.status); + return r.json(); + }) .then(data => { + validateAdminData(data); lastAdminData = data; const order = data.orders.find(o => o.employee_email === email); - if (!order) return; + if (!order || !Array.isArray(order.items)) { + throw new Error('Order not found'); + } editingOrder = email; editQuantities = {}; order.items.forEach(i => { editQuantities[i.name] = i.quantity; }); renderAdminTable(data); + }) + .catch(e => { + if (lastAdminData) renderAdminTable(lastAdminData); + alert('Failed to load order: ' + e.message); }); } diff --git a/src/server/templates/form.css b/src/server/templates/form.css index ba81bd7..78ac7b7 100644 --- a/src/server/templates/form.css +++ b/src/server/templates/form.css @@ -6,6 +6,9 @@ --radius-card: 12px; --radius-control: 8px; --shadow-card: 0 1px 3px rgba(0,0,0,0.08); + --user-avatar-size: 40px; + --user-bar-gap: 12px; + --user-bar-indent: calc(var(--user-avatar-size) + var(--user-bar-gap)); } * { margin: 0; padding: 0; box-sizing: border-box; } @@ -75,11 +78,11 @@ header p { font-size: 0.85rem; opacity: 0.8; } margin-bottom: 12px; } -.user-bar { display: flex; align-items: center; gap: 12px; } +.user-bar { display: flex; align-items: center; gap: var(--user-bar-gap); } .user-avatar { - width: 40px; - height: 40px; + width: var(--user-avatar-size); + height: var(--user-avatar-size); border-radius: 50%; object-fit: cover; display: none; @@ -147,6 +150,7 @@ header p { font-size: 0.85rem; opacity: 0.8; } margin-bottom: 4px; display: -webkit-box; -webkit-box-orient: vertical; + line-clamp: 3; -webkit-line-clamp: 3; overflow: hidden; } @@ -342,13 +346,12 @@ header p { font-size: 0.85rem; opacity: 0.8; } font-size: 1rem; } -@media (max-width: 479px) { - .user-bar { flex-wrap: wrap; } - .user-meta { flex-basis: calc(100% - 52px); } - .user-bar #admin-btn { margin-left: 52px; } -} - @media (max-width: 480px) { + .user-bar { flex-wrap: wrap; } + .user-meta { flex-basis: calc(100% - var(--user-bar-indent)); } + .user-bar #admin-btn { margin-left: var(--user-bar-indent); } + .user-avatar.is-hidden + .user-meta { flex-basis: 100%; } + .user-avatar.is-hidden ~ #admin-btn { margin-left: 0; } .meal-card { display: grid; grid-template-columns: 80px minmax(0, 1fr); diff --git a/src/server/templates/form.html.j2 b/src/server/templates/form.html.j2 index 36d94c1..8a97566 100644 --- a/src/server/templates/form.html.j2 +++ b/src/server/templates/form.html.j2 @@ -14,22 +14,22 @@
-