diff --git a/.cursor/rules/backend/commands.mdc b/.cursor/rules/backend/commands.mdc index 6fd00e522..0b0215e8c 100644 --- a/.cursor/rules/backend/commands.mdc +++ b/.cursor/rules/backend/commands.mdc @@ -62,9 +62,7 @@ public sealed class CreateUserValidator : AbstractValidator public CreateUserValidator() { // ✅ DO: Use the same message for better user experience and easier localization - RuleFor(x => x.Name) - .NotEmpty().WithMessage("Name must be between 1 and 50 characters.") - .MaximumLength(50).WithMessage("Name must be between 1 and 50 characters."); + RuleFor(x => x.Name).Length(1, 50).WithMessage("Name must be between 1 and 50 characters."); } } @@ -98,7 +96,7 @@ public sealed class CreateUserValidator : AbstractValidator { public CreateUserValidator() { - // ❌ DON'T: Use different validation messages for the same property + // ❌ DON'T: Use different validation messages for the same property and redundant validation rules RuleFor(x => x.Name) .NotEmpty().WithMessage("Name must not be empty.") .MaximumLength(50).WithMessage("Name must not be more than 50 characters."); diff --git a/.cursor/rules/end-to-end-tests/e2e-tests.mdc b/.cursor/rules/end-to-end-tests/e2e-tests.mdc new file mode 100644 index 000000000..0b0ebea9a --- /dev/null +++ b/.cursor/rules/end-to-end-tests/e2e-tests.mdc @@ -0,0 +1,262 @@ +--- +description: Rules for end-to-end tests +globs: */tests/e2e/** +alwaysApply: false +--- +# End-to-End Tests + +These rules outline the structure, patterns, and best practices for writing end-to-end tests. + +## Implementation + +1. Use `[CLI_ALIAS] e2e` with these option categories to optimize test execution: + - Test filtering: `--smoke`, `--include-slow`, search terms (e.g., `"@smoke"`, `"smoke"`, `"user"`, `"localization"`), `--browser` + - Change scoping: `--last-failed`, `--only-changed` + - Flaky test detection: `--repeat-each`, `--retries`, `--stop-on-first-failure` + - Performance: `--debug-timings` shows step execution times with color coding + +2. Test Search and Filtering: + - Search by test tags: `[CLI_ALIAS] e2e "@smoke"` or `[CLI_ALIAS] e2e "smoke"` (both work the same) + - Search by test content: `[CLI_ALIAS] e2e "user"` (finds tests with "user" in title or content) + - Search by filename: `[CLI_ALIAS] e2e "localization"` (finds localization-flows.spec.ts) + - Search by specific file: `[CLI_ALIAS] e2e "user-management-flows.spec.ts"` + - Multiple search terms: `[CLI_ALIAS] e2e "user" "management"` + - The CLI automatically detects which self-contained systems contain matching tests and only runs those + +3. Test-Driven Debugging Process: + - Focus on one failing test at a time and make it pass before moving to the next. + - Ensure tests use Playwright's built-in auto-waiting assertions: `toHaveURL()`, `toBeVisible()`, `toBeEnabled()`, `toHaveValue()`, `toContainText()`. + - Consider if root causes can be fixed in the application code, and fix application bugs rather than masking them with test workarounds. + +4. Organize tests in a consistent file structure: + - All e2e test files must be located in `[self-contained-system]/WebApp/tests/e2e/` folder (e.g., `application/account-management/WebApp/tests/e2e/`). + - All test files use the `*-flows.spec.ts` naming convention (e.g., `login-flows.spec.ts`, `signup-flows.spec.ts`, `user-management-flows.spec.ts`). + - Top-level describe blocks must use only these 3 approved tags: `test.describe("@smoke", () => {})`, `test.describe("@comprehensive", () => {})`, `test.describe("@slow", () => {})`. + - `@smoke` tests: + - Critical tests run on deployment of any self-contained system. + - Should be comprehensive scenarios that test core user journeys. + - Keep tests focused on specific flows to reduce fragility while maintaining coverage. + - Focus on must-work functionality with extensive validation steps. + - Include boundary cases and error handling within the same test scenario. + - Avoid testing the same functionality multiple times across different tests. + + - `@comprehensive` tests: + - Thorough tests run when a specific self-contained system is deployed. + - Focus on edge cases, error conditions, and less common scenarios. + - Test specific features in depth with various input combinations. + - Include tests for concurrency, validation rules, accessibility, etc. + - Group related edge cases together to reduce test count while maintaining coverage. + + - `@slow` tests: + - Optional and run only ad-hoc using `--include-slow` flag. + - Any tests that require waiting like `waitForTimeout` (e.g., for OTP timeouts) must be marked as `@slow`. + - Include tests for rate limiting with actual wait times, session timeouts, etc. + - Use `test.setTimeout()` at the individual test level based on actual wait times needed. + +5. Write clear test descriptions and documentation: + - Test descriptions must accurately reflect what the test covers and be kept in sync with test implementation. + - Use descriptive test names that clearly indicate the functionality being tested (e.g., "should handle single and bulk user deletion workflows with dashboard integration"). + - Include JSDoc comments above complex tests listing all major features/scenarios covered. + - When adding new functionality to existing tests, update both the test description and JSDoc comments to reflect changes. + +6. Structure each test with step decorators and proper monitoring: + - All tests must start with `const context = createTestContext(page);` for proper error monitoring. + - Use step decorators: `await step("Complete signup & verify account creation")(async () => { /* test logic */ })();` + - Step naming conventions: + - Always follow "[Business action + details] & [expected outcome]" pattern. + - Use business action verbs like "Sign up", "Login", "Invite", "Rename", "Update", "Delete", "Create", "Submit". + - Never use test/assertion prefixes like "Test", "Verify", "Check", "Validate", "Ensure"; use descriptive business actions instead. + - Every step must include an action (arrange/act) followed by assertions, not pure assertion steps. + - Step structure: + - Use blank lines to separate arrange/act/assert sections within steps. + - Keep shared variable declarations outside steps when used across multiple steps. + - Use section headers with `// === SECTION NAME ===` to group related steps. + - Add JSDoc comments for complex test workflows. + - Use semantic selectors: `page.getByRole("button", { name: "Submit" })`, `page.getByText("Welcome")`, `page.getByLabel("Email")`. + - Assert side effects immediately after actions using `expectToastMessage`, `expectValidationError`, `expectNetworkErrors`. + - Form validation pattern: Use `await blurActiveElement(page);` when updating a textbox the second time before submitting a form to trigger validation. + +7. Timeout Configuration: + - Always use Playwright's built-in auto-waiting assertions: `toHaveURL()`, `toBeVisible()`, `toBeEnabled()`, `toHaveValue()`, `toContainText()`. + - Never add timeouts to `.click()`, `.waitForSelector()`, etc. + - Global timeout configuration is handled in the shared Playwright. Don't change this. + +8. Write deterministic tests - This is critical for reliable testing: + - Each test should have a clear, linear flow of actions and assertions. + - Never use if statements, custom error handling, or try/catch blocks in tests. + - Never use regular expressions in tests; use simple string matching instead. + +9. What to test: + - Enter invalid values, such as empty strings, only whitespace characters, long strings, negative numbers, Unicode, etc. + - Tooltips, keyboard navigation, accessibility, validation messages, translations, responsiveness, etc. + +10. Test Fixtures and Page Management: + - Use appropriate fixtures: `{ page }` for basic tests, `{ anonymousPage }` for tests with existing tenant/owner but not logged in, `{ ownerPage }`, `{ adminPage }`, `{ memberPage }` for authenticated tests. + - Destructure anonymous page data: `const { page, tenant } = anonymousPage; const existingUser = tenant.owner;` + - Pre-logged in users (`ownerPage`, `adminPage`, `memberPage`) are isolated between workers and will not conflict between tests. + - When using pre-logged in users, do not put the tenant or user into an invalid state that could affect other tests. + +11. Test Data and Constants: + - Use underscore separators: `const timeout = 30_000; // 30 seconds` + - Generate unique data: `const email = uniqueEmail();` + - Use faker.js to generate realistic test data: `const firstName = faker.person.firstName(); const email = faker.internet.email();` + - Long string testing: `const longEmail = \`${"a".repeat(90)}@example.com\`; // 101 characters total` + +12. Memory Management in E2E Tests: + - Playwright automatically handles browser context cleanup after tests + - Manual cleanup steps are unnecessary - focus on test clarity over micro-optimizations + - E2E test suites have minimal memory leak concerns due to their limited scope and duration + +## Examples + +### ✅ Good Step Naming Examples +```typescript +// ✅ DO: Business action + details & expected outcome +await step("Submit invalid email & verify validation error")(async () => { + await page.getByLabel("Email").fill("invalid-email"); + await blurActiveElement(page); + + await expectValidationError(context, "Invalid email."); +})(); + +await step("Sign up with valid credentials & verify account creation")(async () => { + await page.getByRole("button", { name: "Submit" }).click(); + + await expect(page.getByText("Welcome")).toBeVisible(); +})(); + +await step("Update user role to admin & verify permission change")(async () => { + const userRow = page.locator("tbody tr").first(); + + await userRow.getByLabel("User actions").click(); + await page.getByRole("menuitem", { name: "Change role" }).click(); + + await expect(page.getByRole("alertdialog", { name: "Change user role" })).toBeVisible(); +})(); +``` + +### ❌ Bad Step Naming Examples +```typescript +// ❌ DON'T: Pure assertion steps without actions +await step("Verify button is visible")(async () => { + await expect(page.getByRole("button")).toBeVisible(); // No action, only assertion +})(); + +// ❌ DON'T: Using test/assertion prefixes +await step("Check user permissions")(async () => { // "Check" is assertion prefix + await expect(page.getByText("Admin")).toBeVisible(); +})(); + +await step("Validate form state")(async () => { // "Validate" is assertion prefix + await expect(page.getByRole("textbox")).toBeEmpty(); +})(); + +await step("Ensure user is deleted")(async () => { // "Ensure" is assertion prefix + await expect(page.getByText("user@example.com")).not.toBeVisible(); +})(); +``` + +### ✅ Complete Test Example +```typescript +import { step } from "@shared/e2e/utils/step-decorator"; +import { expectValidationError, blurActiveElement, createTestContext } from "@shared/e2e/utils/test-assertions"; +import { testUser } from "@shared/e2e/utils/test-data"; + +test.describe("@smoke", () => { + test("should complete signup with validation", async ({ page }) => { + const context = createTestContext(page); + const user = testUser(); + + await step("Submit invalid email & verify validation error")(async () => { + await page.goto("/signup"); + await page.getByLabel("Email").fill("invalid-email"); + await blurActiveElement(page); // ✅ DO: Trigger validation when updating textbox second time + + await expectValidationError(context, "Invalid email."); + })(); + + await step("Sign up with valid email & verify verification redirect")(async () => { + await page.getByLabel("Email").fill(user.email); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/verify"); + })(); + }); +}); + +test.describe("@comprehensive", () => { + test("should handle user management with pre-logged owner", async ({ ownerPage }) => { + createTestContext(ownerPage); // ✅ DO: Create context for pre-logged users + + await step("Access user management & verify owner permissions")(async () => { + await ownerPage.getByRole("button", { name: "Users" }).click(); + + await expect(ownerPage.getByRole("heading", { name: "Users" })).toBeVisible(); + })(); + }); +}); + +test.describe("@slow", () => { + const requestNewCodeTimeout = 30_000; // 30 seconds + const codeValidationTimeout = 60_000; // 5 minutes + const sessionTimeout = codeValidationTimeout + 60_000; // 6 minutes + + test("should handle user logout after to many login attempts", async ({ page }) => { // ✅ DO: use new page, when testing e.g. account lockout + test.setTimeout(sessionTimeout); // ✅ DO: Set timeout based on actual wait times + const context = createTestContext(page); + + // ... + + await step("Wait for code expiration & verify timeout behavior")(async () => { + await page.goto("/login/verify"); + await page.waitForTimeout(codeValidationTimeout); // ✅ DO: Use actual waits in @slow tests + + await expect(page.getByText("Your verification code has expired")).toBeVisible(); + })(); + }); +}); +``` + +```typescript +test.describe("@security", () => { // ❌ DON'T: Don't invent new tags - use @smoke, @comprehensive, @slow only + test("should handle login", async ({ page }) => { + // ❌ DON'T: Skip createTestContext(page); step + page.setDefaultTimeout(5000); // ❌ DON'T: Set timeouts manually - use global config + + // ❌ DON'T: Use test/assertion prefixes in step descriptions + await step("Test login functionality")(async () => { // ❌ Should be "Submit login form & verify authentication" + await step("Verify button is visible")(async () => { // ❌ Should be "Navigate to page & verify button is visible" + await step("Check user permissions")(async () => { // ❌ Should be "Click user menu & verify permissions" + if (page.url().includes("/login/verify")) { // ❌ DON'T: Add conditional logic - tests should be linear + await page.waitForTimeout(2000); // ❌ DON'T: Add manual timeouts + // Continue with verification... // ❌ DON'T: Write verbose explanatory comments + } + + await page.click("#submit-btn"); // ❌ DON'T: Use CSS selectors - use semantic selectors + + // ❌ DON'T: Skip assertions for side effects + })(); + + // ❌ DON'T: Use regular expressions - use simple string matching instead + await expect(page.getByText(/welcome.*home/i)).toBeVisible(); // ❌ Should be: page.getByText("Welcome home") + await expect(page.locator('input[name*="email"]')).toBeFocused(); // ❌ Should be: page.getByLabel("Email") + }); + + // ❌ DON'T: Place assertions outside test functions + expect(page.url().includes("/admin") || page.url().includes("/login")).toBeTruthy(); // ❌ DON'T: Use ambiguous assertions + + // ❌ DON'T: Use try/catch to handle flaky behavior - makes tests unreliable + try { + await page.waitForLoadState("networkidle"); // ❌ DON'T: Add timeout logic in tests + await page.getByRole("button", { name: "Submit" }).click({ timeout: 1000 }); // ❌ DON'T: Add timeouts to actions + } catch (error) { + await page.waitForTimeout(1000); // ❌ DON'T: Add manual waits + console.log("Retrying..."); // ❌ DON'T: Add custom error handling + } +}); + +// ❌ DON'T: Create tests without proper organization +test("isolated test without describe block", async ({ page }) => { + // ❌ DON'T: Violates organization rules +}); +``` diff --git a/.cursor/rules/tools.mdc b/.cursor/rules/tools.mdc index 8500bb2a0..8135674e3 100644 --- a/.cursor/rules/tools.mdc +++ b/.cursor/rules/tools.mdc @@ -9,6 +9,8 @@ alwaysApply: true * BrowserMCP: Use this to troubleshoot frontend issues. The base URL is https://localhost:9000. The website is already running, so never start it manually. * `[PRODUCT_MANAGEMENT_TOOL]`: Use this MCP tool to create and manage product backlog items. +If an MCP Server is not responding, instruct the user to activate it rather than using workarounds like calling `curl` when the browser MCP is unavailable. + # Product Management Tools > **Update this value in ONE place:** @@ -25,7 +27,7 @@ Use the `[CLI_ALIAS]` Developer CLI to build, test, and format backend and front Always use the Developer CLI to build, test, and format code correctly over using direct commands like `npm run format` or `dotnet test`. -**IMPORTANT:** Never fall back to using direct commands like `npm run format` or `dotnet test`. Always use the Developer CLI with the appropriate alias. +**IMPORTANT:** Never fall back to using direct commands like `npm run format`, `dotnet test`, `npx playwright test`, `npm test`, etc. Always use the Developer CLI with the appropriate alias. ## CLI Alias Configuration @@ -67,6 +69,29 @@ After you have completed a backend task and want to ensure that it works as expe [CLI_ALIAS] test --solution-name ``` +## End-to-End Test Commands + +```bash +# Run all end-to-end tests except slow tests +[CLI_ALIAS] e2e + +# Run end-to-end tests for specific so lution +[CLI_ALIAS] e2e --self-contained-system + +# Run end-to-end tests for specific browser +[CLI_ALIAS] e2e --browser + +# Run end-to-end tests for specific search term +[CLI_ALIAS] e2e + +# Run end-to-end tests for specific test tags +[CLI_ALIAS] e2e "@smoke" +[CLI_ALIAS] e2e "smoke" +[CLI_ALIAS] e2e "@comprehensive" +``` + +Any combination of the above parameters is possible. There are other parameters available, but you should only use the ones mentioned above. + ## Format Commands Run these commands before you commit your changes. diff --git a/.cursor/rules/workflows/create-e2e-tests.mdc b/.cursor/rules/workflows/create-e2e-tests.mdc new file mode 100644 index 000000000..cc1091629 --- /dev/null +++ b/.cursor/rules/workflows/create-e2e-tests.mdc @@ -0,0 +1,64 @@ +--- +description: Workflow for creating end-to-end tests +globs: +alwaysApply: false +--- +# E2E Testing Workflow + +This workflow guides you through the process of creating comprehensive end-to-end tests for specific features like login and signup. It focuses on identifying what tests to write, planning complex scenarios, and ensuring tests follow the established conventions. + +## Workflow + +1. Understand the feature under test: + - Study the frontend components and their interactions. + - Review API endpoints and authentication flows. + - Understand validation rules and error handling. + - Identify key user interactions and expected behaviors. + +2. Use Browser MCP to explore the webapp functionality: + - Navigate to the application: `mcp0_browser_navigate({ url: "https://localhost:9000" })`. + - Interact with the feature manually to understand user flows. + - Take snapshots to identify UI elements and their structure. + - Document key interactions and expected behaviors. + - Note any edge cases or potential issues discovered during exploration. + +3. Review existing test examples: + - Read [End-to-End Tests](/.cursor/rules/end-to-end-tests/e2e-tests.mdc) for detailed information. + - Examine [signup.spec.ts](/application/account-management/WebApp/tests/e2e/signup.spec.ts) and [login.spec.ts](/application/account-management/WebApp/tests/e2e/login.spec.ts) for inspiration. + - Note the structure, assertions, test organization, and the "Act & Assert:" comment format. + +4. Plan comprehensive test scenarios: + - Identify standard user journeys through the feature. + - Plan for complex multi-session scenarios like: + - Concurrent sessions: What happens when a user has two tabs open? + - Cross-session state changes: What happens when state changes in one session affect another? + - Authentication conflicts: How does the system handle authentication changes across sessions? + - Form submissions across sessions: What happens with concurrent form submissions? + - Antiforgery token handling: How are antiforgery tokens managed across tabs? + - Browser navigation: Back/forward buttons, refresh, direct URL access. + - Network conditions: Slow connections, disconnections during operations. + - Input validation: Boundary values, special characters, extremely long inputs. + - Accessibility: Keyboard navigation, screen reader compatibility. + - Localization: Testing with different languages and formats. + +5. Categorize tests appropriately: + - `@smoke`: Essential functionality that will run on deployment of any system. + - Create one comprehensive smoke.spec.ts per self-contained system. + - Test complete user journeys: signup → profile setup → invite users → manage roles → tenant settings → logout. + - Include validation errors, retries, and recovery scenarios within the journey. + - `@comprehensive`: More thorough tests covering edge cases that will run on deployment of the system under test. + - Focus on specific feature areas with deep testing of edge cases. + - Group related scenarios to minimize test count while maximizing coverage. + - `@slow`: Tests involving timeouts or waiting periods that will run ad-hoc, when features under test are changed. + +6. Create or update test structure: + - For smoke tests: Create/update `application/[scs-name]/WebApp/tests/e2e/smoke.spec.ts`. + - For comprehensive tests: Create feature-specific files like `user-management.spec.ts`, `authentication.spec.ts`. + - Avoid creating many small, isolated tests - prefer comprehensive scenarios that test multiple aspects. + +## Key principles + +- Comprehensive coverage: Test all critical paths and important edge cases. +- Follow conventions: Adhere to the established patterns in [End-to-End Tests](/.cursor/rules/end-to-end-tests/e2e-tests.mdc). +- Clear organization: Properly categorize tests and use descriptive names. +- Realistic user journeys: Test scenarios that reflect actual user behavior. diff --git a/.cursor/rules/workflows/implement-product-increment.mdc b/.cursor/rules/workflows/implement-product-increment.mdc index 42aef5124..c8adbf7d5 100644 --- a/.cursor/rules/workflows/implement-product-increment.mdc +++ b/.cursor/rules/workflows/implement-product-increment.mdc @@ -17,10 +17,14 @@ Follow these steps which describe in detail how you must implement the tasks in Before implementing each task, review the relevant rules thoroughly: - - For **backend tasks**: - - Review all the [Backend](mdc:.cursor/rules/backend) rule files. - - For **frontend tasks**: - - Review all the [Frontend](mdc:.cursor/rules/frontend) rule files. +- For **backend tasks**: + - Review all the [Backend](mdc:.cursor/rules/backend) rule files. +- For **frontend tasks**: + - Review all the [Frontend](mdc:.cursor/rules/frontend) rule files. +- For **end-to-end tests**: + - Review the [E2E Testing Workflow](mdc:.cursor/rules/workflows/create-e2e-tests.mdc) and all the [End-to-End Tests](mdc:.cursor/rules/end-to-end-tests) rule files. +- For **Developer CLI commands**: + - Review all the [Developer CLI](mdc:.cursor/rules/developer-cli) rule files. These rules define the conventions and must be strictly adhered to during implementation. diff --git a/.gitignore b/.gitignore index 82d719f94..31af6d9aa 100644 --- a/.gitignore +++ b/.gitignore @@ -400,3 +400,10 @@ dist/ # Git submodules .gitmodules + +# Playwright E2E testing artifacts +test-results/ +playwright-report/ +**/playwright/.cache/ +**/.auth/ + diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 8ac133d7a..579e953ad 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,8 +1,9 @@ { "recommendations": [ + "biomejs.biome", "bradlc.vscode-tailwindcss", "ms-azuretools.vscode-bicep", - "github.vscode-github-actions", - "biomejs.biome", + "ms-playwright.playwright", + "github.vscode-github-actions" ] } diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..8893407e9 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,64 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run All Playwright Tests", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/application/node_modules/@playwright/test/cli.js", + "args": ["test"], + "cwd": "${workspaceFolder}/application/account-management/WebApp/tests", + "env": { + "PUBLIC_URL": "https://localhost:9000" + }, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + }, + { + "name": "Run Smoke Tests", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/application/node_modules/@playwright/test/cli.js", + "args": ["test", "--grep", "@smoke"], + "cwd": "${workspaceFolder}/application/account-management/WebApp/tests", + "env": { + "PUBLIC_URL": "https://localhost:9000" + }, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + }, + { + "name": "Debug Current Test (Chrome)", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/application/node_modules/@playwright/test/cli.js", + "args": ["test", "${relativeFile}", "--headed", "--project=chromium", "--timeout=0"], + "cwd": "${workspaceFolder}/application/account-management/WebApp/tests", + "env": { + "PUBLIC_URL": "https://localhost:9000", + "PWDEBUG": "0" + }, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "sourceMaps": true, + "smartStep": true, + "skipFiles": [ + "/**", + "**/node_modules/**" + ] + }, + { + "name": "Debug with Playwright Inspector", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/application/node_modules/@playwright/test/cli.js", + "args": ["test", "${relativeFile}", "--debug", "--project=chromium"], + "cwd": "${workspaceFolder}/application/account-management/WebApp/tests", + "env": { + "PUBLIC_URL": "https://localhost:9000" + }, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index f608d0476..13797a3e5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -27,7 +27,8 @@ "editor.defaultFormatter": "biomejs.biome" }, "files.associations": { - "*.css": "tailwindcss" + "*.css": "tailwindcss", + "*.spec.ts": "typescript" }, "githubIssues.issueBranchTitle": "${issueNumber}-${sanitizedLowercaseIssueTitle}", "githubPullRequests.assignCreated": "${user}", @@ -35,4 +36,6 @@ "biome.lspBin": "./application/node_modules/.bin/biome", "biome.searchInPath": false, "biome.enabled": true, + "playwright.projectDir": "application/End2EndTests", + "playwright.showTrace": true, } diff --git a/.windsurf/rules/end-to-end-tests/e2e-tests.md b/.windsurf/rules/end-to-end-tests/e2e-tests.md new file mode 100644 index 000000000..4a4fc00b0 --- /dev/null +++ b/.windsurf/rules/end-to-end-tests/e2e-tests.md @@ -0,0 +1,263 @@ +--- +trigger: glob +globs: */tests/e2e/** +description: Rules for end-to-end tests +--- + +# End-to-End Tests + +These rules outline the structure, patterns, and best practices for writing end-to-end tests. + +## Implementation + +1. Use `[CLI_ALIAS] e2e` with these option categories to optimize test execution: + - Test filtering: `--smoke`, `--include-slow`, search terms (e.g., `"@smoke"`, `"smoke"`, `"user"`, `"localization"`), `--browser` + - Change scoping: `--last-failed`, `--only-changed` + - Flaky test detection: `--repeat-each`, `--retries`, `--stop-on-first-failure` + - Performance: `--debug-timings` shows step execution times with color coding + +2. Test Search and Filtering: + - Search by test tags: `[CLI_ALIAS] e2e "@smoke"` or `[CLI_ALIAS] e2e "smoke"` (both work the same) + - Search by test content: `[CLI_ALIAS] e2e "user"` (finds tests with "user" in title or content) + - Search by filename: `[CLI_ALIAS] e2e "localization"` (finds localization-flows.spec.ts) + - Search by specific file: `[CLI_ALIAS] e2e "user-management-flows.spec.ts"` + - Multiple search terms: `[CLI_ALIAS] e2e "user" "management"` + - The CLI automatically detects which self-contained systems contain matching tests and only runs those + +3. Test-Driven Debugging Process: + - Focus on one failing test at a time and make it pass before moving to the next. + - Ensure tests use Playwright's built-in auto-waiting assertions: `toHaveURL()`, `toBeVisible()`, `toBeEnabled()`, `toHaveValue()`, `toContainText()`. + - Consider if root causes can be fixed in the application code, and fix application bugs rather than masking them with test workarounds. + +4. Organize tests in a consistent file structure: + - All e2e test files must be located in `[self-contained-system]/WebApp/tests/e2e/` folder (e.g., `application/account-management/WebApp/tests/e2e/`). + - All test files use the `*-flows.spec.ts` naming convention (e.g., `login-flows.spec.ts`, `signup-flows.spec.ts`, `user-management-flows.spec.ts`). + - Top-level describe blocks must use only these 3 approved tags: `test.describe("@smoke", () => {})`, `test.describe("@comprehensive", () => {})`, `test.describe("@slow", () => {})`. + - `@smoke` tests: + - Critical tests run on deployment of any self-contained system. + - Should be comprehensive scenarios that test core user journeys. + - Keep tests focused on specific flows to reduce fragility while maintaining coverage. + - Focus on must-work functionality with extensive validation steps. + - Include boundary cases and error handling within the same test scenario. + - Avoid testing the same functionality multiple times across different tests. + + - `@comprehensive` tests: + - Thorough tests run when a specific self-contained system is deployed. + - Focus on edge cases, error conditions, and less common scenarios. + - Test specific features in depth with various input combinations. + - Include tests for concurrency, validation rules, accessibility, etc. + - Group related edge cases together to reduce test count while maintaining coverage. + + - `@slow` tests: + - Optional and run only ad-hoc using `--include-slow` flag. + - Any tests that require waiting like `waitForTimeout` (e.g., for OTP timeouts) must be marked as `@slow`. + - Include tests for rate limiting with actual wait times, session timeouts, etc. + - Use `test.setTimeout()` at the individual test level based on actual wait times needed. + +5. Write clear test descriptions and documentation: + - Test descriptions must accurately reflect what the test covers and be kept in sync with test implementation. + - Use descriptive test names that clearly indicate the functionality being tested (e.g., "should handle single and bulk user deletion workflows with dashboard integration"). + - Include JSDoc comments above complex tests listing all major features/scenarios covered. + - When adding new functionality to existing tests, update both the test description and JSDoc comments to reflect changes. + +6. Structure each test with step decorators and proper monitoring: + - All tests must start with `const context = createTestContext(page);` for proper error monitoring. + - Use step decorators: `await step("Complete signup & verify account creation")(async () => { /* test logic */ })();` + - Step naming conventions: + - Always follow "[Business action + details] & [expected outcome]" pattern. + - Use business action verbs like "Sign up", "Login", "Invite", "Rename", "Update", "Delete", "Create", "Submit". + - Never use test/assertion prefixes like "Test", "Verify", "Check", "Validate", "Ensure"; use descriptive business actions instead. + - Every step must include an action (arrange/act) followed by assertions, not pure assertion steps. + - Step structure: + - Use blank lines to separate arrange/act/assert sections within steps. + - Keep shared variable declarations outside steps when used across multiple steps. + - Use section headers with `// === SECTION NAME ===` to group related steps. + - Add JSDoc comments for complex test workflows. + - Use semantic selectors: `page.getByRole("button", { name: "Submit" })`, `page.getByText("Welcome")`, `page.getByLabel("Email")`. + - Assert side effects immediately after actions using `expectToastMessage`, `expectValidationError`, `expectNetworkErrors`. + - Form validation pattern: Use `await blurActiveElement(page);` when updating a textbox the second time before submitting a form to trigger validation. + +7. Timeout Configuration: + - Always use Playwright's built-in auto-waiting assertions: `toHaveURL()`, `toBeVisible()`, `toBeEnabled()`, `toHaveValue()`, `toContainText()`. + - Never add timeouts to `.click()`, `.waitForSelector()`, etc. + - Global timeout configuration is handled in the shared Playwright. Don't change this. + +8. Write deterministic tests - This is critical for reliable testing: + - Each test should have a clear, linear flow of actions and assertions. + - Never use if statements, custom error handling, or try/catch blocks in tests. + - Never use regular expressions in tests; use simple string matching instead. + +9. What to test: + - Enter invalid values, such as empty strings, only whitespace characters, long strings, negative numbers, Unicode, etc. + - Tooltips, keyboard navigation, accessibility, validation messages, translations, responsiveness, etc. + +10. Test Fixtures and Page Management: + - Use appropriate fixtures: `{ page }` for basic tests, `{ anonymousPage }` for tests with existing tenant/owner but not logged in, `{ ownerPage }`, `{ adminPage }`, `{ memberPage }` for authenticated tests. + - Destructure anonymous page data: `const { page, tenant } = anonymousPage; const existingUser = tenant.owner;` + - Pre-logged in users (`ownerPage`, `adminPage`, `memberPage`) are isolated between workers and will not conflict between tests. + - When using pre-logged in users, do not put the tenant or user into an invalid state that could affect other tests. + +11. Test Data and Constants: + - Use underscore separators: `const timeout = 30_000; // 30 seconds` + - Generate unique data: `const email = uniqueEmail();` + - Use faker.js to generate realistic test data: `const firstName = faker.person.firstName(); const email = faker.internet.email();` + - Long string testing: `const longEmail = \`${"a".repeat(90)}@example.com\`; // 101 characters total` + +12. Memory Management in E2E Tests: + - Playwright automatically handles browser context cleanup after tests + - Manual cleanup steps are unnecessary - focus on test clarity over micro-optimizations + - E2E test suites have minimal memory leak concerns due to their limited scope and duration + +## Examples + +### ✅ Good Step Naming Examples +```typescript +// ✅ DO: Business action + details & expected outcome +await step("Submit invalid email & verify validation error")(async () => { + await page.getByLabel("Email").fill("invalid-email"); + await blurActiveElement(page); + + await expectValidationError(context, "Invalid email."); +})(); + +await step("Sign up with valid credentials & verify account creation")(async () => { + await page.getByRole("button", { name: "Submit" }).click(); + + await expect(page.getByText("Welcome")).toBeVisible(); +})(); + +await step("Update user role to admin & verify permission change")(async () => { + const userRow = page.locator("tbody tr").first(); + + await userRow.getByLabel("User actions").click(); + await page.getByRole("menuitem", { name: "Change role" }).click(); + + await expect(page.getByRole("alertdialog", { name: "Change user role" })).toBeVisible(); +})(); +``` + +### ❌ Bad Step Naming Examples +```typescript +// ❌ DON'T: Pure assertion steps without actions +await step("Verify button is visible")(async () => { + await expect(page.getByRole("button")).toBeVisible(); // No action, only assertion +})(); + +// ❌ DON'T: Using test/assertion prefixes +await step("Check user permissions")(async () => { // "Check" is assertion prefix + await expect(page.getByText("Admin")).toBeVisible(); +})(); + +await step("Validate form state")(async () => { // "Validate" is assertion prefix + await expect(page.getByRole("textbox")).toBeEmpty(); +})(); + +await step("Ensure user is deleted")(async () => { // "Ensure" is assertion prefix + await expect(page.getByText("user@example.com")).not.toBeVisible(); +})(); +``` + +### ✅ Complete Test Example +```typescript +import { step } from "@shared/e2e/utils/step-decorator"; +import { expectValidationError, blurActiveElement, createTestContext } from "@shared/e2e/utils/test-assertions"; +import { testUser } from "@shared/e2e/utils/test-data"; + +test.describe("@smoke", () => { + test("should complete signup with validation", async ({ page }) => { + const context = createTestContext(page); + const user = testUser(); + + await step("Submit invalid email & verify validation error")(async () => { + await page.goto("/signup"); + await page.getByLabel("Email").fill("invalid-email"); + await blurActiveElement(page); // ✅ DO: Trigger validation when updating textbox second time + + await expectValidationError(context, "Invalid email."); + })(); + + await step("Sign up with valid email & verify verification redirect")(async () => { + await page.getByLabel("Email").fill(user.email); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/verify"); + })(); + }); +}); + +test.describe("@comprehensive", () => { + test("should handle user management with pre-logged owner", async ({ ownerPage }) => { + createTestContext(ownerPage); // ✅ DO: Create context for pre-logged users + + await step("Access user management & verify owner permissions")(async () => { + await ownerPage.getByRole("button", { name: "Users" }).click(); + + await expect(ownerPage.getByRole("heading", { name: "Users" })).toBeVisible(); + })(); + }); +}); + +test.describe("@slow", () => { + const requestNewCodeTimeout = 30_000; // 30 seconds + const codeValidationTimeout = 60_000; // 5 minutes + const sessionTimeout = codeValidationTimeout + 60_000; // 6 minutes + + test("should handle user logout after to many login attempts", async ({ page }) => { // ✅ DO: use new page, when testing e.g. account lockout + test.setTimeout(sessionTimeout); // ✅ DO: Set timeout based on actual wait times + const context = createTestContext(page); + + // ... + + await step("Wait for code expiration & verify timeout behavior")(async () => { + await page.goto("/login/verify"); + await page.waitForTimeout(codeValidationTimeout); // ✅ DO: Use actual waits in @slow tests + + await expect(page.getByText("Your verification code has expired")).toBeVisible(); + })(); + }); +}); +``` + +```typescript +test.describe("@security", () => { // ❌ DON'T: Don't invent new tags - use @smoke, @comprehensive, @slow only + test("should handle login", async ({ page }) => { + // ❌ DON'T: Skip createTestContext(page); step + page.setDefaultTimeout(5000); // ❌ DON'T: Set timeouts manually - use global config + + // ❌ DON'T: Use test/assertion prefixes in step descriptions + await step("Test login functionality")(async () => { // ❌ Should be "Submit login form & verify authentication" + await step("Verify button is visible")(async () => { // ❌ Should be "Navigate to page & verify button is visible" + await step("Check user permissions")(async () => { // ❌ Should be "Click user menu & verify permissions" + if (page.url().includes("/login/verify")) { // ❌ DON'T: Add conditional logic - tests should be linear + await page.waitForTimeout(2000); // ❌ DON'T: Add manual timeouts + // Continue with verification... // ❌ DON'T: Write verbose explanatory comments + } + + await page.click("#submit-btn"); // ❌ DON'T: Use CSS selectors - use semantic selectors + + // ❌ DON'T: Skip assertions for side effects + })(); + + // ❌ DON'T: Use regular expressions - use simple string matching instead + await expect(page.getByText(/welcome.*home/i)).toBeVisible(); // ❌ Should be: page.getByText("Welcome home") + await expect(page.locator('input[name*="email"]')).toBeFocused(); // ❌ Should be: page.getByLabel("Email") + }); + + // ❌ DON'T: Place assertions outside test functions + expect(page.url().includes("/admin") || page.url().includes("/login")).toBeTruthy(); // ❌ DON'T: Use ambiguous assertions + + // ❌ DON'T: Use try/catch to handle flaky behavior - makes tests unreliable + try { + await page.waitForLoadState("networkidle"); // ❌ DON'T: Add timeout logic in tests + await page.getByRole("button", { name: "Submit" }).click({ timeout: 1000 }); // ❌ DON'T: Add timeouts to actions + } catch (error) { + await page.waitForTimeout(1000); // ❌ DON'T: Add manual waits + console.log("Retrying..."); // ❌ DON'T: Add custom error handling + } +}); + +// ❌ DON'T: Create tests without proper organization +test("isolated test without describe block", async ({ page }) => { + // ❌ DON'T: Violates organization rules +}); +``` diff --git a/.windsurf/rules/tools.md b/.windsurf/rules/tools.md index c9ba94473..87db2f21d 100644 --- a/.windsurf/rules/tools.md +++ b/.windsurf/rules/tools.md @@ -9,6 +9,8 @@ description: You have access to several tools and MCP servers * BrowserMCP: Use this to troubleshoot frontend issues. The base URL is https://localhost:9000. The website is already running, so never start it manually. * `[PRODUCT_MANAGEMENT_TOOL]`: Use this MCP tool to create and manage product backlog items. +If an MCP Server is not responding, instruct the user to activate it rather than using workarounds like calling `curl` when the browser MCP is unavailable. + # Product Management Tools > **Update this value in ONE place:** @@ -25,7 +27,7 @@ Use the `[CLI_ALIAS]` Developer CLI to build, test, and format backend and front Always use the Developer CLI to build, test, and format code correctly over using direct commands like `npm run format` or `dotnet test`. -**IMPORTANT:** Never fall back to using direct commands like `npm run format` or `dotnet test`. Always use the Developer CLI with the appropriate alias. +**IMPORTANT:** Never fall back to using direct commands like `npm run format`, `dotnet test`, `npx playwright test`, `npm test`, etc. Always use the Developer CLI with the appropriate alias. ## CLI Alias Configuration @@ -67,6 +69,29 @@ After you have completed a backend task and want to ensure that it works as expe [CLI_ALIAS] test --solution-name ``` +## End-to-End Test Commands + +```bash +# Run all end-to-end tests except slow tests +[CLI_ALIAS] e2e + +# Run end-to-end tests for specific so lution +[CLI_ALIAS] e2e --self-contained-system + +# Run end-to-end tests for specific browser +[CLI_ALIAS] e2e --browser + +# Run end-to-end tests for specific search term +[CLI_ALIAS] e2e + +# Run end-to-end tests for specific test tags +[CLI_ALIAS] e2e "@smoke" +[CLI_ALIAS] e2e "smoke" +[CLI_ALIAS] e2e "@comprehensive" +``` + +Any combination of the above parameters is possible. There are other parameters available, but you should only use the ones mentioned above. + ## Format Commands Run these commands before you commit your changes. diff --git a/.windsurf/workflows/create-e2e-tests.md b/.windsurf/workflows/create-e2e-tests.md new file mode 100644 index 000000000..90aca4bf0 --- /dev/null +++ b/.windsurf/workflows/create-e2e-tests.md @@ -0,0 +1,63 @@ +--- +description: Workflow for creating end-to-end tests +--- + +# E2E Testing Workflow + +This workflow guides you through the process of creating comprehensive end-to-end tests for specific features like login and signup. It focuses on identifying what tests to write, planning complex scenarios, and ensuring tests follow the established conventions. + +## Workflow + +1. Understand the feature under test: + - Study the frontend components and their interactions. + - Review API endpoints and authentication flows. + - Understand validation rules and error handling. + - Identify key user interactions and expected behaviors. + +2. Use Browser MCP to explore the webapp functionality: + - Navigate to the application: `mcp0_browser_navigate({ url: "https://localhost:9000" })`. + - Interact with the feature manually to understand user flows. + - Take snapshots to identify UI elements and their structure. + - Document key interactions and expected behaviors. + - Note any edge cases or potential issues discovered during exploration. + +3. Review existing test examples: + - Read [End-to-End Tests](/.windsurf/rules/end-to-end-tests/e2e-tests.md) for detailed information. + - Examine [signup.spec.ts](/application/account-management/WebApp/tests/e2e/signup.spec.ts) and [login.spec.ts](/application/account-management/WebApp/tests/e2e/login.spec.ts) for inspiration. + - Note the structure, assertions, test organization, and the "Act & Assert:" comment format. + +4. Plan comprehensive test scenarios: + - Identify standard user journeys through the feature. + - Plan for complex multi-session scenarios like: + - Concurrent sessions: What happens when a user has two tabs open? + - Cross-session state changes: What happens when state changes in one session affect another? + - Authentication conflicts: How does the system handle authentication changes across sessions? + - Form submissions across sessions: What happens with concurrent form submissions? + - Antiforgery token handling: How are antiforgery tokens managed across tabs? + - Browser navigation: Back/forward buttons, refresh, direct URL access. + - Network conditions: Slow connections, disconnections during operations. + - Input validation: Boundary values, special characters, extremely long inputs. + - Accessibility: Keyboard navigation, screen reader compatibility. + - Localization: Testing with different languages and formats. + +5. Categorize tests appropriately: + - `@smoke`: Essential functionality that will run on deployment of any system. + - Create one comprehensive smoke.spec.ts per self-contained system. + - Test complete user journeys: signup → profile setup → invite users → manage roles → tenant settings → logout. + - Include validation errors, retries, and recovery scenarios within the journey. + - `@comprehensive`: More thorough tests covering edge cases that will run on deployment of the system under test. + - Focus on specific feature areas with deep testing of edge cases. + - Group related scenarios to minimize test count while maximizing coverage. + - `@slow`: Tests involving timeouts or waiting periods that will run ad-hoc, when features under test are changed. + +6. Create or update test structure: + - For smoke tests: Create/update `application/[scs-name]/WebApp/tests/e2e/smoke.spec.ts`. + - For comprehensive tests: Create feature-specific files like `user-management.spec.ts`, `authentication.spec.ts`. + - Avoid creating many small, isolated tests - prefer comprehensive scenarios that test multiple aspects. + +## Key principles + +- Comprehensive coverage: Test all critical paths and important edge cases. +- Follow conventions: Adhere to the established patterns in [End-to-End Tests](/.windsurf/rules/end-to-end-tests/e2e-tests.md). +- Clear organization: Properly categorize tests and use descriptive names. +- Realistic user journeys: Test scenarios that reflect actual user behavior. diff --git a/.windsurf/workflows/implement-product-increment.md b/.windsurf/workflows/implement-product-increment.md index eb36381f4..8b477e423 100644 --- a/.windsurf/workflows/implement-product-increment.md +++ b/.windsurf/workflows/implement-product-increment.md @@ -16,10 +16,14 @@ Follow these steps which describe in detail how you must implement the tasks in Before implementing each task, review the relevant rules thoroughly: - - For **backend tasks**: - - Review all the [Backend](/.windsurf/rules/backend) rule files. - - For **frontend tasks**: - - Review all the [Frontend](/.windsurf/rules/frontend) rule files. +- For **backend tasks**: + - Review all the [Backend](/.windsurf/rules/backend) rule files. +- For **frontend tasks**: + - Review all the [Frontend](/.windsurf/rules/frontend) rule files. +- For **end-to-end tests**: + - Review the [E2E Testing Workflow](/.windsurf/workflows/create-e2e-tests.md) and all the [End-to-End Tests](/.windsurf/rules/end-to-end-tests) rule files. +- For **Developer CLI commands**: + - Review all the [Developer CLI](/.windsurf/rules/developer-cli) rule files. These rules define the conventions and must be strictly adhered to during implementation. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..09f47617c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,173 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Main Entry Point + +This is the main entry point for AI-based development when working with this codebase, but also serves as a great reference for developers. + +Always follow these rule files very carefully, as they have been crafted to ensure consistency and high-quality code. + +## High-Level Problem Solving Strategy + +1. Understand the problem deeply. Carefully read the instructions and think critically about what is required. +2. Investigate the codebase. Explore relevant files, search for key functions, and gather context. +3. Develop a clear, step-by-step plan. Break down the fix into manageable, incremental steps. +4. Before each code change, always consult the relevant rule files, and follow the rules very carefully. + - Failure to follow the rules is the main reason for making unacceptable changes. +5. Iterate until you are extremely confident the fix is complete. + - When changing code, do not add comments about what you changed. +6. After each change, make sure you follow the rules in Backend Rules or Frontend Rules on how to correctly use the [CLI_ALIAS] CLI tool for building, testing, and formatting the code. + - Failure to use the [CLI_ALIAS] CLI tool after each change is the second most common reason for making unacceptable changes. + - Always use the [CLI_ALIAS] CLI commands as described in Tools for building, testing, formatting, and inspecting code. + +## Rules for implementing changes + +Always consult the relevant rule files before each code change. + +Please note that I often correct or even revert code you generated. If you notice that, take special care not to revert my changes. + +Commit messages should be in imperative form, start with a capital letter, avoid ending punctuation, be a single line, and concisely describe changes and motivation. + +## CLI Alias Configuration + +The `[CLI_ALIAS]` is configured as `pp` (PlatformPlatform CLI). This Developer CLI should be used for all build, test, and format operations instead of direct commands. + +**IMPORTANT:** Never fall back to using direct commands like `npm run format`, `dotnet test`, `npx playwright test`, `npm test`, etc. Always use the Developer CLI with the appropriate alias. + +## Build Commands + +Use these commands continuously when you are working on the codebase. + +```bash +# Build both backend and frontend +pp build + +# Build only backend +pp build --backend + +# Build specific backend solution +pp build --backend --solution-name + +# Build only frontend +pp build --frontend +``` + +## Test Commands + +After you have completed a backend task and want to ensure that it works as expected, run the test commands. + +```bash +# Run all tests +pp test + +# Run tests for specific solution +pp test --solution-name +``` + +## End-to-End Test Commands + +```bash +# Run all end-to-end tests except slow tests +pp e2e + +# Run end-to-end tests for specific solution +pp e2e --self-contained-system + +# Run end-to-end tests for specific browser +pp e2e --browser + +# Run end-to-end tests for specific test +pp e2e --grep + +# Run end-to-end tests for specific test and browser +pp e2e --grep "@tag" + +# Include slow tests (excluded by default) +pp e2e --include-slow +``` + +## Format Commands + +Run these commands before you commit your changes. + +```bash +# Format both backend and frontend (run this before commit) +pp format + +# Format only backend (run this before commit) +pp format --backend + +# Format specific backend solution (run this before commit) +pp format --backend --solution-name + +# Format only frontend (run this before commit) +pp format --frontend +``` +## Rules for implementing changes + +Always consult the relevant rule files before each code change. + +*General Rules*: +- [Tools](/.windsurf/rules/tools.md) - Rules for how to use Developer CLI tools to build, test, and format code correctly over using direct commands like `npm run format` or `dotnet test`. + +*Backend*: +- [Backend](/.windsurf/rules/backend/backend.md) - Core rules for C# development and tooling +- [API Endpoints](/.windsurf/rules/backend/api-endpoints.md) - Rules for ASP.NET minimal API endpoints +- [Commands](/.windsurf/rules/backend/commands.md) - Rules for implementing CQRS commands, validation, handlers, and structure +- [Database Migrations](/.windsurf/rules/backend/database-migrations.md) - Rules for creating database migrations +- [Domain Modeling](/.windsurf/rules/backend/domain-modeling.md) - Rules for creating DDD aggregates, entities, value objects, and Entity Framework configuration +- [External Integrations](/.windsurf/rules/backend/external-integrations.md) - Rules for creating external integration services +- [Queries](/.windsurf/rules/backend/queries.md) - Rules for CQRS queries, including structure, validation, response types, and mapping +- [Repositories](/.windsurf/rules/backend/repositories.md) - Rules for DDD repositories, including tenant scoping, interface conventions, and use of Entity Framework +- [Strongly Typed IDs](/.windsurf/rules/backend/strongly-typed-ids.md) - Rules for creating strongly typed IDs for DDD aggregates and entities +- [Telemetry Events](/.windsurf/rules/backend/telemetry-events.md) - Rules for telemetry events including important rules of where to create events, naming, and what properties to track +- [API Tests](/.windsurf/rules/backend/api-tests.md) - Rules for writing backend API tests + +*Frontend*: +- [Frontend](/.windsurf/rules/frontend/frontend.md) - Core rules for frontend TypeScript and React development + - [Form with Validation](/.windsurf/rules/frontend/form-with-validation.md) - Rules for forms with validation using React Aria Components + - [Modal Dialog](/.windsurf/rules/frontend/modal-dialog.md) - Rules for modal dialogs using React Aria Components + - [React Aria Components](/.windsurf/rules/frontend/react-aria-components.md) - Rules for using React Aria Components + - [TanStack Query API Integration](/.windsurf/rules/frontend/tanstack-query-api-integration.md) - Rules for using TanStack Query with backend APIs + - [Translations](/.windsurf/rules/frontend/translations.md) - Rules for translations and internationalization + +*Infrastructure*: +- [Infrastructure](/.windsurf/rules/infrastructure/infrastructure.md) - Rules for cloud infrastructure and deployment + +*Developer CLI*: +- [Developer CLI](/.windsurf/rules/developer-cli/developer-cli.md) - Rules for implementing Developer CLI commands + +*Workflows*: +- [AI Rules Workflow](/.windsurf/workflows/ai-rules.md) - Workflow for creating and maintaining AI rules +- [Code Review Workflow](/.windsurf/workflows/code-review.md) - Workflow for code review of branches, uncommitted changes, or files +- [Git Commits Workflow](/.windsurf/workflows/git-commits.md) - Workflow for writing effective git commit messages +- [Pull Request Workflow](/.windsurf/workflows/pull-request.md) - Workflow for writing pull request titles and descriptions +- [Update Windsurf Rules Workflow](/.windsurf/workflows/update-windsurfrules.md) - Rules for updating the .windsurfrules file which is used by Windsurf's JetBrains Add-in. + +*End-to-End Testing*: +- [End-to-End Testing](/.windsurf/rules/e2e/e2e.md) - Rules for end-to-end testing + +## Project Structure + +This is a mono repository with multiple self-contained systems (SCS), each being a small monolith. All SCSs follow the same structure. + +- **application/**: Contains application code: + - **account-management/**: An SCS for tenant and user management: + - **WebApp/**: A React, TypeScript SPA. + - **Api/**: .NET 9 minimal API. + - **Core/**: .NET 9 Vertical Sliced Architecture. + - **Workers/**: A .NET Console job. + - **Tests/**: xUnit tests for backend. + - **back-office/**: An empty SCS that will be used to create tools for Support and System Admins: + - **WebApp/**: A React, TypeScript SPA. + - **Api/**: .NET 9 minimal API. + - **Core/**: .NET 9 Vertical Sliced Architecture. + - **Workers/**: A .NET Console job. + - **Tests/**: xUnit tests for backend. + - **AppHost/**: .NET Aspire project for orchestrating SCSs and Docker containers. Never run directly—typically running in watch mode. + - **AppGateway/**: Main entry point using .NET YARP as reverse proxy for all SCSs. + - **shared-kernel/**: Reusable .NET backend shared by all SCSs. + - **shared-webapp/**: Reusable frontend shared by all SCSs. +- **cloud-infrastructure/**: Bash and Azure Bicep scripts (IaC). +- **developer-cli/**: A .NET CLI tool for automating common developer tasks. diff --git a/application/README.md b/application/README.md index 0b8810558..c34eee9ad 100644 --- a/application/README.md +++ b/application/README.md @@ -51,7 +51,7 @@ Self-contained systems in PlatformPlatform are divided into the following core p RuleFor(x => x) .MustAsync((x, cancellationToken)=> userRepository.IsEmailFreeAsync(x.TenantId, x.Email, cancellationToken)) .WithName("Email") - .WithMessage(x => $"The email '{x.Email}' is already in use by another user on this tenant.") + .WithMessage(x => $"The user with '{x.Email}' already exists.") .When(x => !string.IsNullOrEmpty(x.Email)); } } diff --git a/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs b/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs index e8a712893..4a338d30d 100644 --- a/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs +++ b/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs @@ -34,7 +34,11 @@ public async Task Handle(CompleteLoginCommand command, CancellationToken { var login = await loginRepository.GetByIdAsync(command.Id, cancellationToken); - if (login is null) return Result.NotFound($"Login with id '{command.Id}' not found."); + if (login is null) + { + // For security, avoid confirming the existence of login IDs + return Result.BadRequest("The code is wrong or no longer valid."); + } if (login.Completed) { diff --git a/application/account-management/Core/Features/Authentication/Commands/StartLogin.cs b/application/account-management/Core/Features/Authentication/Commands/StartLogin.cs index 779e48b18..fa779e73c 100644 --- a/application/account-management/Core/Features/Authentication/Commands/StartLogin.cs +++ b/application/account-management/Core/Features/Authentication/Commands/StartLogin.cs @@ -24,7 +24,7 @@ public sealed class StartLoginValidator : AbstractValidator { public StartLoginValidator() { - RuleFor(x => x.Email).NotEmpty().SetValidator(new SharedValidations.Email()); + RuleFor(x => x.Email).SetValidator(new SharedValidations.Email()); } } diff --git a/application/account-management/Core/Features/EmailConfirmations/Commands/ResendEmailConfirmationCode.cs b/application/account-management/Core/Features/EmailConfirmations/Commands/ResendEmailConfirmationCode.cs index 02684c4ac..94f395f6b 100644 --- a/application/account-management/Core/Features/EmailConfirmations/Commands/ResendEmailConfirmationCode.cs +++ b/application/account-management/Core/Features/EmailConfirmations/Commands/ResendEmailConfirmationCode.cs @@ -37,11 +37,6 @@ public async Task> Handle(ResendEmai return Result.BadRequest($"The email confirmation with id {emailConfirmation.Id} has already been completed."); } - if (emailConfirmation.ModifiedAt > TimeProvider.System.GetUtcNow().AddSeconds(-30)) - { - return Result.BadRequest("You must wait at least 30 seconds before requesting a new code."); - } - if (emailConfirmation.ResendCount >= EmailConfirmation.MaxResends) { events.CollectEvent(new EmailConfirmationResendBlocked(emailConfirmation.Id, emailConfirmation.Type, emailConfirmation.RetryCount)); diff --git a/application/account-management/Core/Features/EmailConfirmations/Commands/StartEmailConfirmation.cs b/application/account-management/Core/Features/EmailConfirmations/Commands/StartEmailConfirmation.cs index 7c1f8abcd..f3c7b2018 100644 --- a/application/account-management/Core/Features/EmailConfirmations/Commands/StartEmailConfirmation.cs +++ b/application/account-management/Core/Features/EmailConfirmations/Commands/StartEmailConfirmation.cs @@ -20,9 +20,9 @@ public sealed class StartEmailConfirmationValidator : AbstractValidator x.Email).NotEmpty().SetValidator(new SharedValidations.Email()); - RuleFor(x => x.EmailSubject).NotEmpty().MaximumLength(100); - RuleFor(x => x.EmailBody.Contains("{oneTimePassword}")).Equal(true); + RuleFor(x => x.Email).SetValidator(new SharedValidations.Email()); + RuleFor(x => x.EmailSubject).Length(1, 100).WithMessage("Email subject must be between 1 and 100 characters."); + RuleFor(x => x.EmailBody.Contains("{oneTimePassword}")).Equal(true).WithMessage("Email body must contain {oneTimePassword} placeholder."); } } @@ -36,12 +36,8 @@ public async Task> Handle(StartEmailConfi { var existingConfirmations = emailConfirmationRepository.GetByEmail(command.Email).ToArray(); - if (existingConfirmations.Any(c => !c.HasExpired())) - { - return Result.Conflict("Email confirmation for this email has already been started. Please check your spam folder."); - } - - if (existingConfirmations.Count(r => r.CreatedAt > TimeProvider.System.GetUtcNow().AddDays(-1)) > 3) + var lockoutMinutes = command.Type == EmailConfirmationType.Signup ? -60 : -15; + if (existingConfirmations.Count(r => r.CreatedAt > TimeProvider.System.GetUtcNow().AddMinutes(lockoutMinutes)) >= 3) { return Result.TooManyRequests("Too many attempts to confirm this email address. Please try again later."); } diff --git a/application/account-management/Core/Features/Signups/Commands/StartSignup.cs b/application/account-management/Core/Features/Signups/Commands/StartSignup.cs index 7d89213ec..e1bb050e5 100644 --- a/application/account-management/Core/Features/Signups/Commands/StartSignup.cs +++ b/application/account-management/Core/Features/Signups/Commands/StartSignup.cs @@ -22,7 +22,7 @@ public sealed class StartSignupValidator : AbstractValidator { public StartSignupValidator(ITenantRepository tenantRepository) { - RuleFor(x => x.Email).NotEmpty().SetValidator(new SharedValidations.Email()); + RuleFor(x => x.Email).SetValidator(new SharedValidations.Email()); } } diff --git a/application/account-management/Core/Features/Tenants/Commands/UpdateCurrentTenant.cs b/application/account-management/Core/Features/Tenants/Commands/UpdateCurrentTenant.cs index 47b27012f..487ec7127 100644 --- a/application/account-management/Core/Features/Tenants/Commands/UpdateCurrentTenant.cs +++ b/application/account-management/Core/Features/Tenants/Commands/UpdateCurrentTenant.cs @@ -16,10 +16,7 @@ public sealed class UpdateCurrentTenantValidator : AbstractValidator x.Name).NotEmpty(); - RuleFor(x => x.Name).Length(1, 30) - .WithMessage("Name must be between 1 and 30 characters.") - .When(x => !string.IsNullOrEmpty(x.Name)); + RuleFor(x => x.Name).Length(1, 30).WithMessage("Name must be between 1 and 30 characters."); } } diff --git a/application/account-management/Core/Features/Users/Commands/CreateUser.cs b/application/account-management/Core/Features/Users/Commands/CreateUser.cs index a6db7e2fb..a8d13fec2 100644 --- a/application/account-management/Core/Features/Users/Commands/CreateUser.cs +++ b/application/account-management/Core/Features/Users/Commands/CreateUser.cs @@ -1,5 +1,4 @@ using FluentValidation; -using PlatformPlatform.AccountManagement.Features.Tenants.Domain; using PlatformPlatform.AccountManagement.Features.Users.Domain; using PlatformPlatform.AccountManagement.Features.Users.Shared; using PlatformPlatform.AccountManagement.Integrations.Gravatar; @@ -20,15 +19,9 @@ internal sealed record CreateUserCommand(TenantId TenantId, string Email, UserRo internal sealed class CreateUserValidator : AbstractValidator { - public CreateUserValidator(IUserRepository userRepository, ITenantRepository tenantRepository) + public CreateUserValidator() { - RuleFor(x => x.Email).NotEmpty().SetValidator(new SharedValidations.Email()); - - RuleFor(x => x) - .MustAsync((x, cancellationToken) => userRepository.IsEmailFreeAsync(x.Email, cancellationToken)) - .WithName("Email") - .WithMessage(x => $"The email '{x.Email}' is already in use by another user on this tenant.") - .When(x => !string.IsNullOrEmpty(x.Email)); + RuleFor(x => x.Email).SetValidator(new SharedValidations.Email()); } } @@ -47,6 +40,11 @@ public async Task> Handle(CreateUserCommand command, Cancellation throw new UnreachableException("Only when signing up a new tenant, is the TenantID allowed to different than the current tenant."); } + if (await userRepository.IsEmailFreeAsync(command.Email, cancellationToken) == false) + { + return Result.BadRequest($"The user with '{command.Email}' already exists."); + } + var locale = SinglePageAppConfiguration.SupportedLocalizations.Contains(command.PreferredLocale) ? command.PreferredLocale : string.Empty; diff --git a/application/account-management/Core/Features/Users/Commands/InviteUser.cs b/application/account-management/Core/Features/Users/Commands/InviteUser.cs index 269787e54..cd5ab2755 100644 --- a/application/account-management/Core/Features/Users/Commands/InviteUser.cs +++ b/application/account-management/Core/Features/Users/Commands/InviteUser.cs @@ -18,19 +18,14 @@ public sealed record InviteUserCommand(string Email) : ICommand, IRequest { - public InviteUserValidator(IUserRepository userRepository) + public InviteUserValidator() { - RuleFor(x => x.Email).NotEmpty().SetValidator(new SharedValidations.Email()); - - RuleFor(x => x) - .MustAsync((x, cancellationToken) => userRepository.IsEmailFreeAsync(x.Email, cancellationToken)) - .WithName("Email") - .WithMessage(x => $"The email '{x.Email}' is already in use by another user on this tenant.") - .When(x => !string.IsNullOrEmpty(x.Email)); + RuleFor(x => x.Email).SetValidator(new SharedValidations.Email()); } } public sealed class InviteUserHandler( + IUserRepository userRepository, IEmailClient emailClient, IExecutionContext executionContext, IMediator mediator, @@ -44,6 +39,11 @@ public async Task Handle(InviteUserCommand command, CancellationToken ca return Result.Forbidden("Only owners are allowed to invite other users."); } + if (await userRepository.IsEmailFreeAsync(command.Email, cancellationToken) == false) + { + return Result.BadRequest($"The user with '{command.Email}' already exists."); + } + var result = await mediator.Send( new CreateUserCommand(executionContext.TenantId!, command.Email, UserRole.Member, false, null), cancellationToken ); diff --git a/application/account-management/Core/Features/Users/Commands/UpdateCurrentUser.cs b/application/account-management/Core/Features/Users/Commands/UpdateCurrentUser.cs index 06ce179ad..c68338347 100644 --- a/application/account-management/Core/Features/Users/Commands/UpdateCurrentUser.cs +++ b/application/account-management/Core/Features/Users/Commands/UpdateCurrentUser.cs @@ -3,16 +3,13 @@ using PlatformPlatform.AccountManagement.Features.Users.Domain; using PlatformPlatform.SharedKernel.Cqrs; using PlatformPlatform.SharedKernel.Telemetry; -using PlatformPlatform.SharedKernel.Validation; namespace PlatformPlatform.AccountManagement.Features.Users.Commands; [PublicAPI] -public sealed record UpdateCurrentUserCommand(string Email, string FirstName, string LastName, string Title) +public sealed record UpdateCurrentUserCommand(string FirstName, string LastName, string Title) : ICommand, IRequest { - public string Email { get; } = Email.ToLower().Trim(); - public string FirstName { get; } = FirstName.Trim(); public string LastName { get; } = LastName.Trim(); @@ -24,9 +21,8 @@ public sealed class UpdateCurrentUserValidator : AbstractValidator x.Email).NotEmpty().SetValidator(new SharedValidations.Email()); - RuleFor(x => x.FirstName).NotEmpty().MaximumLength(30).WithMessage("First name must be no longer than 30 characters."); - RuleFor(x => x.LastName).NotEmpty().MaximumLength(30).WithMessage("Last name must be no longer than 30 characters."); + RuleFor(x => x.FirstName).Length(1, 30).WithMessage("First name must be between 1 and 30 characters."); + RuleFor(x => x.LastName).Length(1, 30).WithMessage("Last name must be between 1 and 30 characters."); RuleFor(x => x.Title).MaximumLength(50).WithMessage("Title must be no longer than 50 characters."); } } @@ -38,7 +34,6 @@ public async Task Handle(UpdateCurrentUserCommand command, CancellationT { var user = await userRepository.GetLoggedInUserAsync(cancellationToken); - user.UpdateEmail(command.Email); user.Update(command.FirstName, command.LastName, command.Title); userRepository.Update(user); diff --git a/application/account-management/Core/Features/Users/Queries/GetUsers.cs b/application/account-management/Core/Features/Users/Queries/GetUsers.cs index 8753a41eb..b33fb4ba6 100644 --- a/application/account-management/Core/Features/Users/Queries/GetUsers.cs +++ b/application/account-management/Core/Features/Users/Queries/GetUsers.cs @@ -42,9 +42,9 @@ public sealed class GetUsersQueryValidator : AbstractValidator { public GetUsersQueryValidator() { - RuleFor(x => x.Search).MaximumLength(100).WithMessage("The search term must be at most 100 characters."); - RuleFor(x => x.PageSize).InclusiveBetween(0, 1000).WithMessage("The page size must be between 0 and 1000."); - RuleFor(x => x.PageOffset).GreaterThanOrEqualTo(0).WithMessage("The page offset must be greater than or equal to 0."); + RuleFor(x => x.Search).MaximumLength(100).WithMessage("Search must be no longer than 100 characters."); + RuleFor(x => x.PageSize).InclusiveBetween(0, 1000).WithMessage("Page size must be between 0 and 1000."); + RuleFor(x => x.PageOffset).GreaterThanOrEqualTo(0).WithMessage("Page offset must be greater than or equal to 0."); } } diff --git a/application/account-management/Tests/Authentication/CompleteLoginTests.cs b/application/account-management/Tests/Authentication/CompleteLoginTests.cs index 56cdd55f3..c57ae8270 100644 --- a/application/account-management/Tests/Authentication/CompleteLoginTests.cs +++ b/application/account-management/Tests/Authentication/CompleteLoginTests.cs @@ -47,7 +47,7 @@ public async Task CompleteLogin_WhenValid_ShouldCompleteLoginAndCreateTokens() } [Fact] - public async Task CompleteLogin_WhenLoginNotFound_ShouldReturnNotFound() + public async Task CompleteLogin_WhenLoginNotFound_ShouldReturnBadRequest() { // Arrange var invalidLoginId = LoginId.NewId(); @@ -57,8 +57,7 @@ public async Task CompleteLogin_WhenLoginNotFound_ShouldReturnNotFound() var response = await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/login/{invalidLoginId}/complete", command); // Assert - var expectedDetail = $"Login with id '{invalidLoginId}' not found."; - await response.ShouldHaveErrorStatusCode(HttpStatusCode.NotFound, expectedDetail); + await response.ShouldHaveErrorStatusCode(HttpStatusCode.BadRequest, "The code is wrong or no longer valid."); TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); diff --git a/application/account-management/Tests/Authentication/StartLoginTests.cs b/application/account-management/Tests/Authentication/StartLoginTests.cs index 65ecf702f..09d0cf64b 100644 --- a/application/account-management/Tests/Authentication/StartLoginTests.cs +++ b/application/account-management/Tests/Authentication/StartLoginTests.cs @@ -1,10 +1,14 @@ using System.Net; using System.Net.Http.Json; using FluentAssertions; +using Microsoft.AspNetCore.Identity; using NSubstitute; using PlatformPlatform.AccountManagement.Database; using PlatformPlatform.AccountManagement.Features.Authentication.Commands; +using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; +using PlatformPlatform.SharedKernel.Authentication; using PlatformPlatform.SharedKernel.Tests; +using PlatformPlatform.SharedKernel.Tests.Persistence; using PlatformPlatform.SharedKernel.Validation; using Xunit; @@ -53,7 +57,7 @@ public async Task StartLoginCommand_WhenEmailIsEmpty_ShouldFail() // Assert var expectedErrors = new[] { - new ErrorDetail("email", "'Email' must not be empty.") + new ErrorDetail("email", "Email must be in a valid format and no longer than 100 characters.") }; await response.ShouldHaveErrorStatusCode(HttpStatusCode.BadRequest, expectedErrors); @@ -108,4 +112,40 @@ await EmailClient.Received(1).SendAsync( Arg.Any() ); } + + [Fact] + public async Task StartLogin_WhenTooManyAttempts_ShouldReturnTooManyRequests() + { + // Arrange + var email = DatabaseSeeder.Tenant1Owner.Email; + + for (var i = 1; i <= 4; i++) + { + var oneTimePasswordHash = new PasswordHasher().HashPassword(this, OneTimePasswordHelper.GenerateOneTimePassword(6)); + Connection.Insert("EmailConfirmations", [ + ("Id", EmailConfirmationId.NewId().ToString()), + ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-i)), + ("ModifiedAt", null), + ("Email", email.ToLower()), + ("Type", EmailConfirmationType.Login.ToString()), + ("OneTimePasswordHash", oneTimePasswordHash), + ("ValidUntil", TimeProvider.System.GetUtcNow().AddMinutes(-i - 1)), // All should be expired + ("RetryCount", 0), + ("ResendCount", 0), + ("Completed", false) + ] + ); + } + + var command = new StartLoginCommand(email); + + // Act + var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/login/start", command); + + // Assert + await response.ShouldHaveErrorStatusCode(HttpStatusCode.TooManyRequests, "Too many attempts to confirm this email address. Please try again later."); + + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + await EmailClient.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), CancellationToken.None); + } } diff --git a/application/account-management/Tests/Signups/StartSignupTests.cs b/application/account-management/Tests/Signups/StartSignupTests.cs index 4a246f28e..88ed9646e 100644 --- a/application/account-management/Tests/Signups/StartSignupTests.cs +++ b/application/account-management/Tests/Signups/StartSignupTests.cs @@ -66,49 +66,24 @@ public async Task StartSignup_WhenInvalidEmail_ShouldReturnBadRequest() await EmailClient.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), CancellationToken.None); } - [Fact] - public async Task StartSignup_WhenSignupAlreadyStarted_ShouldReturnConflict() - { - // Arrange - var email = Faker.Internet.Email(); - var command = new StartSignupCommand(email); - await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/signups/start", command); - - // Act - var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/signups/start", command); - - // Assert - await response.ShouldHaveErrorStatusCode(HttpStatusCode.Conflict, "Email confirmation for this email has already been started. Please check your spam folder."); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); // Only the first signup should create an event - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("SignupStarted"); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); - await EmailClient.Received(1).SendAsync( - Arg.Is(s => s.Equals(email.ToLower())), - Arg.Any(), - Arg.Any(), - Arg.Any() - ); - } - [Fact] public async Task StartSignup_WhenTooManyAttempts_ShouldReturnTooManyRequests() { // Arrange var email = Faker.Internet.Email().ToLowerInvariant(); - // Create 4 signups within the last day for this email + // Create 4 signups within the last hour for this email for (var i = 1; i <= 4; i++) { var oneTimePasswordHash = new PasswordHasher().HashPassword(this, OneTimePasswordHelper.GenerateOneTimePassword(6)); Connection.Insert("EmailConfirmations", [ ("Id", EmailConfirmationId.NewId().ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow().AddHours(-i)), + ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-i)), ("ModifiedAt", null), ("Email", email), ("Type", EmailConfirmationType.Signup.ToString()), ("OneTimePasswordHash", oneTimePasswordHash), - ("ValidUntil", TimeProvider.System.GetUtcNow().AddHours(-i).AddMinutes(5)), + ("ValidUntil", TimeProvider.System.GetUtcNow().AddMinutes(-i - 1)), // All should be expired ("RetryCount", 0), ("ResendCount", 0), ("Completed", false) diff --git a/application/account-management/Tests/Users/InviteUserTests.cs b/application/account-management/Tests/Users/InviteUserTests.cs index e5fec8b7d..728dbc5d7 100644 --- a/application/account-management/Tests/Users/InviteUserTests.cs +++ b/application/account-management/Tests/Users/InviteUserTests.cs @@ -75,11 +75,7 @@ public async Task InviteUser_WhenUserExists_ShouldReturnBadRequest() var response = await AuthenticatedOwnerHttpClient.PostAsJsonAsync("/api/account-management/users/invite", command); // Assert - var expectedErrors = new[] - { - new ErrorDetail("email", $"The email '{existingUserEmail}' is already in use by another user on this tenant.") - }; - await response.ShouldHaveErrorStatusCode(HttpStatusCode.BadRequest, expectedErrors); + await response.ShouldHaveErrorStatusCode(HttpStatusCode.BadRequest, $"The user with '{existingUserEmail}' already exists."); TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); } diff --git a/application/account-management/Tests/Users/UpdateCurrentUserTests.cs b/application/account-management/Tests/Users/UpdateCurrentUserTests.cs index 4236a372b..ef2d9f79a 100644 --- a/application/account-management/Tests/Users/UpdateCurrentUserTests.cs +++ b/application/account-management/Tests/Users/UpdateCurrentUserTests.cs @@ -15,7 +15,6 @@ public async Task UpdateCurrentUser_WhenValid_ShouldUpdateUser() { // Arrange var command = new UpdateCurrentUserCommand( - Faker.Internet.Email(), Faker.Name.FirstName(), Faker.Name.LastName(), Faker.Name.JobTitle() @@ -34,7 +33,6 @@ public async Task UpdateCurrentUser_WhenInvalid_ShouldReturnBadRequest() // Arrange var command = new UpdateCurrentUserCommand ( - Faker.InvalidEmail(), Faker.Random.String(31), Faker.Random.String(31), Faker.Random.String(51) @@ -46,9 +44,8 @@ public async Task UpdateCurrentUser_WhenInvalid_ShouldReturnBadRequest() // Assert var expectedErrors = new[] { - new ErrorDetail("email", "Email must be in a valid format and no longer than 100 characters."), - new ErrorDetail("firstName", "First name must be no longer than 30 characters."), - new ErrorDetail("lastName", "Last name must be no longer than 30 characters."), + new ErrorDetail("firstName", "First name must be between 1 and 30 characters."), + new ErrorDetail("lastName", "Last name must be between 1 and 30 characters."), new ErrorDetail("title", "Title must be no longer than 50 characters.") }; await response.ShouldHaveErrorStatusCode(HttpStatusCode.BadRequest, expectedErrors); diff --git a/application/account-management/WebApp/routes/(index)/-components/CtaSection.tsx b/application/account-management/WebApp/routes/(index)/-components/CtaSection.tsx index 33b0d1b72..4bcbadb36 100644 --- a/application/account-management/WebApp/routes/(index)/-components/CtaSection.tsx +++ b/application/account-management/WebApp/routes/(index)/-components/CtaSection.tsx @@ -3,7 +3,7 @@ import { Button } from "@repo/ui/components/Button"; // CtaSection: A functional component that displays a call to action export function CtaSection() { return ( -
+

A single solution for you to build on

diff --git a/application/account-management/WebApp/routes/(index)/-components/CtaSection3.tsx b/application/account-management/WebApp/routes/(index)/-components/CtaSection3.tsx index 5bb3a0192..2665f76b0 100644 --- a/application/account-management/WebApp/routes/(index)/-components/CtaSection3.tsx +++ b/application/account-management/WebApp/routes/(index)/-components/CtaSection3.tsx @@ -3,8 +3,8 @@ import { SignUpButton } from "@repo/infrastructure/auth/SignUpButton"; // CtaSection3: A functional component that displays a call to action export function CtaSection3() { return ( -

-
+
+

Start scaling your business today

diff --git a/application/account-management/WebApp/routes/(index)/-components/FeatureSection3.tsx b/application/account-management/WebApp/routes/(index)/-components/FeatureSection3.tsx index b2354c893..282af285b 100644 --- a/application/account-management/WebApp/routes/(index)/-components/FeatureSection3.tsx +++ b/application/account-management/WebApp/routes/(index)/-components/FeatureSection3.tsx @@ -5,7 +5,7 @@ import { createAccountUrl } from "./cdnImages"; // FeatureSection3: A functional component that displays the third feature section export function FeatureSection3() { return ( -

+
diff --git a/application/account-management/WebApp/routes/(index)/-components/FeatureSection4.tsx b/application/account-management/WebApp/routes/(index)/-components/FeatureSection4.tsx index 83802481a..f8f3cd765 100644 --- a/application/account-management/WebApp/routes/(index)/-components/FeatureSection4.tsx +++ b/application/account-management/WebApp/routes/(index)/-components/FeatureSection4.tsx @@ -1,7 +1,7 @@ // FeatureSection4: A functional component that displays a section with features export function FeatureSection4() { return ( -
+

FEATURES

Built by Founders, Engineers and Designers diff --git a/application/account-management/WebApp/routes/(index)/-components/FooterSection.tsx b/application/account-management/WebApp/routes/(index)/-components/FooterSection.tsx index a9a9d9d76..9540528a4 100644 --- a/application/account-management/WebApp/routes/(index)/-components/FooterSection.tsx +++ b/application/account-management/WebApp/routes/(index)/-components/FooterSection.tsx @@ -6,7 +6,7 @@ import { githubLogo, linkedinLogo, logoWrap, slackLogo, twitterLogo, youtubeLogo export function FooterSection() { return ( <> -
+
Join our newsletter
Technology that has your back.
diff --git a/application/account-management/WebApp/routes/(index)/-components/HeroSection.tsx b/application/account-management/WebApp/routes/(index)/-components/HeroSection.tsx index 72f35939f..57c5f6c64 100644 --- a/application/account-management/WebApp/routes/(index)/-components/HeroSection.tsx +++ b/application/account-management/WebApp/routes/(index)/-components/HeroSection.tsx @@ -16,7 +16,7 @@ import { heroDesktopUrl, heroMobileUrl, logoWrap } from "./cdnImages"; // HeroSection: A functional component that displays the hero section export function HeroSection() { return ( -
+
logo diff --git a/application/account-management/WebApp/routes/(index)/-components/TechnologySection2.tsx b/application/account-management/WebApp/routes/(index)/-components/TechnologySection2.tsx index e72f2e027..9d6a6291b 100644 --- a/application/account-management/WebApp/routes/(index)/-components/TechnologySection2.tsx +++ b/application/account-management/WebApp/routes/(index)/-components/TechnologySection2.tsx @@ -3,7 +3,7 @@ import { infrastructure } from "./cdnImages"; // TechnologySection2: A functional component that displays the technology section export function TechnologySection2() { return ( -
+
{/* Display the section title */} diff --git a/application/account-management/WebApp/routes/admin/account/index.tsx b/application/account-management/WebApp/routes/admin/account/index.tsx index dc3aa8116..790bd19b9 100644 --- a/application/account-management/WebApp/routes/admin/account/index.tsx +++ b/application/account-management/WebApp/routes/admin/account/index.tsx @@ -4,16 +4,17 @@ import logoWrap from "@/shared/images/logo-wrap.svg"; import { api } from "@/shared/lib/api/client"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; +import { AppLayout } from "@repo/ui/components/AppLayout"; import { Breadcrumb } from "@repo/ui/components/Breadcrumbs"; import { Button } from "@repo/ui/components/Button"; import { Form } from "@repo/ui/components/Form"; -import { FormErrorMessage } from "@repo/ui/components/FormErrorMessage"; import { TextField } from "@repo/ui/components/TextField"; +import { toastQueue } from "@repo/ui/components/Toast"; import { mutationSubmitter } from "@repo/ui/forms/mutationSubmitter"; import { createFileRoute } from "@tanstack/react-router"; import { Trash2 } from "lucide-react"; -import { useState } from "react"; -import { Label, Separator } from "react-aria-components"; +import { useEffect, useState } from "react"; +import { Separator } from "react-aria-components"; import DeleteAccountConfirmation from "./-components/DeleteAccountConfirmation"; export const Route = createFileRoute("/admin/account/")({ @@ -25,15 +26,27 @@ export function AccountSettings() { const { data: tenant, isLoading } = api.useQuery("get", "/api/account-management/tenants/current"); const updateCurrentTenantMutation = api.useMutation("put", "/api/account-management/tenants/current"); + useEffect(() => { + if (updateCurrentTenantMutation.isSuccess) { + toastQueue.add({ + title: t`Success`, + description: t`Account updated successfully`, + variant: "success", + duration: 3000 + }); + } + }, [updateCurrentTenantMutation.isSuccess]); + if (isLoading) { return null; } return ( <> -
- -
+ + Account @@ -42,69 +55,60 @@ export function AccountSettings() { Settings -
-
-

- Account settings -

-

- Manage your account here. -

-
-
+ } + > +

+ Account settings +

+

+ Manage your account here. +

-
- - {t`Logo`} + +

+ Account information +

+ -
- -
+ Logo - - - + {t`Logo`} + + + -
-

- Danger zone -

- -
-

- - Deleting the account and all associated data. This action cannot be undone, so please proceed with - caution. - -

+
+

+ Danger zone +

+ +
+

+ Delete your account and all data. This action is irreversible—proceed with caution. +

- -
+
- -
-
+
diff --git a/application/account-management/WebApp/routes/admin/index.tsx b/application/account-management/WebApp/routes/admin/index.tsx index 8dee33087..a2d84c3a0 100644 --- a/application/account-management/WebApp/routes/admin/index.tsx +++ b/application/account-management/WebApp/routes/admin/index.tsx @@ -3,6 +3,8 @@ import { TopMenu } from "@/shared/components/topMenu"; import { UserStatus, api } from "@/shared/lib/api/client"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; +import { useUserInfo } from "@repo/infrastructure/auth/hooks"; +import { AppLayout } from "@repo/ui/components/AppLayout"; import { getDateDaysAgo, getTodayIsoDate } from "@repo/utils/date/formatDate"; import { Link, createFileRoute } from "@tanstack/react-router"; @@ -12,37 +14,31 @@ export const Route = createFileRoute("/admin/")({ export default function Home() { const { data: usersSummary } = api.useQuery("get", "/api/account-management/users/summary"); + const userInfo = useUserInfo(); return ( -
+ <> -
- -
-
-

- Welcome home -

-

- Here's your overview of what's happening. -

-
-
-
+ }> +

{userInfo?.firstName ? Welcome home, {userInfo.firstName} : Welcome home}

+

+ Here's your overview of what's happening. +

+
-
- Total users -
-
- Add more in the Users menu -
-
- {usersSummary?.totalUsers ?

{usersSummary.totalUsers}

:

-

} +
+
+ Total users +
+
+ Add more in the Users menu +
+
{usersSummary?.totalUsers ?? "-"}
-
- Active users -
-
- Active users in the past 30 days -
-
- {usersSummary?.activeUsers ?

{usersSummary.activeUsers}

:

-

} +
+
+ Active users +
+
+ Active users in the past 30 days +
+
{usersSummary?.activeUsers ?? "-"}
-
- Invited users -
-
- Users who haven't confirmed their email -
-
- {usersSummary?.pendingUsers ?

{usersSummary.pendingUsers}

:

-

} +
+
+ Invited users +
+
+ Users who haven't confirmed their email +
+
{usersSummary?.pendingUsers ?? "-"}
-
-
+ + ); } diff --git a/application/account-management/WebApp/routes/admin/users/-components/ChangeUserRoleDialog.tsx b/application/account-management/WebApp/routes/admin/users/-components/ChangeUserRoleDialog.tsx index a2fe5ec99..ec575336a 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/ChangeUserRoleDialog.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/ChangeUserRoleDialog.tsx @@ -5,6 +5,7 @@ import { Trans } from "@lingui/react/macro"; import { AlertDialog } from "@repo/ui/components/AlertDialog"; import { Modal } from "@repo/ui/components/Modal"; import { Select, SelectItem } from "@repo/ui/components/Select"; +import { toastQueue } from "@repo/ui/components/Toast"; import { useCallback } from "react"; type UserDetails = components["schemas"]["UserDetails"]; @@ -24,9 +25,19 @@ export function ChangeUserRoleDialog({ user, isOpen, onOpenChange }: Readonly { + const userDisplayName = `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() || user.email; + toastQueue.add({ + title: t`Success`, + description: t`User role updated successfully for ${userDisplayName}`, + variant: "success", + duration: 3000 + }); - onOpenChange(false); + onOpenChange(false); + }); }, [user, changeUserRoleMutation, onOpenChange] ); diff --git a/application/account-management/WebApp/routes/admin/users/-components/DeleteUserDialog.tsx b/application/account-management/WebApp/routes/admin/users/-components/DeleteUserDialog.tsx index 110419708..91025192e 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/DeleteUserDialog.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/DeleteUserDialog.tsx @@ -3,6 +3,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { AlertDialog } from "@repo/ui/components/AlertDialog"; import { Modal } from "@repo/ui/components/Modal"; +import { toastQueue } from "@repo/ui/components/Toast"; import { useCallback } from "react"; type UserDetails = components["schemas"]["UserDetails"]; @@ -11,25 +12,54 @@ interface DeleteUserDialogProps { users: UserDetails[]; isOpen: boolean; onOpenChange: (isOpen: boolean) => void; + onUsersDeleted?: () => void; } -export function DeleteUserDialog({ users, isOpen, onOpenChange }: Readonly) { +export function DeleteUserDialog({ users, isOpen, onOpenChange, onUsersDeleted }: Readonly) { const isSingleUser = users.length === 1; const user = users[0]; - const bulkDeleteUsersMutation = api.useMutation("post", "/api/account-management/users/bulk-delete"); const deleteUserMutation = api.useMutation("delete", "/api/account-management/users/{id}"); + const bulkDeleteUsersMutation = api.useMutation("post", "/api/account-management/users/bulk-delete"); + const userDisplayName = isSingleUser ? `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() || user.email : ""; const handleDelete = useCallback(async () => { if (isSingleUser) { - await deleteUserMutation.mutateAsync({ params: { path: { id: user.id } } }); + deleteUserMutation.mutateAsync({ params: { path: { id: user.id } } }).then(() => { + toastQueue.add({ + title: t`Success`, + description: `User deleted successfully: ${userDisplayName}`, + variant: "success", + duration: 3000 + }); + + onUsersDeleted?.(); + onOpenChange(false); + }); } else { const userIds = users.map((user) => user.id); - await bulkDeleteUsersMutation.mutateAsync({ body: { userIds: userIds } }); - } + await bulkDeleteUsersMutation.mutateAsync({ body: { userIds: userIds } }).then(() => { + toastQueue.add({ + title: t`Success`, + description: `${users.length} users deleted successfully`, + variant: "success", + duration: 3000 + }); - onOpenChange(false); - }, [users, isSingleUser, user, deleteUserMutation, bulkDeleteUsersMutation, onOpenChange]); + onUsersDeleted?.(); + onOpenChange(false); + }); + } + }, [ + isSingleUser, + userDisplayName, + bulkDeleteUsersMutation, + deleteUserMutation, + user, + users, + onUsersDeleted, + onOpenChange + ]); return ( @@ -42,8 +72,7 @@ export function DeleteUserDialog({ users, isOpen, onOpenChange }: Readonly {isSingleUser ? ( - Are you sure you want to delete{" "} - {`${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() || user.email}? + Are you sure you want to delete {userDisplayName}? ) : ( diff --git a/application/account-management/WebApp/routes/admin/users/-components/InviteUserDialog.tsx b/application/account-management/WebApp/routes/admin/users/-components/InviteUserDialog.tsx index 762a80e44..077163c04 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/InviteUserDialog.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/InviteUserDialog.tsx @@ -4,10 +4,10 @@ import { Trans } from "@lingui/react/macro"; import { Button } from "@repo/ui/components/Button"; import { Dialog } from "@repo/ui/components/Dialog"; import { Form } from "@repo/ui/components/Form"; -import { FormErrorMessage } from "@repo/ui/components/FormErrorMessage"; import { Heading } from "@repo/ui/components/Heading"; import { Modal } from "@repo/ui/components/Modal"; import { TextField } from "@repo/ui/components/TextField"; +import { toastQueue } from "@repo/ui/components/Toast"; import { mutationSubmitter } from "@repo/ui/forms/mutationSubmitter"; import { XIcon } from "lucide-react"; import { useEffect } from "react"; @@ -22,6 +22,12 @@ export default function InviteUserDialog({ isOpen, onOpenChange }: Readonly { if (inviteUserMutation.isSuccess) { + toastQueue.add({ + title: t`Success`, + description: t`User invited successfully`, + variant: "success", + duration: 3000 + }); onOpenChange(false); } }, [inviteUserMutation.isSuccess, onOpenChange]); @@ -34,7 +40,7 @@ export default function InviteUserDialog({ isOpen, onOpenChange }: ReadonlyInvite user

- Invite users and assign them roles. They will appear once they log in. + An invitation email will be sent to the user with a link to log in.

-
- + + {/* Filter dialog for small/medium screens */} + + + setIsFilterPanelOpen(false)} + className="absolute top-2 right-2 h-10 w-10 p-2 hover:bg-muted" + /> + + Filters + + +
+ + + + + { + updateFilter({ + startDate: range?.start.toString() ?? undefined, + endDate: range?.end.toString() ?? undefined + }); + }} + label={t`Modified date`} + placeholder={t`Select dates`} + className="w-full" + /> +
+ +
+ + +
+
+
); } diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx index e73fe87dd..6603dcf8e 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -120,149 +120,149 @@ export function UserTable({ selectedUsers, onSelectedUsersChange }: Readonly !isOpen && setUserToDelete(null)} + onUsersDeleted={() => onSelectedUsersChange([])} /> -
- user.id)} - onSelectionChange={handleSelectionChange} - sortDescriptor={sortDescriptor} - onSortChange={handleSortChange} - aria-label={t`Users`} - > - - - Name - - - Email - - - Created - - - Modified - - - Role - - - Actions - - - - {users?.users.map((user) => ( - - -
- -
-
- {user.firstName} {user.lastName} - {user.emailConfirmed ? ( - "" - ) : ( - - Pending - - )} -
-
{user.title ?? ""}
+
user.id)} + onSelectionChange={handleSelectionChange} + sortDescriptor={sortDescriptor} + onSortChange={handleSortChange} + aria-label={t`Users`} + > + + + Name + + + Email + + + Created + + + Modified + + + Role + + + Actions + + + + {users?.users.map((user) => ( + + +
+ +
+
+ {user.firstName} {user.lastName} + {user.emailConfirmed ? ( + "" + ) : ( + + Pending + + )}
+
{user.title ?? ""}
- - {user.email} - {formatDate(user.createdAt)} - {formatDate(user.modifiedAt)} - - {getUserRoleLabel(user.role)} - - -
-
+
+ {user.email} + {formatDate(user.createdAt)} + {formatDate(user.modifiedAt)} + + {getUserRoleLabel(user.role)} + + +
+ + { + if (isOpen) { onSelectedUsersChange([user]); - setUserToDelete(user); - }} - isDisabled={user.id === userInfo?.id} - > - + } + }} + > + - { - if (isOpen) { - onSelectedUsersChange([user]); - } - }} - > - - - - - View profile - - setUserToChangeRole(user)} - > - - - Change role - - - - setUserToDelete(user)} - > - - - Delete - - - - -
-
- - ))} - -
- {users && ( - <> - - - - )} -
+ + + + View profile + + setUserToChangeRole(user)} + > + + + Change role + + + + setUserToDelete(user)} + > + + + Delete + + + + +
+ + + ))} + + + + {users && ( +
+ + +
+ )} ); } diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx index 1fe841970..db9eef215 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx @@ -11,9 +11,10 @@ type UserDetails = components["schemas"]["UserDetails"]; interface UserToolbarProps { selectedUsers: UserDetails[]; + onSelectedUsersChange: (users: UserDetails[]) => void; } -export function UserToolbar({ selectedUsers }: Readonly) { +export function UserToolbar({ selectedUsers, onSelectedUsersChange }: Readonly) { const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -25,7 +26,7 @@ export function UserToolbar({ selectedUsers }: Readonly) { )} @@ -39,7 +40,12 @@ export function UserToolbar({ selectedUsers }: Readonly) { )}
- + onSelectedUsersChange([])} + />
); } diff --git a/application/account-management/WebApp/routes/admin/users/index.tsx b/application/account-management/WebApp/routes/admin/users/index.tsx index 7c6085720..7b8a0c679 100644 --- a/application/account-management/WebApp/routes/admin/users/index.tsx +++ b/application/account-management/WebApp/routes/admin/users/index.tsx @@ -3,6 +3,7 @@ import { TopMenu } from "@/shared/components/topMenu"; import { SortOrder, SortableUserProperties, UserRole, UserStatus, type components } from "@/shared/lib/api/client"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; +import { AppLayout } from "@repo/ui/components/AppLayout"; import { Breadcrumb } from "@repo/ui/components/Breadcrumbs"; import { createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; @@ -32,31 +33,30 @@ export default function UsersPage() { const [selectedUsers, setSelectedUsers] = useState([]); return ( -
+ <> -
- - - Users - - - All users - - -
-
-

+ + Users -

-

- Manage your users and permissions here. -

-
-
+ + + All users + + + } + > +

+ Users +

+

+ Manage your users and permissions here. +

- + -
-
+ + ); } diff --git a/application/account-management/WebApp/routes/login/-shared/loginState.ts b/application/account-management/WebApp/routes/login/-shared/loginState.ts index 0d12fb794..9004e2e18 100644 --- a/application/account-management/WebApp/routes/login/-shared/loginState.ts +++ b/application/account-management/WebApp/routes/login/-shared/loginState.ts @@ -1,22 +1,70 @@ import type { Schemas } from "@/shared/lib/api/client"; -import { t } from "@lingui/core/macro"; interface LoginState { loginId: Schemas["LoginId"]; emailConfirmationId: Schemas["EmailConfirmationId"]; email: string; expireAt: Date; + codeCount: number; + hasRequestedNewCode: boolean; + autoSubmitCode: boolean; + lastSubmittedCode?: string; + currentOtpValue?: string; + validForSeconds?: number; } let currentLoginState: LoginState | undefined; -export function setLoginState(newLogin: LoginState): void { - currentLoginState = newLogin; +export function setLoginState(newLogin: Partial): void { + if (!currentLoginState) { + currentLoginState = { + ...(newLogin as LoginState), + codeCount: 1, // First code + hasRequestedNewCode: false, + autoSubmitCode: true, // Default to auto-submit + lastSubmittedCode: "", // Initialize with empty string + currentOtpValue: "" // Initialize with empty string + }; + } else { + currentLoginState = { + ...currentLoginState, + ...newLogin + }; + } +} + +export function incrementCodeCount(): void { + if (currentLoginState) { + currentLoginState.codeCount += 1; + } +} + +export function setHasRequestedNewCode(value: boolean): void { + if (currentLoginState) { + currentLoginState.hasRequestedNewCode = value; + } +} + +export function setAutoSubmitCode(value: boolean): void { + if (currentLoginState) { + currentLoginState.autoSubmitCode = value; + } } -export function getLoginState() { - if (currentLoginState == null) { - throw new Error(t`No active login.`); +export function setLastSubmittedCode(code: string): void { + if (currentLoginState) { + currentLoginState.lastSubmittedCode = code; } - return currentLoginState; +} + +export function clearLoginState(): void { + currentLoginState = undefined; +} + +export function hasLoginState(): boolean { + return currentLoginState != null; +} + +export function getLoginState(): Partial { + return currentLoginState || { email: "", codeCount: 0, hasRequestedNewCode: false, autoSubmitCode: true }; } diff --git a/application/account-management/WebApp/routes/login/expired.tsx b/application/account-management/WebApp/routes/login/expired.tsx deleted file mode 100644 index 769442051..000000000 --- a/application/account-management/WebApp/routes/login/expired.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { ErrorMessage } from "@/shared/components/ErrorMessage"; -import { HorizontalHeroLayout } from "@/shared/layouts/HorizontalHeroLayout"; -import { Trans } from "@lingui/react/macro"; -import { loginPath } from "@repo/infrastructure/auth/constants"; -import { Content, Heading, IllustratedMessage } from "@repo/ui/components/IllustratedMessage"; -import { Link } from "@repo/ui/components/Link"; -import Timeout from "@spectrum-icons/illustrations/Timeout"; -import { createFileRoute } from "@tanstack/react-router"; -import { getLoginState } from "./-shared/loginState"; - -export const Route = createFileRoute("/login/expired")({ - component: () => ( - - - - ), - errorComponent: (props) => ( - - - - ) -}); - -export function VerificationCodeExpiredMessage() { - const { loginId } = getLoginState(); - - return ( - - - - Error: Verification code has expired - - - The verification code you are trying to use has expired for Login ID: {loginId} - - - Try again - - - ); -} diff --git a/application/account-management/WebApp/routes/login/index.tsx b/application/account-management/WebApp/routes/login/index.tsx index 9e104f654..34f15f6c9 100644 --- a/application/account-management/WebApp/routes/login/index.tsx +++ b/application/account-management/WebApp/routes/login/index.tsx @@ -1,6 +1,6 @@ import { ErrorMessage } from "@/shared/components/ErrorMessage"; import logoMarkUrl from "@/shared/images/logo-mark.svg"; -import poweredByUrl from "@/shared/images/powered-by.svg"; +import logoWrapUrl from "@/shared/images/logo-wrap.svg"; import { HorizontalHeroLayout } from "@/shared/layouts/HorizontalHeroLayout"; import { api } from "@/shared/lib/api/client"; import { t } from "@lingui/core/macro"; @@ -9,14 +9,14 @@ import { loggedInPath, signUpPath } from "@repo/infrastructure/auth/constants"; import { useIsAuthenticated } from "@repo/infrastructure/auth/hooks"; import { Button } from "@repo/ui/components/Button"; import { Form } from "@repo/ui/components/Form"; -import { FormErrorMessage } from "@repo/ui/components/FormErrorMessage"; import { Heading } from "@repo/ui/components/Heading"; import { Link } from "@repo/ui/components/Link"; import { TextField } from "@repo/ui/components/TextField"; import { mutationSubmitter } from "@repo/ui/forms/mutationSubmitter"; import { Navigate, createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; -import { setLoginState } from "./-shared/loginState"; +import { getSignupState } from "../signup/-shared/signupState"; +import { clearLoginState, getLoginState, setLoginState } from "./-shared/loginState"; export const Route = createFileRoute("/login/")({ validateSearch: (search) => { @@ -47,7 +47,9 @@ export const Route = createFileRoute("/login/")({ }); export function LoginForm() { - const [email, setEmail] = useState(""); + const { email: savedEmail } = getLoginState(); + const { email: signupEmail } = getSignupState(); // Prefill from signup page if user navigated here + const [email, setEmail] = useState(savedEmail || signupEmail || ""); const { returnPath } = Route.useSearch(); const startLoginMutation = api.useMutation("post", "/api/account-management/authentication/login/start"); @@ -55,6 +57,7 @@ export function LoginForm() { if (startLoginMutation.isSuccess) { const { loginId, emailConfirmationId, validForSeconds } = startLoginMutation.data; + clearLoginState(); setLoginState({ loginId, emailConfirmationId, @@ -93,16 +96,20 @@ export function LoginForm() { placeholder={t`yourname@example.com`} className="flex w-full flex-col" /> - -
+

Don't have an account? Create one +

+
+ + Powered by + + {t`PlatformPlatform`}
- {t`Powered ); } diff --git a/application/account-management/WebApp/routes/login/verify.tsx b/application/account-management/WebApp/routes/login/verify.tsx index c0eea9e89..9476239c8 100644 --- a/application/account-management/WebApp/routes/login/verify.tsx +++ b/application/account-management/WebApp/routes/login/verify.tsx @@ -1,6 +1,6 @@ import { ErrorMessage } from "@/shared/components/ErrorMessage"; import logoMarkUrl from "@/shared/images/logo-mark.svg"; -import poweredByUrl from "@/shared/images/powered-by.svg"; +import logoWrapUrl from "@/shared/images/logo-wrap.svg"; import { HorizontalHeroLayout } from "@/shared/layouts/HorizontalHeroLayout"; import { api } from "@/shared/lib/api/client"; import { t } from "@lingui/core/macro"; @@ -10,14 +10,20 @@ import { useIsAuthenticated } from "@repo/infrastructure/auth/hooks"; import { Button } from "@repo/ui/components/Button"; import { DigitPattern } from "@repo/ui/components/Digit"; import { Form } from "@repo/ui/components/Form"; -import { FormErrorMessage } from "@repo/ui/components/FormErrorMessage"; import { Link } from "@repo/ui/components/Link"; -import { OneTimeCodeInput } from "@repo/ui/components/OneTimeCodeInput"; +import { OneTimeCodeInput, type OneTimeCodeInputRef } from "@repo/ui/components/OneTimeCodeInput"; +import { toastQueue } from "@repo/ui/components/Toast"; import { mutationSubmitter } from "@repo/ui/forms/mutationSubmitter"; -import { useExpirationTimeout } from "@repo/ui/hooks/useExpiration"; -import { Navigate, createFileRoute } from "@tanstack/react-router"; -import { useEffect } from "react"; -import { getLoginState, setLoginState } from "./-shared/loginState"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { + clearLoginState, + getLoginState, + hasLoginState, + setLastSubmittedCode, + setLoginState +} from "./-shared/loginState"; export const Route = createFileRoute("/login/verify")({ validateSearch: (search) => { @@ -28,10 +34,22 @@ export const Route = createFileRoute("/login/verify")({ }; }, component: function LoginVerifyRoute() { + const navigate = useNavigate(); const isAuthenticated = useIsAuthenticated(); - if (isAuthenticated) { - return ; + useEffect(() => { + if (isAuthenticated) { + navigate({ to: loggedInPath }); + return; + } + + if (!hasLoginState()) { + navigate({ to: "/login", search: { returnPath: "" }, replace: true }); + } + }, [isAuthenticated, navigate]); + + if (isAuthenticated || !hasLoginState()) { + return null; } return ( @@ -47,52 +65,135 @@ export const Route = createFileRoute("/login/verify")({ ) }); +function useCountdown(expireAt: Date) { + const [secondsRemaining, setSecondsRemaining] = useState(() => + Math.max(0, Math.ceil((expireAt.getTime() - Date.now()) / 1000)) + ); + + // Reset the countdown when expireAt changes + useEffect(() => { + setSecondsRemaining(Math.max(0, Math.ceil((expireAt.getTime() - Date.now()) / 1000))); + }, [expireAt]); + + useEffect(() => { + const intervalId = setInterval(() => { + setSecondsRemaining((prev) => { + return Math.max(0, prev - 1); + }); + }, 1000); + + return () => clearInterval(intervalId); + }, []); + + return secondsRemaining; +} + export function CompleteLoginForm() { - const { loginId, emailConfirmationId, email, expireAt } = getLoginState(); - const { expiresInString, isExpired } = useExpirationTimeout(expireAt); + const initialState = getLoginState(); + const { email = "", emailConfirmationId = "" } = initialState; + const initialExpireAt = initialState.expireAt ? new Date(initialState.expireAt) : new Date(); + const [expireAt, setExpireAt] = useState(initialExpireAt); + const secondsRemaining = useCountdown(expireAt); + const isExpired = secondsRemaining === 0; + const oneTimeCodeInputRef = useRef(null); + const [isOneTimeCodeComplete, setIsOneTimeCodeComplete] = useState(false); + const [showRequestLink, setShowRequestLink] = useState(false); + const [hasRequestedNewCode, setHasRequestedNewCode] = useState(false); + const [isRateLimited, setIsRateLimited] = useState(false); + const [autoSubmitCode, setAutoSubmitCode] = useState(true); const { returnPath } = Route.useSearch(); + useEffect(() => { + if (!isExpired && !showRequestLink && !hasRequestedNewCode) { + const timeoutId = setTimeout(() => { + setShowRequestLink(true); + }, 30000); + return () => clearTimeout(timeoutId); + } + }, [isExpired, showRequestLink, hasRequestedNewCode]); + const completeLoginMutation = api.useMutation("post", "/api/account-management/authentication/login/{id}/complete"); + const resendLoginCodeMutation = api.useMutation( + "post", + "/api/account-management/authentication/login/{emailConfirmationId}/resend-code" + ); + useEffect(() => { if (completeLoginMutation.isSuccess) { + clearLoginState(); window.location.href = returnPath ?? loggedInPath; } }, [completeLoginMutation.isSuccess, returnPath]); - const resendLoginCodeMutation = api.useMutation( - "post", - "/api/account-management/authentication/login/{emailConfirmationId}/resend-code" - ); + useEffect(() => { + if (completeLoginMutation.isError) { + const statusCode = completeLoginMutation.error?.status; + if (statusCode === 403) { + setIsRateLimited(true); + setExpireAt(new Date(0)); // Force expiration + } else { + setTimeout(() => { + if (oneTimeCodeInputRef.current) { + oneTimeCodeInputRef.current.focus?.(); + } + }, 100); + } + } + }, [completeLoginMutation.isError, completeLoginMutation.error]); + + const resetAfterResend = useCallback((validForSeconds: number) => { + const newExpireAt = new Date(); + newExpireAt.setSeconds(newExpireAt.getSeconds() + validForSeconds); + setExpireAt(newExpireAt); + getLoginState().expireAt = newExpireAt; + + setIsOneTimeCodeComplete(false); + setShowRequestLink(false); + setIsRateLimited(false); + + setTimeout(() => { + oneTimeCodeInputRef.current?.reset?.(); + oneTimeCodeInputRef.current?.focus?.(); + }, 100); + }, []); useEffect(() => { if (resendLoginCodeMutation.isSuccess && resendLoginCodeMutation.data) { - setLoginState({ - ...getLoginState(), - expireAt: new Date(Date.now() + resendLoginCodeMutation.data.validForSeconds * 1000) + resetAfterResend(resendLoginCodeMutation.data.validForSeconds); + setHasRequestedNewCode(true); + toastQueue.add({ + title: t`Verification code sent`, + description: t`A new verification code has been sent to your email.`, + variant: "success", + duration: 3000 }); } - }, [resendLoginCodeMutation.isSuccess, resendLoginCodeMutation.data]); + }, [resendLoginCodeMutation.isSuccess, resendLoginCodeMutation.data, resetAfterResend]); - useEffect(() => { - if (isExpired) { - window.location.href = "/login/expired"; - } - }, [isExpired]); + const expiresInString = `${Math.floor(secondsRemaining / 60)}:${String(secondsRemaining % 60).padStart(2, "0")}`; return (
{ + const formData = new FormData(event.currentTarget); + const oneTimePassword = formData.get("oneTimePassword") as string; + if (oneTimePassword.length === 6) { + setLastSubmittedCode(oneTimePassword); + } + const handler = mutationSubmitter(completeLoginMutation, { path: { id: getLoginState().loginId ?? "" } }); + return handler(event); + }} validationErrors={completeLoginMutation.error?.errors} validationBehavior="aria" > - +
- {t`Logo`} + {t`Logo`}

@@ -105,49 +206,94 @@ export function CompleteLoginForm() {

{ + setIsOneTimeCodeComplete(isComplete); + + getLoginState().currentOtpValue = value; + + if (isComplete && autoSubmitCode) { + setAutoSubmitCode(false); + setTimeout(() => { + document.querySelector("form")?.requestSubmit(); + }, 10); + } + }} />
- + {!isExpired ? ( +

+ Your verification code is valid for {expiresInString} +

+ ) : ( +

+ Your verification code has expired +

+ )}
-
-
-
- - - -
- ({expiresInString}) + + + )} +
+ { + const loginState = getLoginState(); + clearLoginState(); + setLoginState({ email: loginState?.email ?? "" }); + }} + > + Back to login + +
+ + Powered by + + {t`PlatformPlatform`}
-

- Can't find your code? Check your spam folder. -

- {t`Powered
); diff --git a/application/account-management/WebApp/routes/signup/-shared/signupState.ts b/application/account-management/WebApp/routes/signup/-shared/signupState.ts index 0dbbd630b..7971c769c 100644 --- a/application/account-management/WebApp/routes/signup/-shared/signupState.ts +++ b/application/account-management/WebApp/routes/signup/-shared/signupState.ts @@ -1,21 +1,69 @@ import type { Schemas } from "@/shared/lib/api/client"; -import { t } from "@lingui/core/macro"; interface SignupState { emailConfirmationId: Schemas["EmailConfirmationId"]; email: string; expireAt: Date; + codeCount: number; + hasRequestedNewCode: boolean; + autoSubmitCode: boolean; + lastSubmittedCode?: string; + currentOtpValue?: string; + validForSeconds?: number; } let currentSignupState: SignupState | undefined; -export function setSignupState(newSignup: SignupState): void { - currentSignupState = newSignup; +export function setSignupState(newSignup: Partial): void { + if (!currentSignupState) { + currentSignupState = { + ...(newSignup as SignupState), + codeCount: 1, // First code + hasRequestedNewCode: false, + autoSubmitCode: true, // Default to auto-submit + lastSubmittedCode: "", // Initialize with empty string + currentOtpValue: "" // Initialize with empty string + }; + } else { + currentSignupState = { + ...currentSignupState, + ...newSignup + }; + } +} + +export function incrementCodeCount(): void { + if (currentSignupState) { + currentSignupState.codeCount += 1; + } +} + +export function setHasRequestedNewCode(value: boolean): void { + if (currentSignupState) { + currentSignupState.hasRequestedNewCode = value; + } +} + +export function setAutoSubmitCode(value: boolean): void { + if (currentSignupState) { + currentSignupState.autoSubmitCode = value; + } } -export function getSignupState() { - if (currentSignupState == null) { - throw new Error(t`No active signup session.`); +export function setLastSubmittedCode(code: string): void { + if (currentSignupState) { + currentSignupState.lastSubmittedCode = code; } - return currentSignupState; +} + +export function clearSignupState(): void { + currentSignupState = undefined; +} + +export function hasSignupState(): boolean { + return currentSignupState != null; +} + +export function getSignupState(): Partial { + return currentSignupState || { email: "", codeCount: 0, hasRequestedNewCode: false, autoSubmitCode: true }; } diff --git a/application/account-management/WebApp/routes/signup/expired.tsx b/application/account-management/WebApp/routes/signup/expired.tsx deleted file mode 100644 index e086e1a8f..000000000 --- a/application/account-management/WebApp/routes/signup/expired.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { ErrorMessage } from "@/shared/components/ErrorMessage"; -import { HorizontalHeroLayout } from "@/shared/layouts/HorizontalHeroLayout"; -import { Trans } from "@lingui/react/macro"; -import { signUpPath } from "@repo/infrastructure/auth/constants"; -import { Content, Heading, IllustratedMessage } from "@repo/ui/components/IllustratedMessage"; -import { Link } from "@repo/ui/components/Link"; -import Timeout from "@spectrum-icons/illustrations/Timeout"; -import { createFileRoute } from "@tanstack/react-router"; -import { getSignupState } from "./-shared/signupState"; - -export const Route = createFileRoute("/signup/expired")({ - component: () => ( - - - - ), - errorComponent: (props) => ( - - - - ) -}); - -export function VerificationCodeExpiredMessage() { - const { emailConfirmationId } = getSignupState(); - - return ( - - - - Error: Verification code has expired - - - - The verification code you are trying to use has expired for Email Confirmation ID: {emailConfirmationId} - - - - Try again - - - ); -} diff --git a/application/account-management/WebApp/routes/signup/index.tsx b/application/account-management/WebApp/routes/signup/index.tsx index 792746ec6..1ba5247f7 100644 --- a/application/account-management/WebApp/routes/signup/index.tsx +++ b/application/account-management/WebApp/routes/signup/index.tsx @@ -1,6 +1,6 @@ import { ErrorMessage } from "@/shared/components/ErrorMessage"; import logoMarkUrl from "@/shared/images/logo-mark.svg"; -import poweredByUrl from "@/shared/images/powered-by.svg"; +import logoWrapUrl from "@/shared/images/logo-wrap.svg"; import { HorizontalHeroLayout } from "@/shared/layouts/HorizontalHeroLayout"; import { api } from "@/shared/lib/api/client"; import { t } from "@lingui/core/macro"; @@ -9,7 +9,6 @@ import { loggedInPath, loginPath } from "@repo/infrastructure/auth/constants"; import { useIsAuthenticated } from "@repo/infrastructure/auth/hooks"; import { Button } from "@repo/ui/components/Button"; import { Form } from "@repo/ui/components/Form"; -import { FormErrorMessage } from "@repo/ui/components/FormErrorMessage"; import { Heading } from "@repo/ui/components/Heading"; import { Link } from "@repo/ui/components/Link"; import { Select, SelectItem } from "@repo/ui/components/Select"; @@ -18,7 +17,8 @@ import { mutationSubmitter } from "@repo/ui/forms/mutationSubmitter"; import { Navigate, createFileRoute } from "@tanstack/react-router"; import { DotIcon } from "lucide-react"; import { useState } from "react"; -import { setSignupState } from "./-shared/signupState"; +import { getLoginState } from "../login/-shared/loginState"; +import { clearSignupState, getSignupState, setSignupState } from "./-shared/signupState"; export const Route = createFileRoute("/signup/")({ component: function SignupRoute() { @@ -42,13 +42,16 @@ export const Route = createFileRoute("/signup/")({ }); export function StartSignupForm() { - const [email, setEmail] = useState(""); + const { email: savedEmail } = getSignupState(); + const { email: loginEmail } = getLoginState(); // Prefill from login page if user navigated here + const [email, setEmail] = useState(savedEmail || loginEmail || ""); const startSignupMutation = api.useMutation("post", "/api/account-management/signups/start"); if (startSignupMutation.isSuccess) { const { emailConfirmationId, validForSeconds } = startSignupMutation.data; + clearSignupState(); setSignupState({ emailConfirmationId, email, @@ -98,11 +101,10 @@ export function StartSignupForm() { Europe - -

+

Do you already have an account?{" "} Log in @@ -120,7 +122,12 @@ export function StartSignupForm() {

- {t`Powered +
+ + Powered by + + {t`PlatformPlatform`} +
); } diff --git a/application/account-management/WebApp/routes/signup/verify.tsx b/application/account-management/WebApp/routes/signup/verify.tsx index 9e94df379..647ef4e96 100644 --- a/application/account-management/WebApp/routes/signup/verify.tsx +++ b/application/account-management/WebApp/routes/signup/verify.tsx @@ -1,31 +1,49 @@ import { ErrorMessage } from "@/shared/components/ErrorMessage"; import logoMarkUrl from "@/shared/images/logo-mark.svg"; -import poweredByUrl from "@/shared/images/powered-by.svg"; +import logoWrapUrl from "@/shared/images/logo-wrap.svg"; import { HorizontalHeroLayout } from "@/shared/layouts/HorizontalHeroLayout"; import { api } from "@/shared/lib/api/client"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { loggedInPath, signedUpPath } from "@repo/infrastructure/auth/constants"; +import { loggedInPath } from "@repo/infrastructure/auth/constants"; import { useIsAuthenticated } from "@repo/infrastructure/auth/hooks"; import { preferredLocaleKey } from "@repo/infrastructure/translations/constants"; import { Button } from "@repo/ui/components/Button"; import { DigitPattern } from "@repo/ui/components/Digit"; import { Form } from "@repo/ui/components/Form"; -import { FormErrorMessage } from "@repo/ui/components/FormErrorMessage"; import { Link } from "@repo/ui/components/Link"; -import { OneTimeCodeInput } from "@repo/ui/components/OneTimeCodeInput"; +import { OneTimeCodeInput, type OneTimeCodeInputRef } from "@repo/ui/components/OneTimeCodeInput"; +import { toastQueue } from "@repo/ui/components/Toast"; import { mutationSubmitter } from "@repo/ui/forms/mutationSubmitter"; -import { useExpirationTimeout } from "@repo/ui/hooks/useExpiration"; -import { Navigate, createFileRoute } from "@tanstack/react-router"; -import { useEffect } from "react"; -import { getSignupState, setSignupState } from "./-shared/signupState"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { + clearSignupState, + getSignupState, + hasSignupState, + setLastSubmittedCode, + setSignupState +} from "./-shared/signupState"; export const Route = createFileRoute("/signup/verify")({ component: function SignupVerifyRoute() { + const navigate = useNavigate(); const isAuthenticated = useIsAuthenticated(); - if (isAuthenticated) { - return ; + useEffect(() => { + if (isAuthenticated) { + navigate({ to: loggedInPath }); + return; + } + + if (!hasSignupState()) { + navigate({ to: "/signup", replace: true }); + } + }, [isAuthenticated, navigate]); + + if (isAuthenticated || !hasSignupState()) { + return null; } return ( @@ -41,45 +59,130 @@ export const Route = createFileRoute("/signup/verify")({ ) }); +function useCountdown(expireAt: Date) { + const [secondsRemaining, setSecondsRemaining] = useState(() => + Math.max(0, Math.ceil((expireAt.getTime() - Date.now()) / 1000)) + ); + + // Reset the countdown when expireAt changes + useEffect(() => { + setSecondsRemaining(Math.max(0, Math.ceil((expireAt.getTime() - Date.now()) / 1000))); + }, [expireAt]); + + useEffect(() => { + const intervalId = setInterval(() => { + setSecondsRemaining((prev) => { + return Math.max(0, prev - 1); + }); + }, 1000); + + return () => clearInterval(intervalId); + }, []); + + return secondsRemaining; +} + export function CompleteSignupForm() { - const { email, emailConfirmationId, expireAt } = getSignupState(); - const { expiresInString, isExpired } = useExpirationTimeout(expireAt); + const initialState = getSignupState(); + const { email = "", emailConfirmationId = "" } = initialState; + const initialExpireAt = initialState.expireAt ? new Date(initialState.expireAt) : new Date(); + const [expireAt, setExpireAt] = useState(initialExpireAt); + const secondsRemaining = useCountdown(expireAt); + const isExpired = secondsRemaining === 0; + const oneTimeCodeInputRef = useRef(null); + const [isOneTimeCodeComplete, setIsOneTimeCodeComplete] = useState(false); + const [showRequestLink, setShowRequestLink] = useState(false); + const [hasRequestedNewCode, setHasRequestedNewCode] = useState(false); + const [isRateLimited, setIsRateLimited] = useState(false); + const [autoSubmitCode, setAutoSubmitCode] = useState(true); + + useEffect(() => { + if (!isExpired && !showRequestLink && !hasRequestedNewCode) { + const timeoutId = setTimeout(() => { + setShowRequestLink(true); + }, 30000); + return () => clearTimeout(timeoutId); + } + }, [isExpired, showRequestLink, hasRequestedNewCode]); const completeSignupMutation = api.useMutation( "post", "/api/account-management/signups/{emailConfirmationId}/complete" ); + const resendSignupCodeMutation = api.useMutation( + "post", + "/api/account-management/signups/{emailConfirmationId}/resend-code" + ); + useEffect(() => { if (completeSignupMutation.isSuccess) { - window.location.href = signedUpPath; + clearSignupState(); + window.location.href = loggedInPath; } }, [completeSignupMutation.isSuccess]); - const resendSignupCodeMutation = api.useMutation( - "post", - "/api/account-management/signups/{emailConfirmationId}/resend-code" - ); + useEffect(() => { + if (completeSignupMutation.isError) { + const statusCode = completeSignupMutation.error?.status; + if (statusCode === 403) { + setIsRateLimited(true); + setExpireAt(new Date(0)); // Force expiration + } else { + setTimeout(() => { + if (oneTimeCodeInputRef.current) { + oneTimeCodeInputRef.current.focus?.(); + } + }, 100); + } + } + }, [completeSignupMutation.isError, completeSignupMutation.error]); + + const resetAfterResend = useCallback((validForSeconds: number) => { + const newExpireAt = new Date(); + newExpireAt.setSeconds(newExpireAt.getSeconds() + validForSeconds); + setExpireAt(newExpireAt); + getSignupState().expireAt = newExpireAt; + + setIsOneTimeCodeComplete(false); + setShowRequestLink(false); + setIsRateLimited(false); + + setTimeout(() => { + oneTimeCodeInputRef.current?.reset?.(); + oneTimeCodeInputRef.current?.focus?.(); + }, 100); + }, []); useEffect(() => { if (resendSignupCodeMutation.isSuccess && resendSignupCodeMutation.data) { - setSignupState({ - ...getSignupState(), - expireAt: new Date(Date.now() + resendSignupCodeMutation.data.validForSeconds * 1000) + resetAfterResend(resendSignupCodeMutation.data.validForSeconds); + setHasRequestedNewCode(true); + toastQueue.add({ + title: t`Verification code sent`, + description: t`A new verification code has been sent to your email.`, + variant: "success", + duration: 3000 }); } - }, [resendSignupCodeMutation.isSuccess, resendSignupCodeMutation.data]); + }, [resendSignupCodeMutation.isSuccess, resendSignupCodeMutation.data, resetAfterResend]); - useEffect(() => { - if (isExpired) { - window.location.href = "/signup/expired"; - } - }, [isExpired]); + const expiresInString = `${Math.floor(secondsRemaining / 60)}:${String(secondsRemaining % 60).padStart(2, "0")}`; return (
{ + const formData = new FormData(event.currentTarget); + const oneTimePassword = formData.get("oneTimePassword") as string; + if (oneTimePassword.length === 6) { + setLastSubmittedCode(oneTimePassword); + } + const handler = mutationSubmitter(completeSignupMutation, { + path: { emailConfirmationId: emailConfirmationId } + }); + return handler(event); + }} validationErrors={completeSignupMutation.error?.errors} validationBehavior="aria" > @@ -88,7 +191,7 @@ export function CompleteSignupForm() {
- {t`Logo`} + {t`Logo`}

@@ -101,48 +204,94 @@ export function CompleteSignupForm() {

{ + setIsOneTimeCodeComplete(isComplete); + + getSignupState().currentOtpValue = value; + + if (isComplete && autoSubmitCode) { + setAutoSubmitCode(false); + setTimeout(() => { + document.querySelector("form")?.requestSubmit(); + }, 10); + } + }} />
- + {!isExpired ? ( +

+ Your verification code is valid for {expiresInString} +

+ ) : ( +

+ Your verification code has expired +

+ )}
-
-
-
- - -
- ({expiresInString}) + + + )} +
+ { + const signupState = getSignupState(); + clearSignupState(); + setSignupState({ email: signupState?.email ?? "" }); + }} + > + Back to signup + +
+ + Powered by + + {t`PlatformPlatform`}
-

- Can't find your code? Check your spam folder. -

- {t`Powered
); diff --git a/application/account-management/WebApp/rsbuild.config.ts b/application/account-management/WebApp/rsbuild.config.ts index d0184b235..024958a59 100644 --- a/application/account-management/WebApp/rsbuild.config.ts +++ b/application/account-management/WebApp/rsbuild.config.ts @@ -11,6 +11,14 @@ import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; const customBuildEnv: CustomBuildEnv = {}; export default defineConfig({ + tools: { + rspack: { + // Exclude tests/e2e directory from file watching to prevent hot reloading issues + watchOptions: { + ignored: ["**/tests/**", "**/playwright-report/**"] + } + } + }, plugins: [ pluginReact(), pluginTypeCheck(), diff --git a/application/account-management/WebApp/shared/components/AvatarButton.tsx b/application/account-management/WebApp/shared/components/AvatarButton.tsx index 0c23fc196..c519670f6 100644 --- a/application/account-management/WebApp/shared/components/AvatarButton.tsx +++ b/application/account-management/WebApp/shared/components/AvatarButton.tsx @@ -7,20 +7,41 @@ import { createLoginUrlWithReturnPath } from "@repo/infrastructure/auth/util"; import { Avatar } from "@repo/ui/components/Avatar"; import { Button } from "@repo/ui/components/Button"; import { Menu, MenuHeader, MenuItem, MenuSeparator, MenuTrigger } from "@repo/ui/components/Menu"; +import { useQueryClient } from "@tanstack/react-query"; import { LogOutIcon, UserIcon } from "lucide-react"; import { useEffect, useState } from "react"; export default function AvatarButton({ "aria-label": ariaLabel }: Readonly<{ "aria-label": string }>) { const [isProfileModalOpen, setIsProfileModalOpen] = useState(false); + const [hasAutoOpenedModal, setHasAutoOpenedModal] = useState(false); const userInfo = useUserInfo(); + const queryClient = useQueryClient(); useEffect(() => { - if (userInfo?.isAuthenticated && (!userInfo.firstName || !userInfo.lastName)) { + // Only auto-open the modal once per session if user lacks firstName or lastName + if ( + userInfo?.isAuthenticated && + (!userInfo.firstName || !userInfo.lastName) && + !hasAutoOpenedModal && + !isProfileModalOpen + ) { setIsProfileModalOpen(true); + setHasAutoOpenedModal(true); } - }, [userInfo]); + }, [userInfo, hasAutoOpenedModal, isProfileModalOpen]); + + const handleProfileModalClose = (isOpen: boolean) => { + setIsProfileModalOpen(isOpen); + // No need to check userInfo state here - once modal is closed by user, don't auto-open again + }; const logoutMutation = api.useMutation("post", "/api/account-management/authentication/logout", { + onMutate: async () => { + // Cancel all ongoing queries and remove them from cache to prevent 401 errors + await queryClient.cancelQueries(); + queryClient.clear(); + setHasAutoOpenedModal(false); // Reset for clean state + }, onSuccess: () => { window.location.href = createLoginUrlWithReturnPath(loginPath); }, @@ -44,8 +65,8 @@ export default function AvatarButton({ "aria-label": ariaLabel }: Readonly<{ "ar
-

{userInfo.fullName}

-

{userInfo.title ?? userInfo.email}

+

{userInfo.fullName}

+

{userInfo.title || userInfo.email}

@@ -61,7 +82,7 @@ export default function AvatarButton({ "aria-label": ariaLabel }: Readonly<{ "ar - + ); } diff --git a/application/account-management/WebApp/shared/components/SharedSideMenu.tsx b/application/account-management/WebApp/shared/components/SharedSideMenu.tsx index b17b04d97..ab0938e2e 100644 --- a/application/account-management/WebApp/shared/components/SharedSideMenu.tsx +++ b/application/account-management/WebApp/shared/components/SharedSideMenu.tsx @@ -1,8 +1,22 @@ +import { api } from "@/shared/lib/api/client"; import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; import { Trans } from "@lingui/react/macro"; -import { MenuButton, SideMenu, SideMenuSeparator } from "@repo/ui/components/SideMenu"; -import { CircleUserIcon, HomeIcon, UsersIcon } from "lucide-react"; +import { loginPath } from "@repo/infrastructure/auth/constants"; +import { useUserInfo } from "@repo/infrastructure/auth/hooks"; +import { createLoginUrlWithReturnPath } from "@repo/infrastructure/auth/util"; +import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext"; +import { Avatar } from "@repo/ui/components/Avatar"; +import { Button } from "@repo/ui/components/Button"; +import { Menu, MenuItem, MenuTrigger } from "@repo/ui/components/Menu"; +import { MenuButton, SideMenu, SideMenuSeparator, overlayContext } from "@repo/ui/components/SideMenu"; +import { useThemeMode } from "@repo/ui/theme/mode/ThemeMode"; +import { ThemeMode } from "@repo/ui/theme/mode/utils"; +import { useQueryClient } from "@tanstack/react-query"; +import { CheckIcon, CircleUserIcon, GlobeIcon, HomeIcon, LogOutIcon, SunIcon, UserIcon, UsersIcon } from "lucide-react"; import type React from "react"; +import { use, useContext, useState } from "react"; +import UserProfileModal from "./userModals/UserProfileModal"; type SharedSideMenuProps = { children?: React.ReactNode; @@ -10,15 +24,212 @@ type SharedSideMenuProps = { }; export function SharedSideMenu({ children, ariaLabel }: Readonly) { + const userInfo = useUserInfo(); + const { i18n } = useLingui(); + const { getLocaleInfo, locales, setLocale } = use(translationContext); + const [isProfileModalOpen, setIsProfileModalOpen] = useState(false); + const queryClient = useQueryClient(); + const { themeMode, setThemeMode } = useThemeMode(); + + // Access mobile menu overlay context to close menu when needed + const overlayCtx = useContext(overlayContext); + + const currentLocale = i18n.locale as Locale; + const currentLocaleLabel = getLocaleInfo(currentLocale).label; + + const getThemeName = (mode: ThemeMode) => { + switch (mode) { + case ThemeMode.System: + return t`System`; + case ThemeMode.Light: + return t`Light`; + case ThemeMode.Dark: + return t`Dark`; + default: + return t`System`; + } + }; + + const logoutMutation = api.useMutation("post", "/api/account-management/authentication/logout", { + onMutate: async () => { + await queryClient.cancelQueries(); + queryClient.clear(); + }, + onSuccess: () => { + window.location.href = createLoginUrlWithReturnPath(loginPath); + }, + meta: { + skipQueryInvalidation: true + } + }); + + const topMenuContent = ( +
+ {/* User Profile Section */} +
+ {/* User Profile */} + {userInfo && ( +
+ +
+
{userInfo.fullName}
+
{userInfo.title || userInfo.email}
+
+
+ +
+
+ )} + + {/* Logout */} +
+ +
+ + {/* Theme Section - simple button that cycles themes */} +
+ +
+ + {/* Language Section - styled like menu item */} +
+ + + { + const locale = key.toString() as Locale; + if (locale !== currentLocale) { + setLocale(locale); + } + }} + placement="bottom end" + > + {locales.map((locale) => ( + +
+ {getLocaleInfo(locale).label} + {locale === currentLocale && } +
+
+ ))} +
+
+
+
+ + {/* Divider */} +
+ + {/* Navigation Section for Mobile */} +
+ + Navigation + + + + Organization + + + + {children} +
+ + {/* Spacer to push content up */} +
+
+ ); + return ( - - - - Organization - - - - {children} - + <> + + + + Organization + + + + {children} + + + + ); } diff --git a/application/account-management/WebApp/shared/components/topMenu/index.tsx b/application/account-management/WebApp/shared/components/topMenu/index.tsx index dfbb9df26..5a88735bf 100644 --- a/application/account-management/WebApp/shared/components/topMenu/index.tsx +++ b/application/account-management/WebApp/shared/components/topMenu/index.tsx @@ -3,6 +3,7 @@ import { Trans } from "@lingui/react/macro"; import { LocaleSwitcher } from "@repo/infrastructure/translations/LocaleSwitcher"; import { Breadcrumb, Breadcrumbs } from "@repo/ui/components/Breadcrumbs"; import { Button } from "@repo/ui/components/Button"; +import { Tooltip, TooltipTrigger } from "@repo/ui/components/Tooltip"; import { ThemeModeSelector } from "@repo/ui/theme/ThemeModeSelector"; import { LifeBuoyIcon } from "lucide-react"; import type { ReactNode } from "react"; @@ -14,7 +15,7 @@ interface TopMenuProps { export function TopMenu({ children }: Readonly) { return ( -
- - - - + } + /> +
-
-
{children}
-
+
{children}
+
diff --git a/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json b/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json index 6aaa759b5..2960c5a4d 100644 --- a/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json +++ b/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json @@ -1308,9 +1308,6 @@ "type": "object", "additionalProperties": false, "properties": { - "email": { - "type": "string" - }, "firstName": { "type": "string" }, diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index 70d809955..54fb8a285 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -13,15 +13,24 @@ msgstr "" "Plural-Forms: \n" "X-Generator: @lingui/cli\n" +msgid "A new verification code has been sent to your email." +msgstr "En ny bekræftelseskode er blevet sendt til din e-mail." + msgid "Account" msgstr "Konto" +msgid "Account information" +msgstr "Kontoinformation" + msgid "Account name" msgstr "Kontonavn" msgid "Account settings" msgstr "Kontoindstillinger" +msgid "Account updated successfully" +msgstr "Konto opdateret succesfuldt" + msgid "Actions" msgstr "Handlinger" @@ -50,6 +59,9 @@ msgstr "Alle brugere" msgid "An error occurred while processing your request. {0}" msgstr "Der opstod en fejl under behandlingen af din anmodning. {0}" +msgid "An invitation email will be sent to the user with a link to log in." +msgstr "En invitations-e-mail vil blive sendt til brugeren med et link til login." + msgid "Any role" msgstr "Enhver rolle" @@ -60,19 +72,27 @@ msgstr "Enhver status" msgid "Are you sure you want to delete <0>{0} users?" msgstr "Er du sikker på, at du vil slette <0>{0} brugere?" -#. placeholder {0}: `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() || user.email -msgid "Are you sure you want to delete <0>{0}?" -msgstr "Er du sikker på, at du vil slette <0>{0}?" +msgid "Are you sure you want to delete <0>{userDisplayName}?" +msgstr "Er du sikker på, at du vil slette <0>{userDisplayName}?" + +msgid "Back to login" +msgstr "Tilbage til login" + +msgid "Back to signup" +msgstr "Tilbage til tilmelding" msgid "By continuing, you accept our policies" msgstr "Ved at fortsætte accepterer du vores vilkår" -msgid "Can't find your code? Check your spam folder." -msgstr "Kan du ikke finde din kode? Tjek din spammappe." +msgid "Can't find your code?" +msgstr "Kan du ikke finde din kode?" msgid "Cancel" msgstr "Annuller" +msgid "Change language" +msgstr "Skift sprog" + msgid "Change profile picture" msgstr "Skift profilbillede" @@ -82,6 +102,15 @@ msgstr "Skift rolle" msgid "Change user role" msgstr "Skift brugerrolle" +msgid "Check your spam folder." +msgstr "Tjek din spammappe." + +msgid "Clear" +msgstr "Ryd" + +msgid "Clear filters" +msgstr "Ryd filtre" + msgid "Continue" msgstr "Fortsæt" @@ -94,6 +123,9 @@ msgstr "Oprettet" msgid "Danger zone" msgstr "Farezone" +msgid "Dark" +msgstr "Mørk" + msgid "Delete" msgstr "Slet" @@ -110,11 +142,8 @@ msgstr "Slet bruger" msgid "Delete users" msgstr "Slet brugere" -msgid "Deleting the account and all associated data. This action cannot be undone, so please proceed with caution." -msgstr "Sletning af kontoen og alle tilknyttede data. Denne handling kan ikke fortrydes, så fortsæt med forsigtighed." - -msgid "Didn't receive the code? Resend" -msgstr "Modtog du ikke koden? Send igen" +msgid "Delete your account and all data. This action is irreversible—proceed with caution." +msgstr "Slet din konto og alle data. Denne handling kan ikke fortrydes—fortsæt med forsigtighed." msgid "Do you already have an account?" msgstr "Har du allerede en konto?" @@ -122,15 +151,18 @@ msgstr "Har du allerede en konto?" msgid "Don't have an account? <0>Create one" msgstr "Har du ikke en konto? <0>Opret en" -msgid "E.g., Alex" +msgid "E.g. Alex" msgstr "F.eks. Alex" -msgid "E.g., Software engineer" -msgstr "F.eks., Softwareingeniør" +msgid "E.g. Software engineer" +msgstr "F.eks. Softwareingeniør" -msgid "E.g., Taylor" +msgid "E.g. Taylor" msgstr "F.eks. Taylor" +msgid "Edit" +msgstr "Rediger" + msgid "Edit profile" msgstr "Rediger profil" @@ -146,15 +178,15 @@ msgstr "Indtast din bekræftelseskode" msgid "Error: Something went wrong!" msgstr "Fejl: Noget gik galt!" -msgid "Error: Verification code has expired" -msgstr "Fejl: Bekræftelseskoden er udløbet" - msgid "Europe" msgstr "Europa" msgid "Fetching data..." msgstr "Henter data..." +msgid "Filters" +msgstr "Filtre" + msgid "First name" msgstr "Fornavn" @@ -167,9 +199,6 @@ msgstr "Her er din oversigt over, hvad der sker." msgid "Hi! Welcome back" msgstr "Hej! Velkommen tilbage" -msgid "Hide filters" -msgstr "Skjul filtre" - msgid "Home" msgstr "Hjem" @@ -179,18 +208,18 @@ msgstr "Billedet skal være mindre end 1 MB." msgid "Invite user" msgstr "Inviter bruger" -msgid "Invite users" -msgstr "Inviter brugere" - -msgid "Invite users and assign them roles. They will appear once they log in." -msgstr "Inviter brugere og tildel dem roller. De vil blive vist, når de logger ind." - msgid "Invited users" msgstr "Inviterede brugere" +msgid "Language" +msgstr "Sprog" + msgid "Last name" msgstr "Efternavn" +msgid "Light" +msgstr "Lys" + msgid "Log in" msgstr "Log ind" @@ -212,9 +241,6 @@ msgstr "Administrer dine brugere og tilladelser her." msgid "Member" msgstr "Medlem" -msgid "Menu" -msgstr "Menu" - msgid "Modified" msgstr "Ændret" @@ -224,14 +250,14 @@ msgstr "Ændret dato" msgid "Name" msgstr "Navn" +msgid "Navigation" +msgstr "Navigation" + msgid "Next" msgstr "Næste" -msgid "No active login." -msgstr "Ingen aktiv login." - -msgid "No active signup session." -msgstr "Ingen aktiv tilmeldingssession." +msgid "OK" +msgstr "OK" msgid "Organization" msgstr "Organisation" @@ -242,6 +268,9 @@ msgstr "Ejer" msgid "Pending" msgstr "Afventer" +msgid "PlatformPlatform" +msgstr "PlatformPlatform" + msgid "Please check your email for a verification code sent to <0>{email}" msgstr "Tjek din e-mail for en bekræftelseskode sendt til <0>{email}" @@ -249,7 +278,7 @@ msgid "Please select a JPEG, PNG, GIF, or WebP image." msgstr "Vælg et JPEG-, PNG-, GIF- eller WebP-billede." msgid "Powered by" -msgstr "Drevet af" +msgstr "Powered by" msgid "Preview avatar" msgstr "Forhåndsvisning af avatar" @@ -263,12 +292,18 @@ msgstr "Fortrolighedspolitikker" msgid "Profile picture" msgstr "Profilbillede" +msgid "Profile updated successfully" +msgstr "Profil opdateret succesfuldt" + msgid "Region" msgstr "Region" msgid "Remove profile picture" msgstr "Fjern profilbillede" +msgid "Request a new code" +msgstr "Anmod om en ny kode" + msgid "Role" msgstr "Rolle" @@ -291,6 +326,9 @@ msgstr "Søg" msgid "Select a new role for <0>{0}" msgstr "Vælg en ny rolle for <0>{0}" +msgid "Select dates" +msgstr "Vælg datoer" + msgid "Select language" msgstr "Vælg sprog" @@ -304,19 +342,25 @@ msgid "Show filters" msgstr "Vis filtre" msgid "Sign up in seconds to start building on PlatformPlatform – just like thousands of others." -msgstr "Tilmeld dig på få sekunder for at begynde at bygge på PlatformPlatform – ligesom tusinder af andre." +msgstr "Tilmeld dig på sekunder for at bygge på PlatformPlatform – ligesom tusinder andre." msgid "Signup verification code" msgstr "Tilmeldingsbekræftelseskode" +msgid "Success" +msgstr "Succes" + +msgid "Support" +msgstr "Support" + +msgid "System" +msgstr "System" + msgid "Terms of use" msgstr "Brugsvilkår" -msgid "The verification code you are trying to use has expired for Email Confirmation ID: {emailConfirmationId}" -msgstr "Bekræftelseskoden, du forsøger at bruge, er udløbet for e-mailbekræftelses-ID: {emailConfirmationId}" - -msgid "The verification code you are trying to use has expired for Login ID: {loginId}" -msgstr "Bekræftelseskoden, du forsøger at bruge, er udløbet for login-ID: {loginId}" +msgid "Theme" +msgstr "Tema" msgid "This is the region where your data is stored" msgstr "Dette er den region, hvor dine data er lagret" @@ -342,6 +386,12 @@ msgstr "Opdater dit profilbillede og personlige oplysninger her." msgid "Upload profile picture" msgstr "Upload profilbillede" +msgid "User actions" +msgstr "Brugerhandlinger" + +msgid "User invited successfully" +msgstr "Bruger inviteret succesfuldt" + msgid "User profile" msgstr "Brugerprofil" @@ -351,6 +401,9 @@ msgstr "Brugerprofilmenu" msgid "User role" msgstr "Brugerrolle" +msgid "User role updated successfully for {userDisplayName}" +msgstr "Brugerrolle opdateret succesfuldt for {userDisplayName}" + msgid "User status" msgstr "Brugerstatus" @@ -363,6 +416,9 @@ msgstr "Brugere" msgid "Users who haven't confirmed their email" msgstr "Brugere, der ikke har bekræftet deres e-mail" +msgid "Verification code sent" +msgstr "Bekræftelseskode sendt" + msgid "Verify" msgstr "Bekræft" @@ -384,8 +440,18 @@ msgstr "Se brugere" msgid "Welcome home" msgstr "Velkommen hjem" +#. placeholder {0}: userInfo.firstName +msgid "Welcome home, {0}" +msgstr "Velkommen hjem, {0}" + msgid "You are about to permanently delete the account and the entire data environment via PlatformPlatform.<0/><1/>This action is permanent and irreversible." msgstr "Du er ved at slette kontoen og hele dataomgivelserne permanent via PlatformPlatform.<0/><1/>Denne handling er permanent og kan ikke fortrydes." +msgid "Your verification code has expired" +msgstr "Din bekræftelseskode er udløbet" + +msgid "Your verification code is valid for {expiresInString}" +msgstr "Din bekræftelseskode er gyldig i {expiresInString}" + msgid "yourname@example.com" msgstr "ditnavn@eksempel.com" diff --git a/application/account-management/WebApp/shared/translations/locale/en-US.po b/application/account-management/WebApp/shared/translations/locale/en-US.po index 1b4e2c3f0..e3e5adcfd 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -13,15 +13,24 @@ msgstr "" "Plural-Forms: \n" "X-Generator: @lingui/cli\n" +msgid "A new verification code has been sent to your email." +msgstr "A new verification code has been sent to your email." + msgid "Account" msgstr "Account" +msgid "Account information" +msgstr "Account information" + msgid "Account name" msgstr "Account name" msgid "Account settings" msgstr "Account settings" +msgid "Account updated successfully" +msgstr "Account updated successfully" + msgid "Actions" msgstr "Actions" @@ -50,6 +59,9 @@ msgstr "All users" msgid "An error occurred while processing your request. {0}" msgstr "An error occurred while processing your request. {0}" +msgid "An invitation email will be sent to the user with a link to log in." +msgstr "An invitation email will be sent to the user with a link to log in." + msgid "Any role" msgstr "Any role" @@ -60,19 +72,27 @@ msgstr "Any status" msgid "Are you sure you want to delete <0>{0} users?" msgstr "Are you sure you want to delete <0>{0} users?" -#. placeholder {0}: `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() || user.email -msgid "Are you sure you want to delete <0>{0}?" -msgstr "Are you sure you want to delete <0>{0}?" +msgid "Are you sure you want to delete <0>{userDisplayName}?" +msgstr "Are you sure you want to delete <0>{userDisplayName}?" + +msgid "Back to login" +msgstr "Back to login" + +msgid "Back to signup" +msgstr "Back to signup" msgid "By continuing, you accept our policies" msgstr "By continuing, you accept our policies" -msgid "Can't find your code? Check your spam folder." -msgstr "Can't find your code? Check your spam folder." +msgid "Can't find your code?" +msgstr "Can't find your code?" msgid "Cancel" msgstr "Cancel" +msgid "Change language" +msgstr "Change language" + msgid "Change profile picture" msgstr "Change profile picture" @@ -82,6 +102,15 @@ msgstr "Change role" msgid "Change user role" msgstr "Change user role" +msgid "Check your spam folder." +msgstr "Check your spam folder." + +msgid "Clear" +msgstr "Clear" + +msgid "Clear filters" +msgstr "Clear filters" + msgid "Continue" msgstr "Continue" @@ -94,6 +123,9 @@ msgstr "Created" msgid "Danger zone" msgstr "Danger zone" +msgid "Dark" +msgstr "Dark" + msgid "Delete" msgstr "Delete" @@ -110,11 +142,8 @@ msgstr "Delete user" msgid "Delete users" msgstr "Delete users" -msgid "Deleting the account and all associated data. This action cannot be undone, so please proceed with caution." -msgstr "Deleting the account and all associated data. This action cannot be undone, so please proceed with caution." - -msgid "Didn't receive the code? Resend" -msgstr "Didn't receive the code? Resend" +msgid "Delete your account and all data. This action is irreversible—proceed with caution." +msgstr "Delete your account and all data. This action is irreversible—proceed with caution." msgid "Do you already have an account?" msgstr "Do you already have an account?" @@ -122,14 +151,17 @@ msgstr "Do you already have an account?" msgid "Don't have an account? <0>Create one" msgstr "Don't have an account? <0>Create one" -msgid "E.g., Alex" -msgstr "E.g., Alex" +msgid "E.g. Alex" +msgstr "E.g. Alex" + +msgid "E.g. Software engineer" +msgstr "E.g. Software engineer" -msgid "E.g., Software engineer" -msgstr "E.g., Software engineer" +msgid "E.g. Taylor" +msgstr "E.g. Taylor" -msgid "E.g., Taylor" -msgstr "E.g., Taylor" +msgid "Edit" +msgstr "Edit" msgid "Edit profile" msgstr "Edit profile" @@ -146,15 +178,15 @@ msgstr "Enter your verification code" msgid "Error: Something went wrong!" msgstr "Error: Something went wrong!" -msgid "Error: Verification code has expired" -msgstr "Error: Verification code has expired" - msgid "Europe" msgstr "Europe" msgid "Fetching data..." msgstr "Fetching data..." +msgid "Filters" +msgstr "Filters" + msgid "First name" msgstr "First name" @@ -167,9 +199,6 @@ msgstr "Here's your overview of what's happening." msgid "Hi! Welcome back" msgstr "Hi! Welcome back" -msgid "Hide filters" -msgstr "Hide filters" - msgid "Home" msgstr "Home" @@ -179,18 +208,18 @@ msgstr "Image must be smaller than 1 MB." msgid "Invite user" msgstr "Invite user" -msgid "Invite users" -msgstr "Invite users" - -msgid "Invite users and assign them roles. They will appear once they log in." -msgstr "Invite users and assign them roles. They will appear once they log in." - msgid "Invited users" msgstr "Invited users" +msgid "Language" +msgstr "Language" + msgid "Last name" msgstr "Last name" +msgid "Light" +msgstr "Light" + msgid "Log in" msgstr "Log in" @@ -212,9 +241,6 @@ msgstr "Manage your users and permissions here." msgid "Member" msgstr "Member" -msgid "Menu" -msgstr "Menu" - msgid "Modified" msgstr "Modified" @@ -224,14 +250,14 @@ msgstr "Modified date" msgid "Name" msgstr "Name" +msgid "Navigation" +msgstr "Navigation" + msgid "Next" msgstr "Next" -msgid "No active login." -msgstr "No active login." - -msgid "No active signup session." -msgstr "No active signup session." +msgid "OK" +msgstr "OK" msgid "Organization" msgstr "Organization" @@ -242,6 +268,9 @@ msgstr "Owner" msgid "Pending" msgstr "Pending" +msgid "PlatformPlatform" +msgstr "PlatformPlatform" + msgid "Please check your email for a verification code sent to <0>{email}" msgstr "Please check your email for a verification code sent to <0>{email}" @@ -263,12 +292,18 @@ msgstr "Privacy policies" msgid "Profile picture" msgstr "Profile picture" +msgid "Profile updated successfully" +msgstr "Profile updated successfully" + msgid "Region" msgstr "Region" msgid "Remove profile picture" msgstr "Remove profile picture" +msgid "Request a new code" +msgstr "Request a new code" + msgid "Role" msgstr "Role" @@ -291,6 +326,9 @@ msgstr "Search" msgid "Select a new role for <0>{0}" msgstr "Select a new role for <0>{0}" +msgid "Select dates" +msgstr "Select dates" + msgid "Select language" msgstr "Select language" @@ -309,14 +347,20 @@ msgstr "Sign up in seconds to start building on PlatformPlatform – just like t msgid "Signup verification code" msgstr "Signup verification code" +msgid "Success" +msgstr "Success" + +msgid "Support" +msgstr "Support" + +msgid "System" +msgstr "System" + msgid "Terms of use" msgstr "Terms of use" -msgid "The verification code you are trying to use has expired for Email Confirmation ID: {emailConfirmationId}" -msgstr "The verification code you are trying to use has expired for Email Confirmation ID: {emailConfirmationId}" - -msgid "The verification code you are trying to use has expired for Login ID: {loginId}" -msgstr "The verification code you are trying to use has expired for Login ID: {loginId}" +msgid "Theme" +msgstr "Theme" msgid "This is the region where your data is stored" msgstr "This is the region where your data is stored" @@ -342,6 +386,12 @@ msgstr "Update your profile picture and personal details here." msgid "Upload profile picture" msgstr "Upload profile picture" +msgid "User actions" +msgstr "User actions" + +msgid "User invited successfully" +msgstr "User invited successfully" + msgid "User profile" msgstr "User profile" @@ -351,6 +401,9 @@ msgstr "User profile menu" msgid "User role" msgstr "User role" +msgid "User role updated successfully for {userDisplayName}" +msgstr "User role updated successfully for {userDisplayName}" + msgid "User status" msgstr "User status" @@ -363,6 +416,9 @@ msgstr "Users" msgid "Users who haven't confirmed their email" msgstr "Users who haven't confirmed their email" +msgid "Verification code sent" +msgstr "Verification code sent" + msgid "Verify" msgstr "Verify" @@ -384,8 +440,18 @@ msgstr "View users" msgid "Welcome home" msgstr "Welcome home" +#. placeholder {0}: userInfo.firstName +msgid "Welcome home, {0}" +msgstr "Welcome home, {0}" + msgid "You are about to permanently delete the account and the entire data environment via PlatformPlatform.<0/><1/>This action is permanent and irreversible." msgstr "You are about to permanently delete the account and the entire data environment via PlatformPlatform.<0/><1/>This action is permanent and irreversible." +msgid "Your verification code has expired" +msgstr "Your verification code has expired" + +msgid "Your verification code is valid for {expiresInString}" +msgstr "Your verification code is valid for {expiresInString}" + msgid "yourname@example.com" msgstr "yourname@example.com" diff --git a/application/account-management/WebApp/shared/translations/locale/nl-NL.po b/application/account-management/WebApp/shared/translations/locale/nl-NL.po index c127aa714..ff03728af 100644 --- a/application/account-management/WebApp/shared/translations/locale/nl-NL.po +++ b/application/account-management/WebApp/shared/translations/locale/nl-NL.po @@ -13,15 +13,24 @@ msgstr "" "Plural-Forms: \n" "X-Generator: @lingui/cli\n" +msgid "A new verification code has been sent to your email." +msgstr "Een nieuwe verificatiecode is naar je e-mail verzonden." + msgid "Account" msgstr "Account" +msgid "Account information" +msgstr "Accountinformatie" + msgid "Account name" msgstr "Accountnaam" msgid "Account settings" msgstr "Accountinstellingen" +msgid "Account updated successfully" +msgstr "Account succesvol bijgewerkt" + msgid "Actions" msgstr "Acties" @@ -50,6 +59,9 @@ msgstr "Alle gebruikers" msgid "An error occurred while processing your request. {0}" msgstr "Er is een fout opgetreden bij het verwerken van uw verzoek. {0}" +msgid "An invitation email will be sent to the user with a link to log in." +msgstr "Een uitnodigingsmail wordt naar de gebruiker gestuurd met een link om in te loggen." + msgid "Any role" msgstr "Elke rol" @@ -60,19 +72,27 @@ msgstr "Elke status" msgid "Are you sure you want to delete <0>{0} users?" msgstr "Weet je zeker dat je <0>{0} gebruikers wilt verwijderen?" -#. placeholder {0}: `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() || user.email -msgid "Are you sure you want to delete <0>{0}?" -msgstr "Weet je zeker dat je <0>{0}? wilt verwijderen?" +msgid "Are you sure you want to delete <0>{userDisplayName}?" +msgstr "Weet je zeker dat je <0>{userDisplayName} wilt verwijderen?" + +msgid "Back to login" +msgstr "Terug naar inloggen" + +msgid "Back to signup" +msgstr "Terug naar aanmelding" msgid "By continuing, you accept our policies" -msgstr "Door verder te gaan, accepteer je onze voorwaarden" +msgstr "Door te gaan, accepteer je onze voorwaarden" -msgid "Can't find your code? Check your spam folder." -msgstr "Kun je je code niet vinden? Controleer je spammap." +msgid "Can't find your code?" +msgstr "Kun je je code niet vinden?" msgid "Cancel" msgstr "Annuleren" +msgid "Change language" +msgstr "Taal wijzigen" + msgid "Change profile picture" msgstr "Profielfoto wijzigen" @@ -82,6 +102,15 @@ msgstr "Rol wijzigen" msgid "Change user role" msgstr "Gebruikersrol wijzigen" +msgid "Check your spam folder." +msgstr "Controleer je spammap." + +msgid "Clear" +msgstr "Wissen" + +msgid "Clear filters" +msgstr "Filters wissen" + msgid "Continue" msgstr "Verder" @@ -94,6 +123,9 @@ msgstr "Aangemaakt" msgid "Danger zone" msgstr "Gevaarzone" +msgid "Dark" +msgstr "Donker" + msgid "Delete" msgstr "Verwijderen" @@ -110,11 +142,8 @@ msgstr "Gebruiker verwijderen" msgid "Delete users" msgstr "Gebruikers verwijderen" -msgid "Deleting the account and all associated data. This action cannot be undone, so please proceed with caution." -msgstr "Het account en alle bijbehorende gegevens worden verwijderd. Deze actie kan niet ongedaan worden gemaakt, dus ga voorzichtig te werk." - -msgid "Didn't receive the code? Resend" -msgstr "Code niet ontvangen? Verstuur opnieuw" +msgid "Delete your account and all data. This action is irreversible—proceed with caution." +msgstr "Verwijder je account en alle gegevens. Deze actie is onomkeerbaar—ga zorgvuldig te werk." msgid "Do you already have an account?" msgstr "Heb je al een account?" @@ -122,14 +151,17 @@ msgstr "Heb je al een account?" msgid "Don't have an account? <0>Create one" msgstr "Heb je geen account? <0>Maak er één aan" -msgid "E.g., Alex" -msgstr "Bijv., Alex" +msgid "E.g. Alex" +msgstr "Bijv. Alex" + +msgid "E.g. Software engineer" +msgstr "Bijv. Software engineer" -msgid "E.g., Software engineer" -msgstr "Bijv., Software engineer" +msgid "E.g. Taylor" +msgstr "Bijv. Taylor" -msgid "E.g., Taylor" -msgstr "Bijv., Taylor" +msgid "Edit" +msgstr "Bewerken" msgid "Edit profile" msgstr "Profiel bewerken" @@ -146,15 +178,15 @@ msgstr "Voer je verificatiecode in" msgid "Error: Something went wrong!" msgstr "Fout: Er is iets misgegaan!" -msgid "Error: Verification code has expired" -msgstr "Fout: Verificatiecode is verlopen" - msgid "Europe" msgstr "Europa" msgid "Fetching data..." msgstr "Gegevens ophalen..." +msgid "Filters" +msgstr "Filters" + msgid "First name" msgstr "Voornaam" @@ -167,9 +199,6 @@ msgstr "Hier is je overzicht van wat er gebeurt." msgid "Hi! Welcome back" msgstr "Hallo! Welkom terug" -msgid "Hide filters" -msgstr "Filters verbergen" - msgid "Home" msgstr "Home" @@ -179,18 +208,18 @@ msgstr "Afbeelding moet kleiner zijn dan 1 MB." msgid "Invite user" msgstr "Gebruiker uitnodigen" -msgid "Invite users" -msgstr "Gebruikers uitnodigen" - -msgid "Invite users and assign them roles. They will appear once they log in." -msgstr "Nodig gebruikers uit en wijs hen rollen toe. Ze verschijnen zodra ze inloggen." - msgid "Invited users" msgstr "Uitgenodigde gebruikers" +msgid "Language" +msgstr "Taal" + msgid "Last name" msgstr "Achternaam" +msgid "Light" +msgstr "Licht" + msgid "Log in" msgstr "Inloggen" @@ -212,9 +241,6 @@ msgstr "Beheer je gebruikers en rechten hier." msgid "Member" msgstr "Lid" -msgid "Menu" -msgstr "Menu" - msgid "Modified" msgstr "Gewijzigd" @@ -224,14 +250,14 @@ msgstr "Gewijzigde datum" msgid "Name" msgstr "Naam" +msgid "Navigation" +msgstr "Navigatie" + msgid "Next" msgstr "Volgende" -msgid "No active login." -msgstr "Geen actieve login." - -msgid "No active signup session." -msgstr "Geen actieve registratiesessie." +msgid "OK" +msgstr "OK" msgid "Organization" msgstr "Organisatie" @@ -242,6 +268,9 @@ msgstr "Eigenaar" msgid "Pending" msgstr "In behandeling" +msgid "PlatformPlatform" +msgstr "PlatformPlatform" + msgid "Please check your email for a verification code sent to <0>{email}" msgstr "Controleer je e-mail voor een verificatiecode verzonden naar <0>{email}" @@ -249,7 +278,7 @@ msgid "Please select a JPEG, PNG, GIF, or WebP image." msgstr "Selecteer een JPEG-, PNG-, GIF- of WebP-afbeelding." msgid "Powered by" -msgstr "Mogelijk gemaakt door" +msgstr "Powered by" msgid "Preview avatar" msgstr "Voorbeeld avatar" @@ -263,12 +292,18 @@ msgstr "Privacybeleid" msgid "Profile picture" msgstr "Profielfoto" +msgid "Profile updated successfully" +msgstr "Profiel succesvol bijgewerkt" + msgid "Region" msgstr "Regio" msgid "Remove profile picture" msgstr "Profielfoto verwijderen" +msgid "Request a new code" +msgstr "Vraag een nieuwe code aan" + msgid "Role" msgstr "Rol" @@ -291,6 +326,9 @@ msgstr "Zoeken" msgid "Select a new role for <0>{0}" msgstr "Selecteer een nieuwe rol voor <0>{0}" +msgid "Select dates" +msgstr "Selecteer datums" + msgid "Select language" msgstr "Selecteer taal" @@ -304,19 +342,25 @@ msgid "Show filters" msgstr "Filters tonen" msgid "Sign up in seconds to start building on PlatformPlatform – just like thousands of others." -msgstr "Meld je in enkele seconden aan om te beginnen met bouwen op PlatformPlatform – net als duizenden anderen." +msgstr "Meld je aan en begin direct met bouwen op PlatformPlatform – net als duizenden anderen." msgid "Signup verification code" msgstr "Verificatiecode voor aanmelding" +msgid "Success" +msgstr "Succes" + +msgid "Support" +msgstr "Ondersteuning" + +msgid "System" +msgstr "Systeem" + msgid "Terms of use" msgstr "Gebruiksvoorwaarden" -msgid "The verification code you are trying to use has expired for Email Confirmation ID: {emailConfirmationId}" -msgstr "De verificatiecode die je probeert te gebruiken is verlopen voor E-mailbevestiging ID: {emailConfirmationId}" - -msgid "The verification code you are trying to use has expired for Login ID: {loginId}" -msgstr "De verificatiecode die je probeert te gebruiken is verlopen voor Login ID: {loginId}" +msgid "Theme" +msgstr "Thema" msgid "This is the region where your data is stored" msgstr "Dit is de regio waar je gegevens zijn opgeslagen" @@ -342,6 +386,12 @@ msgstr "Werk hier je profielfoto en persoonlijke gegevens bij." msgid "Upload profile picture" msgstr "Profielfoto uploaden" +msgid "User actions" +msgstr "Gebruikersacties" + +msgid "User invited successfully" +msgstr "Gebruiker succesvol uitgenodigd" + msgid "User profile" msgstr "Gebruikersprofiel" @@ -351,6 +401,9 @@ msgstr "Gebruikersprofielmenu" msgid "User role" msgstr "Gebruikersrol" +msgid "User role updated successfully for {userDisplayName}" +msgstr "Gebruikersrol succesvol bijgewerkt voor {userDisplayName}" + msgid "User status" msgstr "Gebruikersstatus" @@ -363,6 +416,9 @@ msgstr "Gebruikers" msgid "Users who haven't confirmed their email" msgstr "Gebruikers die hun e-mail niet hebben bevestigd" +msgid "Verification code sent" +msgstr "Verificatiecode verzonden" + msgid "Verify" msgstr "Verifiëren" @@ -384,8 +440,18 @@ msgstr "Gebruikers bekijken" msgid "Welcome home" msgstr "Welkom home" +#. placeholder {0}: userInfo.firstName +msgid "Welcome home, {0}" +msgstr "Welkom home, {0}" + msgid "You are about to permanently delete the account and the entire data environment via PlatformPlatform.<0/><1/>This action is permanent and irreversible." msgstr "Je staat op het punt om het account en de volledige gegevensomgeving permanent te verwijderen via PlatformPlatform.<0/><1/>Deze actie is definitief en onomkeerbaar." +msgid "Your verification code has expired" +msgstr "Je verificatiecode is verlopen" + +msgid "Your verification code is valid for {expiresInString}" +msgstr "Je verificatiecode is geldig voor {expiresInString}" + msgid "yourname@example.com" msgstr "jouwnaam@voorbeeld.com" diff --git a/application/account-management/WebApp/tests/e2e/localization-flows.spec.ts b/application/account-management/WebApp/tests/e2e/localization-flows.spec.ts new file mode 100644 index 000000000..8eb09cb9e --- /dev/null +++ b/application/account-management/WebApp/tests/e2e/localization-flows.spec.ts @@ -0,0 +1,163 @@ +import { expect } from "@playwright/test"; +import { test } from "@shared/e2e/fixtures/page-auth"; +import { step } from "@shared/e2e/utils/step-decorator"; +import { createTestContext, expectToastMessage } from "@shared/e2e/utils/test-assertions"; +import { completeSignupFlow, getVerificationCode, testUser } from "@shared/e2e/utils/test-data"; + +test.describe("@comprehensive", () => { + test("should handle language changes across signup, authentication, and logout flows", async ({ page }) => { + const context = createTestContext(page); + const user = testUser(); + + await step("Navigate to signup page & verify default English interface")(async () => { + await page.goto("/signup"); + + await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible(); + })(); + + await step("Change language to Danish on signup page & verify interface updates")(async () => { + await page.getByRole("button", { name: "Select language" }).click(); + await page.getByRole("menuitem", { name: "Dansk" }).click(); + + await expect(page.getByRole("heading", { name: "Opret din konto" })).toBeVisible(); + await expect(page.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("da-DK"); + })(); + + await step("Complete signup with Danish interface & verify language persists through flow")(async () => { + await page.getByRole("textbox", { name: "E-mail" }).fill(user.email); + await page.getByRole("button", { name: "Opret din konto" }).click(); + + await expect(page).toHaveURL("/signup/verify"); + await expect(page.getByRole("heading", { name: "Indtast din bekræftelseskode" })).toBeVisible(); + })(); + + await step("Complete verification with Danish interface & verify navigation to admin")(async () => { + await page.keyboard.type(getVerificationCode()); // The verification code auto submits + + await expect(page).toHaveURL("/admin"); + })(); + + await step("Complete profile setup in Danish & verify profile form works")(async () => { + await expect(page.getByRole("dialog", { name: "Brugerprofil" })).toBeVisible(); + await page.getByRole("textbox", { name: "Fornavn" }).fill(user.firstName); + await page.getByRole("textbox", { name: "Efternavn" }).fill(user.lastName); + await page.getByRole("textbox", { name: "Titel" }).fill("CEO"); + await page.getByRole("button", { name: "Gem ændringer" }).click(); + + await expectToastMessage(context, "Profil opdateret succesfuldt"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + await expect(page.getByRole("heading", { name: "Velkommen hjem" })).toBeVisible(); + })(); + + await step("Logout from Danish interface & verify language persists after logout")(async () => { + await page.getByRole("button", { name: "Brugerprofilmenu" }).click(); + await page.getByRole("menuitem", { name: "Log ud" }).click(); + + await expect(page).toHaveURL("/login?returnPath=%2Fadmin"); + await expect(page.getByRole("heading", { name: "Hej! Velkommen tilbage" })).toBeVisible(); + await expect(page.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("da-DK"); + })(); + + await step("Change login page language to English & verify interface updates")(async () => { + await page.getByRole("button", { name: "Vælg sprog" }).click(); + await page.getByRole("menuitem", { name: "English" }).click(); + + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + await expect(page.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("en-US"); + })(); + + await step("Login with English interface & verify language resets to saved preference")(async () => { + await page.getByRole("textbox", { name: "Email" }).fill(user.email); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/login/verify?returnPath=%2Fadmin"); + await expect(page.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); + })(); + + await step("Complete login verification & verify language resets to user's saved preference")(async () => { + await page.keyboard.type(getVerificationCode()); // The verification code auto submits + + await expect(page).toHaveURL("/admin"); + await expect(page.getByRole("heading", { name: "Velkommen hjem" })).toBeVisible(); + await expect(page.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("da-DK"); + })(); + + await step("Reset to English for cleanup & verify language change works")(async () => { + await page.getByRole("button", { name: "Vælg sprog" }).click(); + await page.getByRole("menuitem", { name: "English" }).click(); + + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + + await page.reload(); // Fix bug where localStorage is not updated before page reload + + await expect(page.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("en-US"); + })(); + }); + + test("should handle language persistence across different user sessions", async ({ browser }) => { + const page1 = await (await browser.newContext()).newPage(); + const page2 = await (await browser.newContext()).newPage(); + + const testContext1 = createTestContext(page1); + const testContext2 = createTestContext(page2); + const user1 = testUser(); + const user2 = testUser(); + + await step("Complete signup for first user with Danish & verify preference saved")(async () => { + await page1.goto("/signup"); + await page1.getByRole("button", { name: "Select language" }).click(); + await page1.getByRole("menuitem", { name: "Dansk" }).click(); + await expect(page1.getByRole("heading", { name: "Opret din konto" })).toBeVisible(); + await page1.getByRole("textbox", { name: "E-mail" }).fill(user1.email); + await page1.getByRole("button", { name: "Opret din konto" }).click(); + await expect(page1).toHaveURL("/signup/verify"); + await page1.keyboard.type(getVerificationCode()); // The verification code auto submits + await page1.getByRole("textbox", { name: "Fornavn" }).fill(user1.firstName); + await page1.getByRole("textbox", { name: "Efternavn" }).fill(user1.lastName); + await page1.getByRole("button", { name: "Gem ændringer" }).click(); + + await expectToastMessage(testContext1, 200, "Profil opdateret succesfuldt"); + await expect(page1.getByRole("heading", { name: "Velkommen hjem" })).toBeVisible(); + await expect(page1.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("da-DK"); + })(); + + await step("Complete signup for second user with default English & verify different language preference")( + async () => { + await completeSignupFlow(page2, expect, user2, testContext2, true); + + await expect(page2.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + await expect(page2.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("en-US"); + } + )(); + + await step("Login first user in new browser context & verify language preference persists")(async () => { + const newContext1 = await browser.newContext(); + const newPage1 = await newContext1.newPage(); + + await newPage1.goto("/login"); + await newPage1.getByRole("textbox", { name: "Email" }).fill(user1.email); + await newPage1.getByRole("button", { name: "Continue" }).click(); + await expect(newPage1).toHaveURL("/login/verify"); + await newPage1.keyboard.type(getVerificationCode()); // The verification code auto submits + + await expect(newPage1).toHaveURL("/admin"); + await expect(newPage1.getByRole("heading", { name: "Velkommen hjem" })).toBeVisible(); + await expect(newPage1.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("da-DK"); + })(); + + await step("Login second user in new browser context & verify language preference persists")(async () => { + const newContext2 = await browser.newContext(); + const newPage2 = await newContext2.newPage(); + + await newPage2.goto("/login"); + await newPage2.getByRole("textbox", { name: "Email" }).fill(user2.email); + await newPage2.getByRole("button", { name: "Continue" }).click(); + await expect(newPage2).toHaveURL("/login/verify"); + await newPage2.keyboard.type(getVerificationCode()); // The verification code auto submits + + await expect(newPage2).toHaveURL("/admin"); + await expect(newPage2.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + await expect(newPage2.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("en-US"); + })(); + }); +}); diff --git a/application/account-management/WebApp/tests/e2e/login-flows.spec.ts b/application/account-management/WebApp/tests/e2e/login-flows.spec.ts new file mode 100644 index 000000000..a995bf7cd --- /dev/null +++ b/application/account-management/WebApp/tests/e2e/login-flows.spec.ts @@ -0,0 +1,299 @@ +import { expect } from "@playwright/test"; +import { test } from "@shared/e2e/fixtures/page-auth"; +import { step } from "@shared/e2e/utils/step-decorator"; +import { + blurActiveElement, + createTestContext, + expectNetworkErrors, + expectToastMessage, + expectValidationError +} from "@shared/e2e/utils/test-assertions"; +import { completeSignupFlow, getVerificationCode, testUser } from "@shared/e2e/utils/test-data"; + +test.describe("@smoke", () => { + test("should handle login flow with validation, security, authentication protection, and logout", async ({ + anonymousPage + }) => { + const { page, tenant } = anonymousPage; + const existingUser = tenant.owner; + const context = createTestContext(page); + + // === EMAIL VALIDATION EDGE CASES === + await step("Submit empty email form & verify validation error")(async () => { + await page.goto("/login"); + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/login"); + await expectValidationError(context, "Email must be in a valid format and no longer than 100 characters."); + })(); + + await step("Enter invalid email format & verify validation error")(async () => { + await page.getByRole("textbox", { name: "Email" }).fill("invalid-email"); + await blurActiveElement(page); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/login"); + await expectValidationError(context, "Email must be in a valid format and no longer than 100 characters."); + })(); + + await step("Enter email exceeding maximum length & verify validation error")(async () => { + const longEmail = `${"a".repeat(90)}@example.com`; // 101 characters total + await page.getByRole("textbox", { name: "Email" }).fill(longEmail); + await blurActiveElement(page); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/login"); + await expectValidationError(context, "Email must be in a valid format and no longer than 100 characters."); + })(); + + await step("Enter email with consecutive dots & verify validation error")(async () => { + await page.getByRole("textbox", { name: "Email" }).fill("test..user@example.com"); + await blurActiveElement(page); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/login"); + await expectValidationError(context, "Email must be in a valid format and no longer than 100 characters."); + })(); + + // === SUCCESSFUL LOGIN FLOW === + await step("Enter valid code & verify navigation")(async () => { + await page.getByRole("textbox", { name: "Email" }).fill(existingUser.email); + await blurActiveElement(page); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/login/verify"); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); + await expect(page.getByRole("button", { name: "Verify" })).toBeDisabled(); + await expect(page.getByText("Can't find your code? Check your spam folder.").first()).toBeVisible(); + await expect(page.getByText("Request a new code")).not.toBeVisible(); + })(); + + await step("Enter wrong verification code & verify error and focus reset")(async () => { + await page.keyboard.type("WRONG1"); // The verification code auto submits the first time + + await expectToastMessage(context, 400, "The code is wrong or no longer valid."); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); + })(); + + await step("Complete successful login & verify navigation")(async () => { + await page.locator('input[autocomplete="one-time-code"]').first().focus(); + await page.keyboard.type(getVerificationCode()); // The verification does not auto submit the second time + await page.getByRole("button", { name: "Verify" }).click(); + + await expect(page).toHaveURL("/admin"); + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + })(); + + // === AUTHENTICATION PROTECTION === + await step("Logout from authenticated session & verify redirect to login")(async () => { + await page.getByRole("button", { name: "User profile menu" }).click(); + await page.getByRole("menuitem", { name: "Log out" }).click(); + + await expect(page).toHaveURL("/login?returnPath=%2Fadmin"); + })(); + + await step("Access protected routes while unauthenticated & verify redirect to login")(async () => { + await page.goto("/admin/users"); + await expect(page).toHaveURL("/login?returnPath=%2Fadmin%2Fusers"); + + await expectNetworkErrors(context, [401]); + + await page.goto("/admin"); + + await expect(page).toHaveURL("/login?returnPath=%2Fadmin"); + await expectNetworkErrors(context, [401]); + })(); + + // === SECURITY EDGE CASES === + await step("Navigate with malicious redirect URL & verify prevention")(async () => { + await page.goto("/login?returnPath=http://hacker.com"); + + await expect(page).toHaveURL("/login"); + })(); + + await step("Complete login after back navigation & verify authentication works")(async () => { + await page.getByRole("textbox", { name: "Email" }).fill(existingUser.email); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page).toHaveURL("/login/verify"); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); + await page.keyboard.type(getVerificationCode()); // The verification code auto submits + + await expect(page).toHaveURL("/admin"); + })(); + }); +}); + +test.describe("@comprehensive", () => { + test("should enforce rate limiting for failed login attempts", async ({ page }) => { + const context = createTestContext(page); + const user = testUser(); + + await step("Create test user for rate limiting test & verify user created")(async () => { + await completeSignupFlow(page, expect, user, context, false); + })(); + + await step("Navigate to login and submit email & verify navigation")(async () => { + await page.goto("/login"); + await page.getByRole("textbox", { name: "Email" }).fill(user.email); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/login/verify"); + })(); + + await step("Check verification page state & verify initial help text displays")(async () => { + await expect(page.getByText("Can't find your code? Check your spam folder.").first()).toBeVisible(); + await expect(page.getByText("Request a new code")).not.toBeVisible(); + })(); + + await step("Enter first wrong code & verify error and focus reset")(async () => { + await page.keyboard.type("WRONG1"); // The verification code auto submits the first time + + await expectToastMessage(context, 400, "The code is wrong or no longer valid."); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); + })(); + + await step("Enter second wrong code & verify error and focus reset")(async () => { + await page.keyboard.type("WRONG2"); + await page.getByRole("button", { name: "Verify" }).click(); + + await expectToastMessage(context, 400, "The code is wrong or no longer valid."); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); + })(); + + await step("Enter third wrong code & verify error and focus reset")(async () => { + await page.keyboard.type("WRONG3"); + await page.getByRole("button", { name: "Verify" }).click(); + + await expectToastMessage(context, 400, "The code is wrong or no longer valid."); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); + })(); + + await step("Enter fourth wrong code & verify rate limiting triggers")(async () => { + await page.keyboard.type("WRONG4"); + await page.getByRole("button", { name: "Verify" }).click(); + + await expect(page.getByText("Too many attempts, please request a new code.").first()).toBeVisible(); + await expectToastMessage(context, 403, "Too many attempts, please request a new code."); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeDisabled(); + await expect(page.getByRole("button", { name: "Verify" })).toBeDisabled(); + })(); + }); +}); + +test.describe("@comprehensive", () => { + test("should show detailed error message when too many login attempts are made", async ({ page }) => { + const context = createTestContext(page); + const user = testUser(); + + await step("Create test user for rate limiting test & verify user created")(async () => { + await completeSignupFlow(page, expect, user, context, false); + })(); + + await step("First 3 login attempts & verify navigation to verify page")(async () => { + for (let attempt = 1; attempt <= 3; attempt++) { + await page.goto("/login"); + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + + await page.getByRole("textbox", { name: "Email" }).fill(user.email); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page).toHaveURL("/login/verify"); + } + })(); + + await step("4th attempt triggers rate limiting & verify error message")(async () => { + await page.goto("/login"); + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + await page.getByRole("textbox", { name: "Email" }).fill(user.email); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/login"); + await expectToastMessage( + context, + 429, + "Too many attempts to confirm this email address. Please try again later." + ); + })(); + }); +}); + +test.describe("@slow", () => { + const requestNewCodeTimeout = 30000; // 30 seconds + const codeValidationTimeout = 60000; // 5 minutes + const sessionTimeout = codeValidationTimeout + 60000; // 6 minutes + + test("should allow resend code 30 seconds after login but then not after code has expired", async ({ page }) => { + test.setTimeout(sessionTimeout); + const context = createTestContext(page); + const user = testUser(); + + await step("Create test user and navigate to verify & verify initial state")(async () => { + await completeSignupFlow(page, expect, user, context, false); + await page.goto("/login"); + await page.getByRole("textbox", { name: "Email" }).fill(user.email); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/login/verify"); + await expect(page.getByText("Can't find your code? Check your spam folder.").first()).toBeVisible(); + })(); + + await step("Wait 30 seconds & verify request code button appears")(async () => { + await page.waitForTimeout(requestNewCodeTimeout); + + await expect( + page.getByRole("textbox", { name: "Can't find your code? Check your spam folder." }) + ).not.toBeVisible(); + await expect(page.getByText("Request a new code")).toBeVisible(); + })(); + + await step("Click request new code & verify success message and button hides")(async () => { + await page.getByRole("button", { name: "Request a new code" }).click(); + + await expectToastMessage(context, "A new verification code has been sent to your email."); + await expect(page.getByRole("button", { name: "Request a new code" })).not.toBeVisible(); + await expect(page.getByText("Can't find your code? Check your spam folder.")).toBeVisible(); + })(); + + await step("Wait for code expiration & verify expiration message displays")(async () => { + await page.waitForTimeout(codeValidationTimeout); + + await expect(page).toHaveURL("/login/verify"); + await expect(page.getByText("Your verification code has expired")).toBeVisible(); + await expect(page.getByRole("button", { name: "Request a new code" })).not.toBeVisible(); + await expect(page.getByText("Can't find your code? Check your spam folder.")).toBeVisible(); + })(); + }); + + test("should allow resend code 5 minutes after login when code has expired", async ({ page }) => { + test.setTimeout(sessionTimeout); + const context = createTestContext(page); + const user = testUser(); + + await step("Create test user and start login & verify navigation")(async () => { + await completeSignupFlow(page, expect, user, context, false); + await page.goto("/login"); + await page.getByRole("textbox", { name: "Email" }).fill(user.email); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/login/verify"); + await expect(page.getByText("Can't find your code? Check your spam folder.")).toBeVisible(); + })(); + + await step("Wait for code expiration & verify request button becomes available")(async () => { + await page.waitForTimeout(codeValidationTimeout); + + await expect(page).toHaveURL("/login/verify"); + await expect(page.getByText("Your verification code has expired")).toBeVisible(); + await expect(page.getByText("Can't find your code? Check your spam folder.")).not.toBeVisible(); + await expect(page.getByText("Request a new code")).toBeVisible(); + })(); + + await step("Request new code & verify success and button hides")(async () => { + await page.getByRole("button", { name: "Request a new code" }).click(); + + await expectToastMessage(context, "A new verification code has been sent to your email."); + await expect(page.getByRole("button", { name: "Request a new code" })).not.toBeVisible(); + await expect(page.getByText("Can't find your code? Check your spam folder.")).toBeVisible(); + })(); + }); +}); diff --git a/application/account-management/WebApp/tests/e2e/signup-flows.spec.ts b/application/account-management/WebApp/tests/e2e/signup-flows.spec.ts new file mode 100644 index 000000000..faab54161 --- /dev/null +++ b/application/account-management/WebApp/tests/e2e/signup-flows.spec.ts @@ -0,0 +1,360 @@ +import { expect } from "@playwright/test"; +import { test } from "@shared/e2e/fixtures/page-auth"; +import { step } from "@shared/e2e/utils/step-decorator"; +import { + blurActiveElement, + createTestContext, + expectToastMessage, + expectValidationError +} from "@shared/e2e/utils/test-assertions"; +import { getVerificationCode, testUser, uniqueEmail } from "@shared/e2e/utils/test-data"; + +test.describe("@smoke", () => { + test("should handle signup flow with validation, profile setup, and account management", async ({ browser }) => { + // Create two browser contexts to simulate different sessions + const context = await browser.newContext(); + const page = await context.newPage(); + const testContext = createTestContext(page); + const user = testUser(); + + // === SIGNUP INITIATION === + await step("Navigate directly to signup page & verify signup process starts")(async () => { + await page.goto("/signup"); + + await expect(page).toHaveURL("/signup"); + await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible(); + })(); + + // === EMAIL VALIDATION EDGE CASES === + await step("Submit form with empty email & verify validation error")(async () => { + await page.getByRole("button", { name: "Create your account" }).click(); + + await expect(page).toHaveURL("/signup"); + await expect(page.getByText("Email must be in a valid format and no longer than 100 characters.")).toBeVisible(); + })(); + + await step("Enter invalid email format & verify validation error")(async () => { + await page.getByRole("textbox", { name: "Email" }).fill("invalid-email"); + await blurActiveElement(page); + await page.getByRole("button", { name: "Create your account" }).click(); + + await expect(page).toHaveURL("/signup"); + await expect(page.getByText("Email must be in a valid format and no longer than 100 characters.")).toBeVisible(); + })(); + + await step("Enter email with consecutive dots & verify validation error")(async () => { + await page.getByRole("textbox", { name: "Email" }).fill("test..user@example.com"); + await blurActiveElement(page); + await page.getByRole("button", { name: "Create your account" }).click(); + + await expect(page).toHaveURL("/signup"); + await expect(page.getByText("Email must be in a valid format and no longer than 100 characters.")).toBeVisible(); + })(); + + await step("Enter email exceeding maximum length & verify validation error")(async () => { + const longEmail = `${"a".repeat(90)}@example.com`; // 101 characters total + await page.getByRole("textbox", { name: "Email" }).fill(longEmail); + await blurActiveElement(page); + await page.getByRole("button", { name: "Create your account" }).click(); + + await expect(page).toHaveURL("/signup"); + await expect(page.getByText("Email must be in a valid format and no longer than 100 characters.")).toBeVisible(); + })(); + + // === SUCCESSFUL SIGNUP FLOW === + await step("Complete signup with valid email & verify navigation to verification page with initial state")( + async () => { + await page.getByRole("textbox", { name: "Email" }).fill(user.email); + await blurActiveElement(page); + await expect(page.getByText("Europe")).toBeVisible(); + await page.getByRole("button", { name: "Create your account" }).click(); + + await expect(page).toHaveURL("/signup/verify"); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); + await expect(page.getByRole("button", { name: "Verify" })).toBeDisabled(); + } + )(); + + // === VERIFICATION CODE VALIDATION === + await step("Enter wrong verification code & verify error and focus reset")(async () => { + await page.keyboard.type("WRONG1"); + + await expectToastMessage(testContext, 400, "The code is wrong or no longer valid."); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); + })(); + + await step("Type verification code & verify submit button enables")(async () => { + await page.keyboard.type(getVerificationCode()); + + await expect(page.getByRole("button", { name: "Verify" })).toBeEnabled(); + })(); + + await step("Click verify button & verify navigation to admin")(async () => { + await page.getByRole("button", { name: "Verify" }).click(); + + await expect(page).toHaveURL("/admin"); + })(); + + // === PROFILE FORM VALIDATION & COMPLETION === + await step("Submit profile form with empty fields & verify validation errors appear")(async () => { + await expect(page.getByRole("dialog", { name: "User profile" })).toBeVisible(); + await page.getByRole("button", { name: "Save changes" }).click(); + + await expectValidationError(testContext, "First name must be between 1 and 30 characters."); + await expectValidationError(testContext, "Last name must be between 1 and 30 characters."); + })(); + + await step("Fill form with one field too long and one missing & verify all validation errors appear")(async () => { + const longName = "A".repeat(31); + const longTitle = "B".repeat(51); + await page.getByRole("textbox", { name: "First name" }).fill(longName); + await page.getByRole("textbox", { name: "Last name" }).clear(); + await page.getByRole("textbox", { name: "Title" }).fill(longTitle); + await page.getByRole("button", { name: "Save changes" }).click(); + + await expect(page.getByRole("dialog")).toBeVisible(); + await expectValidationError(testContext, "First name must be between 1 and 30 characters."); + await expectValidationError(testContext, "Last name must be between 1 and 30 characters."); + await expectValidationError(testContext, "Title must be no longer than 50 characters."); + })(); + + await step("Complete profile setup with valid data & verify navigation to dashboard")(async () => { + await page.getByRole("textbox", { name: "First name" }).fill(user.firstName); + await page.getByRole("textbox", { name: "Last name" }).fill(user.lastName); + await page.getByRole("textbox", { name: "Title" }).fill("CEO & Founder"); + await page.getByRole("button", { name: "Save changes" }).click(); + + await expectToastMessage(testContext, 200, "Profile updated successfully"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + })(); + + // === AVATAR & PROFILE FUNCTIONALITY === + await step("Click avatar button & verify it shows initials and profile information")(async () => { + const initials = user.firstName.charAt(0) + user.lastName.charAt(0); + await expect(page.getByRole("button", { name: "User profile menu" })).toContainText(initials); + await page.getByRole("button", { name: "User profile menu" }).click(); + await expect(page.getByText(`${user.firstName} ${user.lastName}`)).toBeVisible(); + await expect(page.getByText("CEO & Founder")).toBeVisible(); + await page.getByRole("menuitem", { name: "Edit profile" }).click(); + await expect(page.getByRole("textbox", { name: "Title" })).toHaveValue("CEO & Founder"); + await page.getByRole("button", { name: "Cancel" }).click(); + + await expect(page.getByRole("dialog")).not.toBeVisible(); + })(); + + // === AUTHENTICATED NAVIGATION PROTECTION === + await step("Navigate to signup page while authenticated & verify redirect to admin")(async () => { + await page.goto("/signup"); + + await expect(page).toHaveURL("/admin"); + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + })(); + + // === ACCOUNT MANAGEMENT === + await step("Clear account name field & verify validation error appears")(async () => { + await page.getByRole("button", { name: "Account" }).first().click(); + await expect(page.getByRole("heading", { name: "Account settings" })).toBeVisible(); + await page.getByRole("textbox", { name: "Account name" }).clear(); + await page.getByRole("button", { name: "Save changes" }).click(); + + await expectValidationError(testContext, "Name must be between 1 and 30 characters."); + })(); + + await step("Update account name & verify successful save")(async () => { + const newAccountName = `Tech Corp ${Date.now()}`; + await page.getByRole("textbox", { name: "Account name" }).fill(newAccountName); + await page.getByRole("button", { name: "Save changes" }).focus(); // WebKit requires explicit focus before clicking + await page.getByRole("button", { name: "Save changes" }).click(); + + await expectToastMessage(testContext, 200, "Account updated successfully"); + })(); + + await step("Update user profile title & verify successful profile update")(async () => { + await page.getByRole("button", { name: "User profile menu" }).click(); + await page.getByRole("menuitem", { name: "Edit profile" }).click(); + await expect(page.getByRole("dialog", { name: "User profile" })).toBeVisible(); + await page.getByRole("textbox", { name: "Title" }).fill("Chief Executive Officer"); + await page.getByRole("button", { name: "Save changes" }).click(); + + await expectToastMessage(testContext, 200, "Profile updated successfully"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + })(); + + await step("Access protected account route & verify session maintains authentication")(async () => { + await page.getByRole("button", { name: "Account" }).first().click(); + + await expect(page.getByRole("textbox", { name: "Account name" })).toBeVisible(); + })(); + + await step("Cleanup browser context & verify no errors")(async () => { + await context.close(); + })(); + }); +}); + +test.describe("@comprehensive", () => { + test("should enforce rate limiting for verification attempts and handle direct access", async ({ page }) => { + const context = createTestContext(page); + const user = testUser(); + + await step("Navigate to signup and enter email & verify navigation")(async () => { + await page.goto("/signup"); + await page.getByRole("textbox", { name: "Email" }).fill(user.email); + await blurActiveElement(page); + await page.getByRole("button", { name: "Create your account" }).click(); + + await expect(page).toHaveURL("/signup/verify"); + })(); + + await step("Enter first wrong code & verify error and focus reset")(async () => { + await page.keyboard.type("WRONG1"); + await expectToastMessage(context, 400, "The code is wrong or no longer valid."); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); + })(); + + await step("Enter second wrong code & verify error and focus reset")(async () => { + await page.keyboard.type("WRONG2"); + await page.getByRole("button", { name: "Verify" }).click(); + await expectToastMessage(context, 400, "The code is wrong or no longer valid."); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); + })(); + + await step("Enter third wrong code & verify error and focus reset")(async () => { + await page.keyboard.type("WRONG3"); + await page.getByRole("button", { name: "Verify" }).click(); + await expectToastMessage(context, 400, "The code is wrong or no longer valid."); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); + })(); + + await step("Enter fourth wrong code & verify rate limiting triggers")(async () => { + await page.keyboard.type("WRONG4"); + await page.getByRole("button", { name: "Verify" }).click(); + await expect(page.getByText("Too many attempts, please request a new code.").first()).toBeVisible(); + await expectToastMessage(context, 403, "Too many attempts, please request a new code."); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeDisabled(); + await expect(page.getByRole("button", { name: "Verify" })).toBeDisabled(); + })(); + }); + + test("should show detailed error message when too many signup attempts are made", async ({ page }) => { + const context = createTestContext(page); + const testEmail = uniqueEmail(); + + await step("First 3 signup attempts & verify navigation to verify page")(async () => { + for (let attempt = 1; attempt <= 3; attempt++) { + await page.goto("/signup"); + await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible(); + + await page.getByRole("textbox", { name: "Email" }).fill(testEmail); + await page.getByRole("button", { name: "Create your account" }).click(); + await expect(page).toHaveURL("/signup/verify"); + } + })(); + + await step("4th attempt triggers rate limiting & verify error message")(async () => { + await page.goto("/signup"); + await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible(); + await page.getByRole("textbox", { name: "Email" }).fill(testEmail); + await page.getByRole("button", { name: "Create your account" }).click(); + + await expect(page).toHaveURL("/signup"); + await expectToastMessage( + context, + 429, + "Too many attempts to confirm this email address. Please try again later." + ); + })(); + }); +}); + +test.describe("@slow", () => { + const requestNewCodeTimeout = 30_000; // 30 seconds + const codeValidationTimeout = 60_000; // 5 minutes + const sessionTimeout = codeValidationTimeout + 60_000; // 6 minutes + + test("should allow resend code 30 seconds after signup but then not after code has expired", async ({ page }) => { + test.setTimeout(sessionTimeout); + const context = createTestContext(page); + const user = testUser(); + + await step("Start signup and navigate to verify & verify initial state")(async () => { + await page.goto("/signup"); + await page.getByRole("textbox", { name: "Email" }).fill(user.email); + await blurActiveElement(page); + await page.getByRole("button", { name: "Create your account" }).click(); + + await expect(page).toHaveURL("/signup/verify"); + await expect(page.getByText("Can't find your code? Check your spam folder.").first()).toBeVisible(); + })(); + + await step( + "Wait 30 seconds before & verify Check your spam folder is not visible and that 'Request a new code' IS available" + )(async () => { + await page.waitForTimeout(requestNewCodeTimeout); + + await expect( + page.getByRole("textbox", { name: "Can't find your code? Check your spam folder." }) + ).not.toBeVisible(); + await expect(page.getByText("Request a new code")).toBeVisible(); + })(); + + await step( + "Click Request a new code & verify success toast message and that 'Request a new code' is NOT available" + )(async () => { + await page.getByRole("button", { name: "Request a new code" }).click(); + + await expectToastMessage(context, "A new verification code has been sent to your email."); + await expect(page.getByRole("button", { name: "Request a new code" })).not.toBeVisible(); + await expect(page.getByText("Can't find your code? Check your spam folder.")).toBeVisible(); + })(); + + await step("Wait for expiration & verify inline expiration message and that 'Request a new code' is NOT available")( + async () => { + await page.waitForTimeout(codeValidationTimeout); + + await expect(page).toHaveURL("/signup/verify"); + await expect(page.getByText("Your verification code has expired")).toBeVisible(); + await expect(page.getByRole("button", { name: "Request a new code" })).not.toBeVisible(); + await expect(page.getByText("Can't find your code? Check your spam folder.")).toBeVisible(); + } + )(); + }); + + test("should allow resend code 5 minutes after signup when code has expired", async ({ page }) => { + test.setTimeout(sessionTimeout); + const context = createTestContext(page); + const user = testUser(); + + await step("Start signup and navigate to verify & verify initial state")(async () => { + await page.goto("/signup"); + await page.getByRole("textbox", { name: "Email" }).fill(user.email); + await blurActiveElement(page); + await page.getByRole("button", { name: "Create your account" }).click(); + + await expect(page).toHaveURL("/signup/verify"); + await expect(page.getByText("Can't find your code? Check your spam folder.")).toBeVisible(); + })(); + + await step( + "Wait 5 minutes for code expiration & verify inline expiration message and that 'Request a new code' IS available" + )(async () => { + await page.waitForTimeout(codeValidationTimeout); + + await expect(page).toHaveURL("/signup/verify"); + await expect(page.getByText("Your verification code has expired")).toBeVisible(); + await expect(page.getByText("Can't find your code? Check your spam folder.")).not.toBeVisible(); + await expect(page.getByText("Request a new code")).toBeVisible(); + })(); + + await step("Request a new code & verify success toast message and that 'Request a new code' is NOT available")( + async () => { + await page.getByRole("button", { name: "Request a new code" }).click(); + + await expectToastMessage(context, "A new verification code has been sent to your email."); + await expect(page.getByRole("button", { name: "Request a new code" })).not.toBeVisible(); + await expect(page.getByText("Can't find your code? Check your spam folder.")).toBeVisible(); + } + )(); + }); +}); diff --git a/application/account-management/WebApp/tests/e2e/theme-and-responsiveness-flows.spec.ts b/application/account-management/WebApp/tests/e2e/theme-and-responsiveness-flows.spec.ts new file mode 100644 index 000000000..81c70d0b6 --- /dev/null +++ b/application/account-management/WebApp/tests/e2e/theme-and-responsiveness-flows.spec.ts @@ -0,0 +1,113 @@ +import { expect } from "@playwright/test"; +import { test } from "@shared/e2e/fixtures/page-auth"; +import { step } from "@shared/e2e/utils/step-decorator"; +import { createTestContext } from "@shared/e2e/utils/test-assertions"; + +test.describe("@comprehensive", () => { + test("should handle theme and responsiveness across viewport sizes and authentication", async ({ ownerPage }) => { + createTestContext(ownerPage); + + const initialThemeClass = await ownerPage.locator("html").getAttribute("class"); + const initialIsLight = initialThemeClass?.includes("light"); + const firstTheme = initialIsLight ? "dark" : "light"; + const secondTheme = initialIsLight ? "light" : "dark"; + + await step("Start on admin dashboard & verify welcome page loads")(async () => { + await ownerPage.goto("/admin"); + + await expect(ownerPage.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + })(); + + await step("Verify theme toggle button accessibility & click to change theme")(async () => { + const themeButton = ownerPage.getByRole("button", { name: "Toggle theme" }); + await expect(themeButton).toHaveAttribute("aria-label", "Toggle theme"); + + await themeButton.click(); + + await expect(ownerPage.locator("html")).toHaveClass(firstTheme); + })(); + + await step("Verify desktop side menu navigation elements are visible & verify large screen behavior")(async () => { + await expect(ownerPage.getByRole("button", { name: "Home" })).toBeVisible(); + await expect(ownerPage.getByRole("button", { name: "Account" })).toBeVisible(); + await expect(ownerPage.getByRole("button", { name: "Users" })).toBeVisible(); + await expect(ownerPage.getByRole("button", { name: "Toggle collapsed menu" })).toBeVisible(); + })(); + + await step("Collapse desktop sidebar & verify layout adapts")(async () => { + await ownerPage.getByRole("button", { name: "Toggle collapsed menu" }).click(); + + await expect(ownerPage.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + await expect(ownerPage.getByRole("button", { name: "Toggle collapsed menu" })).toBeVisible(); + })(); + + await step("Switch to large desktop viewport & verify interface scales properly")(async () => { + await ownerPage.setViewportSize({ width: 2560, height: 1440 }); + + await expect(ownerPage.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + await expect(ownerPage.locator("html")).toHaveClass(firstTheme); + await expect(ownerPage.getByRole("button", { name: "Home" })).toBeVisible(); + await expect(ownerPage.getByRole("button", { name: "Toggle collapsed menu" })).toBeVisible(); + })(); + + await step("Switch to tablet viewport & verify interface adapts and theme persists")(async () => { + await ownerPage.setViewportSize({ width: 768, height: 1024 }); + + await expect(ownerPage.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + await expect(ownerPage.locator("html")).toHaveClass(firstTheme); + })(); + + await step("Verify tablet breakpoint navigation behavior & verify elements remain visible")(async () => { + await expect(ownerPage.getByRole("button", { name: "Home" })).toBeVisible(); + await expect(ownerPage.getByRole("button", { name: "Toggle collapsed menu" })).toBeVisible(); + })(); + + await step("Switch to mobile viewport & verify mobile interface and responsive behavior")(async () => { + await ownerPage.setViewportSize({ width: 375, height: 667 }); + + await expect(ownerPage.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + await expect(ownerPage.locator("html")).toHaveClass(firstTheme); + await expect(ownerPage.getByRole("button", { name: "Home" })).not.toBeVisible(); + await expect(ownerPage.getByRole("button", { name: "Users" })).not.toBeVisible(); + await expect(ownerPage.getByRole("button", { name: "Account" })).not.toBeVisible(); + const mobileMenuButton = ownerPage.getByRole("button", { name: "Toggle collapsed menu" }); + await expect(mobileMenuButton).toBeVisible(); + })(); + + await step("Toggle mobile theme button & verify theme changes")(async () => { + // Open mobile menu first + await ownerPage.getByRole("button", { name: "Toggle collapsed menu" }).click(); + + // Find and click theme toggle button inside mobile menu + await ownerPage.getByRole("button", { name: "Theme" }).click(); + + await expect(ownerPage.locator("html")).toHaveClass(secondTheme); + })(); + + await step("Return to desktop viewport & verify responsive transitions & side menu restoration")(async () => { + await ownerPage.setViewportSize({ width: 1920, height: 1080 }); + + await expect(ownerPage.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + await expect(ownerPage.locator("html")).toHaveClass(secondTheme); + await expect(ownerPage.getByRole("button", { name: "Home" })).toBeVisible(); + await expect(ownerPage.getByRole("button", { name: "Account" })).toBeVisible(); + await expect(ownerPage.getByRole("button", { name: "Users" })).toBeVisible(); + await expect(ownerPage.getByRole("button", { name: "Toggle collapsed menu" })).toBeVisible(); + })(); + + await step("Cycle through theme one more time & verify theme cycle works completely")(async () => { + const finalThemeButton = ownerPage.getByRole("button", { name: "Toggle theme" }); + await finalThemeButton.click(); + + await expect(ownerPage.locator("html")).toHaveClass(firstTheme); + })(); + + await step("Logout from account & verify theme persists across authentication")(async () => { + await ownerPage.getByRole("button", { name: "User profile menu" }).click(); + await ownerPage.getByRole("menuitem", { name: "Log out" }).click(); + + await expect(ownerPage).toHaveURL("/login?returnPath=%2Fadmin"); + await expect(ownerPage.locator("html")).toHaveClass(firstTheme); + })(); + }); +}); diff --git a/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts b/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts new file mode 100644 index 000000000..3985c5728 --- /dev/null +++ b/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts @@ -0,0 +1,451 @@ +import { expect } from "@playwright/test"; +import { test } from "@shared/e2e/fixtures/page-auth"; +import { step } from "@shared/e2e/utils/step-decorator"; +import { createTestContext, expectToastMessage, expectValidationError } from "@shared/e2e/utils/test-assertions"; +import { completeSignupFlow, getVerificationCode, testUser } from "@shared/e2e/utils/test-data"; + +test.describe("@smoke", () => { + // Set a larger viewport size to avoid positioning issues in Chrome + test.use({ viewport: { width: 1600, height: 900 } }); + /** + * COMPREHENSIVE USER MANAGEMENT WORKFLOW + * + * Tests the complete end-to-end user management journey including: + * - User invitation process with validation (invalid email, duplicate email) + * - Role management (changing user roles from Member to Admin) + * - Permission system (testing what owners vs admins can/cannot do) + * - Search and filtering functionality (email search, role filtering) + * - User permission restrictions (what owners vs admins can/cannot do) + */ + test("should handle user invitation, role management & permissions workflow", async ({ page }) => { + const context = createTestContext(page); + const owner = testUser(); + const adminUser = testUser(); + const memberUser = testUser(); + + await step("Complete owner signup & verify owner account creation")(async () => { + await completeSignupFlow(page, expect, owner, context); + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + })(); + + await step("Navigate to users page & verify owner is listed")(async () => { + await page.getByRole("button", { name: "Users" }).click(); + + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + await expect(page.locator("tbody").locator("tr")).toHaveCount(1); + await expect(page.getByText(`${owner.firstName} ${owner.lastName}`)).toBeVisible(); + await expect(page.getByText(owner.email)).toBeVisible(); + await expect(page.getByText("Owner")).toBeVisible(); + })(); + + await step("Submit invalid email invitation & verify validation error")(async () => { + await page.getByRole("button", { name: "Invite user" }).click(); + await expect(page.getByRole("dialog", { name: "Invite user" })).toBeVisible(); + await page.getByRole("textbox", { name: "Email" }).fill("invalid-email"); + await page.getByRole("button", { name: "Send invite" }).click(); + + await expectValidationError(context, "Email must be in a valid format and no longer than 100 characters."); + await expect(page.getByRole("dialog")).toBeVisible(); + })(); + + await step("Invite member user & verify successful invitation")(async () => { + await page.getByRole("textbox", { name: "Email" }).fill(memberUser.email); + await page.getByRole("button", { name: "Send invite" }).click(); + + await expectToastMessage(context, "User invited successfully"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + await expect(page.locator("tbody").locator("tr")).toHaveCount(2); + await expect(page.getByText(`${memberUser.email}`)).toBeVisible(); + })(); + + await step("Invite admin user & verify successful invitation")(async () => { + await page.getByRole("button", { name: "Invite user" }).click(); + await page.getByRole("textbox", { name: "Email" }).fill(adminUser.email); + await page.getByRole("button", { name: "Send invite" }).click(); + + await expectToastMessage(context, "User invited successfully"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + await expect(page.locator("tbody").locator("tr")).toHaveCount(3); + await expect(page.getByText(`${adminUser.email}`)).toBeVisible(); + })(); + + await step("Select Admin role from dropdown & verify role change completes")(async () => { + const adminUserRow = page.locator("tbody tr").filter({ hasText: adminUser.email }); + await adminUserRow.getByLabel("User actions").click(); + await page.getByRole("menuitem", { name: "Change role" }).click(); + await expect(page.getByRole("alertdialog", { name: "Change user role" })).toBeVisible(); + await page.getByRole("button", { name: "Member User role" }).click(); + await page.getByRole("option", { name: "Admin" }).click(); + + await expectToastMessage(context, `User role updated successfully for ${adminUser.email}`); + await expect(page.getByRole("alertdialog", { name: "Change user role" })).not.toBeVisible(); + await expect(adminUserRow).toContainText("Admin"); + })(); + + await step("Click to unselect row after role change & verify selection clears")(async () => { + const adminUserRow = page.locator("tbody tr").filter({ hasText: adminUser.email }); + await expect(adminUserRow).toHaveAttribute("aria-selected", "true"); + await adminUserRow.click(); // Unselect the row + + await expect(adminUserRow).not.toHaveAttribute("aria-selected", "true"); + })(); + + await step("Attempt to invite duplicate user email & verify error message appears")(async () => { + await page.getByRole("button", { name: "Invite user" }).click(); + await page.getByRole("textbox", { name: "Email" }).fill(memberUser.email); + await page.getByRole("button", { name: "Send invite" }).click(); + + await expectToastMessage(context, 400, `The user with '${memberUser.email}' already exists.`); + + await page.getByRole("button", { name: "Cancel" }).click(); + + await expect(page.getByRole("dialog")).not.toBeVisible(); + })(); + + await step("Check users table & verify invited users appear with correct roles")(async () => { + const userTable = page.locator("tbody"); + await expect(userTable.locator("tr")).toHaveCount(3); // owner + 2 invited users + await expect(userTable).toContainText(adminUser.email); + await expect(userTable).toContainText(memberUser.email); + await expect(page.getByText("Member").first()).toBeVisible(); + })(); + + await step("Try to delete owner account & verify action restrictions")(async () => { + const ownerRowSelf = page.locator("tbody tr").filter({ hasText: owner.email }); + await ownerRowSelf.getByLabel("User actions").click(); + + await expect(page.getByRole("menuitem", { name: "Delete user" })).not.toBeVisible(); + await expect(page.getByRole("menuitem", { name: "Change role" })).toBeDisabled(); + + await page.keyboard.press("Escape"); + })(); + + await step("Filter users by email search & verify filtered results display correctly")(async () => { + const userTable = page.locator("tbody"); + await page.getByPlaceholder("Search").fill(adminUser.email); + await page.keyboard.press("Enter"); // Trigger search immediately without debounce + + await expect(userTable.locator("tr")).toHaveCount(1); + await expect(userTable).toContainText(adminUser.email); + await expect(userTable).not.toContainText(owner.email); + await expect(userTable).not.toContainText(memberUser.email); + + await page.getByPlaceholder("Search").clear(); + await page.keyboard.press("Enter"); // Trigger search immediately to show all results + + await expect(userTable.locator("tr")).toHaveCount(3); + await expect(userTable).toContainText(adminUser.email); + await expect(userTable).toContainText(memberUser.email); + })(); + + await step("Filter users by role & verify role-based filtering works correctly")(async () => { + const userTable = page.locator("tbody"); + await page.getByRole("button", { name: "Show filters" }).click(); + await page.getByRole("button", { name: "Any role User role" }).click(); + await page.getByRole("option", { name: "Owner" }).click(); + + await expect(userTable.locator("tr")).toHaveCount(1); // After filtering by Owner role, should only have 1 owner (the original) + await expect(userTable).toContainText(owner.email); + await expect(userTable).not.toContainText(adminUser.email); + + await page.getByRole("button", { name: "Owner User role" }).click(); + await page.getByRole("option", { name: "Any role" }).click(); + + await expect(userTable).toContainText(adminUser.email); + })(); + + await step("Logout from owner account to test admin permissions")(async () => { + await page.getByRole("button", { name: "Home" }).click(); + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + await page.getByRole("button", { name: "User profile menu" }).click(); + await page.getByRole("menuitem", { name: "Log out" }).click(); + + await expect(page).toHaveURL("/login?returnPath=%2Fadmin"); + })(); + + await step("Login as admin user & verify successful authentication")(async () => { + await page.getByRole("textbox", { name: "Email" }).fill(adminUser.email); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page).toHaveURL("/login/verify?returnPath=%2Fadmin"); + await page.keyboard.type(getVerificationCode()); + + await expect(page).toHaveURL("/admin"); + })(); + + await step("Complete admin user profile setup & verify profile form completion")(async () => { + await expect(page.getByRole("dialog", { name: "User profile" })).toBeVisible(); + await page.getByRole("textbox", { name: "First name" }).fill(adminUser.firstName); + await page.getByRole("textbox", { name: "Last name" }).fill(adminUser.lastName); + await page.getByRole("textbox", { name: "Title" }).fill("Administrator"); + await page.getByRole("button", { name: "Save changes" }).click(); + + await expectToastMessage(context, "Profile updated successfully"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + })(); + + await step("Navigate to users page as admin & verify admin can see all users")(async () => { + await page.getByRole("button", { name: "Users" }).click(); + + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + await expect(page.locator("tbody tr")).toHaveCount(3); // owner + admin + member + })(); + + await step("Open member user menu as admin & verify limited actions available")(async () => { + const memberUserRow = page.locator("tbody tr").filter({ hasText: memberUser.email }); + await memberUserRow.getByLabel("User actions").click({ force: true }); + + await expect(page.getByRole("menuitem", { name: "Change role" })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "Delete user" })).not.toBeVisible(); + })(); + }); +}); + +test.describe("@comprehensive", () => { + /** + * USER DELETION WORKFLOWS WITH DASHBOARD INTEGRATION + * + * Tests comprehensive user deletion functionality with dashboard context including: + * - Dashboard metrics integration (user count displays) + * - URL-based filtering (active users link) + * - Single user deletion via menu actions + * - Bulk user selection by clicking rows with Ctrl/Cmd modifier + * - Bulk deletion of multiple users via "Delete X users" button + * - Owner protection mechanisms (deletion restrictions) + * - UI state management after deletions (selection clearing, button visibility) + */ + test("should handle single and bulk user deletion workflows with dashboard integration", async ({ page }) => { + const context = createTestContext(page); + const owner = testUser(); + const user1 = testUser(); + const user2 = testUser(); + const user3 = testUser(); + + // === USER SETUP SECTION === + await step("Complete owner signup & navigate to users page")(async () => { + await completeSignupFlow(page, expect, owner, context); + await page.goto("/admin/users"); + + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + })(); + + await step("Invite multiple users & verify they are added to the list")(async () => { + const usersToInvite = [user1, user2, user3]; + + for (const user of usersToInvite) { + await page.getByRole("button", { name: "Invite user" }).click(); + await page.getByRole("textbox", { name: "Email" }).fill(user.email); + await page.getByRole("button", { name: "Send invite" }).click(); + + await expectToastMessage(context, "User invited successfully"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + } + + await expect(page.locator("tbody tr")).toHaveCount(4); // owner + 3 invited users + })(); + + // === DASHBOARD METRICS SECTION === + await step("Navigate to dashboard & verify user count metrics display correctly")(async () => { + await page.goto("/admin"); + + await expect(page.getByRole("link", { name: "View users" })).toContainText("4"); + await expect(page.getByRole("link", { name: "View active users" })).toContainText("1"); + await expect(page.getByRole("link", { name: "View invited users" })).toContainText("3"); + })(); + + // === URL FILTERING SECTION === + await step("Click invited users link & verify URL filtering works correctly")(async () => { + await page.getByRole("link", { name: "View invited users" }).click(); + + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + await expect(page.locator("tbody tr")).toHaveCount(3); + await expect(page.url()).toContain("userStatus=Pending"); + })(); + + // === ADVANCED FILTERING SECTION === + await step("Verify all filter options are available")(async () => { + // Filters should already be visible due to URL filtering from previous step + await expect(page.getByLabel("User role").first()).toBeVisible(); + await expect(page.getByLabel("User status").first()).toBeVisible(); + await expect(page.getByLabel("Modified date").first()).toBeVisible(); + })(); + + await step("Filter by Owner role & verify only owner shown")(async () => { + // First dismiss any open dropdown and clear status filter + await page.keyboard.press("Escape"); + await page.getByLabel("User status").first().click(); + await page.getByRole("option", { name: "Any status" }).click(); + + // Now filter by Owner role + await page.getByLabel("User role").first().click(); + await page.getByRole("option", { name: "Owner" }).click(); + + // Verify only owner is shown + await expect(page.locator("tbody tr")).toHaveCount(1); + await expect(page.locator("tbody")).toContainText(owner.email); + await expect(page.locator("tbody")).not.toContainText(user1.email); + await expect(page.locator("tbody")).not.toContainText(user2.email); + })(); + + await step("Filter by Member role & verify only members shown")(async () => { + // Change filter to Member role + await page.getByLabel("User role").first().click(); + await page.getByRole("option", { name: "Member" }).click(); + + // Verify only member users are shown + await expect(page.locator("tbody tr")).toHaveCount(3); + await expect(page.locator("tbody")).toContainText(user1.email); + await expect(page.locator("tbody")).toContainText(user2.email); + await expect(page.locator("tbody")).toContainText(user3.email); + await expect(page.locator("tbody")).not.toContainText(owner.email); + })(); + + await step("Filter by Pending status & verify only pending users shown")(async () => { + // Reset role filter and set status filter + await page.getByLabel("User role").first().click(); + await page.getByRole("option", { name: "Any role" }).click(); + + await page.getByLabel("User status").first().click(); + await page.getByRole("option", { name: "Pending" }).click(); + + // Verify only pending users are shown (invited users who haven't confirmed) + await expect(page.locator("tbody tr")).toHaveCount(3); + await expect(page.locator("tbody")).toContainText(user1.email); + await expect(page.locator("tbody")).toContainText(user2.email); + await expect(page.locator("tbody")).toContainText(user3.email); + await expect(page.locator("tbody")).not.toContainText(owner.email); + })(); + + await step("Filter by Active status & verify only active users shown")(async () => { + // Change filter to Active status + await page.getByLabel("User status").first().click(); + await page.getByRole("option", { name: "Active" }).click(); + + // Verify only active users are shown (owner who has confirmed email) + await expect(page.locator("tbody tr")).toHaveCount(1); + await expect(page.locator("tbody")).toContainText(owner.email); + await expect(page.locator("tbody")).not.toContainText(user1.email); + })(); + + await step("Filter by past date range & verify no users shown")(async () => { + // Reset status filter first + await page.getByLabel("User status").first().click(); + await page.getByRole("option", { name: "Any status" }).click(); + + // Open date picker + await page.getByLabel("Modified date").first().click(); + + // Set start date to January 1, 2024 + await page.locator('[role="spinbutton"][aria-label="month, Start Date, "]').clear(); + await page.locator('[role="spinbutton"][aria-label="month, Start Date, "]').type("01"); + await page.locator('[role="spinbutton"][aria-label="day, Start Date, "]').clear(); + await page.locator('[role="spinbutton"][aria-label="day, Start Date, "]').type("01"); + await page.locator('[role="spinbutton"][aria-label="year, Start Date, "]').clear(); + await page.locator('[role="spinbutton"][aria-label="year, Start Date, "]').type("2024"); + + // Set end date to December 31, 2024 + await page.locator('[role="spinbutton"][aria-label="month, End Date, "]').clear(); + await page.locator('[role="spinbutton"][aria-label="month, End Date, "]').type("12"); + await page.locator('[role="spinbutton"][aria-label="day, End Date, "]').clear(); + await page.locator('[role="spinbutton"][aria-label="day, End Date, "]').type("31"); + await page.locator('[role="spinbutton"][aria-label="year, End Date, "]').clear(); + await page.locator('[role="spinbutton"][aria-label="year, End Date, "]').type("2024"); + + // Close the calendar + await page.keyboard.press("Escape"); + + // Verify no users are shown for the past date range (users were created in 2025) + await expect(page.locator("tbody tr")).toHaveCount(0); + })(); + + // === CLEAR FILTERS FOR CLEAN DELETION TESTS === + await step("Clear all filters & verify all users shown again for clean deletion tests")(async () => { + // Reset any remaining filters to show all users + await page.getByRole("button", { name: "Clear filters" }).click(); + + // Verify all users are shown again + await expect(page.locator("tbody tr")).toHaveCount(4); + await expect(page.locator("tbody")).toContainText(owner.email); + await expect(page.locator("tbody")).toContainText(user1.email); + await expect(page.locator("tbody")).toContainText(user2.email); + await expect(page.locator("tbody")).toContainText(user3.email); + })(); + + // === SINGLE USER DELETION SECTION === + await step("Delete single user via menu & verify removal")(async () => { + const user1Row = page.locator("tbody tr").filter({ hasText: user1.email }); + await user1Row.getByLabel("User actions").click(); + await page.getByRole("menuitem", { name: "Delete" }).click(); + + await expect(page.getByRole("alertdialog", { name: "Delete user" })).toBeVisible(); + await expect(page.getByText(`Are you sure you want to delete ${user1.email}?`)).toBeVisible(); + + await page.getByRole("button", { name: "Delete" }).click(); + + await expectToastMessage(context, `User deleted successfully: ${user1.email}`); + await expect(page.getByRole("alertdialog")).not.toBeVisible(); + await expect(page.locator("tbody tr")).toHaveCount(3); // owner + user2 + user3 + await expect(page.getByText(user1.email)).not.toBeVisible(); + await expect(page.locator("tbody")).toContainText(owner.email); + await expect(page.locator("tbody")).toContainText(user2.email); + await expect(page.locator("tbody")).toContainText(user3.email); + })(); + + // === BULK USER SELECTION SECTION === + await step("Select remaining two users by clicking rows & verify selection state")(async () => { + const user2Row = page.locator("tbody tr").filter({ hasText: user2.email }); + const user3Row = page.locator("tbody tr").filter({ hasText: user3.email }); + + // Select first user by clicking the row + await user2Row.click(); + await expect(user2Row).toHaveAttribute("aria-selected", "true"); + await expect(page.getByRole("button", { name: "Delete user" })).toBeVisible(); + + // Select second user by clicking the row (should enable multi-selection) + await user3Row.click({ modifiers: ["ControlOrMeta"] }); + await expect(user3Row).toHaveAttribute("aria-selected", "true"); + await expect(user2Row).toHaveAttribute("aria-selected", "true"); + await expect(page.getByRole("button", { name: "Delete 2 users" })).toBeVisible(); + })(); + + // === BULK USER DELETION SECTION === + await step("Cancel bulk deletion & verify users remain selected")(async () => { + await page.getByRole("button", { name: "Delete 2 users" }).click(); + + await expect(page.getByRole("alertdialog", { name: "Delete users" })).toBeVisible(); + await expect(page.getByText("Are you sure you want to delete 2 users?")).toBeVisible(); + + await page.getByRole("button", { name: "Cancel" }).click(); + + await expect(page.getByRole("alertdialog")).not.toBeVisible(); + await expect(page.locator("tbody tr")).toHaveCount(3); // All users still present + await expect(page.getByRole("button", { name: "Delete 2 users" })).toBeVisible(); // Selection maintained + })(); + + await step("Confirm bulk delete selected users & verify removal")(async () => { + await page.getByRole("button", { name: "Delete 2 users" }).click(); + + await expect(page.getByRole("alertdialog", { name: "Delete users" })).toBeVisible(); + await page.getByRole("button", { name: "Delete" }).click(); + + await expectToastMessage(context, "2 users deleted successfully"); + await expect(page.getByRole("alertdialog")).not.toBeVisible(); + await expect(page.locator("tbody tr")).toHaveCount(1); // Only owner left + await expect(page.getByText(user2.email)).not.toBeVisible(); + await expect(page.getByText(user3.email)).not.toBeVisible(); + await expect(page.locator("tbody")).toContainText(owner.email); + await expect(page.getByRole("button", { name: "Delete 2 users" })).not.toBeVisible(); + await expect(page.getByRole("button", { name: "Invite user" })).toBeVisible(); + })(); + + // === OWNER PROTECTION SECTION === + await step("Verify owner menu delete option is disabled")(async () => { + const ownerRow = page.locator("tbody tr").filter({ hasText: owner.email }); + await ownerRow.getByLabel("User actions").click(); + + await expect(page.getByRole("menuitem", { name: "Delete" })).toBeDisabled(); + + await page.keyboard.press("Escape"); + })(); + }); +}); diff --git a/application/account-management/WebApp/tests/playwright.config.ts b/application/account-management/WebApp/tests/playwright.config.ts new file mode 100644 index 000000000..6256a1e95 --- /dev/null +++ b/application/account-management/WebApp/tests/playwright.config.ts @@ -0,0 +1,11 @@ +/// +import { defineConfig } from "@playwright/test"; +import baseConfig from "../../../shared-webapp/tests/e2e/playwright.config"; +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + ...baseConfig, + testDir: ".", + testMatch: "**/*.spec.ts" +}); diff --git a/application/account-management/WebApp/tests/tsconfig.json b/application/account-management/WebApp/tests/tsconfig.json new file mode 100644 index 000000000..7ece77c7c --- /dev/null +++ b/application/account-management/WebApp/tests/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Account Management E2E Tests", + "extends": "@repo/config/typescript/react-app.json", + "compilerOptions": { + "types": ["node", "@playwright/test"], + "target": "ES2022", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "paths": { + "@/*": ["../*"], + "@shared/e2e/fixtures/*": ["../../../shared-webapp/tests/e2e/fixtures/*"], + "@shared/e2e/utils/*": ["../../../shared-webapp/tests/e2e/utils/*"], + "@shared/e2e/auth/*": ["../../../shared-webapp/tests/e2e/auth/*"], + "@shared/e2e/types/*": ["../../../shared-webapp/tests/e2e/types/*"] + } + }, + "include": ["./**/*.ts", "./**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/application/account-management/WebApp/tsconfig.json b/application/account-management/WebApp/tsconfig.json index cfa0b5464..1581d50de 100644 --- a/application/account-management/WebApp/tsconfig.json +++ b/application/account-management/WebApp/tsconfig.json @@ -7,5 +7,5 @@ "@/*": ["./*"] } }, - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "tests/**"] } diff --git a/application/back-office/WebApp/routes/back-office/index.tsx b/application/back-office/WebApp/routes/back-office/index.tsx index d51592e7b..897b58977 100644 --- a/application/back-office/WebApp/routes/back-office/index.tsx +++ b/application/back-office/WebApp/routes/back-office/index.tsx @@ -2,6 +2,7 @@ import { SharedSideMenu } from "@/shared/components/SharedSideMenu"; import { TopMenu } from "@/shared/components/topMenu"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; +import { AppLayout } from "@repo/ui/components/AppLayout"; import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/back-office/")({ @@ -10,24 +11,19 @@ export const Route = createFileRoute("/back-office/")({ export default function Home() { return ( -
+ <> -
- -
-
-

- Welcome to the Back Office -

-

- - Manage tenants, view system data, see exceptions, and perform various tasks for operational and support - teams. - -

-
-
-
-
+ }> +

+ Welcome to the Back Office +

+

+ + Manage tenants, view system data, see exceptions, and perform various tasks for operational and support + teams. + +

+
+ ); } diff --git a/application/back-office/WebApp/rsbuild.config.ts b/application/back-office/WebApp/rsbuild.config.ts index b34e8d99f..6c3e40deb 100644 --- a/application/back-office/WebApp/rsbuild.config.ts +++ b/application/back-office/WebApp/rsbuild.config.ts @@ -11,6 +11,14 @@ import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; const customBuildEnv: CustomBuildEnv = {}; export default defineConfig({ + tools: { + rspack: { + // Exclude tests/e2e directory from file watching to prevent hot reloading issues + watchOptions: { + ignored: ["**/tests/**", "**/playwright-report/**"] + } + } + }, plugins: [ pluginReact(), pluginTypeCheck(), diff --git a/application/back-office/WebApp/shared/components/topMenu/index.tsx b/application/back-office/WebApp/shared/components/topMenu/index.tsx index 716c387a4..49b806866 100644 --- a/application/back-office/WebApp/shared/components/topMenu/index.tsx +++ b/application/back-office/WebApp/shared/components/topMenu/index.tsx @@ -6,7 +6,7 @@ import { Button } from "@repo/ui/components/Button"; import { ThemeModeSelector } from "@repo/ui/theme/ThemeModeSelector"; import { LifeBuoyIcon } from "lucide-react"; import type { ReactNode } from "react"; -import { lazy } from "react"; +import { Suspense, lazy } from "react"; const AvatarButton = lazy(() => import("account-management/AvatarButton")); @@ -31,7 +31,9 @@ export function TopMenu({ children }: Readonly) { - + }> + +
); diff --git a/application/back-office/WebApp/tests/e2e/homepage.spec.ts b/application/back-office/WebApp/tests/e2e/homepage.spec.ts new file mode 100644 index 000000000..2bb85554f --- /dev/null +++ b/application/back-office/WebApp/tests/e2e/homepage.spec.ts @@ -0,0 +1,20 @@ +import { expect } from "@playwright/test"; +import { test } from "@shared/e2e/fixtures/page-auth"; +import { step } from "@shared/e2e/utils/step-decorator"; +import { createTestContext } from "@shared/e2e/utils/test-assertions"; +import {} from "@shared/e2e/utils/test-data"; + +test.describe("@smoke", () => { + test("Navigate to back-office & verify homepage loads correctly", async ({ ownerPage }) => { + createTestContext(ownerPage); + + await step("Navigate to back-office & verify homepage loads correctly")(async () => { + await ownerPage.goto("/"); + await ownerPage.goto("/back-office"); + + await expect(ownerPage).toHaveURL("/back-office"); + await expect(ownerPage.getByRole("heading", { name: "Welcome to the Back Office" })).toBeVisible(); + await expect(ownerPage.getByText("Manage tenants, view system data")).toBeVisible(); + })(); + }); +}); diff --git a/application/back-office/WebApp/tests/playwright.config.ts b/application/back-office/WebApp/tests/playwright.config.ts new file mode 100644 index 000000000..83e7b9826 --- /dev/null +++ b/application/back-office/WebApp/tests/playwright.config.ts @@ -0,0 +1,12 @@ +/// +import { defineConfig } from "@playwright/test"; +import baseConfig from "../../../shared-webapp/tests/e2e/playwright.config"; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + ...baseConfig, + testDir: ".", + testMatch: "**/*.spec.ts" +}); diff --git a/application/back-office/WebApp/tests/tsconfig.json b/application/back-office/WebApp/tests/tsconfig.json new file mode 100644 index 000000000..0bc37c34b --- /dev/null +++ b/application/back-office/WebApp/tests/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Back Office E2E Tests", + "extends": "@repo/config/typescript/react-app.json", + "compilerOptions": { + "types": ["node", "@playwright/test"], + "paths": { + "@/*": ["../*"], + "@shared/e2e/fixtures/*": ["../../../shared-webapp/tests/e2e/fixtures/*"], + "@shared/e2e/utils/*": ["../../../shared-webapp/tests/e2e/utils/*"], + "@shared/e2e/auth/*": ["../../../shared-webapp/tests/e2e/auth/*"], + "@shared/e2e/types/*": ["../../../shared-webapp/tests/e2e/types/*"] + } + }, + "include": ["./**/*.ts", "./**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/application/back-office/WebApp/tsconfig.json b/application/back-office/WebApp/tsconfig.json index c5f095bd0..ecd4fb3ef 100644 --- a/application/back-office/WebApp/tsconfig.json +++ b/application/back-office/WebApp/tsconfig.json @@ -7,5 +7,5 @@ "@/*": ["./*"] } }, - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "tests/**"] } diff --git a/application/package-lock.json b/application/package-lock.json index fbb7221f4..506aab135 100644 --- a/application/package-lock.json +++ b/application/package-lock.json @@ -37,9 +37,11 @@ }, "devDependencies": { "@biomejs/biome": "1.9.4", + "@faker-js/faker": "8.4.1", "@lingui/cli": "5.1.0", "@lingui/format-po": "5.1.0", "@lingui/swc-plugin": "5.0.1", + "@playwright/test": "1.52.0", "@rsbuild/core": "1.1.10", "@rsbuild/plugin-react": "1.1.0", "@rsbuild/plugin-svgr": "1.0.6", @@ -48,10 +50,12 @@ "@tailwindcss/container-queries": "0.1.1", "@tanstack/router-devtools": "1.90.0", "@tanstack/router-plugin": "1.87.13", + "@types/node": "^22.15.28", "@types/react": "19.0.0", "@types/react-dom": "19.0.0", "openapi-typescript": "7.4.4", "openapi-typescript-helpers": "0.0.15", + "playwright": "1.52.0", "rimraf": "6.0.1", "tailwindcss": "3.4.16", "tailwindcss-animate": "1.0.7", @@ -80,6 +84,11 @@ "@repo/ui": "*" } }, + "End2EndTests": { + "name": "end-2-endtests", + "version": "1.0.0", + "extraneous": true + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -962,6 +971,23 @@ "node": ">=12" } }, + "node_modules/@faker-js/faker": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz", + "integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0", + "npm": ">=6.14.13" + } + }, "node_modules/@fontsource/inter": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.1.0.tgz", @@ -1850,6 +1876,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@react-aria/accordion": { "version": "3.0.0-alpha.36", "resolved": "https://registry.npmjs.org/@react-aria/accordion/-/accordion-3.0.0-alpha.36.tgz", @@ -4629,13 +4671,13 @@ } }, "node_modules/@types/node": { - "version": "22.10.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", - "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", + "version": "22.15.28", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.28.tgz", + "integrity": "sha512-I0okKVDmyKR281I0UIFV7EWAWRnR0gkuSKob5wVcByyyhr7Px/slhkQapcYX4u00ekzNWaS1gznKZnuzxwo4pw==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "node_modules/@types/parse-json": { @@ -6940,6 +6982,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -8867,9 +8956,9 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "devOptional": true, "license": "MIT" }, diff --git a/application/package.json b/application/package.json index 9a6305443..b023fbe56 100644 --- a/application/package.json +++ b/application/package.json @@ -42,9 +42,11 @@ }, "devDependencies": { "@biomejs/biome": "1.9.4", + "@faker-js/faker": "8.4.1", "@lingui/cli": "5.1.0", "@lingui/format-po": "5.1.0", "@lingui/swc-plugin": "5.0.1", + "@playwright/test": "1.52.0", "@rsbuild/core": "1.1.10", "@rsbuild/plugin-react": "1.1.0", "@rsbuild/plugin-svgr": "1.0.6", @@ -53,10 +55,12 @@ "@tailwindcss/container-queries": "0.1.1", "@tanstack/router-devtools": "1.90.0", "@tanstack/router-plugin": "1.87.13", + "@types/node": "^22.15.28", "@types/react": "19.0.0", "@types/react-dom": "19.0.0", "openapi-typescript": "7.4.4", "openapi-typescript-helpers": "0.0.15", + "playwright": "1.52.0", "rimraf": "6.0.1", "tailwindcss": "3.4.16", "tailwindcss-animate": "1.0.7", diff --git a/application/shared-kernel/SharedKernel/Validation/SharedValidations.cs b/application/shared-kernel/SharedKernel/Validation/SharedValidations.cs index 50a7b66d1..07756a338 100644 --- a/application/shared-kernel/SharedKernel/Validation/SharedValidations.cs +++ b/application/shared-kernel/SharedKernel/Validation/SharedValidations.cs @@ -9,11 +9,11 @@ public sealed class Email : AbstractValidator // While emails can be longer, we will limit them to 100 characters which should be enough for most cases private const int EmailMaxLength = 100; - public Email() + public Email(bool allowEmpty = false) { const string errorMessage = "Email must be in a valid format and no longer than 100 characters."; - RuleFor(email => email) + var rule = RuleFor(email => email) .EmailAddress() .WithMessage(errorMessage) .MaximumLength(EmailMaxLength) @@ -34,8 +34,12 @@ public Email() !parts[1].EndsWith('.'); } ) - .WithMessage(errorMessage) - .When(email => !string.IsNullOrEmpty(email)); + .WithMessage(errorMessage); + + if (!allowEmpty) + { + rule.NotEmpty().WithMessage(errorMessage); + } } } diff --git a/application/shared-webapp/infrastructure/translations/LocaleSwitcher.tsx b/application/shared-webapp/infrastructure/translations/LocaleSwitcher.tsx index add273031..e13e354a7 100644 --- a/application/shared-webapp/infrastructure/translations/LocaleSwitcher.tsx +++ b/application/shared-webapp/infrastructure/translations/LocaleSwitcher.tsx @@ -4,7 +4,7 @@ import { AuthenticationContext } from "@repo/infrastructure/auth/AuthenticationP import { enhancedFetch } from "@repo/infrastructure/http/httpClient"; import { Button } from "@repo/ui/components/Button"; import { Menu, MenuItem, MenuTrigger } from "@repo/ui/components/Menu"; -import { CheckIcon, LanguagesIcon } from "lucide-react"; +import { CheckIcon, GlobeIcon } from "lucide-react"; import { use, useContext, useMemo } from "react"; import { type Locale, translationContext } from "./TranslationContext"; import { preferredLocaleKey } from "./constants"; @@ -48,7 +48,7 @@ export function LocaleSwitcher({ "aria-label": ariaLabel }: { "aria-label": stri return ( {items.map((item) => ( diff --git a/application/shared-webapp/package.json b/application/shared-webapp/package.json new file mode 100644 index 000000000..9b4a70e63 --- /dev/null +++ b/application/shared-webapp/package.json @@ -0,0 +1,12 @@ +{ + "name": "@repo/shared-webapp", + "version": "1.0.0", + "private": true, + "exports": { + "./tests/e2e/utils/*": "./tests/e2e/utils/*", + "./tests/e2e/config/*": "./tests/e2e/config/*" + }, + "scripts": { + "test": "playwright test" + } +} diff --git a/application/shared-webapp/tests/e2e/auth/auth-state-manager.ts b/application/shared-webapp/tests/e2e/auth/auth-state-manager.ts new file mode 100644 index 000000000..c65548ad1 --- /dev/null +++ b/application/shared-webapp/tests/e2e/auth/auth-state-manager.ts @@ -0,0 +1,107 @@ +import { promises as fs } from "node:fs"; +import type { BrowserContext, Page } from "@playwright/test"; +import { + getStorageStatePath, + isAuthenticationStateValid, + loadAuthenticationState, + saveAuthenticationState +} from "@shared/e2e/auth/storage-state"; +import type { UserRole } from "@shared/e2e/types/auth"; + +/** + * Authentication state manager for handling persistence and validation + */ +export class AuthStateManager { + private readonly workerIndex: number; + private readonly selfContainedSystemPrefix?: string; + + constructor(workerIndex: number, selfContainedSystemPrefix?: string) { + this.workerIndex = workerIndex; + this.selfContainedSystemPrefix = selfContainedSystemPrefix; + } + + /** + * Get the storage state file path for a specific role + * @param role User role + * @returns Path to the storage state file + */ + getStateFilePath(role: UserRole): string { + return getStorageStatePath(this.workerIndex, role.toLowerCase(), this.selfContainedSystemPrefix); + } + + /** + * Check if authentication state exists and is valid for a role + * @param role User role + * @returns Promise resolving to true if auth state is valid + */ + async hasValidAuthState(role: UserRole): Promise { + const filePath = this.getStateFilePath(role); + return await isAuthenticationStateValid(filePath); + } + + /** + * Load authentication state for a role into a browser context + * @param context Browser context + * @param role User role + * @returns Promise resolving when state is loaded + */ + async loadAuthState(context: BrowserContext, role: UserRole): Promise { + const filePath = this.getStateFilePath(role); + await loadAuthenticationState(context, filePath); + } + + /** + * Save authentication state for a role from a page + * @param page Playwright page + * @param role User role + * @returns Promise resolving when state is saved + */ + async saveAuthState(page: Page, role: UserRole): Promise { + const filePath = this.getStateFilePath(role); + await saveAuthenticationState(page, filePath); + } + + /** + * Test if the authentication state is still valid by checking URL after navigation + * @param page Playwright page with loaded auth state + * @returns Promise resolving to true if auth is still valid + */ + async validateAuthState(page: Page): Promise { + try { + // Navigate to a protected route + await page.goto("/admin"); + + // If we get redirected to login, auth is invalid + // If we stay on /admin (or any admin route), auth is valid + return !page.url().includes("/login"); + } catch { + // If any error occurs during validation, consider auth invalid + return false; + } + } + + /** + * Clear authentication state for a specific role + * @param role User role + * @returns Promise resolving when state is cleared + */ + async clearAuthState(role: UserRole): Promise { + const filePath = this.getStateFilePath(role); + try { + await fs.unlink(filePath); + } catch { + // File might not exist, which is fine + } + } + +} + +/** + * Create an AuthStateManager instance for the current worker + * @param workerIndex Playwright worker index + * @param selfContainedSystemPrefix Optional system prefix + * @returns AuthStateManager instance + */ +export function createAuthStateManager(workerIndex: number, selfContainedSystemPrefix?: string): AuthStateManager { + return new AuthStateManager(workerIndex, selfContainedSystemPrefix); +} diff --git a/application/shared-webapp/tests/e2e/auth/storage-state.ts b/application/shared-webapp/tests/e2e/auth/storage-state.ts new file mode 100644 index 000000000..b942cf1a3 --- /dev/null +++ b/application/shared-webapp/tests/e2e/auth/storage-state.ts @@ -0,0 +1,77 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import type { BrowserContext, Page } from "@playwright/test"; + +/** + * Save authentication state from a page's context to a file + * Only captures cookies as session storage is not needed for PlatformPlatform's token architecture + */ +export async function saveAuthenticationState(page: Page, filePath: string): Promise { + // Ensure directory exists + await ensureDirectoryExists(path.dirname(filePath)); + + // Save storage state (cookies and localStorage) + await page.context().storageState({ path: filePath }); +} + +/** + * Load authentication state from a file into a browser context + */ +export async function loadAuthenticationState(_context: BrowserContext, filePath: string): Promise { + // Storage state is loaded when creating the context, not after + // This function is mainly for validation and future use + const exists = await fileExists(filePath); + if (!exists) { + throw new Error(`Authentication state file not found: ${filePath}`); + } +} + +/** + * Get the storage state file path for a specific worker, role, and system + */ +export function getStorageStatePath(workerIndex: number, userRole: string, selfContainedSystemPrefix?: string): string { + const baseDir = path.join(process.cwd(), "tests/test-results/auth-state"); + const systemPrefix = selfContainedSystemPrefix ?? "default"; + return path.join(baseDir, systemPrefix, `worker-${workerIndex}-${userRole.toLowerCase()}.json`); +} + +/** + * Check if an authentication state file is valid and exists + */ +export async function isAuthenticationStateValid(filePath: string): Promise { + try { + const exists = await fileExists(filePath); + if (!exists) { + return false; + } + + // Check if file is not empty and contains valid JSON + const content = await fs.readFile(filePath, "utf-8"); + const state = JSON.parse(content); + + // Basic validation - should have cookies or origins + return state && (state.cookies || state.origins); + } catch { + return false; + } +} + +// Helper functions +async function ensureDirectoryExists(dirPath: string): Promise { + try { + await fs.mkdir(dirPath, { recursive: true }); + } catch (error: unknown) { + if (error instanceof Error && "code" in error && error.code !== "EEXIST") { + throw error; + } + } +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} diff --git a/application/shared-webapp/tests/e2e/auth/tenant-provisioning.ts b/application/shared-webapp/tests/e2e/auth/tenant-provisioning.ts new file mode 100644 index 000000000..2b4ada18b --- /dev/null +++ b/application/shared-webapp/tests/e2e/auth/tenant-provisioning.ts @@ -0,0 +1,85 @@ +import { expect } from "@playwright/test"; +import type { Tenant, User } from "@shared/e2e/types/auth"; + +/** + * Create a tenant with owner, admin, and member users + * @param workerIndex Playwright worker index for unique tenant identification + * @param selfContainedSystemPrefix Optional prefix to separate tenant pools between systems + * @returns Tenant object with user information + */ +export function createTenantWithUsers(workerIndex: number, selfContainedSystemPrefix?: string): Tenant { + const prefix = selfContainedSystemPrefix ? `${selfContainedSystemPrefix}-` : ""; + const timestamp = Date.now(); + const tenantName = `${prefix}e2e-tenant-${workerIndex}-${timestamp}`; + + // Generate unique emails for each role with timestamp to avoid conflicts across test runs + const ownerEmailAddress = `e2e-${prefix}-owner-${workerIndex}-${timestamp}@platformplatform.net`; + const adminEmailAddress = `e2e-${prefix}-admin-${workerIndex}-${timestamp}@platformplatform.net`; + const memberEmailAddress = `e2e-${prefix}-member-${workerIndex}-${timestamp}@platformplatform.net`; + + // Create User objects for each role + const owner: User = { + email: ownerEmailAddress, + firstName: "TestOwner", + lastName: `Worker${workerIndex}`, + role: "Owner" + }; + + const admin: User = { + email: adminEmailAddress, + firstName: "TestAdmin", + lastName: `Worker${workerIndex}`, + role: "Admin" + }; + + const member: User = { + email: memberEmailAddress, + firstName: "TestMember", + lastName: `Worker${workerIndex}`, + role: "Member" + }; + + // Return tenant structure - actual signup will be implemented when needed + const tenantId = `tenant-${workerIndex}-${timestamp}`; + + return { + tenantId, + tenantName, + owner, + admin, + member + }; +} + +/** + * Ensure that all tenant users exist in the backend + * This provisions the users through the signup flow if they don't already exist + * @param tenant Tenant object with user information + * @returns Promise that resolves when all users are ensured to exist + */ +export async function ensureTenantUsersExist(tenant: Tenant): Promise { + // Import the authentication utilities dynamically to avoid circular dependencies + const { createAuthStateManager } = await import("../auth/auth-state-manager.js"); + const { completeSignupFlow } = await import("../utils/test-data.js"); + + // Create a temporary browser context for user provisioning + const { chromium } = await import("@playwright/test"); + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + // Create the owner user through centralized signup flow + const { createTestContext } = await import("../utils/test-assertions.js"); + const testContext = createTestContext(page); + await completeSignupFlow(page, expect, tenant.owner, testContext); + + // Save authentication state for reuse + const authManager = createAuthStateManager(0, "account-management"); // Use worker 0 for shared users + await authManager.saveAuthState(page, "Owner"); + } finally { + // Cleanup - always close browser resources + await context.close(); + await browser.close(); + } +} diff --git a/application/shared-webapp/tests/e2e/fixtures/page-auth.ts b/application/shared-webapp/tests/e2e/fixtures/page-auth.ts new file mode 100644 index 000000000..b7c0a357d --- /dev/null +++ b/application/shared-webapp/tests/e2e/fixtures/page-auth.ts @@ -0,0 +1,240 @@ +import { type Browser, type BrowserContext, type Page, test as base, expect } from "@playwright/test"; +import { createAuthStateManager } from "@shared/e2e/auth/auth-state-manager"; +import { getSelfContainedSystemPrefix, getWorkerTenant } from "@shared/e2e/fixtures/worker-auth"; +import type { Tenant, User, UserRole } from "@shared/e2e/types/auth"; +import { completeSignupFlow } from "@shared/e2e/utils/test-data"; +import { createTestContext, assertNoUnexpectedErrors, type TestContext } from "@shared/e2e/utils/test-assertions"; + + +// Extend the global interface to include testTenant +declare global { + interface Window { + testTenant: Tenant; + } +} + +/** + * Role-specific page fixtures for authenticated testing + */ +export interface PageAuthFixtures { + /** + * Authenticated page instance as tenant owner + */ + ownerPage: Page; + + /** + * Authenticated page instance as tenant admin + */ + adminPage: Page; + + /** + * Authenticated page instance as tenant member + */ + memberPage: Page; + + /** + * Anonymous (unauthenticated) page with tenant provisioned + * Useful for testing login/signup flows from a clean state while ensuring users exist + */ + anonymousPage: { page: Page; tenant: Tenant }; +} + +/** + * Perform fresh authentication by going through signup/login flow + */ +async function performFreshAuthentication( + browserContext: BrowserContext, + role: UserRole, + tenant: Tenant | undefined, + authManager: ReturnType +): Promise { + if (!tenant) { + throw new Error("Tenant data is required for fresh authentication"); + } + + // Create a new page for authentication + const page = await browserContext.newPage(); + + // Get the user for this role + const user = getUserForRole(tenant, role); + + // Use the centralized signup flow utility + const testContext = createTestContext(page); + await completeSignupFlow(page, expect, user, testContext); + + // Ensure any modal dialogs are closed by waiting for them to disappear + try { + await page.locator('[role="dialog"]').waitFor({ state: "detached", timeout: 2000 }); + } catch { + // Dialog might not exist or already be closed, which is fine + } + + // Save authentication state + await authManager.saveAuthState(page, role); + + return page; +} + +/** + * Get user for a specific role from tenant data + */ +function getUserForRole(tenant: Tenant, role: UserRole): User { + switch (role) { + case "Owner": + return tenant.owner; + case "Admin": + return tenant.admin; + case "Member": + return tenant.member; + default: + throw new Error(`Unknown role: ${role}`); + } +} + +/** + * Create an authenticated context and page for a specific user role + */ +async function createAuthenticatedContextAndPage( + browser: Browser, + role: UserRole, + workerIndex: number, + selfContainedSystemPrefix?: string, + tenant?: Tenant +): Promise<{ context: BrowserContext; page: Page }> { + const authManager = createAuthStateManager(workerIndex, selfContainedSystemPrefix); + + // Check if we have valid auth state + const hasValidAuth = await authManager.hasValidAuthState(role); + + let context: BrowserContext; + let page: Page; + + if (hasValidAuth) { + // Create context with existing auth state + context = await browser.newContext({ + storageState: authManager.getStateFilePath(role) + }); + page = await context.newPage(); + + // Validate that authentication is still working + const isStillValid = await authManager.validateAuthState(page); + if (!isStillValid) { + // Clear invalid auth state and create fresh session + await authManager.clearAuthState(role); + await context.close(); + + // Create fresh context and perform authentication + context = await browser.newContext(); + page = await performFreshAuthentication(context, role, tenant, authManager); + } + } else { + // Create fresh context and perform authentication + context = await browser.newContext(); + page = await performFreshAuthentication(context, role, tenant, authManager); + } + + return { context, page }; +} + +/** + * Extended test with role-specific authenticated page fixtures + */ +export const test = base.extend({ + ownerPage: async ({ browser }, use, testInfo) => { + const workerIndex = testInfo.parallelIndex; + const systemPrefix = getSelfContainedSystemPrefix(); + + // Get tenant for this worker + const tenant = await getWorkerTenant(workerIndex, systemPrefix); + + // Create authenticated context and page for owner + const { context, page } = await createAuthenticatedContextAndPage( + browser, + "Owner", + workerIndex, + systemPrefix, + tenant + ); + + await use(page); + + // Cleanup - close the context and page + await context.close(); + }, + + adminPage: async ({ browser }, use, testInfo) => { + const workerIndex = testInfo.parallelIndex; + const systemPrefix = getSelfContainedSystemPrefix(); + + // Get tenant for this worker + const tenant = await getWorkerTenant(workerIndex, systemPrefix); + + // Create authenticated context and page for admin + const { context, page } = await createAuthenticatedContextAndPage( + browser, + "Admin", + workerIndex, + systemPrefix, + tenant + ); + + await use(page); + + // Cleanup - close the context and page + await context.close(); + }, + + memberPage: async ({ browser }, use, testInfo) => { + const workerIndex = testInfo.parallelIndex; + const systemPrefix = getSelfContainedSystemPrefix(); + + // Get tenant for this worker + const tenant = await getWorkerTenant(workerIndex, systemPrefix); + + // Create authenticated context and page for member + const { context, page } = await createAuthenticatedContextAndPage( + browser, + "Member", + workerIndex, + systemPrefix, + tenant + ); + + await use(page); + + // Cleanup - close the context and page + await context.close(); + }, + + anonymousPage: async ({ browser }, use, testInfo) => { + const workerIndex = testInfo.parallelIndex; + const systemPrefix = getSelfContainedSystemPrefix(); + + // Get tenant for this worker - ensure users exist for testing existing user flows + const tenant = await getWorkerTenant(workerIndex, systemPrefix, { + workerIndex, + selfContainedSystemPrefix: systemPrefix, + ensureUsersExist: true + }); + + // Create a fresh, unauthenticated context and page + const context = await browser.newContext(); + const page = await context.newPage(); + + await use({ page, tenant }); + + // Cleanup - close the context and page + await context.close(); + } +}); + +// Global afterEach hook to automatically run error checking for ALL tests +base.afterEach(({ page }) => { + if (page) { + // Retrieve the existing context that was created during the test + const existingContext = (page as Page & { __testContext?: TestContext }).__testContext; + if (existingContext) { + assertNoUnexpectedErrors(existingContext); + } + } +}); diff --git a/application/shared-webapp/tests/e2e/fixtures/worker-auth.ts b/application/shared-webapp/tests/e2e/fixtures/worker-auth.ts new file mode 100644 index 000000000..820256d56 --- /dev/null +++ b/application/shared-webapp/tests/e2e/fixtures/worker-auth.ts @@ -0,0 +1,73 @@ +import { getStorageStatePath, isAuthenticationStateValid } from "@shared/e2e/auth/storage-state"; +import { createTenantWithUsers, ensureTenantUsersExist } from "@shared/e2e/auth/tenant-provisioning"; +import type { Tenant, TenantProvisioningOptions } from "@shared/e2e/types/auth"; + +/** + * Worker-scoped tenant cache to ensure each worker gets a unique tenant + */ +const workerTenantCache = new Map(); + +/** + * Get or create a tenant for the current worker + * This ensures each Playwright worker gets a unique tenant for parallel execution + * @param workerIndex Playwright worker index from testInfo.parallelIndex + * @param selfContainedSystemPrefix Optional prefix for system separation + * @param options Optional provisioning options + * @returns Promise resolving to a unique tenant for this worker + */ +export async function getWorkerTenant( + workerIndex: number, + selfContainedSystemPrefix?: string, + options?: TenantProvisioningOptions +): Promise { + const cacheKey = `${workerIndex}-${selfContainedSystemPrefix || "default"}`; + + // Return cached tenant if available + if (workerTenantCache.has(cacheKey)) { + const cachedTenant = workerTenantCache.get(cacheKey); + if (cachedTenant) { + // If we need to ensure users exist, do that now + if (options?.ensureUsersExist) { + await ensureTenantUsersExist(cachedTenant); + } + return cachedTenant; + } + } + + // Check if we have valid authentication state for the owner (primary user) + const ownerStorageStatePath = getStorageStatePath(workerIndex, "owner", selfContainedSystemPrefix); + const hasValidAuth = await isAuthenticationStateValid(ownerStorageStatePath); + + let tenant: Tenant; + + if (hasValidAuth) { + // Reuse existing tenant - reconstruct from storage path pattern + tenant = createTenantWithUsers(workerIndex, selfContainedSystemPrefix); + } else { + // Create new tenant with all users + tenant = createTenantWithUsers(workerIndex, selfContainedSystemPrefix); + + // Actual tenant creation will be implemented when authentication is needed + } + + // If we need to ensure users exist, do that now + if (options?.ensureUsersExist) { + await ensureTenantUsersExist(tenant); + } + + // Cache the tenant for this worker + workerTenantCache.set(cacheKey, tenant); + return tenant; +} + +/** + * Extract the self-contained system prefix from the current working directory or test context + * @returns The self-contained system prefix (e.g., "account-management" or "back-office") + */ +export function getSelfContainedSystemPrefix(): string | undefined { + // Try to extract from current working directory + const cwd = process.cwd(); + const match = cwd.match(/application\/([^\/]+)\/WebApp/); + return match ? match[1] : undefined; +} + diff --git a/application/shared-webapp/tests/e2e/playwright.config.ts b/application/shared-webapp/tests/e2e/playwright.config.ts new file mode 100644 index 000000000..de52f8496 --- /dev/null +++ b/application/shared-webapp/tests/e2e/playwright.config.ts @@ -0,0 +1,117 @@ +/// +import { defineConfig, devices } from "@playwright/test"; +import { getBaseUrl, isWindows } from "./utils/constants"; + +let workers: number | undefined; +if (process.env.CI) { + workers = 1; // Limit to 1 worker on CI +} else if (isWindows) { + workers = 4; // Limit to 4 workers on Windows to avoid performance issues +} else { + workers = undefined; // On non-Windows systems, use all available CPUs +} + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + // Run tests in files in parallel + fullyParallel: true, + + // Fail the build on CI if you accidentally left test.only in the source code. + forbidOnly: !!process.env.CI, + + // Retry on CI only + retries: process.env.CI ? 2 : 0, + + // Opt out of parallel tests on CI. + + workers: workers, + + // Reporter to use. See https://playwright.dev/docs/test-reporters + reporter: process.env.CI ? "github" : [["list"], ["html", { open: "never", outputFolder: "test-results/playwright-report" }]], + + // Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. + use: { + // Base URL to use in actions like `await page.goto('/')`. + // biome-ignore lint/style/useNamingConvention: Using Playwright's required property name + baseURL: getBaseUrl(), + + // Default timeout for actions like click(), fill(), etc. + actionTimeout: 10000, + + // Browser launch options + launchOptions: { + // Slow motion delay controlled by CLI --slow-mo flag + slowMo: process.env.PLAYWRIGHT_SLOW_MO ? Number.parseInt(process.env.PLAYWRIGHT_SLOW_MO) : 0 + }, + + // Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer + trace: "on-first-retry", + // Take screenshot on failure + screenshot: "only-on-failure", + // Record video - always use retain-on-failure for better HTML report compatibility + // Videos will be recorded for failed tests and can be forced on via CLI if needed + video: process.env.PLAYWRIGHT_VIDEO_MODE === "on" ? "on" : "retain-on-failure" + }, + + // Global timeout for each test (double timeout for slow motion) + timeout: (() => { + const baseTimeout = process.env.PLAYWRIGHT_TIMEOUT ? Number.parseInt(process.env.PLAYWRIGHT_TIMEOUT) : 30000; + const isSlowMotion = !!process.env.PLAYWRIGHT_SLOW_MO; + return isSlowMotion ? baseTimeout * 2 : baseTimeout; + })(), + expect: { + timeout: 10000 + }, + + // Output directories - centralized test artifacts + outputDir: "test-results/test-artifacts/", + + // Configure projects for major browsers + projects: [ + // Smoke tests run first (all browsers) - matches @smoke tag in any file + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + grep: /@smoke/ + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + grep: /@smoke/ + }, + { + name: "webkit", + use: { + ...devices["Desktop Safari"], + // Ignore HTTPS errors only for WebKit on Windows, as it's stricter than other browsers + // biome-ignore lint/style/useNamingConvention: + ignoreHTTPSErrors: isWindows + }, + grep: /@smoke/ + }, + + // Comprehensive tests run second (all browsers) - matches @comprehensive tag in any file + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + grepInvert: /@smoke/ + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + grepInvert: /@smoke/ + }, + { + name: "webkit", + use: { + ...devices["Desktop Safari"], + // Ignore HTTPS errors only for WebKit on Windows, as it's stricter than other browsers + // biome-ignore lint/style/useNamingConvention: + ignoreHTTPSErrors: isWindows + }, + grepInvert: /@smoke/ + }, + ] +}); diff --git a/application/shared-webapp/tests/e2e/tsconfig.json b/application/shared-webapp/tests/e2e/tsconfig.json new file mode 100644 index 000000000..7c0a460f5 --- /dev/null +++ b/application/shared-webapp/tests/e2e/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "end-to-end-tests", + "extends": "@repo/config/typescript/node-library.json", + "compilerOptions": { + "baseUrl": ".", + "types": ["node"], + "target": "ES2022", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "paths": { + "@shared/e2e/fixtures/*": ["./fixtures/*"], + "@shared/e2e/utils/*": ["./utils/*"], + "@shared/e2e/auth/*": ["./auth/*"], + "@shared/e2e/types/*": ["./types/*"] + } + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "test-results/**"] +} diff --git a/application/shared-webapp/tests/e2e/types/auth.ts b/application/shared-webapp/tests/e2e/types/auth.ts new file mode 100644 index 000000000..6ad44564d --- /dev/null +++ b/application/shared-webapp/tests/e2e/types/auth.ts @@ -0,0 +1,43 @@ + +/** + * Authentication types and interfaces for E2E testing + */ + +/** + * User roles available in the system - must match UserInfoEnv.role from environment.d.ts + */ +export type UserRole = "Owner" | "Admin" | "Member"; + +/** + * User interface containing all user information for E2E testing + */ +export interface User { + email: string; + firstName: string; + lastName: string; + role: UserRole; +} + +/** + * Tenant interface containing all user information for E2E testing + */ +export interface Tenant { + tenantId: string; + tenantName: string; + owner: User; + admin: User; + member: User; +} + + +/** + * Configuration options for tenant provisioning + */ +export interface TenantProvisioningOptions { + workerIndex: number; + selfContainedSystemPrefix?: string; + isolated?: boolean; + ensureUsersExist?: boolean; +} + + diff --git a/application/shared-webapp/tests/e2e/utils/constants.ts b/application/shared-webapp/tests/e2e/utils/constants.ts new file mode 100644 index 000000000..2d404ca44 --- /dev/null +++ b/application/shared-webapp/tests/e2e/utils/constants.ts @@ -0,0 +1,23 @@ +/// + +/** + * Shared constants for End2End tests + */ + +const DEFAULT_BASE_URL = "https://localhost:9000"; + +export const isWindows = process.platform === "win32"; + +/** + * Get the base URL for tests + */ +export function getBaseUrl(): string { + return process.env.PUBLIC_URL ?? DEFAULT_BASE_URL; +} + +/** + * Check if we're running against localhost + */ +export function isLocalhost(): boolean { + return getBaseUrl() === DEFAULT_BASE_URL; +} diff --git a/application/shared-webapp/tests/e2e/utils/step-decorator.ts b/application/shared-webapp/tests/e2e/utils/step-decorator.ts new file mode 100644 index 000000000..082c8b8c3 --- /dev/null +++ b/application/shared-webapp/tests/e2e/utils/step-decorator.ts @@ -0,0 +1,199 @@ +import { test } from '@playwright/test'; + +interface StepOptions { + /** Expected timeout in milliseconds for slow operations (e.g., waiting for OTP timeouts) */ + timeout?: number; +} + +/** + * Decorator function that wraps methods in Playwright test steps. + * + * @param description - Required description for the test step + * @param options - Optional configuration for the step + * @returns Method decorator that wraps the original method in test.step() + * + * @example + * ```typescript + * class MyPageObject { + * @step('Navigate to users page') + * async navigateToUsersPage() { + * await this.page.goto('/users'); + * } + * + * @step('Wait for OTP timeout', { timeout: 30000 }) + * async waitForOtpTimeout() { + * await this.page.waitForTimeout(30000); + * } + * } + * + * // Direct function usage + * await step('Delete user & verify removal', { timeout: 5000 })(async () => { + * await deleteUser(); + * await expectToastMessage(context, "User deleted successfully"); + * })(); + * ``` + */ +export function step(description: string, options: StepOptions = {}): any { + // Support both decorator usage and direct function wrapping + function stepFunction(targetOrFunction: any, propertyKey?: string, descriptor?: PropertyDescriptor): any { + // If called with a function directly (not as decorator) + if (typeof targetOrFunction === 'function' && !propertyKey) { + const originalFunction = targetOrFunction; + return function (this: any, ...args: any[]) { + return test.step(description, async () => { + const startTime = performance.now(); + const result = originalFunction.apply(this, args); + const finalResult = result && typeof result.then === 'function' ? await result : result; + const endTime = performance.now(); + const duration = endTime - startTime; + + // Check for slow steps that might indicate missing toast assertions + // Skip slow step detection during debug mode or slow-mo to prevent false failures + const isDebugMode = Boolean(process.env.PLAYWRIGHT_SLOW_MO) || + process.env.PLAYWRIGHT_DEBUG === '1' || + process.env.PWDEBUG === '1'; + + const slowThreshold = 3500; // 3.5 seconds + const allowedTimeout = options.timeout || slowThreshold; + + if (!isDebugMode) { + if (duration >= slowThreshold && !options.timeout) { + const durationSeconds = (duration / 1000).toFixed(1); + throw new Error( + `❌ Step "${description}" took ${durationSeconds}s, which exceeds the ${slowThreshold/1000}s threshold.\n\n` + + `💡 This usually indicates missing toast assertions. Unasserted toasts cause 3+ second delays.\n` + + ` Solutions:\n` + + ` • Add missing toast assertion: await expectToastMessage(context, "expected message")\n` + + ` • If this step is intentionally slow, add timeout: step("${description}", { timeout: ${Math.ceil(duration)} })` + ); + } else if (duration > allowedTimeout) { + const durationSeconds = (duration / 1000).toFixed(1); + const timeoutSeconds = (allowedTimeout / 1000).toFixed(1); + throw new Error( + `❌ Step "${description}" took ${durationSeconds}s, which exceeds the allowed timeout of ${timeoutSeconds}s.\n\n` + + `💡 Consider increasing the timeout or optimizing the step.` + ); + } + } + + // Debug timing output if enabled + if (process.env.PLAYWRIGHT_SHOW_DEBUG_TIMING === 'true') { + const timestamp = new Date().toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3 + }); + const durationSeconds = (duration / 1000).toFixed(3); + + // Color coding: green (<250ms), yellow (250ms-1s), red (>1s) + let colorCode = '\x1b[32m'; // Green + if (duration >= 1000) { + colorCode = '\x1b[31m'; // Red + } else if (duration >= 250) { + colorCode = '\x1b[33m'; // Yellow + } + const resetCode = '\x1b[0m'; + + console.log(`${timestamp} - ${colorCode}[${durationSeconds}s]${resetCode} - ${description}`); + } + + return finalResult; + }); + }; + } + + // Decorator usage + const target = targetOrFunction; + + // Get the descriptor if not provided + if (!descriptor) { + descriptor = Object.getOwnPropertyDescriptor(target, propertyKey!) || { + value: target[propertyKey!], + writable: true, + enumerable: false, + configurable: true + }; + } + + const originalMethod = descriptor.value; + + if (!originalMethod || typeof originalMethod !== 'function') { + throw new Error(`@step decorator can only be applied to methods. Property '${propertyKey}' is not a method.`); + } + + descriptor.value = function (this: any, ...args: any[]) { + return test.step(description, async () => { + const startTime = performance.now(); + const result = originalMethod.apply(this, args); + const finalResult = result && typeof result.then === 'function' ? await result : result; + const endTime = performance.now(); + const duration = endTime - startTime; + + // Check for slow steps that might indicate missing toast assertions + // Skip slow step detection during debug mode or slow-mo to prevent false failures + const isDebugMode = Boolean(process.env.PLAYWRIGHT_SLOW_MO) || + process.env.PLAYWRIGHT_DEBUG === '1' || + process.env.PWDEBUG === '1'; + + const slowThreshold = 3500; // 3.5 seconds + const allowedTimeout = options.timeout || slowThreshold; + + if (!isDebugMode) { + if (duration >= slowThreshold && !options.timeout) { + const durationSeconds = (duration / 1000).toFixed(1); + throw new Error( + `❌ Step "${description}" took ${durationSeconds}s, which exceeds the ${slowThreshold/1000}s threshold.\n\n` + + `💡 This usually indicates missing toast assertions. Unasserted toasts cause 3+ second delays.\n` + + ` Solutions:\n` + + ` • Add missing toast assertion: await expectToastMessage(context, "expected message")\n` + + ` • If this step is intentionally slow, add timeout: step("${description}", { timeout: ${Math.ceil(duration)} })` + ); + } else if (duration > allowedTimeout) { + const durationSeconds = (duration / 1000).toFixed(1); + const timeoutSeconds = (allowedTimeout / 1000).toFixed(1); + throw new Error( + `❌ Step "${description}" took ${durationSeconds}s, which exceeds the allowed timeout of ${timeoutSeconds}s.\n\n` + + `💡 Consider increasing the timeout or optimizing the step.` + ); + } + } + + // Debug timing output if enabled + if (process.env.PLAYWRIGHT_SHOW_DEBUG_TIMING === 'true') { + const timestamp = new Date().toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3 + }); + const durationSeconds = (duration / 1000).toFixed(3); + + // Color coding: green (<250ms), yellow (250ms-1s), red (>1s) + let colorCode = '\x1b[32m'; // Green + if (duration >= 1000) { + colorCode = '\x1b[31m'; // Red + } else if (duration >= 250) { + colorCode = '\x1b[33m'; // Yellow + } + const resetCode = '\x1b[0m'; + + console.log(`${timestamp} - ${colorCode}[${durationSeconds}s]${resetCode} - ${description}`); + } + + return finalResult; + }); + }; + + // For legacy decorator support, define the property if needed + if (!Object.getOwnPropertyDescriptor(target, propertyKey!)) { + Object.defineProperty(target, propertyKey!, descriptor); + } + + return descriptor; + } + + return stepFunction; +} \ No newline at end of file diff --git a/application/shared-webapp/tests/e2e/utils/test-assertions.ts b/application/shared-webapp/tests/e2e/utils/test-assertions.ts new file mode 100644 index 000000000..32315865a --- /dev/null +++ b/application/shared-webapp/tests/e2e/utils/test-assertions.ts @@ -0,0 +1,457 @@ +import type { ConsoleMessage, Page } from "@playwright/test"; +import { expect } from "@playwright/test"; + +/** + * Interface for monitoring results - captures ALL errors/messages for strict assertion + */ +export interface MonitoringResults { + consoleMessages: ConsoleMessage[]; + networkErrors: string[]; + expectedStatusCodes: number[]; +} + +/** + * Test context that holds page and monitoring for simplified function calls + */ +export interface TestContext { + page: Page; + monitoring: MonitoringResults; +} + +/** + * Options for expectToastMessage function + */ +interface AssertToastOptions { + expectNetworkError?: boolean; +} + +/** + * Create a test context with page and monitoring for simplified function calls + * @param page Playwright page instance + * @returns Test context with page and monitoring + */ +export function createTestContext(page: Page): TestContext { + const monitoring = startMonitoring(page); + const context = { page, monitoring }; + + // Store context on page object so afterEach hook can access the same instance + (page as Page & { __testContext?: TestContext }).__testContext = context; + + return context; +} + +/** + * Internal function to start monitoring console messages, network errors, and toast messages for a page + * @param page Playwright page instance + * @returns Monitoring results object that will be populated during test execution + */ +function startMonitoring(page: Page): MonitoringResults { + const results: MonitoringResults = { + consoleMessages: [], + networkErrors: [], + expectedStatusCodes: [] + }; + + // Monitor console errors and warnings with filtering for expected messages + page.on("console", (consoleMessage) => { + if (["warning", "error"].includes(consoleMessage.type())) { + const message = consoleMessage.text(); + + // Filter out expected console messages in test environment + const expectedMessages = [ + "Error with Permissions-Policy header: Unrecognized feature: 'web-share'", + "If you do not provide a visible label, you must specify an aria-label or aria-labelledby attribute for accessibility", + "Content-Security-Policy:", + "MouseEvent.mozInputSource is deprecated", + "A PressResponder was rendered without a pressable child", + "WebSocket connection to", // Hot reload/dev server WebSocket connections + "Refused to connect to ws://", // WebSocket CSP violations from dev servers + "Refused to connect to wss://", // Secure WebSocket CSP violations from dev servers + "Loading failed for the