From 9435a4c3fdf9e571979b5bedb123ae7eaa769bfd Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 17 Apr 2024 01:52:14 +0200 Subject: [PATCH 1/5] [PUI] Pricing UX improvements (#7053) * Only render categories in overview if there is data Red #7025 * add option to disable accordions * remove unneeded log * make optional * add disabled state to panels * add missing panels to overview * use enum for refs * add quickjump anchors * use navigation function instaed * make links more distinguishable * fix type * format ticks using currency * add tooltip formatter --- .../components/charts/tooltipFormatter.tsx | 9 +++ .../src/pages/part/PartPricingPanel.tsx | 65 ++++++++++++++--- .../pages/part/pricing/BomPricingPanel.tsx | 35 ++++++--- .../pages/part/pricing/PriceBreakPanel.tsx | 24 ++++++- .../part/pricing/PricingOverviewPanel.tsx | 71 +++++++++++++++---- .../src/pages/part/pricing/PricingPanel.tsx | 46 +++++++++--- .../part/pricing/PurchaseHistoryPanel.tsx | 20 +++++- .../pages/part/pricing/SaleHistoryPanel.tsx | 20 +++++- .../part/pricing/SupplierPricingPanel.tsx | 21 +++++- .../part/pricing/VariantPricingPanel.tsx | 15 +++- 10 files changed, 274 insertions(+), 52 deletions(-) create mode 100644 src/frontend/src/components/charts/tooltipFormatter.tsx diff --git a/src/frontend/src/components/charts/tooltipFormatter.tsx b/src/frontend/src/components/charts/tooltipFormatter.tsx new file mode 100644 index 0000000000..73b3b97703 --- /dev/null +++ b/src/frontend/src/components/charts/tooltipFormatter.tsx @@ -0,0 +1,9 @@ +import { formatCurrency } from '../../defaults/formatters'; + +export function tooltipFormatter(label: any, currency: string) { + return ( + formatCurrency(label, { + currency: currency + })?.toString() ?? '' + ); +} diff --git a/src/frontend/src/pages/part/PartPricingPanel.tsx b/src/frontend/src/pages/part/PartPricingPanel.tsx index 0f570e681e..44e8439405 100644 --- a/src/frontend/src/pages/part/PartPricingPanel.tsx +++ b/src/frontend/src/pages/part/PartPricingPanel.tsx @@ -1,6 +1,6 @@ import { t } from '@lingui/macro'; import { Accordion, Alert, LoadingOverlay, Stack, Text } from '@mantine/core'; -import { ReactNode, useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { UserRoles } from '../../enums/Roles'; @@ -15,6 +15,19 @@ import SaleHistoryPanel from './pricing/SaleHistoryPanel'; import SupplierPricingPanel from './pricing/SupplierPricingPanel'; import VariantPricingPanel from './pricing/VariantPricingPanel'; +export enum panelOptions { + overview = 'overview', + purchase = 'purchase', + internal = 'internal', + supplier = 'supplier', + bom = 'bom', + variant = 'variant', + sale_pricing = 'sale-pricing', + sale_history = 'sale-history', + override = 'override', + overall = 'overall' +} + export default function PartPricingPanel({ part }: { part: any }) { const user = useUserState(); @@ -40,6 +53,17 @@ export default function PartPricingPanel({ part }: { part: any }) { return user.hasViewRole(UserRoles.sales_order) && part?.salable; }, [user, part]); + const [value, setValue] = useState([panelOptions.overview]); + function doNavigation(panel: panelOptions) { + if (!value.includes(panel)) { + setValue([...value, panel]); + } + const element = document.getElementById(panel); + if (element) { + element.scrollIntoView(); + } + } + return ( @@ -49,18 +73,27 @@ export default function PartPricingPanel({ part }: { part: any }) { )} {pricing && ( - + } - label="overview" + content={ + + } + label={panelOptions.overview} title={t`Pricing Overview`} visible={true} /> } - label="purchase" + label={panelOptions.purchase} title={t`Purchase History`} visible={purchaseOrderPricing} + disabled={ + !pricing?.purchase_cost_min || !pricing?.purchase_cost_max + } /> } - label="internal" + label={panelOptions.internal} title={t`Internal Pricing`} visible={internalPricing} + disabled={ + !pricing?.internal_cost_min || !pricing?.internal_cost_max + } /> } - label="supplier" + label={panelOptions.supplier} title={t`Supplier Pricing`} visible={purchaseOrderPricing} + disabled={ + !pricing?.supplier_price_min || !pricing?.supplier_price_max + } /> } - label="bom" + label={panelOptions.bom} title={t`BOM Pricing`} visible={part?.assembly} + disabled={!pricing?.bom_cost_min || !pricing?.bom_cost_max} /> } - label="variant" + label={panelOptions.variant} title={t`Variant Pricing`} visible={part?.is_template} + disabled={!pricing?.variant_cost_min || !pricing?.variant_cost_max} /> } - label="sale-pricing" + label={panelOptions.sale_pricing} title={t`Sale Pricing`} visible={salesOrderPricing} + disabled={!pricing?.sale_price_min || !pricing?.sale_price_max} /> } - label="sale-history" + label={panelOptions.sale_history} title={t`Sale History`} visible={salesOrderPricing} + disabled={!pricing?.sale_history_min || !pricing?.sale_history_max} /> )} diff --git a/src/frontend/src/pages/part/pricing/BomPricingPanel.tsx b/src/frontend/src/pages/part/pricing/BomPricingPanel.tsx index 98a561d6c0..b8228b0334 100644 --- a/src/frontend/src/pages/part/pricing/BomPricingPanel.tsx +++ b/src/frontend/src/pages/part/pricing/BomPricingPanel.tsx @@ -21,7 +21,12 @@ import { } from 'recharts'; import { CHART_COLORS } from '../../../components/charts/colors'; -import { formatDecimal, formatPriceRange } from '../../../defaults/formatters'; +import { tooltipFormatter } from '../../../components/charts/tooltipFormatter'; +import { + formatCurrency, + formatDecimal, + formatPriceRange +} from '../../../defaults/formatters'; import { ApiEndpoints } from '../../../enums/ApiEndpoints'; import { ModelType } from '../../../enums/ModelType'; import { useTable } from '../../../hooks/UseTable'; @@ -32,7 +37,7 @@ import { InvenTreeTable } from '../../../tables/InvenTreeTable'; import { NoPricingData } from './PricingPanel'; // Display BOM data as a pie chart -function BomPieChart({ data }: { data: any[] }) { +function BomPieChart({ data, currency }: { data: any[]; currency: string }) { return ( @@ -64,20 +69,30 @@ function BomPieChart({ data }: { data: any[] }) { /> ))} - + tooltipFormatter(label, currency)} + /> ); } // Display BOM data as a bar chart -function BomBarChart({ data }: { data: any[] }) { +function BomBarChart({ data, currency }: { data: any[]; currency: string }) { return ( - - + + formatCurrency(value, { + currency: currency + })?.toString() ?? '' + } + /> + tooltipFormatter(label, currency)} + /> {bomPricingData.length > 0 ? ( - {chartType == 'bar' && } - {chartType == 'pie' && } + {chartType == 'bar' && ( + + )} + {chartType == 'pie' && ( + + )} { + if (table.records.length === 0) { + return ''; + } + return table.records[0].currency; + }, [table.records]); + return ( <> {newPriceBreak.modal} @@ -166,8 +174,18 @@ export default function PriceBreakPanel({ - - + + formatCurrency(value, { + currency: currency + })?.toString() ?? '' + } + /> + + tooltipFormatter(label, currency) + } + /> void; }): ReactNode { const columns: DataTableColumn[] = useMemo(() => { return [ @@ -47,10 +59,17 @@ export default function PricingOverviewPanel({ accessor: 'title', title: t`Pricing Category`, render: (record: PricingOverviewEntry) => { + const is_link = record.name !== panelOptions.overall; return ( {record.icon} - {record.title} + {is_link ? ( + doNavigation(record.name)}> + {record.title} + + ) : ( + {record.title} + )} ); } @@ -86,56 +105,70 @@ export default function PricingOverviewPanel({ const overviewData: PricingOverviewEntry[] = useMemo(() => { return [ { - name: 'internal', + name: panelOptions.internal, title: t`Internal Pricing`, icon: , min_value: pricing?.internal_cost_min, max_value: pricing?.internal_cost_max }, { - name: 'bom', + name: panelOptions.bom, title: t`BOM Pricing`, icon: , min_value: pricing?.bom_cost_min, max_value: pricing?.bom_cost_max }, { - name: 'purchase', + name: panelOptions.purchase, title: t`Purchase Pricing`, icon: , min_value: pricing?.purchase_cost_min, max_value: pricing?.purchase_cost_max }, { - name: 'supplier', + name: panelOptions.supplier, title: t`Supplier Pricing`, icon: , min_value: pricing?.supplier_price_min, max_value: pricing?.supplier_price_max }, { - name: 'variants', + name: panelOptions.variant, title: t`Variant Pricing`, icon: , min_value: pricing?.variant_cost_min, max_value: pricing?.variant_cost_max }, { - name: 'override', + name: panelOptions.sale_pricing, + title: t`Sale Pricing`, + icon: , + min_value: pricing?.sale_price_min, + max_value: pricing?.sale_price_max + }, + { + name: panelOptions.sale_history, + title: t`Sale History`, + icon: , + min_value: pricing?.sale_history_min, + max_value: pricing?.sale_history_max + }, + { + name: panelOptions.override, title: t`Override Pricing`, icon: , min_value: pricing?.override_min, max_value: pricing?.override_max }, { - name: 'overall', + name: panelOptions.overall, title: t`Overall Pricing`, icon: , min_value: pricing?.overall_min, max_value: pricing?.overall_max } ].filter((entry) => { - return entry.min_value !== null || entry.max_value !== null; + return !(entry.min_value == null || entry.max_value == null); }); }, [part, pricing]); @@ -158,8 +191,18 @@ export default function PricingOverviewPanel({ - - + + formatCurrency(value, { + currency: pricing?.currency + })?.toString() ?? '' + } + /> + + tooltipFormatter(label, pricing?.currency) + } + /> + {props.disabled && ( + } + /> + )} + + + ); +} export default function PricingPanel({ content, label, title, - visible + visible, + disabled = undefined }: { content: ReactNode; - label: string; + label: panelOptions; title: string; visible: boolean; + disabled?: boolean | undefined; }): ReactNode { + const is_disabled = disabled === undefined ? false : disabled; return ( visible && ( - - + + {title} - - {content} + + {!is_disabled && content} ) ); diff --git a/src/frontend/src/pages/part/pricing/PurchaseHistoryPanel.tsx b/src/frontend/src/pages/part/pricing/PurchaseHistoryPanel.tsx index e71854efa9..ae88a6d7c4 100644 --- a/src/frontend/src/pages/part/pricing/PurchaseHistoryPanel.tsx +++ b/src/frontend/src/pages/part/pricing/PurchaseHistoryPanel.tsx @@ -12,6 +12,7 @@ import { } from 'recharts'; import { CHART_COLORS } from '../../../components/charts/colors'; +import { tooltipFormatter } from '../../../components/charts/tooltipFormatter'; import { formatCurrency, renderDate } from '../../../defaults/formatters'; import { ApiEndpoints } from '../../../enums/ApiEndpoints'; import { useTable } from '../../../hooks/UseTable'; @@ -95,6 +96,13 @@ export default function PurchaseHistoryPanel({ ]; }, []); + const currency: string = useMemo(() => { + if (table.records.length === 0) { + return ''; + } + return table.records[0].purchase_price_currency; + }, [table.records]); + const purchaseHistoryData = useMemo(() => { return table.records.map((record: any) => { return { @@ -126,8 +134,16 @@ export default function PurchaseHistoryPanel({ - - + + formatCurrency(value, { + currency: currency + })?.toString() ?? '' + } + /> + tooltipFormatter(label, currency)} + /> { + if (table.records.length === 0) { + return ''; + } + return table.records[0].sale_price_currency; + }, [table.records]); + const saleHistoryData = useMemo(() => { return table.records.map((record: any) => { return { @@ -90,8 +98,16 @@ export default function SaleHistoryPanel({ part }: { part: any }): ReactNode { - - + + formatCurrency(value, { + currency: currency + })?.toString() ?? '' + } + /> + tooltipFormatter(label, currency)} + /> { + if (table.records.length === 0) { + return ''; + } + return table.records[0].currency; + }, [table.records]); + const supplierPricingData = useMemo(() => { return table.records.map((record: any) => { return { @@ -58,8 +67,16 @@ export default function SupplierPricingPanel({ part }: { part: any }) { - - + + formatCurrency(value, { + currency: currency + })?.toString() ?? '' + } + /> + tooltipFormatter(label, currency)} + /> - - + + formatCurrency(value, { + currency: pricing?.currency + })?.toString() ?? '' + } + /> + + tooltipFormatter(label, pricing?.currency) + } + /> Date: Wed, 17 Apr 2024 08:21:19 +0200 Subject: [PATCH 2/5] [PUI] Add quick login via url (#7022) * add login with URL params * use login function for faster tests * reduce timeout possiblities in job * remove unused imports * remove debug --- src/frontend/src/pages/Auth/Login.tsx | 15 ++++- src/frontend/tests/defaults.ts | 5 ++ src/frontend/tests/pui_command.spec.ts | 27 +++----- src/frontend/tests/pui_general.spec.ts | 89 ++++++++++---------------- src/frontend/tests/pui_stock.spec.ts | 33 ++++------ 5 files changed, 75 insertions(+), 94 deletions(-) diff --git a/src/frontend/src/pages/Auth/Login.tsx b/src/frontend/src/pages/Auth/Login.tsx index 12f2fcf2b5..77e2b541e5 100644 --- a/src/frontend/src/pages/Auth/Login.tsx +++ b/src/frontend/src/pages/Auth/Login.tsx @@ -2,7 +2,7 @@ import { Trans, t } from '@lingui/macro'; import { Center, Container, Paper, Text } from '@mantine/core'; import { useDisclosure, useToggle } from '@mantine/hooks'; import { useEffect } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { setApiDefaults } from '../../App'; import { AuthFormOptions } from '../../components/forms/AuthFormOptions'; @@ -13,7 +13,7 @@ import { } from '../../components/forms/AuthenticationForm'; import { InstanceOptions } from '../../components/forms/InstanceOptions'; import { defaultHostKey } from '../../defaults/defaultHostList'; -import { checkLoginState } from '../../functions/auth'; +import { checkLoginState, doBasicLogin } from '../../functions/auth'; import { useServerApiState } from '../../states/ApiState'; import { useLocalState } from '../../states/LocalState'; @@ -33,6 +33,7 @@ export default function Login() { const [loginMode, setMode] = useDisclosure(true); const navigate = useNavigate(); const location = useLocation(); + const [searchParams] = useSearchParams(); // Data manipulation functions function ChangeHost(newHost: string): void { @@ -48,6 +49,16 @@ export default function Login() { } checkLoginState(navigate, location?.state?.redirectFrom, true); + + // check if we got login params (login and password) + if (searchParams.has('login') && searchParams.has('password')) { + doBasicLogin( + searchParams.get('login') ?? '', + searchParams.get('password') ?? '' + ).then(() => { + navigate(location?.state?.redirectFrom ?? '/home'); + }); + } }, []); // Fetch server data on mount if no server data is present diff --git a/src/frontend/tests/defaults.ts b/src/frontend/tests/defaults.ts index 40a0b0a209..5f6bf71a5a 100644 --- a/src/frontend/tests/defaults.ts +++ b/src/frontend/tests/defaults.ts @@ -4,3 +4,8 @@ export const user = { username: 'allaccess', password: 'nolimits' }; + +export const adminuser = { + username: 'admin', + password: 'inventree' +}; diff --git a/src/frontend/tests/pui_command.spec.ts b/src/frontend/tests/pui_command.spec.ts index a50aa96cc5..520542d99d 100644 --- a/src/frontend/tests/pui_command.spec.ts +++ b/src/frontend/tests/pui_command.spec.ts @@ -2,13 +2,10 @@ import { expect, systemKey, test } from './baseFixtures.js'; import { user } from './defaults.js'; test('PUI - Quick Command', async ({ page }) => { - await page.goto('./platform/'); - await expect(page).toHaveTitle('InvenTree'); - await page.waitForURL('**/platform/'); - await page.getByLabel('username').fill(user.username); - await page.getByLabel('password').fill(user.password); - await page.getByRole('button', { name: 'Log in' }).click(); - await page.waitForURL('**/platform'); + await page.goto( + `./platform/login/?login=${user.username}&password=${user.password}` + ); + await page.waitForURL('**/platform/*'); await page.goto('./platform/'); await expect(page).toHaveTitle('InvenTree'); @@ -48,18 +45,14 @@ test('PUI - Quick Command', async ({ page }) => { }); test('PUI - Quick Command - no keys', async ({ page }) => { + await page.goto( + `./platform/login/?login=${user.username}&password=${user.password}` + ); + await page.waitForURL('**/platform/*'); await page.goto('./platform/'); - await expect(page).toHaveTitle('InvenTree'); - await page.waitForURL('**/platform/'); - await page.getByLabel('username').fill(user.username); - await page.getByLabel('password').fill(user.password); - await page.getByRole('button', { name: 'Log in' }).click(); - await page.waitForURL('**/platform'); - await expect(page).toHaveTitle('InvenTree'); - await page.waitForURL('**/platform'); - // wait for the page to load - 0.5s - await page.waitForTimeout(500); + // wait for the page to load + await page.waitForTimeout(200); // Open Spotlight with Button await page.getByRole('button', { name: 'Open spotlight' }).click(); diff --git a/src/frontend/tests/pui_general.spec.ts b/src/frontend/tests/pui_general.spec.ts index 75d9345690..55896a11c3 100644 --- a/src/frontend/tests/pui_general.spec.ts +++ b/src/frontend/tests/pui_general.spec.ts @@ -1,14 +1,11 @@ -import { expect, test } from './baseFixtures.js'; -import { user } from './defaults.js'; +import { test } from './baseFixtures.js'; +import { adminuser, user } from './defaults.js'; test('PUI - Parts', async ({ page }) => { - await page.goto('./platform/'); - await expect(page).toHaveTitle('InvenTree'); - await page.waitForURL('**/platform/'); - await page.getByLabel('username').fill(user.username); - await page.getByLabel('password').fill(user.password); - await page.getByRole('button', { name: 'Log in' }).click(); - await page.waitForURL('**/platform'); + await page.goto( + `./platform/login/?login=${user.username}&password=${user.password}` + ); + await page.waitForURL('**/platform/*'); await page.goto('./platform/home'); await page.getByRole('tab', { name: 'Parts' }).click(); @@ -39,13 +36,10 @@ test('PUI - Parts', async ({ page }) => { }); test('PUI - Parts - Manufacturer Parts', async ({ page }) => { - await page.goto('./platform/'); - await expect(page).toHaveTitle('InvenTree'); - await page.waitForURL('**/platform/'); - await page.getByLabel('username').fill(user.username); - await page.getByLabel('password').fill(user.password); - await page.getByRole('button', { name: 'Log in' }).click(); - await page.waitForURL('**/platform'); + await page.goto( + `./platform/login/?login=${user.username}&password=${user.password}` + ); + await page.waitForURL('**/platform/*'); await page.goto('./platform/part/84/manufacturers'); await page.getByRole('tab', { name: 'Manufacturers' }).click(); @@ -57,13 +51,10 @@ test('PUI - Parts - Manufacturer Parts', async ({ page }) => { }); test('PUI - Parts - Supplier Parts', async ({ page }) => { - await page.goto('./platform/'); - await expect(page).toHaveTitle('InvenTree'); - await page.waitForURL('**/platform/'); - await page.getByLabel('username').fill(user.username); - await page.getByLabel('password').fill(user.password); - await page.getByRole('button', { name: 'Log in' }).click(); - await page.waitForURL('**/platform'); + await page.goto( + `./platform/login/?login=${user.username}&password=${user.password}` + ); + await page.waitForURL('**/platform/*'); await page.goto('./platform/part/15/suppliers'); await page.getByRole('tab', { name: 'Suppliers' }).click(); @@ -75,13 +66,10 @@ test('PUI - Parts - Supplier Parts', async ({ page }) => { }); test('PUI - Sales', async ({ page }) => { - await page.goto('./platform/'); - await expect(page).toHaveTitle('InvenTree'); - await page.waitForURL('**/platform/'); - await page.getByLabel('username').fill(user.username); - await page.getByLabel('password').fill(user.password); - await page.getByRole('button', { name: 'Log in' }).click(); - await page.waitForURL('**/platform'); + await page.goto( + `./platform/login/?login=${user.username}&password=${user.password}` + ); + await page.waitForURL('**/platform/*'); await page.goto('./platform/sales/'); await page.waitForURL('**/platform/sales/**'); @@ -131,13 +119,11 @@ test('PUI - Sales', async ({ page }) => { }); test('PUI - Scanning', async ({ page }) => { + await page.goto( + `./platform/login/?login=${user.username}&password=${user.password}` + ); + await page.waitForURL('**/platform/*'); await page.goto('./platform/'); - await expect(page).toHaveTitle('InvenTree'); - await page.waitForURL('**/platform/'); - await page.getByLabel('username').fill(user.username); - await page.getByLabel('password').fill(user.password); - await page.getByRole('button', { name: 'Log in' }).click(); - await page.waitForURL('**/platform'); await page.getByLabel('Homenav').click(); await page.getByRole('button', { name: 'System Information' }).click(); @@ -158,13 +144,11 @@ test('PUI - Scanning', async ({ page }) => { }); test('PUI - Admin', async ({ page }) => { - await page.goto('./platform/'); - await expect(page).toHaveTitle('InvenTree'); + await page.goto( + `./platform/login/?login=${adminuser.username}&password=${adminuser.password}` + ); await page.waitForURL('**/platform/*'); - await page.getByLabel('username').fill('admin'); - await page.getByLabel('password').fill('inventree'); - await page.getByRole('button', { name: 'Log in' }).click(); - await page.waitForURL('**/platform'); + await page.goto('./platform/'); // User settings await page.getByRole('button', { name: 'admin' }).click(); @@ -213,13 +197,11 @@ test('PUI - Admin', async ({ page }) => { }); test('PUI - Language / Color', async ({ page }) => { - await page.goto('./platform/'); - await expect(page).toHaveTitle('InvenTree'); + await page.goto( + `./platform/login/?login=${user.username}&password=${user.password}` + ); await page.waitForURL('**/platform/*'); - await page.getByLabel('username').fill(user.username); - await page.getByLabel('password').fill(user.password); - await page.getByRole('button', { name: 'Log in' }).click(); - await page.waitForURL('**/platform'); + await page.goto('./platform/'); await page.getByRole('button', { name: 'Ally Access' }).click(); await page.getByRole('menuitem', { name: 'Logout' }).click(); @@ -253,13 +235,10 @@ test('PUI - Language / Color', async ({ page }) => { }); test('PUI - Company', async ({ page }) => { - await page.goto('./platform/'); - await expect(page).toHaveTitle('InvenTree'); - await page.waitForURL('**/platform/'); - await page.getByLabel('username').fill(user.username); - await page.getByLabel('password').fill(user.password); - await page.getByRole('button', { name: 'Log in' }).click(); - await page.waitForURL('**/platform'); + await page.goto( + `./platform/login/?login=${user.username}&password=${user.password}` + ); + await page.waitForURL('**/platform/*'); await page.goto('./platform/company/1/details'); await page diff --git a/src/frontend/tests/pui_stock.spec.ts b/src/frontend/tests/pui_stock.spec.ts index b4f7e9ec14..dd70f442c4 100644 --- a/src/frontend/tests/pui_stock.spec.ts +++ b/src/frontend/tests/pui_stock.spec.ts @@ -1,14 +1,11 @@ -import { expect, test } from './baseFixtures.js'; +import { test } from './baseFixtures.js'; import { user } from './defaults.js'; test('PUI - Stock', async ({ page }) => { - await page.goto('./platform/'); - await expect(page).toHaveTitle('InvenTree'); - await page.waitForURL('**/platform/'); - await page.getByLabel('username').fill(user.username); - await page.getByLabel('password').fill(user.password); - await page.getByRole('button', { name: 'Log in' }).click(); - await page.waitForURL('**/platform'); + await page.goto( + `./platform/login/?login=${user.username}&password=${user.password}` + ); + await page.waitForURL('**/platform/*'); await page.goto('./platform/stock'); await page.waitForURL('**/platform/stock/location/index/details'); @@ -24,13 +21,11 @@ test('PUI - Stock', async ({ page }) => { }); test('PUI - Build', async ({ page }) => { + await page.goto( + `./platform/login/?login=${user.username}&password=${user.password}` + ); + await page.waitForURL('**/platform/*'); await page.goto('./platform/'); - await expect(page).toHaveTitle('InvenTree'); - await page.waitForURL('**/platform/'); - await page.getByLabel('username').fill(user.username); - await page.getByLabel('password').fill(user.password); - await page.getByRole('button', { name: 'Log in' }).click(); - await page.waitForURL('**/platform'); await page.getByRole('tab', { name: 'Build' }).click(); await page.getByText('Widget Assembly Variant').click(); @@ -44,13 +39,11 @@ test('PUI - Build', async ({ page }) => { }); test('PUI - Purchasing', async ({ page }) => { + await page.goto( + `./platform/login/?login=${user.username}&password=${user.password}` + ); + await page.waitForURL('**/platform/*'); await page.goto('./platform/'); - await expect(page).toHaveTitle('InvenTree'); - await page.waitForURL('**/platform/'); - await page.getByLabel('username').fill(user.username); - await page.getByLabel('password').fill(user.password); - await page.getByRole('button', { name: 'Log in' }).click(); - await page.waitForURL('**/platform'); await page.getByRole('tab', { name: 'Purchasing' }).click(); await page.getByRole('cell', { name: 'PO0012' }).click(); From df5fcf7d62e45f6bd1e527c445c7887a0e86863d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Apr 2024 16:39:18 +1000 Subject: [PATCH 3/5] Bump gunicorn from 21.2.0 to 22.0.0 in /src/backend (#7054) * Bump gunicorn from 21.2.0 to 22.0.0 in /src/backend Bumps [gunicorn](https://github.com/benoitc/gunicorn) from 21.2.0 to 22.0.0. - [Release notes](https://github.com/benoitc/gunicorn/releases) - [Commits](https://github.com/benoitc/gunicorn/compare/21.2.0...22.0.0) --- updated-dependencies: - dependency-name: gunicorn dependency-type: direct:production ... Signed-off-by: dependabot[bot] * req fix * bump versions everywhere --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Matthias Mair --- contrib/container/requirements.txt | 2 +- src/backend/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contrib/container/requirements.txt b/contrib/container/requirements.txt index 8450c5251b..47968937a5 100644 --- a/contrib/container/requirements.txt +++ b/contrib/container/requirements.txt @@ -12,7 +12,7 @@ mysqlclient>=2.2.0 mariadb>=1.1.8 # gunicorn web server -gunicorn>=21.2.0 +gunicorn>=22.0.0 # LDAP required packages django-auth-ldap # Django integration for ldap auth diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt index 6703456096..e04b24e685 100644 --- a/src/backend/requirements.txt +++ b/src/backend/requirements.txt @@ -134,7 +134,7 @@ googleapis-common-protos==1.63.0 # opentelemetry-exporter-otlp-proto-http grpcio==1.62.1 # via opentelemetry-exporter-otlp-proto-grpc -gunicorn==21.2.0 +gunicorn==22.0.0 html5lib==1.1 # via weasyprint icalendar==5.0.12 From d24219fec3c8505f1624e117e139c2a4f2d200ff Mon Sep 17 00:00:00 2001 From: Emmanuel Ferdman Date: Wed, 17 Apr 2024 12:42:59 +0300 Subject: [PATCH 4/5] Update broken links (#7019) * Update broken links * Update broken links --- RELEASE.md | 2 +- docs/docs/develop/contributing.md | 2 +- docs/docs/extend/plugins/urls.md | 2 +- docs/docs/report/stock_location.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 44979fa0b2..f4370acbe3 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -4,7 +4,7 @@ Checklist of steps to perform at each code release ### Update Version String -Update `INVENTREE_SW_VERSION` in [version.py](https://github.com/inventree/InvenTree/blob/master/InvenTree/InvenTree/version.py) +Update `INVENTREE_SW_VERSION` in [version.py](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/InvenTree/version.py) ### Increment API Version diff --git a/docs/docs/develop/contributing.md b/docs/docs/develop/contributing.md index 12ed77faf7..06df11fabb 100644 --- a/docs/docs/develop/contributing.md +++ b/docs/docs/develop/contributing.md @@ -96,7 +96,7 @@ The HEAD of the "stable" branch represents the latest stable release code. ## API versioning -The [API version](https://github.com/inventree/InvenTree/blob/master/InvenTree/InvenTree/api_version.py) needs to be bumped every time when the API is changed. +The [API version](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/InvenTree/api_version.py) needs to be bumped every time when the API is changed. ## Environment diff --git a/docs/docs/extend/plugins/urls.md b/docs/docs/extend/plugins/urls.md index efa0ca8f7c..4931904d39 100644 --- a/docs/docs/extend/plugins/urls.md +++ b/docs/docs/extend/plugins/urls.md @@ -65,7 +65,7 @@ Additionally, add the following imports after the extended line. #### Blocks The page_base file is split into multiple sections called blocks. This allows you to implement sections of the webpage while getting many items like navbars, sidebars, and general layout provided for you. -The current default page base can be found [here](https://github.com/inventree/InvenTree/blob/master/InvenTree/templates/page_base.html). Look through this file to determine overridable blocks. The [stock app](https://github.com/inventree/InvenTree/tree/master/InvenTree/stock) offers a great example of implementing these blocks. +The current default page base can be found [here](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/templates/page_base.html). Look through this file to determine overridable blocks. The [stock app](https://github.com/inventree/InvenTree/tree/master/src/backend/InvenTree/stock) offers a great example of implementing these blocks. !!! warning "Sidebar Block" You may notice that implementing the `sidebar` block doesn't initially work. Be sure to enable the sidebar using JavaScript. This can be achieved by appending the following code, replacing `label` with a label of your choosing, to the end of your template file. diff --git a/docs/docs/report/stock_location.md b/docs/docs/report/stock_location.md index 27fb1b729b..e1712c06d9 100644 --- a/docs/docs/report/stock_location.md +++ b/docs/docs/report/stock_location.md @@ -13,4 +13,4 @@ You can use all content variables from the [StockLocation](./context_variables.m A default report template is provided out of the box, which can be used as a starting point for developing custom return order report templates. -View the [source code](https://github.com/inventree/InvenTree/blob/master/InvenTree/report/templates/report/inventree_slr_report.html) for the default stock location report template. +View the [source code](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/report/templates/report/inventree_slr_report.html) for the default stock location report template. From 0ba7f7ece548420584ebc1bf2abf22382e173a5d Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 17 Apr 2024 21:35:20 +1000 Subject: [PATCH 5/5] [PUI] Session authentication (#6970) * Adjust backend cookie settings * Allow CORS requests to /accounts/ * Refactor frontend code - Remove API token functions - Simplify cookie approach - Add isLoggedIn method * Adjust REST_AUTH settings * Cleanup auth functions in auth.tsx * Adjust CSRF_COOKIE_SAMESITE value * Fix login request * Prevent session auth on login view - Existing (invalid) session token causes 403 * Refactor ApiImage - Point to the right host - Simplify code - Now we use session cookies, so it *Just Works* * Fix download for attachment table - Now works with remote host * Cleanup settings.py * Refactor login / logout notifications * Update API version * Update src/frontend/src/components/items/AttachmentLink.tsx Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com> * fix assert url * Remove comment * Add explicit page to logout user * Change tests to first logout * Prune dead code * Adjust tests * Cleanup * Direct to login view * Trying something * Update CUI test * Fix basic tests * Refactoring * Fix basic checks * Fix for PUI command tests * More test updates * Add speciifc test for quick login * More cleanup of playwright tests * Add some missing icons * Fix typo * Ignore coverage report for playwright test * Remove coveralls upload task --------- Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com> Co-authored-by: Matthias Mair --- .github/workflows/qc_checks.yaml | 9 -- .../InvenTree/InvenTree/api_version.py | 8 +- src/backend/InvenTree/InvenTree/settings.py | 18 ++- src/backend/InvenTree/InvenTree/urls.py | 1 + src/backend/InvenTree/users/api.py | 16 ++- src/backend/InvenTree/users/models.py | 11 ++ src/frontend/src/App.tsx | 24 +--- .../components/forms/AuthenticationForm.tsx | 43 +++--- .../src/components/images/ApiImage.tsx | 62 ++------- .../src/components/items/AttachmentLink.tsx | 16 ++- src/frontend/src/components/nav/Layout.tsx | 6 +- src/frontend/src/enums/ApiEndpoints.tsx | 5 +- src/frontend/src/functions/auth.tsx | 122 +++++++----------- src/frontend/src/functions/icons.tsx | 3 + src/frontend/src/functions/notifications.tsx | 26 ++++ src/frontend/src/pages/Auth/Logout.tsx | 34 +++++ src/frontend/src/router.tsx | 2 + src/frontend/src/states/SessionState.tsx | 37 ------ src/frontend/src/states/SettingsState.tsx | 6 +- src/frontend/src/states/StatusState.tsx | 4 +- src/frontend/src/states/UserState.tsx | 6 +- src/frontend/src/states/states.tsx | 4 +- src/frontend/src/views/DesktopAppView.tsx | 9 +- src/frontend/tests/cui.spec.ts | 9 +- src/frontend/tests/defaults.ts | 6 + src/frontend/tests/login.ts | 37 ++++++ src/frontend/tests/pui_basic.spec.ts | 51 +++++--- src/frontend/tests/pui_command.spec.ts | 35 ++--- src/frontend/tests/pui_general.spec.ts | 66 ++++------ src/frontend/tests/pui_stock.spec.ts | 24 +--- 30 files changed, 341 insertions(+), 359 deletions(-) create mode 100644 src/frontend/src/pages/Auth/Logout.tsx delete mode 100644 src/frontend/src/states/SessionState.tsx create mode 100644 src/frontend/tests/login.ts diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 693e0b7539..0f33bbbabe 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -544,15 +544,6 @@ jobs: - name: Report coverage if: always() run: cd src/frontend && npx nyc report --report-dir ./coverage --temp-dir .nyc_output --reporter=lcov --exclude-after-remap false - - name: Upload Coverage Report to Coveralls - if: always() - uses: coverallsapp/github-action@3dfc5567390f6fa9267c0ee9c251e4c8c3f18949 # pin@v2.2.3 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - flag-name: pui - git-commit: ${{ github.sha }} - git-branch: ${{ github.ref }} - parallel: true - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4.3.0 if: always() diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index c6edfc2d96..b0fde97af8 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 187 +INVENTREE_API_VERSION = 188 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ -v187 - 2024-03-10 : https://github.com/inventree/InvenTree/pull/6985 +v188 - 2024-04-16 : https://github.com/inventree/InvenTree/pull/6970 + - Adds session authentication support for the API + - Improvements for login / logout endpoints for better support of React web interface + +v187 - 2024-04-10 : https://github.com/inventree/InvenTree/pull/6985 - Allow Part list endpoint to be sorted by pricing_min and pricing_max values - Allow BomItem list endpoint to be sorted by pricing_min and pricing_max values - Allow InternalPrice and SalePrice endpoints to be sorted by quantity diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index bd60fdf598..a32a2fbd77 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -492,10 +492,18 @@ if DEBUG: 'rest_framework.renderers.BrowsableAPIRenderer' ) -# dj-rest-auth # JWT switch USE_JWT = get_boolean_setting('INVENTREE_USE_JWT', 'use_jwt', False) REST_USE_JWT = USE_JWT + +# dj-rest-auth +REST_AUTH = { + 'SESSION_LOGIN': True, + 'TOKEN_MODEL': 'users.models.ApiToken', + 'TOKEN_CREATOR': 'users.models.default_create_token', + 'USE_JWT': USE_JWT, +} + OLD_PASSWORD_FIELD_ENABLED = True REST_AUTH_REGISTER_SERIALIZERS = { 'REGISTER_SERIALIZER': 'InvenTree.forms.CustomRegisterSerializer' @@ -510,6 +518,7 @@ if USE_JWT: ) INSTALLED_APPS.append('rest_framework_simplejwt') + # WSGI default setting WSGI_APPLICATION = 'InvenTree.wsgi.application' @@ -1092,6 +1101,13 @@ if not TESTING and len(CSRF_TRUSTED_ORIGINS) == 0: ) sys.exit(-1) +# Additional CSRF settings +CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN' +CSRF_COOKIE_NAME = 'csrftoken' +CSRF_COOKIE_SAMESITE = 'Lax' +SESSION_COOKIE_SECURE = True +SESSION_COOKIE_SAMESITE = 'Lax' + USE_X_FORWARDED_HOST = get_boolean_setting( 'INVENTREE_USE_X_FORWARDED_HOST', config_key='use_x_forwarded_host', diff --git a/src/backend/InvenTree/InvenTree/urls.py b/src/backend/InvenTree/InvenTree/urls.py index c71f567206..ab3dc008a2 100644 --- a/src/backend/InvenTree/InvenTree/urls.py +++ b/src/backend/InvenTree/InvenTree/urls.py @@ -160,6 +160,7 @@ apipatterns = [ SocialAccountDisconnectView.as_view(), name='social_account_disconnect', ), + path('login/', users.api.Login.as_view(), name='api-login'), path('logout/', users.api.Logout.as_view(), name='api-logout'), path( 'login-redirect/', diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index 17def22835..43fe8ad521 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -8,9 +8,11 @@ from django.contrib.auth.models import Group, User from django.urls import include, path, re_path from django.views.generic.base import RedirectView -from dj_rest_auth.views import LogoutView +from dj_rest_auth.views import LoginView, LogoutView from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view from rest_framework import exceptions, permissions +from rest_framework.authentication import BasicAuthentication +from rest_framework.decorators import authentication_classes from rest_framework.response import Response from rest_framework.views import APIView @@ -205,6 +207,18 @@ class GroupList(ListCreateAPI): ordering_fields = ['name'] +@authentication_classes([BasicAuthentication]) +@extend_schema_view( + post=extend_schema( + responses={200: OpenApiResponse(description='User successfully logged in')} + ) +) +class Login(LoginView): + """API view for logging in via API.""" + + ... + + @extend_schema_view( post=extend_schema( responses={200: OpenApiResponse(description='User successfully logged out')} diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py index e2ddcd3417..fc4cc141ec 100644 --- a/src/backend/InvenTree/users/models.py +++ b/src/backend/InvenTree/users/models.py @@ -56,6 +56,17 @@ def default_token_expiry(): return InvenTree.helpers.current_date() + datetime.timedelta(days=365) +def default_create_token(token_model, user, serializer): + """Generate a default value for the token.""" + token = token_model.objects.filter(user=user, name='', revoked=False) + + if token.exists(): + return token.first() + + else: + return token_model.objects.create(user=user, name='') + + class ApiToken(AuthToken, InvenTree.models.MetadataMixin): """Extends the default token model provided by djangorestframework.authtoken. diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 0ec3e8576c..fff83a6af8 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,40 +1,24 @@ import { QueryClient } from '@tanstack/react-query'; import axios from 'axios'; -import { getCsrfCookie } from './functions/auth'; import { useLocalState } from './states/LocalState'; -import { useSessionState } from './states/SessionState'; // Global API instance export const api = axios.create({}); /* * Setup default settings for the Axios API instance. - * - * This includes: - * - Base URL - * - Authorization token (if available) - * - CSRF token (if available) */ export function setApiDefaults() { const host = useLocalState.getState().host; - const token = useSessionState.getState().token; api.defaults.baseURL = host; api.defaults.timeout = 2500; - api.defaults.headers.common['Authorization'] = token - ? `Token ${token}` - : undefined; - if (!!getCsrfCookie()) { - api.defaults.withCredentials = true; - api.defaults.xsrfCookieName = 'csrftoken'; - api.defaults.xsrfHeaderName = 'X-CSRFToken'; - } else { - api.defaults.withCredentials = false; - api.defaults.xsrfCookieName = undefined; - api.defaults.xsrfHeaderName = undefined; - } + api.defaults.withCredentials = true; + api.defaults.withXSRFToken = true; + api.defaults.xsrfCookieName = 'csrftoken'; + api.defaults.xsrfHeaderName = 'X-CSRFToken'; } export const queryClient = new QueryClient(); diff --git a/src/frontend/src/components/forms/AuthenticationForm.tsx b/src/frontend/src/components/forms/AuthenticationForm.tsx index 31c2d7c28b..c97d24bd29 100644 --- a/src/frontend/src/components/forms/AuthenticationForm.tsx +++ b/src/frontend/src/components/forms/AuthenticationForm.tsx @@ -12,16 +12,14 @@ import { } from '@mantine/core'; import { useForm } from '@mantine/form'; import { useDisclosure } from '@mantine/hooks'; -import { notifications } from '@mantine/notifications'; -import { IconCheck } from '@tabler/icons-react'; import { useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { api } from '../../App'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; -import { doBasicLogin, doSimpleLogin } from '../../functions/auth'; +import { doBasicLogin, doSimpleLogin, isLoggedIn } from '../../functions/auth'; +import { showLoginNotification } from '../../functions/notifications'; import { apiUrl, useServerApiState } from '../../states/ApiState'; -import { useSessionState } from '../../states/SessionState'; import { SsoButton } from '../buttons/SSOButton'; export function AuthenticationForm() { @@ -46,19 +44,18 @@ export function AuthenticationForm() { ).then(() => { setIsLoggingIn(false); - if (useSessionState.getState().hasToken()) { - notifications.show({ + if (isLoggedIn()) { + showLoginNotification({ title: t`Login successful`, - message: t`Welcome back!`, - color: 'green', - icon: + message: t`Logged in successfully` }); + navigate(location?.state?.redirectFrom ?? '/home'); } else { - notifications.show({ + showLoginNotification({ title: t`Login failed`, message: t`Check your input and try again.`, - color: 'red' + success: false }); } }); @@ -67,18 +64,15 @@ export function AuthenticationForm() { setIsLoggingIn(false); if (ret?.status === 'ok') { - notifications.show({ + showLoginNotification({ title: t`Mail delivery successful`, - message: t`Check your inbox for the login link. If you have an account, you will receive a login link. Check in spam too.`, - color: 'green', - icon: , - autoClose: false + message: t`Check your inbox for the login link. If you have an account, you will receive a login link. Check in spam too.` }); } else { - notifications.show({ - title: t`Input error`, + showLoginNotification({ + title: t`Mail delivery failed`, message: t`Check your input and try again.`, - color: 'red' + success: false }); } }); @@ -193,11 +187,9 @@ export function RegistrationForm() { .then((ret) => { if (ret?.status === 204) { setIsRegistering(false); - notifications.show({ + showLoginNotification({ title: t`Registration successful`, - message: t`Please confirm your email address to complete the registration`, - color: 'green', - icon: + message: t`Please confirm your email address to complete the registration` }); navigate('/home'); } @@ -212,11 +204,10 @@ export function RegistrationForm() { if (err.response?.data?.non_field_errors) { err_msg = err.response.data.non_field_errors; } - notifications.show({ + showLoginNotification({ title: t`Input error`, message: t`Check your input and try again. ` + err_msg, - color: 'red', - autoClose: 30000 + success: false }); } }); diff --git a/src/frontend/src/components/images/ApiImage.tsx b/src/frontend/src/components/images/ApiImage.tsx index d8f457b427..ce123f2ed3 100644 --- a/src/frontend/src/components/images/ApiImage.tsx +++ b/src/frontend/src/components/images/ApiImage.tsx @@ -1,71 +1,27 @@ /** - * Component for loading an image from the InvenTree server, - * using the API's token authentication. + * Component for loading an image from the InvenTree server * * Image caching is handled automagically by the browsers cache */ import { Image, ImageProps, Skeleton, Stack } from '@mantine/core'; -import { useId } from '@mantine/hooks'; -import { useQuery } from '@tanstack/react-query'; -import { useState } from 'react'; +import { useMemo } from 'react'; -import { api } from '../../App'; +import { useLocalState } from '../../states/LocalState'; /** * Construct an image container which will load and display the image */ export function ApiImage(props: ImageProps) { - const [image, setImage] = useState(''); + const { host } = useLocalState.getState(); - const [authorized, setAuthorized] = useState(true); - - const queryKey = useId(); - - const _imgQuery = useQuery({ - queryKey: ['image', queryKey, props.src], - enabled: - authorized && - props.src != undefined && - props.src != null && - props.src != '', - queryFn: async () => { - if (!props.src) { - return null; - } - return api - .get(props.src, { - responseType: 'blob' - }) - .then((response) => { - switch (response.status) { - case 200: - let img = new Blob([response.data], { - type: response.headers['content-type'] - }); - let url = URL.createObjectURL(img); - setImage(url); - break; - default: - // User is not authorized to view this image, or the image is not available - setImage(''); - setAuthorized(false); - break; - } - - return response; - }) - .catch((_error) => { - return null; - }); - }, - refetchOnMount: true, - refetchOnWindowFocus: false - }); + const imageUrl = useMemo(() => { + return `${host}${props.src}`; + }, [host, props.src]); return ( - {image && image.length > 0 ? ( - + {imageUrl ? ( + ) : ( s.host); + + const url = useMemo(() => { + if (external) { + return attachment; + } + + return `${host}${attachment}`; + }, [host, attachment, external]); + return ( {external ? : attachmentIcon(attachment)} - + {text} diff --git a/src/frontend/src/components/nav/Layout.tsx b/src/frontend/src/components/nav/Layout.tsx index 91a6cd00c6..c9a4c436b3 100644 --- a/src/frontend/src/components/nav/Layout.tsx +++ b/src/frontend/src/components/nav/Layout.tsx @@ -6,17 +6,15 @@ import { useEffect, useState } from 'react'; import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { getActions } from '../../defaults/actions'; +import { isLoggedIn } from '../../functions/auth'; import { InvenTreeStyle } from '../../globalStyle'; -import { useSessionState } from '../../states/SessionState'; import { Footer } from './Footer'; import { Header } from './Header'; export const ProtectedRoute = ({ children }: { children: JSX.Element }) => { - const [token] = useSessionState((state) => [state.token]); - const location = useLocation(); - if (!token) { + if (!isLoggedIn()) { return ( ); diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 821bbd973e..dd5fab30da 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -15,14 +15,15 @@ export enum ApiEndpoints { user_roles = 'user/roles/', user_token = 'user/token/', user_simple_login = 'email/generate/', - user_reset = 'auth/password/reset/', // Note leading prefix here - user_reset_set = 'auth/password/reset/confirm/', // Note leading prefix here + user_reset = 'auth/password/reset/', + user_reset_set = 'auth/password/reset/confirm/', user_sso = 'auth/social/', user_sso_remove = 'auth/social/:id/disconnect/', user_emails = 'auth/emails/', user_email_remove = 'auth/emails/:id/remove/', user_email_verify = 'auth/emails/:id/verify/', user_email_primary = 'auth/emails/:id/primary/', + user_login = 'auth/login/', user_logout = 'auth/logout/', user_register = 'auth/registration/', diff --git a/src/frontend/src/functions/auth.tsx b/src/frontend/src/functions/auth.tsx index d0f010e9c2..c1d7dbd7ac 100644 --- a/src/frontend/src/functions/auth.tsx +++ b/src/frontend/src/functions/auth.tsx @@ -1,15 +1,13 @@ import { t } from '@lingui/macro'; import { notifications } from '@mantine/notifications'; -import { IconCheck } from '@tabler/icons-react'; import axios from 'axios'; import { api, setApiDefaults } from '../App'; import { ApiEndpoints } from '../enums/ApiEndpoints'; import { apiUrl } from '../states/ApiState'; import { useLocalState } from '../states/LocalState'; -import { useSessionState } from '../states/SessionState'; - -const tokenName: string = 'inventree-web-app'; +import { fetchGlobalStates } from '../states/states'; +import { showLoginNotification } from './notifications'; /** * Attempt to login using username:password combination. @@ -24,26 +22,35 @@ export const doBasicLogin = async (username: string, password: string) => { return; } - // At this stage, we can assume that we are not logged in, and we have no token - useSessionState.getState().clearToken(); + clearCsrfCookie(); - // Request new token from the server - await axios - .get(apiUrl(ApiEndpoints.user_token), { - auth: { username, password }, - baseURL: host, - timeout: 2000, - params: { - name: tokenName + const login_url = apiUrl(ApiEndpoints.user_login); + + // Attempt login with + await api + .post( + login_url, + { + username: username, + password: password + }, + { + baseURL: host } - }) + ) .then((response) => { - if (response.status == 200 && response.data.token) { - // A valid token has been returned - save, and login - useSessionState.getState().setToken(response.data.token); + switch (response.status) { + case 200: + fetchGlobalStates(); + break; + default: + clearCsrfCookie(); + break; } }) - .catch(() => {}); + .catch(() => { + clearCsrfCookie(); + }); }; /** @@ -53,27 +60,15 @@ export const doBasicLogin = async (username: string, password: string) => { */ export const doLogout = async (navigate: any) => { // Logout from the server session - await api.post(apiUrl(ApiEndpoints.user_logout)).catch(() => { - // If an error occurs here, we are likely already logged out + await api.post(apiUrl(ApiEndpoints.user_logout)).finally(() => { + clearCsrfCookie(); navigate('/login'); - return; + + showLoginNotification({ + title: t`Logged Out`, + message: t`Successfully logged out` + }); }); - - // Logout from this session - // Note that clearToken() then calls setApiDefaults() - clearCsrfCookie(); - useSessionState.getState().clearToken(); - - notifications.hide('login'); - notifications.show({ - id: 'login', - title: t`Logout successful`, - message: t`You have been logged out`, - color: 'green', - icon: - }); - - navigate('/login'); }; export const doSimpleLogin = async (email: string) => { @@ -134,55 +129,33 @@ export function checkLoginState( ) { setApiDefaults(); + if (redirect == '/') { + redirect = '/home'; + } + // Callback function when login is successful const loginSuccess = () => { - notifications.hide('login'); - notifications.show({ - id: 'login', + showLoginNotification({ title: t`Logged In`, - message: t`Found an existing login - welcome back!`, - color: 'green', - icon: + message: t`Successfully logged in` }); + navigate(redirect ?? '/home'); }; // Callback function when login fails const loginFailure = () => { - useSessionState.getState().clearToken(); if (!no_redirect) { navigate('/login', { state: { redirectFrom: redirect } }); } }; - if (useSessionState.getState().hasToken()) { - // An existing token is available - check if it works + // Check the 'user_me' endpoint to see if the user is logged in + if (isLoggedIn()) { api - .get(apiUrl(ApiEndpoints.user_me), { - timeout: 2000 - }) - .then((val) => { - if (val.status === 200) { - // Success: we are logged in (and we already have a token) - loginSuccess(); - } else { - loginFailure(); - } - }) - .catch(() => { - loginFailure(); - }); - } else if (getCsrfCookie()) { - // Try to fetch a new token using the CSRF cookie - api - .get(apiUrl(ApiEndpoints.user_token), { - params: { - name: tokenName - } - }) + .get(apiUrl(ApiEndpoints.user_me)) .then((response) => { - if (response.status == 200 && response.data.token) { - useSessionState.getState().setToken(response.data.token); + if (response.status == 200) { loginSuccess(); } else { loginFailure(); @@ -192,7 +165,6 @@ export function checkLoginState( loginFailure(); }); } else { - // No token, no cookie - redirect to login page loginFailure(); } } @@ -209,8 +181,12 @@ export function getCsrfCookie() { return cookieValue; } +export function isLoggedIn() { + return !!getCsrfCookie(); +} + /* - * Clear out the CSRF cookie (force session logout) + * Clear out the CSRF and session cookies (force session logout) */ export function clearCsrfCookie() { document.cookie = diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx index 6d8b0bed1d..b362d08ce5 100644 --- a/src/frontend/src/functions/icons.tsx +++ b/src/frontend/src/functions/icons.tsx @@ -7,6 +7,7 @@ import { IconBuilding, IconBuildingFactory2, IconBuildingStore, + IconBusinessplan, IconCalendar, IconCalendarStats, IconCategory, @@ -100,6 +101,7 @@ const icons = { info: IconInfoCircle, details: IconInfoCircle, parameters: IconList, + list: IconList, stock: IconPackages, variants: IconVersions, allocations: IconBookmarks, @@ -171,6 +173,7 @@ const icons = { customer: IconUser, quantity: IconNumbers, progress: IconProgressCheck, + total_cost: IconBusinessplan, reference: IconHash, serial: IconHash, website: IconWorld, diff --git a/src/frontend/src/functions/notifications.tsx b/src/frontend/src/functions/notifications.tsx index 9682e8738c..0306d1d92c 100644 --- a/src/frontend/src/functions/notifications.tsx +++ b/src/frontend/src/functions/notifications.tsx @@ -1,5 +1,6 @@ import { t } from '@lingui/macro'; import { notifications } from '@mantine/notifications'; +import { IconCircleCheck, IconExclamationCircle } from '@tabler/icons-react'; /** * Show a notification that the feature is not yet implemented @@ -34,3 +35,28 @@ export function invalidResponse(returnCode: number) { color: 'red' }); } + +/* + * Display a login / logout notification message. + * Any existing login notification(s) will be hidden. + */ +export function showLoginNotification({ + title, + message, + success = true +}: { + title: string; + message: string; + success?: boolean; +}) { + notifications.hide('login'); + + notifications.show({ + title: title, + message: message, + color: success ? 'green' : 'red', + icon: success ? : , + id: 'login', + autoClose: 5000 + }); +} diff --git a/src/frontend/src/pages/Auth/Logout.tsx b/src/frontend/src/pages/Auth/Logout.tsx new file mode 100644 index 0000000000..25dfd04c4e --- /dev/null +++ b/src/frontend/src/pages/Auth/Logout.tsx @@ -0,0 +1,34 @@ +import { Trans } from '@lingui/macro'; +import { Card, Container, Group, Loader, Stack, Text } from '@mantine/core'; +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { doLogout } from '../../functions/auth'; + +/* Expose a route for explicit logout via URL */ +export default function Logout() { + const navigate = useNavigate(); + + useEffect(() => { + doLogout(navigate); + }, []); + + return ( + <> + + + + + + Logging out + + + + + + + + + + ); +} diff --git a/src/frontend/src/router.tsx b/src/frontend/src/router.tsx index c376dab343..a80a0fdce1 100644 --- a/src/frontend/src/router.tsx +++ b/src/frontend/src/router.tsx @@ -103,6 +103,7 @@ export const AdminCenter = Loadable( export const NotFound = Loadable(lazy(() => import('./pages/NotFound'))); export const Login = Loadable(lazy(() => import('./pages/Auth/Login'))); +export const Logout = Loadable(lazy(() => import('./pages/Auth/Logout'))); export const Logged_In = Loadable(lazy(() => import('./pages/Auth/Logged-In'))); export const Reset = Loadable(lazy(() => import('./pages/Auth/Reset'))); export const Set_Password = Loadable( @@ -163,6 +164,7 @@ export const routes = ( }> } />, + } />, } /> } /> } /> diff --git a/src/frontend/src/states/SessionState.tsx b/src/frontend/src/states/SessionState.tsx deleted file mode 100644 index 5ac12407d7..0000000000 --- a/src/frontend/src/states/SessionState.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { create } from 'zustand'; -import { createJSONStorage, persist } from 'zustand/middleware'; - -import { setApiDefaults } from '../App'; -import { fetchGlobalStates } from './states'; - -interface SessionStateProps { - token?: string; - setToken: (newToken?: string) => void; - clearToken: () => void; - hasToken: () => boolean; -} - -/* - * State manager for user login information. - */ -export const useSessionState = create()( - persist( - (set, get) => ({ - token: undefined, - clearToken: () => { - set({ token: undefined }); - }, - setToken: (newToken) => { - set({ token: newToken }); - - setApiDefaults(); - fetchGlobalStates(); - }, - hasToken: () => !!get().token - }), - { - name: 'session-state', - storage: createJSONStorage(() => sessionStorage) - } - ) -); diff --git a/src/frontend/src/states/SettingsState.tsx b/src/frontend/src/states/SettingsState.tsx index 61aec8ff08..85fdba8ab3 100644 --- a/src/frontend/src/states/SettingsState.tsx +++ b/src/frontend/src/states/SettingsState.tsx @@ -5,9 +5,9 @@ import { create, createStore } from 'zustand'; import { api } from '../App'; import { ApiEndpoints } from '../enums/ApiEndpoints'; +import { isLoggedIn } from '../functions/auth'; import { isTrue } from '../functions/conversion'; import { PathParams, apiUrl } from './ApiState'; -import { useSessionState } from './SessionState'; import { Setting, SettingsLookup } from './states'; export interface SettingsStateProps { @@ -29,7 +29,7 @@ export const useGlobalSettingsState = create( lookup: {}, endpoint: ApiEndpoints.settings_global_list, fetchSettings: async () => { - if (!useSessionState.getState().hasToken()) { + if (!isLoggedIn()) { return; } @@ -63,7 +63,7 @@ export const useUserSettingsState = create((set, get) => ({ lookup: {}, endpoint: ApiEndpoints.settings_user_list, fetchSettings: async () => { - if (!useSessionState.getState().hasToken()) { + if (!isLoggedIn()) { return; } diff --git a/src/frontend/src/states/StatusState.tsx b/src/frontend/src/states/StatusState.tsx index 51b31f851d..57e845f33a 100644 --- a/src/frontend/src/states/StatusState.tsx +++ b/src/frontend/src/states/StatusState.tsx @@ -6,8 +6,8 @@ import { StatusCodeListInterface } from '../components/render/StatusRenderer'; import { statusCodeList } from '../defaults/backendMappings'; import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ModelType } from '../enums/ModelType'; +import { isLoggedIn } from '../functions/auth'; import { apiUrl } from './ApiState'; -import { useSessionState } from './SessionState'; type StatusLookup = Record; @@ -24,7 +24,7 @@ export const useGlobalStatusState = create()( setStatus: (newStatus: StatusLookup) => set({ status: newStatus }), fetchStatus: async () => { // Fetch status data for rendering labels - if (!useSessionState.getState().hasToken()) { + if (!isLoggedIn()) { return; } diff --git a/src/frontend/src/states/UserState.tsx b/src/frontend/src/states/UserState.tsx index 621a5a5c82..35d8a82979 100644 --- a/src/frontend/src/states/UserState.tsx +++ b/src/frontend/src/states/UserState.tsx @@ -3,8 +3,8 @@ import { create } from 'zustand'; import { api } from '../App'; import { ApiEndpoints } from '../enums/ApiEndpoints'; import { UserPermissions, UserRoles } from '../enums/Roles'; +import { isLoggedIn } from '../functions/auth'; import { apiUrl } from './ApiState'; -import { useSessionState } from './SessionState'; import { UserProps } from './states'; interface UserStateProps { @@ -37,7 +37,7 @@ export const useUserState = create((set, get) => ({ }, setUser: (newUser: UserProps) => set({ user: newUser }), fetchUserState: async () => { - if (!useSessionState.getState().hasToken()) { + if (!isLoggedIn()) { return; } @@ -56,7 +56,7 @@ export const useUserState = create((set, get) => ({ }; set({ user: user }); }) - .catch((_error) => { + .catch((error) => { console.error('Error fetching user data'); }); diff --git a/src/frontend/src/states/states.tsx b/src/frontend/src/states/states.tsx index fba98fc4c6..0ec0139a14 100644 --- a/src/frontend/src/states/states.tsx +++ b/src/frontend/src/states/states.tsx @@ -1,6 +1,6 @@ import { setApiDefaults } from '../App'; +import { isLoggedIn } from '../functions/auth'; import { useServerApiState } from './ApiState'; -import { useSessionState } from './SessionState'; import { useGlobalSettingsState, useUserSettingsState } from './SettingsState'; import { useGlobalStatusState } from './StatusState'; import { useUserState } from './UserState'; @@ -126,7 +126,7 @@ export type SettingsLookup = { * Necessary on login, or if locale is changed. */ export function fetchGlobalStates() { - if (!useSessionState.getState().hasToken()) { + if (!isLoggedIn()) { return; } diff --git a/src/frontend/src/views/DesktopAppView.tsx b/src/frontend/src/views/DesktopAppView.tsx index a48445272d..1ee5f84f2e 100644 --- a/src/frontend/src/views/DesktopAppView.tsx +++ b/src/frontend/src/views/DesktopAppView.tsx @@ -5,10 +5,10 @@ import { BrowserRouter } from 'react-router-dom'; import { queryClient } from '../App'; import { BaseContext } from '../contexts/BaseContext'; import { defaultHostList } from '../defaults/defaultHostList'; +import { isLoggedIn } from '../functions/auth'; import { base_url } from '../main'; import { routes } from '../router'; import { useLocalState } from '../states/LocalState'; -import { useSessionState } from '../states/SessionState'; import { useGlobalSettingsState, useUserSettingsState @@ -28,20 +28,19 @@ export default function DesktopAppView() { // Server Session const [fetchedServerSession, setFetchedServerSession] = useState(false); - const sessionState = useSessionState.getState(); - const [token] = sessionState.token ? [sessionState.token] : [null]; + useEffect(() => { if (Object.keys(hostList).length === 0) { useLocalState.setState({ hostList: defaultHostList }); } - if (token && !fetchedServerSession) { + if (isLoggedIn() && !fetchedServerSession) { setFetchedServerSession(true); fetchUserState(); fetchGlobalSettings(); fetchUserSettings(); } - }, [token, fetchedServerSession]); + }, [fetchedServerSession]); return ( diff --git a/src/frontend/tests/cui.spec.ts b/src/frontend/tests/cui.spec.ts index f33ee66b0e..66f705f25d 100644 --- a/src/frontend/tests/cui.spec.ts +++ b/src/frontend/tests/cui.spec.ts @@ -4,11 +4,10 @@ import { classicUrl, user } from './defaults'; test('CUI - Index', async ({ page }) => { await page.goto(`${classicUrl}/api/`); - await page.goto(`${classicUrl}/index/`); - await expect(page).toHaveTitle('InvenTree Demo Server | Sign In'); - await expect( - page.getByRole('heading', { name: 'InvenTree Demo Server' }) - ).toBeVisible(); + await page.goto(`${classicUrl}/index/`, { timeout: 10000 }); + console.log('Page title:', await page.title()); + await expect(page).toHaveTitle(RegExp('^InvenTree.*Sign In$')); + await expect(page.getByRole('heading', { name: 'Sign In' })).toBeVisible(); await page.getByLabel('username').fill(user.username); await page.getByLabel('password').fill(user.password); diff --git a/src/frontend/tests/defaults.ts b/src/frontend/tests/defaults.ts index 5f6bf71a5a..3ceaa5b9fa 100644 --- a/src/frontend/tests/defaults.ts +++ b/src/frontend/tests/defaults.ts @@ -1,6 +1,12 @@ export const classicUrl = 'http://127.0.0.1:8000'; +export const baseUrl = `${classicUrl}/platform`; +export const loginUrl = `${baseUrl}/login`; +export const logoutUrl = `${baseUrl}/logout`; +export const homeUrl = `${baseUrl}/home`; + export const user = { + name: 'Ally Access', username: 'allaccess', password: 'nolimits' }; diff --git a/src/frontend/tests/login.ts b/src/frontend/tests/login.ts new file mode 100644 index 0000000000..a8165c4f61 --- /dev/null +++ b/src/frontend/tests/login.ts @@ -0,0 +1,37 @@ +import { expect } from './baseFixtures.js'; +import { baseUrl, loginUrl, logoutUrl, user } from './defaults'; + +/* + * Perform form based login operation from the "login" URL + */ +export const doLogin = async (page, username?: string, password?: string) => { + username = username ?? user.username; + password = password ?? user.password; + + await page.goto(logoutUrl); + await page.goto(loginUrl); + await expect(page).toHaveTitle(RegExp('^InvenTree.*$')); + await page.waitForURL('**/platform/login'); + await page.getByLabel('username').fill(username); + await page.getByLabel('password').fill(password); + await page.getByRole('button', { name: 'Log in' }).click(); + await page.waitForURL('**/platform/home'); + await page.waitForTimeout(250); +}; + +/* + * Perform a quick login based on passing URL parameters + */ +export const doQuickLogin = async ( + page, + username?: string, + password?: string +) => { + username = username ?? user.username; + password = password ?? user.password; + + // await page.goto(logoutUrl); + await page.goto(`${baseUrl}/login/?login=${username}&password=${password}`); + await page.waitForURL('**/platform/home'); + await page.waitForTimeout(250); +}; diff --git a/src/frontend/tests/pui_basic.spec.ts b/src/frontend/tests/pui_basic.spec.ts index 9d2eb43cd1..eadb2187b4 100644 --- a/src/frontend/tests/pui_basic.spec.ts +++ b/src/frontend/tests/pui_basic.spec.ts @@ -1,28 +1,37 @@ import { expect, test } from './baseFixtures.js'; -import { classicUrl, user } from './defaults.js'; +import { baseUrl, loginUrl, logoutUrl, user } from './defaults.js'; +import { doLogin, doQuickLogin } from './login.js'; -test('PUI - Basic test via django', async ({ page }) => { - await page.goto(`${classicUrl}/platform/`); - await expect(page).toHaveTitle('InvenTree Demo Server'); - await page.waitForURL('**/platform/'); - await page.getByLabel('username').fill(user.username); - await page.getByLabel('password').fill(user.password); - await page.getByRole('button', { name: 'Log in' }).click(); - await page.waitForURL('**/platform/*'); - await page.goto(`${classicUrl}/platform/`); +test('PUI - Basic Login Test', async ({ page }) => { + await doLogin(page); - await expect(page).toHaveTitle('InvenTree Demo Server'); -}); + // Check that the username is provided + await page.getByText(user.username); -test('PUI - Basic test', async ({ page }) => { - await page.goto('./platform/'); - await expect(page).toHaveTitle('InvenTree'); - await page.waitForURL('**/platform/'); - await page.getByLabel('username').fill(user.username); - await page.getByLabel('password').fill(user.password); - await page.getByRole('button', { name: 'Log in' }).click(); + await expect(page).toHaveTitle(RegExp('^InvenTree')); + + // Go to the dashboard + await page.goto(baseUrl); await page.waitForURL('**/platform'); - await page.goto('./platform/'); - await expect(page).toHaveTitle('InvenTree'); + await page + .getByRole('heading', { name: `Welcome to your Dashboard, ${user.name}` }) + .click(); +}); + +test('PUI - Quick Login Test', async ({ page }) => { + await doQuickLogin(page); + + // Check that the username is provided + await page.getByText(user.username); + + await expect(page).toHaveTitle(RegExp('^InvenTree')); + + // Go to the dashboard + await page.goto(baseUrl); + await page.waitForURL('**/platform'); + + await page + .getByRole('heading', { name: `Welcome to your Dashboard, ${user.name}` }) + .click(); }); diff --git a/src/frontend/tests/pui_command.spec.ts b/src/frontend/tests/pui_command.spec.ts index 520542d99d..f722d04bba 100644 --- a/src/frontend/tests/pui_command.spec.ts +++ b/src/frontend/tests/pui_command.spec.ts @@ -1,26 +1,14 @@ -import { expect, systemKey, test } from './baseFixtures.js'; -import { user } from './defaults.js'; +import { systemKey, test } from './baseFixtures.js'; +import { baseUrl } from './defaults.js'; +import { doQuickLogin } from './login.js'; test('PUI - Quick Command', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - await page.waitForURL('**/platform/*'); - await page.goto('./platform/'); - - await expect(page).toHaveTitle('InvenTree'); - await page.waitForURL('**/platform/'); - await page - .getByRole('heading', { name: 'Welcome to your Dashboard,' }) - .click(); - await page.waitForTimeout(500); + await doQuickLogin(page); // Open Spotlight with Keyboard Shortcut await page.locator('body').press(`${systemKey}+k`); await page.waitForTimeout(200); - await page - .getByRole('button', { name: 'Dashboard Go to the InvenTree dashboard' }) - .click(); + await page.getByRole('tab', { name: 'Dashboard' }).click(); await page .locator('div') .filter({ hasText: /^Dashboard$/ }) @@ -44,15 +32,8 @@ test('PUI - Quick Command', async ({ page }) => { await page.waitForURL('**/platform/dashboard'); }); -test('PUI - Quick Command - no keys', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - await page.waitForURL('**/platform/*'); - await page.goto('./platform/'); - - // wait for the page to load - await page.waitForTimeout(200); +test('PUI - Quick Command - No Keys', async ({ page }) => { + await doQuickLogin(page); // Open Spotlight with Button await page.getByRole('button', { name: 'Open spotlight' }).click(); @@ -118,7 +99,7 @@ test('PUI - Quick Command - no keys', async ({ page }) => { await page.waitForURL('https://docs.inventree.org/**'); // Test addition of new actions - await page.goto('./platform/playground'); + await page.goto(`${baseUrl}/playground`); await page .locator('div') .filter({ hasText: /^Playground$/ }) diff --git a/src/frontend/tests/pui_general.spec.ts b/src/frontend/tests/pui_general.spec.ts index 55896a11c3..3dead1ce79 100644 --- a/src/frontend/tests/pui_general.spec.ts +++ b/src/frontend/tests/pui_general.spec.ts @@ -1,17 +1,15 @@ -import { test } from './baseFixtures.js'; -import { adminuser, user } from './defaults.js'; +import { expect, test } from './baseFixtures.js'; +import { baseUrl } from './defaults.js'; +import { doQuickLogin } from './login.js'; test('PUI - Parts', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - await page.waitForURL('**/platform/*'); - await page.goto('./platform/home'); + await doQuickLogin(page); + await page.goto(`${baseUrl}/home`); await page.getByRole('tab', { name: 'Parts' }).click(); - await page.goto('./platform/part/'); + await page.waitForURL('**/platform/part/category/index/details'); - await page.goto('./platform/part/category/index/parts'); + await page.goto(`${baseUrl}/part/category/index/parts`); await page.getByText('1551ABK').click(); await page.getByRole('tab', { name: 'Allocations' }).click(); await page.getByRole('tab', { name: 'Used In' }).click(); @@ -36,12 +34,10 @@ test('PUI - Parts', async ({ page }) => { }); test('PUI - Parts - Manufacturer Parts', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - await page.waitForURL('**/platform/*'); + await doQuickLogin(page); + + await page.goto(`${baseUrl}/part/84/manufacturers`); - await page.goto('./platform/part/84/manufacturers'); await page.getByRole('tab', { name: 'Manufacturers' }).click(); await page.getByText('Hammond Manufacturing').click(); await page.getByRole('tab', { name: 'Parameters' }).click(); @@ -51,12 +47,10 @@ test('PUI - Parts - Manufacturer Parts', async ({ page }) => { }); test('PUI - Parts - Supplier Parts', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - await page.waitForURL('**/platform/*'); + await doQuickLogin(page); + + await page.goto(`${baseUrl}/part/15/suppliers`); - await page.goto('./platform/part/15/suppliers'); await page.getByRole('tab', { name: 'Suppliers' }).click(); await page.getByRole('cell', { name: 'DIG-84670-SJI' }).click(); await page.getByRole('tab', { name: 'Received Stock' }).click(); // @@ -66,12 +60,10 @@ test('PUI - Parts - Supplier Parts', async ({ page }) => { }); test('PUI - Sales', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - await page.waitForURL('**/platform/*'); + await doQuickLogin(page); + + await page.goto(`${baseUrl}/sales/`); - await page.goto('./platform/sales/'); await page.waitForURL('**/platform/sales/**'); await page.waitForURL('**/platform/sales/index/salesorders'); await page.getByRole('tab', { name: 'Return Orders' }).click(); @@ -119,11 +111,7 @@ test('PUI - Sales', async ({ page }) => { }); test('PUI - Scanning', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - await page.waitForURL('**/platform/*'); - await page.goto('./platform/'); + await doQuickLogin(page); await page.getByLabel('Homenav').click(); await page.getByRole('button', { name: 'System Information' }).click(); @@ -144,11 +132,8 @@ test('PUI - Scanning', async ({ page }) => { }); test('PUI - Admin', async ({ page }) => { - await page.goto( - `./platform/login/?login=${adminuser.username}&password=${adminuser.password}` - ); - await page.waitForURL('**/platform/*'); - await page.goto('./platform/'); + // Note here we login with admin access + await doQuickLogin(page, 'admin', 'inventree'); // User settings await page.getByRole('button', { name: 'admin' }).click(); @@ -197,11 +182,7 @@ test('PUI - Admin', async ({ page }) => { }); test('PUI - Language / Color', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - await page.waitForURL('**/platform/*'); - await page.goto('./platform/'); + await doQuickLogin(page); await page.getByRole('button', { name: 'Ally Access' }).click(); await page.getByRole('menuitem', { name: 'Logout' }).click(); @@ -235,12 +216,9 @@ test('PUI - Language / Color', async ({ page }) => { }); test('PUI - Company', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - await page.waitForURL('**/platform/*'); + await doQuickLogin(page); - await page.goto('./platform/company/1/details'); + await page.goto(`${baseUrl}/company/1/details`); await page .locator('div') .filter({ hasText: /^DigiKey Electronics$/ }) diff --git a/src/frontend/tests/pui_stock.spec.ts b/src/frontend/tests/pui_stock.spec.ts index dd70f442c4..2aea0c0516 100644 --- a/src/frontend/tests/pui_stock.spec.ts +++ b/src/frontend/tests/pui_stock.spec.ts @@ -1,13 +1,11 @@ -import { test } from './baseFixtures.js'; -import { user } from './defaults.js'; +import { expect, test } from './baseFixtures.js'; +import { baseUrl, user } from './defaults.js'; +import { doQuickLogin } from './login.js'; test('PUI - Stock', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - await page.waitForURL('**/platform/*'); + await doQuickLogin(page); - await page.goto('./platform/stock'); + await page.goto(`${baseUrl}/stock`); await page.waitForURL('**/platform/stock/location/index/details'); await page.getByRole('tab', { name: 'Stock Items' }).click(); await page.getByRole('cell', { name: '1551ABK' }).click(); @@ -21,11 +19,7 @@ test('PUI - Stock', async ({ page }) => { }); test('PUI - Build', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - await page.waitForURL('**/platform/*'); - await page.goto('./platform/'); + await doQuickLogin(page); await page.getByRole('tab', { name: 'Build' }).click(); await page.getByText('Widget Assembly Variant').click(); @@ -39,11 +33,7 @@ test('PUI - Build', async ({ page }) => { }); test('PUI - Purchasing', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - await page.waitForURL('**/platform/*'); - await page.goto('./platform/'); + await doQuickLogin(page); await page.getByRole('tab', { name: 'Purchasing' }).click(); await page.getByRole('cell', { name: 'PO0012' }).click();