From 1692edc6cf554f8db7e247074b772cb7cd587eea Mon Sep 17 00:00:00 2001 From: carsonlayden Date: Tue, 2 Jun 2026 13:23:08 -0700 Subject: [PATCH 1/7] Route #51 quick-actions through double-write so recharge syncs the simulator --- admin-webapp/src/api/admin.js | 28 ++++++++++++++ admin-webapp/src/api/admin.test.js | 59 +++++++++++++++++++++++++++++ admin-webapp/src/pages/BotsPage.jsx | 25 +++++++++--- 3 files changed, 106 insertions(+), 6 deletions(-) diff --git a/admin-webapp/src/api/admin.js b/admin-webapp/src/api/admin.js index eeba459..0f44589 100644 --- a/admin-webapp/src/api/admin.js +++ b/admin-webapp/src/api/admin.js @@ -11,6 +11,8 @@ import { updateBot as botnetUpdate, deleteBot as botnetDelete, listBots as botnetList, + rechargeBot as botnetRecharge, + updateServicingStatus as botnetServicing, } from './bots.js' import { @@ -73,4 +75,30 @@ export async function removeBot(id, name) { return { botnet: botnetResult, simulator: simResult } } +// #51 Quick-action: recharge. BotNet sets the battery to 100; mirror that to +// the simulator's powerLevel so the runtime bot reflects the recharge. +export async function rechargeBot(id, name) { + const botnetResult = await botnetRecharge(id) + if (botnetResult.error) { + return { botnet: botnetResult, simulator: null } + } + if (!simulatorConfig.configured) { + return { botnet: botnetResult, simulator: { ok: false, skipped: true } } + } + const botId = toBotId(name) + const simResult = await updateSimulatorBot(botId, { powerLevel: 100 }) + return { botnet: botnetResult, simulator: simResult } +} + +// #51 Quick-action: toggle servicing status. BotNet is the source of truth. +// The simulator's UpdateBotRequest exposes no settable status field (BotStatus +// is managed internally by the simulation), so this is a BotNet-only write. +export async function setServicingStatus(id, isServicingCustomer) { + const botnetResult = await botnetServicing(id, isServicingCustomer) + return { + botnet: botnetResult, + simulator: { ok: true, skipped: true, reason: 'Simulator has no settable status field' }, + } +} + export { simulatorConfig } diff --git a/admin-webapp/src/api/admin.test.js b/admin-webapp/src/api/admin.test.js index 3f1cf72..23a5fcd 100644 --- a/admin-webapp/src/api/admin.test.js +++ b/admin-webapp/src/api/admin.test.js @@ -5,6 +5,8 @@ vi.mock('./bots.js', () => ({ updateBot: vi.fn(), deleteBot: vi.fn(), listBots: vi.fn(), + rechargeBot: vi.fn(), + updateServicingStatus: vi.fn(), })) vi.mock('./simulator.js', () => ({ @@ -130,3 +132,60 @@ describe('modifyBot (issue #50)', () => { expect(result.simulator.skipped).toBe(true) }) }) + +describe('rechargeBot (issue #51)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('recharges in BotNet, then sets the simulator powerLevel to 100', async () => { + bots.rechargeBot.mockResolvedValue({ source: 'api', data: { id: 5, batteryLevel: 100 } }) + sim.updateSimulatorBot.mockResolvedValue({ ok: true, data: {} }) + + const result = await admin.rechargeBot(5, 'Bot-005') + + expect(bots.rechargeBot).toHaveBeenCalledWith(5) + expect(sim.updateSimulatorBot).toHaveBeenCalledWith('bot-005', { powerLevel: 100 }) + expect(result.botnet.data.batteryLevel).toBe(100) + expect(result.simulator.ok).toBe(true) + }) + + it('surfaces a simulator failure as a partial-failure result', async () => { + bots.rechargeBot.mockResolvedValue({ source: 'api', data: { id: 6 } }) + sim.updateSimulatorBot.mockResolvedValue({ ok: false, error: '404 Not Found' }) + + const result = await admin.rechargeBot(6, 'Bot-006') + + expect(result.simulator.ok).toBe(false) + expect(result.simulator.error).toContain('404') + }) + + it('does not call the simulator if BotNet recharge errored', async () => { + bots.rechargeBot.mockResolvedValue({ source: 'api', error: '404 Not Found' }) + + const result = await admin.rechargeBot(99, 'ghost') + + expect(sim.updateSimulatorBot).not.toHaveBeenCalled() + expect(result.simulator).toBeNull() + }) +}) + +describe('setServicingStatus (issue #51)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('writes to BotNet and skips the simulator (no settable status field)', async () => { + bots.updateServicingStatus.mockResolvedValue({ + source: 'api', + data: { id: 5, isServicingCustomer: true }, + }) + + const result = await admin.setServicingStatus(5, true) + + expect(bots.updateServicingStatus).toHaveBeenCalledWith(5, true) + expect(sim.updateSimulatorBot).not.toHaveBeenCalled() + expect(result.botnet.data.isServicingCustomer).toBe(true) + expect(result.simulator.skipped).toBe(true) + }) +}) diff --git a/admin-webapp/src/pages/BotsPage.jsx b/admin-webapp/src/pages/BotsPage.jsx index 9b9daa9..f4fe85d 100644 --- a/admin-webapp/src/pages/BotsPage.jsx +++ b/admin-webapp/src/pages/BotsPage.jsx @@ -4,9 +4,11 @@ import { registerBot, modifyBot, removeBot, + rechargeBot, + setServicingStatus, simulatorConfig, } from '../api/admin.js' -import { apiConfig as botnetConfig, rechargeBot, updateServicingStatus } from '../api/bots.js' +import { apiConfig as botnetConfig } from '../api/bots.js' import BotDialog from '../components/BotDialog.jsx' import ConfirmDialog from '../components/ConfirmDialog.jsx' @@ -45,16 +47,27 @@ export default function BotsPage() { refresh() }, [refresh]) - async function onRecharge(id) { - setBusyId(id) - await rechargeBot(id) + // #51 Quick-action: recharge (double-writes battery=100 to BotNet + simulator) + async function onRecharge(bot) { + setBusyId(bot.id) + const result = await rechargeBot(bot.id, bot.name) + const sim = result?.simulator + if (!result?.botnet?.error && sim && !sim.ok && !sim.skipped) { + setBanner({ + tone: 'warn', + text: `Bot #${bot.id} recharged in BotNet, but simulator sync failed: ${sim.error || 'unknown error'}.`, + }) + } else { + setBanner(null) + } await refresh() setBusyId(null) } + // #51 Quick-action: toggle servicing status (BotNet-only — see admin.js) async function onToggleServicing(bot) { setBusyId(bot.id) - await updateServicingStatus(bot.id, !bot.isServicingCustomer) + await setServicingStatus(bot.id, !bot.isServicingCustomer) await refresh() setBusyId(null) } @@ -209,7 +222,7 @@ export default function BotsPage() { + + + +
+ + + + + + + + + + + + {loading && visible.length === 0 && ( + + + + )} + {!loading && visible.length === 0 && ( + + + + )} + {visible.map((order) => { + const c = statusColor(order.status) + return ( + + + + + + + + ) + })} + +
Order IDCustomerAssigned BotStatusCreated
Loading orders…
+ {orders.length === 0 + ? 'No orders yet.' + : `No orders with status "${statusFilter}".`} +
+ {shortId(order.id)} + {customerName(order.customerId)}{order.assignedBotId || Unassigned} + + {order.status} + + {formatTime(order.createdAt)}
+
+ + + + ) +} + +const styles = { + section: { padding: '0 2rem 2rem' }, + header: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-end', + gap: '1rem', + marginBottom: '1rem', + flexWrap: 'wrap', + }, + headerRight: { display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }, + h1: { margin: 0, fontSize: '1.75rem' }, + sub: { margin: '0.25rem 0 0', color: 'var(--text-dim)' }, + filterLabel: { color: 'var(--text-dim)', fontSize: '0.9rem' }, + select: { + background: 'var(--bg-elev)', + color: 'var(--text)', + border: '1px solid var(--border)', + padding: '0.4rem 0.6rem', + borderRadius: '8px', + fontSize: '0.9rem', + }, + secondaryBtn: { + background: 'transparent', + color: 'var(--text)', + border: '1px solid var(--border)', + padding: '0.5rem 1rem', + borderRadius: '8px', + fontSize: '0.9rem', + }, + tableWrap: { + background: 'var(--bg-elev)', + border: '1px solid var(--border)', + borderRadius: '12px', + overflow: 'hidden', + }, + table: { width: '100%', borderCollapse: 'collapse' }, + th: { + textAlign: 'left', + padding: '0.75rem 1rem', + background: 'var(--bg-elev-2)', + color: 'var(--text-dim)', + fontWeight: 500, + fontSize: '0.85rem', + textTransform: 'uppercase', + letterSpacing: '0.04em', + borderBottom: '1px solid var(--border)', + }, + td: { + padding: '0.75rem 1rem', + borderBottom: '1px solid var(--border)', + fontSize: '0.95rem', + }, + pill: { + padding: '0.2rem 0.6rem', + border: '1px solid', + borderRadius: '999px', + fontSize: '0.8rem', + }, + footer: { + marginTop: '1rem', + fontSize: '0.8rem', + color: 'var(--text-dim)', + display: 'flex', + gap: '1.5rem', + flexWrap: 'wrap', + }, + code: { + background: 'var(--bg-elev-2)', + border: '1px solid var(--border)', + padding: '0.15rem 0.4rem', + borderRadius: '4px', + fontFamily: 'ui-monospace, Consolas, monospace', + fontSize: '0.78rem', + }, +} From 1abe596c64782f1ce14add627c25fb2536826b1f Mon Sep 17 00:00:00 2001 From: carsonlayden Date: Tue, 2 Jun 2026 13:53:46 -0700 Subject: [PATCH 4/7] Scaffold #54 Entra sign-in behind blank env vars (auth disabled until configured) --- .github/workflows/AdminWebpage-Deploy-WF.yml | 9 + admin-webapp/.env.example | 7 + admin-webapp/package-lock.json | 1376 +++++++++++++++--- admin-webapp/package.json | 2 + admin-webapp/src/App.jsx | 68 +- admin-webapp/src/api/bots.js | 8 +- admin-webapp/src/api/orders.js | 8 +- admin-webapp/src/api/simulator.js | 8 +- admin-webapp/src/auth/AuthGate.jsx | 89 ++ admin-webapp/src/auth/authConfig.js | 38 + admin-webapp/src/auth/authConfig.test.js | 12 + admin-webapp/src/auth/msalInstance.js | 6 + admin-webapp/src/auth/token.js | 23 + admin-webapp/src/components/UserMenu.jsx | 33 + admin-webapp/src/main.jsx | 45 +- 15 files changed, 1459 insertions(+), 273 deletions(-) create mode 100644 admin-webapp/src/auth/AuthGate.jsx create mode 100644 admin-webapp/src/auth/authConfig.js create mode 100644 admin-webapp/src/auth/authConfig.test.js create mode 100644 admin-webapp/src/auth/msalInstance.js create mode 100644 admin-webapp/src/auth/token.js create mode 100644 admin-webapp/src/components/UserMenu.jsx diff --git a/.github/workflows/AdminWebpage-Deploy-WF.yml b/.github/workflows/AdminWebpage-Deploy-WF.yml index bac356c..ba3e4ba 100644 --- a/.github/workflows/AdminWebpage-Deploy-WF.yml +++ b/.github/workflows/AdminWebpage-Deploy-WF.yml @@ -31,6 +31,12 @@ env: BOTNET_API_URL: https://ewu-deliverybotsystem-api.mangocoast-332176b0.westus2.azurecontainerapps.io SIMULATOR_API_URL: https://deliverybot-robot-simulator.mangocoast-332176b0.westus2.azurecontainerapps.io ORDER_SERVICE_URL: https://deliverybot-order-service.mangocoast-332176b0.westus2.azurecontainerapps.io + # Entra ID staff sign-in (issue #54). Blank → auth disabled (app runs open). + # Fill these in from the app registration to switch sign-in on, then push. + # Client/tenant/group IDs are not secrets (a public SPA exposes them anyway). + ENTRA_CLIENT_ID: "" + ENTRA_TENANT_ID: "" + ENTRA_ADMIN_GROUP_ID: "" jobs: provision-and-deploy: @@ -106,6 +112,9 @@ jobs: VITE_BOTNET_API_URL: ${{ env.BOTNET_API_URL }} VITE_SIMULATOR_API_URL: ${{ env.SIMULATOR_API_URL }} VITE_ORDER_SERVICE_URL: ${{ env.ORDER_SERVICE_URL }} + VITE_ENTRA_CLIENT_ID: ${{ env.ENTRA_CLIENT_ID }} + VITE_ENTRA_TENANT_ID: ${{ env.ENTRA_TENANT_ID }} + VITE_ENTRA_ADMIN_GROUP_ID: ${{ env.ENTRA_ADMIN_GROUP_ID }} run: npm run build # ── 5. Deploy the build to the App Service ──────────────────────────── diff --git a/admin-webapp/.env.example b/admin-webapp/.env.example index 42ce52d..5be7645 100644 --- a/admin-webapp/.env.example +++ b/admin-webapp/.env.example @@ -14,3 +14,10 @@ VITE_SIMULATOR_API_URL=https://deliverybot-robot-simulator.mangocoast-332176b0.w # Base URL of the Order Service (issue #22). Backs the Orders tab (issue #53). # When unset, the Orders view falls back to mock data. VITE_ORDER_SERVICE_URL=https://deliverybot-order-service.mangocoast-332176b0.westus2.azurecontainerapps.io + +# Entra ID staff sign-in (issue #54). Leave blank to run with auth disabled. +# Fill from the "DeliveryBot Admin App" registration to enable sign-in. +VITE_ENTRA_CLIENT_ID= +VITE_ENTRA_TENANT_ID= +# Object ID of the DeliveryBot-Admin security group (gates who can sign in). +VITE_ENTRA_ADMIN_GROUP_ID= diff --git a/admin-webapp/package-lock.json b/admin-webapp/package-lock.json index 60412ad..20d64ce 100644 --- a/admin-webapp/package-lock.json +++ b/admin-webapp/package-lock.json @@ -8,6 +8,8 @@ "name": "admin-webapp", "version": "0.0.0", "dependencies": { + "@azure/msal-browser": "^5.11.0", + "@azure/msal-react": "^5.4.2", "react": "^19.2.6", "react-dom": "^19.2.6" }, @@ -55,6 +57,41 @@ "dev": true, "license": "ISC" }, + "node_modules/@azure/msal-browser": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.11.0.tgz", + "integrity": "sha512-zkGNYS3TwY8lUpPIafAmsFCYZbgFixY9y/LZB9GUg0IILoHTqpN26j5OrkL1AQThh/YdZsawe4iWXfp85lFVxg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@azure/msal-common": "16.6.2" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.6.2", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.6.2.tgz", + "integrity": "sha512-hQjjsekAjB00cM1EmatWJlzhEoK2Qhz7Rj5gvM6tYf8iL7RM3tkxlpU9fG0+ofkulzg9AEEA6dIEnSmDr5ZqUA==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-react": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-5.4.2.tgz", + "integrity": "sha512-UK4xVqwhdi0qKebb76Z9oI6lLnNPmpcSdoBcBBnLao4+GXPJNA1HP2Tj5mWQSwd0EPTBCcV4msNiepPdxLk+ng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@azure/msal-browser": "^5.11.0", + "react": "^16.8.0 || ^17 || ^18 || ^19.2.1" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -435,9 +472,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "cpu": [ "ppc64" ], @@ -448,13 +485,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "cpu": [ "arm" ], @@ -465,13 +502,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "cpu": [ "arm64" ], @@ -482,13 +519,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "cpu": [ "x64" ], @@ -499,13 +536,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "cpu": [ "arm64" ], @@ -516,13 +553,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "cpu": [ "x64" ], @@ -533,13 +570,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "cpu": [ "arm64" ], @@ -550,13 +587,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "cpu": [ "x64" ], @@ -567,13 +604,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "cpu": [ "arm" ], @@ -584,13 +621,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "cpu": [ "arm64" ], @@ -601,13 +638,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "cpu": [ "ia32" ], @@ -618,13 +655,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "cpu": [ "loong64" ], @@ -635,13 +672,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "cpu": [ "mips64el" ], @@ -652,13 +689,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "cpu": [ "ppc64" ], @@ -669,13 +706,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "cpu": [ "riscv64" ], @@ -686,13 +723,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "cpu": [ "s390x" ], @@ -703,13 +740,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "cpu": [ "x64" ], @@ -720,13 +757,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "cpu": [ "x64" ], @@ -737,13 +791,30 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "cpu": [ "x64" ], @@ -754,13 +825,30 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "cpu": [ "x64" ], @@ -771,13 +859,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "cpu": [ "arm64" ], @@ -788,13 +876,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "cpu": [ "ia32" ], @@ -805,13 +893,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "cpu": [ "x64" ], @@ -822,7 +910,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -2459,46 +2547,6 @@ "node": ">= 0.4" } }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4371,130 +4419,951 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/vite-node/node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true }, "lightningcss": { "optional": true }, - "sass": { + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { "optional": true }, - "sass-embedded": { + "@vitest/browser": { "optional": true }, - "stylus": { + "@vitest/ui": { "optional": true }, - "sugarss": { + "happy-dom": { "optional": true }, - "terser": { + "jsdom": { "optional": true } } }, - "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" } }, "node_modules/vitest/node_modules/@vitest/mocker": { @@ -4524,6 +5393,45 @@ } } }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, "node_modules/vitest/node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/admin-webapp/package.json b/admin-webapp/package.json index 7494a9b..d07084b 100644 --- a/admin-webapp/package.json +++ b/admin-webapp/package.json @@ -12,6 +12,8 @@ "test:watch": "vitest" }, "dependencies": { + "@azure/msal-browser": "^5.11.0", + "@azure/msal-react": "^5.4.2", "react": "^19.2.6", "react-dom": "^19.2.6" }, diff --git a/admin-webapp/src/App.jsx b/admin-webapp/src/App.jsx index c435d97..dc689c4 100644 --- a/admin-webapp/src/App.jsx +++ b/admin-webapp/src/App.jsx @@ -1,6 +1,9 @@ import { useState } from 'react' import BotsPage from './pages/BotsPage.jsx' import OrdersPage from './pages/OrdersPage.jsx' +import AuthGate from './auth/AuthGate.jsx' +import UserMenu from './components/UserMenu.jsx' +import { authEnabled } from './auth/authConfig.js' const tabs = [ { id: 'bots', label: 'Bots' }, @@ -12,39 +15,42 @@ export default function App() { const [active, setActive] = useState('bots') return ( -
- +
+ {tabs.map((t) => ( + + ))} +
+ {authEnabled && } + -
- {active === 'bots' && } - {active === 'orders' && } - {active === 'config' && ( - - )} -
-
+
+ {active === 'bots' && } + {active === 'orders' && } + {active === 'config' && ( + + )} +
+ + ) } diff --git a/admin-webapp/src/api/bots.js b/admin-webapp/src/api/bots.js index d255da3..eaf5d52 100644 --- a/admin-webapp/src/api/bots.js +++ b/admin-webapp/src/api/bots.js @@ -3,6 +3,8 @@ // If the API is unreachable, calls fall back to mock data so the admin app can // be demoed without the backend deployed. +import { getAuthHeaders } from '../auth/token.js' + const baseUrl = (import.meta.env.VITE_BOTNET_API_URL ?? '').replace(/\/+$/, '') const mockBots = [ @@ -45,7 +47,11 @@ async function callOrMock(path, init, mockResult) { return { data: mockResult, source: 'mock' } } try { - const res = await fetch(`${baseUrl}${path}`, init) + const authHeaders = await getAuthHeaders() + const res = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { ...(init?.headers), ...authHeaders }, + }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = res.status === 204 ? null : await res.json() return { data, source: 'api' } diff --git a/admin-webapp/src/api/orders.js b/admin-webapp/src/api/orders.js index 09005d5..8560830 100644 --- a/admin-webapp/src/api/orders.js +++ b/admin-webapp/src/api/orders.js @@ -4,6 +4,8 @@ // Falls back to clearly-labeled mock data when VITE_ORDER_SERVICE_URL is unset // so the admin app can be demoed without the Order Service deployed. +import { getAuthHeaders } from '../auth/token.js' + const baseUrl = (import.meta.env.VITE_ORDER_SERVICE_URL ?? '').replace(/\/+$/, '') // Mirrors the OrderStatus enum in OrderService/Models/OrderStatus.cs. @@ -64,7 +66,11 @@ async function callOrMock(path, init, mockResult) { return { data: mockResult, source: 'mock' } } try { - const res = await fetch(`${baseUrl}${path}`, init) + const authHeaders = await getAuthHeaders() + const res = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { ...(init?.headers), ...authHeaders }, + }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = res.status === 204 ? null : await res.json() return { data, source: 'api' } diff --git a/admin-webapp/src/api/simulator.js b/admin-webapp/src/api/simulator.js index dadf3b5..9a28002 100644 --- a/admin-webapp/src/api/simulator.js +++ b/admin-webapp/src/api/simulator.js @@ -3,6 +3,8 @@ // Falls back to a no-op when VITE_SIMULATOR_API_URL is unset so the admin app // still works against BotNet alone. +import { getAuthHeaders } from '../auth/token.js' + const baseUrl = (import.meta.env.VITE_SIMULATOR_API_URL ?? '').replace(/\/+$/, '') // Spokane city center, used as the default location for newly registered bots. @@ -24,7 +26,11 @@ async function call(path, init) { return { ok: false, skipped: true, reason: 'Simulator URL not configured' } } try { - const res = await fetch(`${baseUrl}${path}`, init) + const authHeaders = await getAuthHeaders() + const res = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { ...(init?.headers), ...authHeaders }, + }) if (!res.ok) { let detail = `HTTP ${res.status}` try { diff --git a/admin-webapp/src/auth/AuthGate.jsx b/admin-webapp/src/auth/AuthGate.jsx new file mode 100644 index 0000000..84cd004 --- /dev/null +++ b/admin-webapp/src/auth/AuthGate.jsx @@ -0,0 +1,89 @@ +import { useEffect } from 'react' +import { useMsal, useIsAuthenticated } from '@azure/msal-react' +import { authEnabled, loginRequest, ADMIN_GROUP_ID } from './authConfig.js' + +// Wraps the app. When auth is disabled, renders children as-is. When enabled, +// requires an interactive sign-in and (if a group is configured) membership in +// the DeliveryBot-Admin group before showing the app. +export default function AuthGate({ children }) { + if (!authEnabled) return children + return {children} +} + +function GatedContent({ children }) { + const { instance, accounts } = useMsal() + const isAuthenticated = useIsAuthenticated() + + useEffect(() => { + if (!isAuthenticated) { + instance.loginRedirect(loginRequest).catch((err) => console.error(err)) + } + }, [isAuthenticated, instance]) + + if (!isAuthenticated) { + return + } + + const account = accounts[0] + const groups = account?.idTokenClaims?.groups ?? [] + const inAdminGroup = !ADMIN_GROUP_ID || groups.includes(ADMIN_GROUP_ID) + + if (!inAdminGroup) { + return ( + instance.logoutRedirect()} + /> + ) + } + + return children +} + +function Centered({ title, body, onSignOut }) { + return ( +
+
+ 🤖 +

{title}

+

{body}

+ {onSignOut && ( + + )} +
+
+ ) +} + +const styles = { + wrap: { + minHeight: '100vh', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '2rem', + }, + card: { + textAlign: 'center', + maxWidth: '24rem', + padding: '2.5rem', + border: '1px solid var(--border)', + borderRadius: '16px', + background: 'var(--bg-elev)', + }, + mark: { fontSize: '2.5rem' }, + title: { margin: '0.75rem 0 0.5rem', fontSize: '1.5rem' }, + body: { margin: 0, color: 'var(--text-dim)' }, + btn: { + marginTop: '1.25rem', + background: 'var(--accent)', + color: 'white', + border: 'none', + padding: '0.5rem 1rem', + borderRadius: '8px', + fontSize: '0.9rem', + }, +} diff --git a/admin-webapp/src/auth/authConfig.js b/admin-webapp/src/auth/authConfig.js new file mode 100644 index 0000000..e107582 --- /dev/null +++ b/admin-webapp/src/auth/authConfig.js @@ -0,0 +1,38 @@ +// Entra ID (Azure AD) configuration for staff sign-in (issue #54). +// +// Auth is gated behind env vars. While they're blank (e.g. before the Entra +// app registration exists) `authEnabled` is false and the admin app runs with +// auth disabled — so local dev and the current deployment keep working. Fill +// the VITE_ENTRA_* vars in (workflow env / .env.local) to switch auth on. + +const clientId = import.meta.env.VITE_ENTRA_CLIENT_ID ?? '' +const tenantId = import.meta.env.VITE_ENTRA_TENANT_ID ?? '' + +// Object ID of the DeliveryBot-Admin security group. When set, only members +// may use the app; when blank, any signed-in user is allowed. +export const ADMIN_GROUP_ID = import.meta.env.VITE_ENTRA_ADMIN_GROUP_ID ?? '' + +// Auth turns on only when both the client and tenant IDs are present. +export const authEnabled = Boolean(clientId && tenantId) + +const redirectUri = + typeof window !== 'undefined' ? window.location.origin : 'http://localhost:5173' + +export const msalConfig = { + auth: { + clientId, + authority: `https://login.microsoftonline.com/${tenantId}`, + redirectUri, + postLogoutRedirectUri: redirectUri, + }, + cache: { + cacheLocation: 'sessionStorage', + storeAuthStateInCookie: false, + }, +} + +// openid + profile yield the ID token (name) and group claims. Add API scopes +// here once the backends validate bearer tokens. +export const loginRequest = { + scopes: ['openid', 'profile', 'User.Read'], +} diff --git a/admin-webapp/src/auth/authConfig.test.js b/admin-webapp/src/auth/authConfig.test.js new file mode 100644 index 0000000..f19b6ac --- /dev/null +++ b/admin-webapp/src/auth/authConfig.test.js @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest' +import { authEnabled, ADMIN_GROUP_ID } from './authConfig.js' + +describe('authConfig (issue #54)', () => { + it('disables auth when the Entra env vars are blank', () => { + // The scaffold ships with blank VITE_ENTRA_* so the app runs open until an + // app registration exists. This guards against accidentally shipping a + // half-configured auth setup that locks everyone out. + expect(authEnabled).toBe(false) + expect(ADMIN_GROUP_ID).toBe('') + }) +}) diff --git a/admin-webapp/src/auth/msalInstance.js b/admin-webapp/src/auth/msalInstance.js new file mode 100644 index 0000000..c043235 --- /dev/null +++ b/admin-webapp/src/auth/msalInstance.js @@ -0,0 +1,6 @@ +import { PublicClientApplication } from '@azure/msal-browser' +import { authEnabled, msalConfig } from './authConfig.js' + +// A real MSAL instance is only constructed when auth is configured. While +// disabled this stays null and the app renders without an MsalProvider. +export const msalInstance = authEnabled ? new PublicClientApplication(msalConfig) : null diff --git a/admin-webapp/src/auth/token.js b/admin-webapp/src/auth/token.js new file mode 100644 index 0000000..60b89d6 --- /dev/null +++ b/admin-webapp/src/auth/token.js @@ -0,0 +1,23 @@ +import { authEnabled, loginRequest } from './authConfig.js' + +// Returns an Authorization header for outbound API calls when a user is signed +// in, or {} when auth is disabled / no token is available. The msal-browser +// module is only imported when auth is enabled, so it stays out of the test +// and mock-mode code paths. +export async function getAuthHeaders() { + if (!authEnabled) return {} + const { msalInstance } = await import('./msalInstance.js') + if (!msalInstance) return {} + + const account = msalInstance.getActiveAccount() ?? msalInstance.getAllAccounts()[0] + if (!account) return {} + + try { + const result = await msalInstance.acquireTokenSilent({ ...loginRequest, account }) + return { Authorization: `Bearer ${result.accessToken}` } + } catch { + // Silent acquisition can fail (e.g. expired session). Don't block the call; + // the backend simply receives no token. + return {} + } +} diff --git a/admin-webapp/src/components/UserMenu.jsx b/admin-webapp/src/components/UserMenu.jsx new file mode 100644 index 0000000..886e59e --- /dev/null +++ b/admin-webapp/src/components/UserMenu.jsx @@ -0,0 +1,33 @@ +import { useMsal } from '@azure/msal-react' + +// Shows the signed-in staff member's name and a sign-out control in the top +// nav. Only rendered when auth is enabled (inside the MsalProvider). +export default function UserMenu() { + const { instance, accounts } = useMsal() + const account = accounts[0] + const name = account?.name ?? account?.username ?? 'Signed in' + + return ( +
+ + {name} + + +
+ ) +} + +const styles = { + wrap: { display: 'flex', alignItems: 'center', gap: '0.6rem' }, + name: { color: 'var(--text)', fontSize: '0.9rem', fontWeight: 500 }, + btn: { + background: 'transparent', + color: 'var(--text-dim)', + border: '1px solid var(--border)', + padding: '0.4rem 0.75rem', + borderRadius: '8px', + fontSize: '0.85rem', + }, +} diff --git a/admin-webapp/src/main.jsx b/admin-webapp/src/main.jsx index b9a1a6d..fc0196e 100644 --- a/admin-webapp/src/main.jsx +++ b/admin-webapp/src/main.jsx @@ -1,10 +1,45 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { MsalProvider } from '@azure/msal-react' import './index.css' import App from './App.jsx' +import { authEnabled } from './auth/authConfig.js' +import { msalInstance } from './auth/msalInstance.js' -createRoot(document.getElementById('root')).render( - - - , -) +const root = createRoot(document.getElementById('root')) + +function render() { + root.render( + + {authEnabled && msalInstance ? ( + + + + ) : ( + + )} + , + ) +} + +if (authEnabled && msalInstance) { + // MSAL v5 requires initialize() before any auth call; handle a returning + // redirect before first render so the account is available immediately. + msalInstance + .initialize() + .then(() => msalInstance.handleRedirectPromise()) + .then((result) => { + if (result?.account) msalInstance.setActiveAccount(result.account) + const existing = msalInstance.getAllAccounts() + if (!msalInstance.getActiveAccount() && existing.length > 0) { + msalInstance.setActiveAccount(existing[0]) + } + render() + }) + .catch((err) => { + console.error(err) + render() + }) +} else { + render() +} From 519ceca65f17df9ca2052574095186cbfcdbc1b6 Mon Sep 17 00:00:00 2001 From: carsonlayden Date: Tue, 2 Jun 2026 14:06:26 -0700 Subject: [PATCH 5/7] Split admin deploy: drop Terraform, build and deploy only (provisioning handled by common IaC) --- .github/workflows/AdminWebpage-Deploy-WF.yml | 52 +++----------------- 1 file changed, 6 insertions(+), 46 deletions(-) diff --git a/.github/workflows/AdminWebpage-Deploy-WF.yml b/.github/workflows/AdminWebpage-Deploy-WF.yml index ba3e4ba..7fdb2ab 100644 --- a/.github/workflows/AdminWebpage-Deploy-WF.yml +++ b/.github/workflows/AdminWebpage-Deploy-WF.yml @@ -1,8 +1,8 @@ name: AdminWebpage-Deploy-WF -# Provisions the Admin Web App App Service via Terraform, then builds and -# deploys the React SPA to it. Auth uses the same OIDC federated identity -# Phil configured for the BotNet API workflow, so no new secrets are needed. +# Builds and deploys the Admin Web App React SPA to Azure App Service. +# Infrastructure provisioning (App Service, etc.) is handled separately by the +# common IaC workflow — this workflow only builds and deploys the app. on: workflow_dispatch: @@ -10,13 +10,11 @@ on: branches: [main] paths: - "admin-webapp/**" - - "Iac/admin-webapp/**" - ".github/workflows/AdminWebpage-Deploy-WF.yml" push: branches: [main] paths: - "admin-webapp/**" - - "Iac/admin-webapp/**" - ".github/workflows/AdminWebpage-Deploy-WF.yml" permissions: @@ -26,8 +24,6 @@ permissions: env: RESOURCE_GROUP: ewu-deliverybotsystem-rg APP_SERVICE_NAME: WA-DeliveryBot-Admin-dev - TFSTATE_STORAGE_ACCOUNT: dbstfstate01 - TFSTATE_CONTAINER: tfstate BOTNET_API_URL: https://ewu-deliverybotsystem-api.mangocoast-332176b0.westus2.azurecontainerapps.io SIMULATOR_API_URL: https://deliverybot-robot-simulator.mangocoast-332176b0.westus2.azurecontainerapps.io ORDER_SERVICE_URL: https://deliverybot-order-service.mangocoast-332176b0.westus2.azurecontainerapps.io @@ -39,7 +35,7 @@ env: ENTRA_ADMIN_GROUP_ID: "" jobs: - provision-and-deploy: + build-and-deploy: runs-on: ubuntu-latest steps: @@ -54,43 +50,7 @@ jobs: tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - # ── 2. Ensure the Terraform state container exists ──────────────────── - # `az storage container create` is idempotent; safe to run every time. - - name: Ensure TF state container exists - run: | - az storage container create \ - --name "$TFSTATE_CONTAINER" \ - --account-name "$TFSTATE_STORAGE_ACCOUNT" \ - --auth-mode login \ - --only-show-errors - - # ── 3. Provision App Service via Terraform ──────────────────────────── - - name: Setup Terraform - uses: hashicorp/setup-terraform@v3 - with: - terraform_version: "1.9.5" - - - name: Terraform Init - working-directory: ./Iac/admin-webapp - env: - ARM_USE_OIDC: "true" - ARM_USE_AZUREAD: "true" - ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - run: terraform init -input=false - - - name: Terraform Apply - working-directory: ./Iac/admin-webapp - env: - ARM_USE_OIDC: "true" - ARM_USE_AZUREAD: "true" - ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - run: terraform apply -input=false -auto-approve - - # ── 4. Build the SPA with upstream URLs baked in ────────────────────── + # ── 2. Build the SPA with upstream URLs baked in ────────────────────── - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -117,7 +77,7 @@ jobs: VITE_ENTRA_ADMIN_GROUP_ID: ${{ env.ENTRA_ADMIN_GROUP_ID }} run: npm run build - # ── 5. Deploy the build to the App Service ──────────────────────────── + # ── 3. Deploy the build to the App Service ───────────────────────────── - name: Deploy to Azure App Service uses: azure/webapps-deploy@v3 with: From 629ad6f8bc56258f20a9624e4ad06ed124fe9dd9 Mon Sep 17 00:00:00 2001 From: carsonlayden Date: Tue, 2 Jun 2026 15:21:41 -0700 Subject: [PATCH 6/7] Enable Entra staff sign-in with the app registration values. Closes #54 --- .github/workflows/AdminWebpage-Deploy-WF.yml | 6 +++--- admin-webapp/.env.example | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/AdminWebpage-Deploy-WF.yml b/.github/workflows/AdminWebpage-Deploy-WF.yml index 7fdb2ab..fbddd2a 100644 --- a/.github/workflows/AdminWebpage-Deploy-WF.yml +++ b/.github/workflows/AdminWebpage-Deploy-WF.yml @@ -30,9 +30,9 @@ env: # Entra ID staff sign-in (issue #54). Blank → auth disabled (app runs open). # Fill these in from the app registration to switch sign-in on, then push. # Client/tenant/group IDs are not secrets (a public SPA exposes them anyway). - ENTRA_CLIENT_ID: "" - ENTRA_TENANT_ID: "" - ENTRA_ADMIN_GROUP_ID: "" + ENTRA_CLIENT_ID: "b5a029c3-d046-4005-9497-23ba18df70b2" + ENTRA_TENANT_ID: "37321907-14a5-4390-987d-ec0c66c655cd" + ENTRA_ADMIN_GROUP_ID: "14fcd995-e89f-4020-b5ff-4a9b48a5824e" jobs: build-and-deploy: diff --git a/admin-webapp/.env.example b/admin-webapp/.env.example index 5be7645..b15d4ec 100644 --- a/admin-webapp/.env.example +++ b/admin-webapp/.env.example @@ -17,7 +17,7 @@ VITE_ORDER_SERVICE_URL=https://deliverybot-order-service.mangocoast-332176b0.wes # Entra ID staff sign-in (issue #54). Leave blank to run with auth disabled. # Fill from the "DeliveryBot Admin App" registration to enable sign-in. -VITE_ENTRA_CLIENT_ID= -VITE_ENTRA_TENANT_ID= +VITE_ENTRA_CLIENT_ID=b5a029c3-d046-4005-9497-23ba18df70b2 +VITE_ENTRA_TENANT_ID=37321907-14a5-4390-987d-ec0c66c655cd # Object ID of the DeliveryBot-Admin security group (gates who can sign in). -VITE_ENTRA_ADMIN_GROUP_ID= +VITE_ENTRA_ADMIN_GROUP_ID=14fcd995-e89f-4020-b5ff-4a9b48a5824e From 96bb43be6fb1f96007bdae9ce82a62a168d87882 Mon Sep 17 00:00:00 2001 From: carsonlayden Date: Tue, 2 Jun 2026 16:07:17 -0700 Subject: [PATCH 7/7] Fix Entra sign-in redirect loop with MsalAuthenticationTemplate; make env-derived tests deterministic --- admin-webapp/src/api/orders.test.js | 18 +++++++-- admin-webapp/src/auth/AuthGate.jsx | 48 +++++++++++++++--------- admin-webapp/src/auth/authConfig.test.js | 30 +++++++++++---- admin-webapp/src/main.jsx | 22 +++++++---- 4 files changed, 80 insertions(+), 38 deletions(-) diff --git a/admin-webapp/src/api/orders.test.js b/admin-webapp/src/api/orders.test.js index 0b8c175..65f3d79 100644 --- a/admin-webapp/src/api/orders.test.js +++ b/admin-webapp/src/api/orders.test.js @@ -1,12 +1,22 @@ -import { describe, it, expect } from 'vitest' -import { listOrders, ORDER_STATUSES, apiConfig } from './orders.js' +import { describe, it, expect, vi, afterEach } from 'vitest' +import { ORDER_STATUSES } from './orders.js' describe('orders client (issue #53)', () => { + afterEach(() => { + vi.unstubAllEnvs() + vi.resetModules() + }) + it('falls back to mock data when no Order Service URL is configured', async () => { - expect(apiConfig.configured).toBe(false) + // Stub the env empty and re-import so the result is deterministic even when + // a local .env.local supplies a real URL. + vi.resetModules() + vi.stubEnv('VITE_ORDER_SERVICE_URL', '') + const mod = await import('./orders.js?nocache=' + Date.now()) - const { data, source } = await listOrders() + expect(mod.apiConfig.configured).toBe(false) + const { data, source } = await mod.listOrders() expect(source).toBe('mock') expect(Array.isArray(data)).toBe(true) expect(data.length).toBeGreaterThan(0) diff --git a/admin-webapp/src/auth/AuthGate.jsx b/admin-webapp/src/auth/AuthGate.jsx index 84cd004..197353e 100644 --- a/admin-webapp/src/auth/AuthGate.jsx +++ b/admin-webapp/src/auth/AuthGate.jsx @@ -1,29 +1,28 @@ -import { useEffect } from 'react' -import { useMsal, useIsAuthenticated } from '@azure/msal-react' +import { MsalAuthenticationTemplate, useMsal } from '@azure/msal-react' +import { InteractionType } from '@azure/msal-browser' import { authEnabled, loginRequest, ADMIN_GROUP_ID } from './authConfig.js' // Wraps the app. When auth is disabled, renders children as-is. When enabled, -// requires an interactive sign-in and (if a group is configured) membership in -// the DeliveryBot-Admin group before showing the app. +// MsalAuthenticationTemplate drives an interactive redirect sign-in (it manages +// the in-progress state, so it won't loop), then GroupGate enforces membership +// in the DeliveryBot-Admin group before showing the app. export default function AuthGate({ children }) { if (!authEnabled) return children - return {children} + + return ( + + {children} + + ) } -function GatedContent({ children }) { +function GroupGate({ children }) { const { instance, accounts } = useMsal() - const isAuthenticated = useIsAuthenticated() - - useEffect(() => { - if (!isAuthenticated) { - instance.loginRedirect(loginRequest).catch((err) => console.error(err)) - } - }, [isAuthenticated, instance]) - - if (!isAuthenticated) { - return - } - const account = accounts[0] const groups = account?.idTokenClaims?.groups ?? [] const inAdminGroup = !ADMIN_GROUP_ID || groups.includes(ADMIN_GROUP_ID) @@ -41,6 +40,19 @@ function GatedContent({ children }) { return children } +function Loading() { + return +} + +function ErrorScreen({ error }) { + return ( + + ) +} + function Centered({ title, body, onSignOut }) { return (
diff --git a/admin-webapp/src/auth/authConfig.test.js b/admin-webapp/src/auth/authConfig.test.js index f19b6ac..df38d32 100644 --- a/admin-webapp/src/auth/authConfig.test.js +++ b/admin-webapp/src/auth/authConfig.test.js @@ -1,12 +1,26 @@ -import { describe, it, expect } from 'vitest' -import { authEnabled, ADMIN_GROUP_ID } from './authConfig.js' +import { describe, it, expect, vi, afterEach } from 'vitest' +// authEnabled is derived from env, so stub the env and re-import for a +// deterministic result regardless of any local .env.local. describe('authConfig (issue #54)', () => { - it('disables auth when the Entra env vars are blank', () => { - // The scaffold ships with blank VITE_ENTRA_* so the app runs open until an - // app registration exists. This guards against accidentally shipping a - // half-configured auth setup that locks everyone out. - expect(authEnabled).toBe(false) - expect(ADMIN_GROUP_ID).toBe('') + afterEach(() => { + vi.unstubAllEnvs() + vi.resetModules() + }) + + it('disables auth when the Entra env vars are blank', async () => { + vi.resetModules() + vi.stubEnv('VITE_ENTRA_CLIENT_ID', '') + vi.stubEnv('VITE_ENTRA_TENANT_ID', '') + const mod = await import('./authConfig.js?nocache=' + Date.now()) + expect(mod.authEnabled).toBe(false) + }) + + it('enables auth when both client and tenant IDs are set', async () => { + vi.resetModules() + vi.stubEnv('VITE_ENTRA_CLIENT_ID', 'test-client-id') + vi.stubEnv('VITE_ENTRA_TENANT_ID', 'test-tenant-id') + const mod = await import('./authConfig.js?nocache=' + Date.now()) + expect(mod.authEnabled).toBe(true) }) }) diff --git a/admin-webapp/src/main.jsx b/admin-webapp/src/main.jsx index fc0196e..8b68125 100644 --- a/admin-webapp/src/main.jsx +++ b/admin-webapp/src/main.jsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { MsalProvider } from '@azure/msal-react' +import { EventType } from '@azure/msal-browser' import './index.css' import App from './App.jsx' import { authEnabled } from './auth/authConfig.js' @@ -23,17 +24,22 @@ function render() { } if (authEnabled && msalInstance) { - // MSAL v5 requires initialize() before any auth call; handle a returning - // redirect before first render so the account is available immediately. + // MSAL v5 requires initialize() before use. MsalProvider itself handles the + // returning redirect response — we must NOT also call handleRedirectPromise + // here, or the two race and the sign-in loops. We just keep the active + // account in sync from the cache and from successful logins. msalInstance .initialize() - .then(() => msalInstance.handleRedirectPromise()) - .then((result) => { - if (result?.account) msalInstance.setActiveAccount(result.account) - const existing = msalInstance.getAllAccounts() - if (!msalInstance.getActiveAccount() && existing.length > 0) { - msalInstance.setActiveAccount(existing[0]) + .then(() => { + const accounts = msalInstance.getAllAccounts() + if (accounts.length > 0) { + msalInstance.setActiveAccount(accounts[0]) } + msalInstance.addEventCallback((event) => { + if (event.eventType === EventType.LOGIN_SUCCESS && event.payload?.account) { + msalInstance.setActiveAccount(event.payload.account) + } + }) render() }) .catch((err) => {