diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 862b7ef450..e9b4fe09f9 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -12,16 +12,30 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20 - - name: Install pnpm - uses: pnpm/action-setup@v4 + cache: 'pnpm' + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + playwright-${{ runner.os }}- + - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Install Playwright Browsers - run: pnpm exec playwright install --with-deps chromium + run: pnpm exec playwright install chromium + - name: E2E Tests run: pnpm run e2e - uses: actions/upload-artifact@v4 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6bd4d86ce9..a1ae3077ec 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,21 +15,30 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20 - - name: Install pnpm - uses: pnpm/action-setup@v4 + cache: 'pnpm' + - name: Audit dependencies run: pnpm audit --audit-level high + - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Svelte Diagnostics run: pnpm run check + - name: Linter run: pnpm run lint + - name: Unit Tests run: pnpm run test + - name: Build Console run: pnpm run build diff --git a/e2e/auth/navigation.ts b/e2e/auth/navigation.ts new file mode 100644 index 0000000000..30daca3de0 --- /dev/null +++ b/e2e/auth/navigation.ts @@ -0,0 +1,92 @@ +import { test, type Page } from '@playwright/test'; + +export function buildAuthUrl(region: string, projectId: string, path: string = ''): string { + return `./project-${region}-${projectId}/auth${path}`; +} + +export function buildAuthUrlPattern(region: string, projectId: string, path: string = ''): RegExp { + return new RegExp(`/project-${region}-${projectId}/auth${path}`); +} + +export async function navigateToUsers( + page: Page, + region: string, + projectId: string +): Promise { + return test.step('navigate to users', async () => { + const expectedPattern = new RegExp(`/project-${region}-${projectId}/auth(/\\?.*)?$`); + if (expectedPattern.test(page.url())) { + return; + } + + await page.goto(buildAuthUrl(region, projectId)); + await page.waitForURL(buildAuthUrlPattern(region, projectId)); + await page.getByRole('heading', { name: 'Auth' }).waitFor({ state: 'visible' }); + }); +} + +export async function navigateToUser( + page: Page, + region: string, + projectId: string, + userId: string +): Promise { + return test.step(`navigate to user ${userId}`, async () => { + const expectedPattern = new RegExp( + `/project-${region}-${projectId}/auth/user-${userId}(/\\?.*)?$` + ); + + if (expectedPattern.test(page.url())) { + return; + } + + await page.goto(buildAuthUrl(region, projectId, `/user-${userId}`)); + await page.waitForURL(buildAuthUrlPattern(region, projectId, `/user-${userId}`)); + await page.locator('input[id="name"]').waitFor({ state: 'visible' }); + }); +} + +export async function navigateToTeams( + page: Page, + region: string, + projectId: string +): Promise { + return test.step('navigate to teams', async () => { + await page.goto(buildAuthUrl(region, projectId, '/teams')); + await page.waitForURL(buildAuthUrlPattern(region, projectId, '/teams')); + }); +} + +export async function navigateToTeam( + page: Page, + region: string, + projectId: string, + teamId: string +): Promise { + return test.step(`navigate to team ${teamId}`, async () => { + await page.goto(buildAuthUrl(region, projectId, `/teams/team-${teamId}`)); + await page.waitForURL(buildAuthUrlPattern(region, projectId, `/teams/team-${teamId}`)); + }); +} + +export async function navigateToSecurity( + page: Page, + region: string, + projectId: string +): Promise { + return test.step('navigate to security settings', async () => { + await page.goto(buildAuthUrl(region, projectId, '/security')); + await page.waitForURL(buildAuthUrlPattern(region, projectId, '/security')); + }); +} + +export async function navigateToTemplates( + page: Page, + region: string, + projectId: string +): Promise { + return test.step('navigate to templates', async () => { + await page.goto(buildAuthUrl(region, projectId, '/templates')); + await page.waitForURL(buildAuthUrlPattern(region, projectId, '/templates')); + }); +} diff --git a/e2e/auth/users.ts b/e2e/auth/users.ts new file mode 100644 index 0000000000..0e2757a83c --- /dev/null +++ b/e2e/auth/users.ts @@ -0,0 +1,477 @@ +import { test, expect, type Page } from '@playwright/test'; +import { navigateToUsers, navigateToUser, buildAuthUrlPattern } from './navigation'; + +export type CreateUserOptions = { + name?: string; + email?: string; + phone?: string; + password?: string; + userId?: string; +}; + +export type UserMetadata = { + id: string; + name?: string; + email?: string; + phone?: string; +}; + +export type UserPrefs = { + [key: string]: string; +}; + +async function dismissNotification(page: Page, messagePattern: RegExp): Promise { + const notification = page.locator('.toast').filter({ hasText: messagePattern }); + await expect(notification).toBeVisible({ timeout: 15000 }); + try { + const closeButtonByName = notification.getByRole('button', { name: /dismiss|close/i }); + if ((await closeButtonByName.count()) > 0) { + await closeButtonByName.first().click(); + } else { + await notification.getByRole('button').first().click(); + } + } catch { + await expect(notification).not.toBeVisible({ timeout: 15000 }); + return; + } + await expect(notification).not.toBeVisible({ timeout: 15000 }); +} + +export async function createUser( + page: Page, + region: string, + projectId: string, + options: CreateUserOptions = {} +): Promise { + return test.step('create user', async () => { + await navigateToUsers(page, region, projectId); + + await page.getByRole('button', { name: 'Create user' }).first().click(); + + const modal = page.locator('dialog[open]').filter({ hasText: 'Create user' }); + await modal.waitFor({ state: 'visible' }); + + if (options.name) { + await modal.locator('id=name').fill(options.name); + } + + if (options.email) { + await modal.locator('id=email').fill(options.email); + } + + if (options.phone) { + await modal.locator('id=phone').fill(options.phone); + } + + if (options.password) { + await modal.locator('id=password').fill(options.password); + } + + if (options.userId) { + await modal.getByText('User ID').click(); + await modal.locator('id=id').fill(options.userId); + } + + await modal.getByRole('button', { name: 'Create', exact: true }).click(); + await modal.waitFor({ state: 'hidden' }); + await expect(page.getByText(/has been created/i)).toBeVisible(); + await dismissNotification(page, /has been created/i); + await page.waitForURL(/\/auth\/user-[^/]+$/); + + const currentUrl = page.url(); + const userIdMatch = currentUrl.match(/\/auth\/user-([^/]+)/); + const userId = userIdMatch ? userIdMatch[1] : options.userId || ''; + + if (options.name) { + await expect(page.locator('input[id="name"]')).toHaveValue(options.name); + } + if (options.email) { + await expect(page.locator('input[id="email"]')).toHaveValue(options.email); + } + if (options.phone) { + await expect(page.locator('input[id="phone"]')).toHaveValue(options.phone); + } + + return { + id: userId, + name: options.name, + email: options.email, + phone: options.phone + }; + }); +} + +export async function searchUser(page: Page, query: string): Promise { + return test.step(`search user: ${query}`, async () => { + const searchInput = page.getByPlaceholder(/Search by name, email, phone, or ID/i); + await searchInput.clear(); + await searchInput.fill(query); + await searchInput.press('Enter'); + await page.waitForURL(/[?&]search=/); + await expect(searchInput).toHaveValue(query); + }); +} + +export async function clearUserSearch(page: Page): Promise { + return test.step('clear user search', async () => { + const searchInput = page.getByPlaceholder(/Search by name, email, phone, or ID/i); + await searchInput.clear(); + + // wait for URL to drop the search param + await expect(page).not.toHaveURL(/search=/); + await expect(searchInput).toHaveValue(''); + }); +} + +export async function deleteUser( + page: Page, + region: string, + projectId: string, + userId: string +): Promise { + return test.step(`delete user ${userId}`, async () => { + await navigateToUser(page, region, projectId, userId); + + await page.getByRole('heading', { name: 'Delete user' }).scrollIntoViewIfNeeded(); + await page.getByRole('button', { name: 'Delete', exact: true }).first().click(); + + const dialog = page.locator('dialog[open]'); + await dialog.waitFor({ state: 'visible' }); + await dialog.getByRole('button', { name: 'Delete', exact: true }).click(); + + await page.waitForURL(buildAuthUrlPattern(region, projectId, '(/\\?.*)?$')); + await expect(page.getByText(/has been deleted/i)).toBeVisible(); + await dismissNotification(page, /has been deleted/i); + await searchUser(page, userId); + const userRow = page.locator('[role="row"]').filter({ hasText: userId }); + await expect(userRow).not.toBeVisible(); + + await page.getByPlaceholder(/Search by name, email, phone, or ID/i).clear(); + }); +} + +export async function updateUserName( + page: Page, + region: string, + projectId: string, + userId: string, + newName: string +): Promise { + return test.step(`update user name to: ${newName}`, async () => { + await navigateToUser(page, region, projectId, userId); + + const nameSection = page.locator('form').filter({ + has: page.getByRole('heading', { name: 'Name' }) + }); + + const nameInput = nameSection.locator('id=name'); + await nameInput.waitFor({ state: 'visible', timeout: 15000 }); + await nameInput.fill(newName); + const updateButton = nameSection.getByRole('button', { name: 'Update' }); + await expect(updateButton).toBeEnabled({ timeout: 15000 }); + await updateButton.click(); + await expect(page.getByText(/name has been updated/i)).toBeVisible(); + await dismissNotification(page, /name has been updated/i); + await expect(page.locator('input[id="name"]')).toHaveValue(newName); + }); +} + +export async function updateUserEmail( + page: Page, + region: string, + projectId: string, + userId: string, + newEmail: string +): Promise { + return test.step(`update user email to: ${newEmail}`, async () => { + await navigateToUser(page, region, projectId, userId); + + const emailSection = page.locator('form').filter({ + has: page.getByRole('heading', { name: 'Email' }) + }); + + const emailInput = emailSection.locator('id=email'); + await emailInput.waitFor({ state: 'visible', timeout: 15000 }); + await emailInput.fill(newEmail); + const updateButton = emailSection.getByRole('button', { name: 'Update' }); + await expect(updateButton).toBeEnabled({ timeout: 15000 }); + await updateButton.click(); + await expect(page.getByText(/email has been updated/i)).toBeVisible(); + await dismissNotification(page, /email has been updated/i); + await expect(page.locator('input[id="email"]')).toHaveValue(newEmail); + }); +} + +export async function updateUserPhone( + page: Page, + region: string, + projectId: string, + userId: string, + newPhone: string +): Promise { + return test.step(`update user phone to: ${newPhone}`, async () => { + await navigateToUser(page, region, projectId, userId); + + const phoneSection = page.locator('form').filter({ + has: page.getByRole('heading', { name: 'Phone' }) + }); + + const phoneInput = phoneSection.locator('id=phone'); + await phoneInput.waitFor({ state: 'visible', timeout: 15000 }); + await phoneInput.fill(newPhone); + const updateButton = phoneSection.getByRole('button', { name: 'Update' }); + await expect(updateButton).toBeEnabled({ timeout: 15000 }); + await updateButton.click(); + await expect(page.getByText(/phone has been updated/i)).toBeVisible(); + await dismissNotification(page, /phone has been updated/i); + await expect(page.locator('input[id="phone"]')).toHaveValue(newPhone); + }); +} + +export async function updateUserPassword( + page: Page, + region: string, + projectId: string, + userId: string, + newPassword: string +): Promise { + return test.step(`update user password`, async () => { + await navigateToUser(page, region, projectId, userId); + + const passwordSection = page.locator('form').filter({ + has: page.getByRole('heading', { name: 'Password' }) + }); + + const passwordInput = passwordSection.locator('#newPassword'); + await passwordInput.waitFor({ state: 'visible', timeout: 15000 }); + await passwordInput.fill(newPassword); + const updateButton = passwordSection.getByRole('button', { name: 'Update' }); + await expect(updateButton).toBeEnabled({ timeout: 15000 }); + await updateButton.click(); + await expect(page.getByText(/password has been updated/i)).toBeVisible(); + await dismissNotification(page, /password has been updated/i); + }); +} + +export async function updateUserStatus( + page: Page, + region: string, + projectId: string, + userId: string, + enabled: boolean +): Promise { + return test.step(`update user status to: ${enabled ? 'unblocked' : 'blocked'}`, async () => { + await navigateToUser(page, region, projectId, userId); + + const buttonText = enabled ? 'Unblock account' : 'Block account'; + const button = page.getByRole('button', { name: buttonText }); + await button.waitFor({ state: 'visible', timeout: 15000 }); + await button.click(); + await expect(page.getByText(/has been (blocked|unblocked)/i)).toBeVisible(); + await dismissNotification(page, /has been (blocked|unblocked)/i); + + // Now verify the badge appears/disappears in the status section + const statusSection = page.locator('[data-user-status]'); + if (!enabled) { + await expect(statusSection.getByText('blocked')).toBeVisible({ timeout: 5000 }); + } else { + await expect(statusSection.getByText('blocked')).not.toBeVisible({ timeout: 5000 }); + } + }); +} + +export async function updateUserLabels( + page: Page, + region: string, + projectId: string, + userId: string, + labels: string[] +): Promise { + return test.step(`update user labels: ${labels.join(', ')}`, async () => { + await navigateToUser(page, region, projectId, userId); + + const labelsSection = page.locator('form').filter({ + has: page.getByRole('heading', { name: 'Labels' }) + }); + + const tagsInput = labelsSection.locator('input[id="user-labels"]'); + await tagsInput.scrollIntoViewIfNeeded(); + + const existingTags = labelsSection.locator('[role="button"]').filter({ hasText: /×/i }); + + const count = await existingTags.count(); + for (let i = 0; i < count; i++) { + await existingTags.first().click(); + } + + for (const label of labels) { + await tagsInput.fill(label); + await tagsInput.press('Enter'); + } + + const updateButton = labelsSection.getByRole('button', { name: 'Update' }); + await expect(updateButton).toBeEnabled({ timeout: 5000 }); + await updateButton.click(); + await expect(page.getByText(/have been updated/i)).toBeVisible(); + await dismissNotification(page, /have been updated/i); + + const reloadedLabelsSection = page.locator('form').filter({ + has: page.getByRole('heading', { name: 'Labels' }) + }); + + for (const label of labels) { + await expect(reloadedLabelsSection.getByText(label)).toBeVisible(); + } + }); +} + +export async function updateUserEmailVerification( + page: Page, + region: string, + projectId: string, + userId: string, + shouldVerify: boolean +): Promise { + return test.step(`update user email verification to: ${shouldVerify}`, async () => { + await navigateToUser(page, region, projectId, userId); + + // Ensure the actions area is rendered (anchor on block/unblock button) + const actionsAnchor = page.getByRole('button', { name: /Block account|Unblock account/ }); + await actionsAnchor.waitFor({ state: 'visible', timeout: 15000 }); + await actionsAnchor.scrollIntoViewIfNeeded(); + + const verifyButton = page.getByRole('button', { name: /Verify account|Unverify account/ }); + await verifyButton.waitFor({ state: 'visible', timeout: 15000 }); + await verifyButton.click(); + + const dropList = page.locator('ul.drop-list'); + await dropList.waitFor({ state: 'visible', timeout: 15000 }); + + const dropdownItem = dropList + .locator('li.drop-list-item') + .filter({ hasText: /(Verify|Unverify) email/ }) + .locator('button'); + + await expect(dropdownItem).toBeEnabled({ timeout: 15000 }); + await dropdownItem.click(); + + await expect(page.getByText(/has been (verified|unverified)/i)).toBeVisible({ + timeout: 15000 + }); + await dismissNotification(page, /has been (verified|unverified)/i); + }); +} + +export async function updateUserPhoneVerification( + page: Page, + region: string, + projectId: string, + userId: string, + shouldVerify: boolean +): Promise { + return test.step(`update user phone verification to: ${shouldVerify}`, async () => { + await navigateToUser(page, region, projectId, userId); + + // Ensure the actions area is rendered (anchor on block/unblock button) + const actionsAnchor = page.getByRole('button', { name: /Block account|Unblock account/ }); + await actionsAnchor.waitFor({ state: 'visible', timeout: 15000 }); + await actionsAnchor.scrollIntoViewIfNeeded(); + + const verifyButton = page.getByRole('button', { name: /Verify account|Unverify account/ }); + await verifyButton.waitFor({ state: 'visible', timeout: 15000 }); + await verifyButton.click(); + + const dropList = page.locator('ul.drop-list'); + await dropList.waitFor({ state: 'visible', timeout: 15000 }); + + const dropdownItem = dropList + .locator('li.drop-list-item') + .filter({ hasText: /(Verify|Unverify) phone/ }) + .locator('button'); + + await expect(dropdownItem).toBeVisible({ timeout: 15000 }); + await expect(dropdownItem).toBeEnabled({ timeout: 15000 }); + await dropdownItem.click(); + + await expect(page.getByText(/has been (verified|unverified)/i)).toBeVisible({ + timeout: 15000 + }); + await dismissNotification(page, /has been (verified|unverified)/i); + }); +} + +export async function updateUserPrefs( + page: Page, + region: string, + projectId: string, + userId: string, + prefs: UserPrefs +): Promise { + return test.step(`update user preferences`, async () => { + await navigateToUser(page, region, projectId, userId); + + const prefsSection = page.locator('form').filter({ + has: page.getByRole('heading', { name: 'Preferences' }) + }); + + await prefsSection.scrollIntoViewIfNeeded(); + + // fill preferences + const prefEntries = Object.entries(prefs); + for (let i = 0; i < prefEntries.length; i++) { + const [key, value] = prefEntries[i]; + + const keyInput = prefsSection.locator(`id=key-${i}`); + const valueInput = prefsSection.locator(`id=value-${i}`); + + await keyInput.fill(key); + await valueInput.fill(value); + + if (i < prefEntries.length - 1) { + await prefsSection.getByRole('button', { name: 'Add preference' }).click(); + } + } + + await prefsSection.getByRole('button', { name: 'Update' }).click(); + await expect(page.getByText(/Preferences have been updated/i)).toBeVisible(); + await dismissNotification(page, /Preferences have been updated/i); + }); +} + +export async function updateUserMfa( + page: Page, + region: string, + projectId: string, + userId: string, + enable: boolean +): Promise { + return test.step(`update user MFA to: ${enable ? 'enabled' : 'disabled'}`, async () => { + await navigateToUser(page, region, projectId, userId); + + const mfaSection = page.locator('form').filter({ + has: page.getByRole('heading', { name: 'Multi-factor authentication' }) + }); + + const mfaToggle = mfaSection.getByRole('switch'); + const isCurrentlyEnabled = (await mfaToggle.getAttribute('aria-checked')) === 'true'; + + if (isCurrentlyEnabled !== enable) { + await mfaToggle.click(); + await mfaSection.getByRole('button', { name: 'Update' }).click(); + await expect(page.getByText(/Multi-factor authentication has been/i)).toBeVisible(); + await dismissNotification(page, /Multi-factor authentication has been/i); + } + + const reloadedMfaSection = page.locator('form').filter({ + has: page.getByRole('heading', { name: 'Multi-factor authentication' }) + }); + + const verifyToggle = reloadedMfaSection.getByRole('switch'); + + if (enable) { + await expect(verifyToggle).toHaveAttribute('aria-checked', 'true'); + } else { + await expect(verifyToggle).toHaveAttribute('aria-checked', 'false'); + } + }); +} diff --git a/e2e/fixtures/base.ts b/e2e/fixtures/base.ts new file mode 100644 index 0000000000..24dafa912c --- /dev/null +++ b/e2e/fixtures/base.ts @@ -0,0 +1,44 @@ +import { test as base } from '@playwright/test'; +import { cleanupTestAccount } from '../helpers/delete'; + +import { registerUserStep } from '../steps/account'; +import { createFreeProject } from '../steps/free-project'; +import { createProProject } from '../steps/pro-project'; + +export type ProjectMetadata = { + id: string; + region: string; + organizationId: string; +}; + +type ProjectFixtures = { + project: ProjectMetadata; + tier: 'free' | 'pro' | 'scale' /* for later */; +}; + +export const test = base.extend({ + tier: ['free', { option: true }], + project: [ + async ({ page, tier }, use) => { + await registerUserStep(page); + + let project: ProjectMetadata; + switch (tier) { + case 'free': + project = await createFreeProject(page); + break; + case 'pro': + case 'scale': + project = await createProProject(page); + break; + } + + await use(project); + + await cleanupTestAccount(page, project.region, project.id, project.organizationId); + }, + { auto: true } + ] +}); + +export { expect } from '@playwright/test'; diff --git a/e2e/helpers/delete.ts b/e2e/helpers/delete.ts new file mode 100644 index 0000000000..3aa55d8b65 --- /dev/null +++ b/e2e/helpers/delete.ts @@ -0,0 +1,138 @@ +import { test, type Page } from '@playwright/test'; + +export async function deleteProject(page: Page, region: string, projectId: string) { + return test.step('delete project', async () => { + await page.goto(`./project-${region}-${projectId}/settings`); + + // Get the project name from the data attribute + const projectName = await page.locator('[data-project-name]').textContent(); + + // Click the Delete button in the CardGrid actions section + await page.getByRole('button', { name: 'Delete', exact: true }).click(); + + // Wait for modal to open + const dialog = page.locator('dialog[open]'); + await dialog.waitFor({ state: 'visible', timeout: 5000 }); + // await expect(dialog).toBeVisible(); + + // Type the project name to confirm + await dialog.locator('#project-name').fill(projectName?.trim() || ''); + + // Click the Delete button in the modal + await dialog.getByRole('button', { name: 'Delete', exact: true }).click(); + + // Wait for navigation back to organization + await page.waitForURL(/\/organization-[^/]+$/); + }); +} + +export async function deleteOrganization(page: Page, organizationId: string) { + return test.step('delete organization', async () => { + await page.goto(`./organization-${organizationId}/settings`); + + // Get the organization name from the data attribute + const organizationName = await page.locator('[data-organization-name]').textContent(); + + // Click the Delete button in the CardGrid actions section + await page.getByRole('button', { name: 'Delete', exact: true }).click(); + + // Wait for modal to open + const dialog = page.locator('dialog[open]'); + await dialog.waitFor({ state: 'visible', timeout: 5000 }); + // await expect(dialog).toBeVisible(); + + // Type the organization name to confirm + await dialog.locator('#organization-name').fill(organizationName?.trim() || ''); + + // Click the Delete button in the modal + await dialog.getByRole('button', { name: 'Delete', exact: true }).click(); + + // Wait for navigation away from organization (to account/organizations or onboarding) + await page.waitForURL(/\/(account\/organizations|onboarding\/create-organization)/); + }); +} + +export async function deleteAccount(page: Page, maxRetries = 3) { + return test.step('delete account', async () => { + for (let attempt = 0; attempt < maxRetries; attempt++) { + await page.goto('./account'); + + // click the Delete button in the CardGrid actions section + const trigger = page.getByRole('button', { name: 'Delete', exact: true }); + await trigger.waitFor({ state: 'visible', timeout: 15000 }); + await trigger.click(); + + // wait for confirm modal to open + const dialog = page.locator('dialog[open]'); + await dialog.waitFor({ state: 'visible', timeout: 5000 }); + // await expect(dialog).toBeVisible({ timeout: 15000 }); + + // click the confirm button in the modal + const confirm = dialog.getByRole('button', { name: 'Delete', exact: true }); + await confirm.waitFor({ state: 'visible', timeout: 5000 }); + // await expect(confirm).toBeVisible({ timeout: 15000 }); + // await expect(confirm).toBeEnabled({ timeout: 15000 }); + try { + await confirm.click({ timeout: 5000 }); + } catch { + const retryConfirm = dialog.getByRole('button', { name: 'Delete', exact: true }); + await retryConfirm.waitFor({ state: 'visible', timeout: 5000 }); + // await expect(retryConfirm).toBeEnabled({ timeout: 15000 }); + await retryConfirm.click({ timeout: 5000 }); + } + + // check if we got an error about active memberships + const membershipError = page.getByText(/active memberships/i); + const errorVisible = await membershipError + .isVisible({ timeout: 2000 }) + .catch(() => false); + + if (errorVisible) { + console.log( + `Attempt ${attempt + 1}: Account deletion failed due to active memberships. Retrying...` + ); + + await page.keyboard.press('Escape').catch(() => {}); + // await expect(dialog).toBeHidden({ timeout: 15000 }); + await page.waitForTimeout(1000); + continue; + } + + const successToast = page.getByText(/Account was deleted/i); + await successToast.isVisible({ timeout: 5000 }).catch(() => false); + + return; + } + + throw new Error( + 'Failed to delete account after multiple retries due to active memberships' + ); + }); +} + +export async function cleanupTestAccount( + page: Page, + region: string, + projectId: string, + organizationId: string +) { + return test.step('cleanup test account', async () => { + try { + await deleteProject(page, region, projectId); + } catch (error) { + console.log('Failed to delete project:', error); + } + + try { + await deleteOrganization(page, organizationId); + } catch (error) { + console.log('Failed to delete organization:', error); + } + + try { + await deleteAccount(page); + } catch (error) { + console.log('Failed to delete account:', error); + } + }); +} diff --git a/e2e/helpers/region.ts b/e2e/helpers/region.ts new file mode 100644 index 0000000000..e6defd1725 --- /dev/null +++ b/e2e/helpers/region.ts @@ -0,0 +1,38 @@ +import type { Page, Locator } from '@playwright/test'; + +export async function selectRandomRegion(page: Page, dialog: Locator): Promise { + if (process.env.PUBLIC_APPWRITE_MULTI_REGION !== 'true') return 'fra'; + + const regionPicker = dialog.locator('button[role="combobox"]'); + if (await regionPicker.isVisible()) { + await regionPicker.click(); + + const allOptions = await page.getByRole('option').all(); + const options = []; + + for (const option of allOptions) { + const isDisabled = await option.getAttribute('aria-disabled'); + if (isDisabled !== 'true') { + options.push(option); + } + } + + if (options.length > 0) { + const randomIndex = Math.floor(Math.random() * options.length); + const selectedOption = options[randomIndex]; + + const regionCode = + (await selectedOption.getAttribute('data-value')) || + (await selectedOption.getAttribute('value')); + + await selectedOption.click(); + + // remove quotes if present in the attribute value + const cleanedRegionCode = regionCode?.replace(/^["']|["']$/g, ''); + + return cleanedRegionCode || 'fra'; + } + } + + return 'fra'; +} diff --git a/e2e/journeys/auth-free.spec.ts b/e2e/journeys/auth-free.spec.ts new file mode 100644 index 0000000000..13dc4ec2cf --- /dev/null +++ b/e2e/journeys/auth-free.spec.ts @@ -0,0 +1,136 @@ +import { test, expect } from '../fixtures/base'; +import { + createUser, + searchUser, + clearUserSearch, + updateUserName, + updateUserEmail, + updateUserPhone, + updateUserPassword, + updateUserStatus, + updateUserLabels, + updateUserEmailVerification, + updateUserPhoneVerification, + updateUserPrefs, + updateUserMfa, + deleteUser +} from '../auth/users'; +import { navigateToUsers } from '../auth/navigation'; + +test('auth flow - free tier', async ({ page, project }) => { + const user = await createUser(page, project.region, project.id, { + name: 'Test User', + email: 'testuser@example.com', + phone: '+12345678901', + password: 'password123' + }); + + await test.step('verify user appears in list', async () => { + await navigateToUsers(page, project.region, project.id); + await expect(page.getByText('Test User')).toBeVisible(); + await expect(page.getByText('testuser@example.com')).toBeVisible(); + }); + + const user2 = await createUser(page, project.region, project.id, { + name: 'Second User', + email: 'second@second.com', + password: 'password456' + }); + + const user3 = await createUser(page, project.region, project.id, { + name: 'Third User', + email: 'third@example.com', + phone: '+13334445555', + password: 'password789' + }); + + await updateUserName(page, project.region, project.id, user.id, 'Updated Test User'); + await updateUserEmail(page, project.region, project.id, user.id, 'updated@example.com'); + await updateUserPhone(page, project.region, project.id, user.id, '+19876543210'); + await updateUserPassword(page, project.region, project.id, user.id, 'newpassword123'); + + await updateUserStatus(page, project.region, project.id, user.id, false); + await test.step('verify blocked status', async () => { + await navigateToUsers(page, project.region, project.id); + const userRow = page.locator('[role="row"]').filter({ hasText: 'Updated Test User' }); + await expect(userRow.getByText('blocked')).toBeVisible({ timeout: 15000 }); + }); + + await updateUserStatus(page, project.region, project.id, user.id, true); + await test.step('verify unblocked status', async () => { + await navigateToUsers(page, project.region, project.id); + const userRow = page.locator('[role="row"]').filter({ hasText: 'Updated Test User' }); + await expect(userRow.getByText('blocked')).not.toBeVisible({ timeout: 15000 }); + }); + + await updateUserLabels(page, project.region, project.id, user.id, ['test', 'e2e', 'freeTier']); + + await test.step('search by name', async () => { + await navigateToUsers(page, project.region, project.id); + await searchUser(page, 'Updated'); + await expect(page.getByText('Updated Test User')).toBeVisible(); + await expect(page.getByText('Second User')).not.toBeVisible(); + await expect(page.getByText('Third User')).not.toBeVisible(); + }); + + await test.step('search by email', async () => { + await navigateToUsers(page, project.region, project.id); + await searchUser(page, 'updated@example.com'); + await expect(page.getByText('updated@example.com')).toBeVisible(); + await expect(page.getByText('second@second.com')).not.toBeVisible(); + }); + + await test.step('verify multiple users', async () => { + await clearUserSearch(page); + await navigateToUsers(page, project.region, project.id); + + const updatedRow = page.locator('[role="row"]').filter({ hasText: 'Updated Test User' }); + const secondRow = page.locator('[role="row"]').filter({ hasText: 'Second User' }); + const thirdRow = page.locator('[role="row"]').filter({ hasText: 'Third User' }); + + await expect(updatedRow).toBeVisible({ timeout: 5000 }); + await expect(secondRow).toBeVisible({ timeout: 5000 }); + await expect(thirdRow).toBeVisible({ timeout: 5000 }); + }); + + await test.step('test email and phone verification', async () => { + const userRow = page.locator('[role="row"]').filter({ hasText: 'Updated Test User' }); + await expect(userRow.getByText('unverified')).toBeVisible(); + + await updateUserEmailVerification(page, project.region, project.id, user.id, true); + await navigateToUsers(page, project.region, project.id); + await expect(userRow.getByText('verified email')).toBeVisible(); + + await updateUserPhoneVerification(page, project.region, project.id, user.id, true); + await navigateToUsers(page, project.region, project.id); + await expect(userRow.getByText('verified')).toBeVisible(); + + await updateUserPhoneVerification(page, project.region, project.id, user.id, false); + await navigateToUsers(page, project.region, project.id); + await expect(userRow.getByText('verified email')).toBeVisible(); + }); + + await test.step('test user preferences', async () => { + await updateUserPrefs(page, project.region, project.id, user.id, { + theme: 'dark', + language: 'en', + timezone: 'UTC' + }); + }); + + await test.step('test MFA toggle', async () => { + await updateUserMfa(page, project.region, project.id, user.id, true); + await updateUserMfa(page, project.region, project.id, user.id, false); + }); + + await deleteUser(page, project.region, project.id, user.id); + await deleteUser(page, project.region, project.id, user2.id); + await deleteUser(page, project.region, project.id, user3.id); + + await test.step('verify users deleted', async () => { + await navigateToUsers(page, project.region, project.id); + await expect(page.getByText('Updated Test User')).not.toBeVisible(); + await expect(page.getByText('Second User')).not.toBeVisible(); + await expect(page.getByText('Third User')).not.toBeVisible(); + }); +}); diff --git a/e2e/journeys/onboarding-free.spec.ts b/e2e/journeys/onboarding-free.spec.ts index 622088d970..efa974fc9e 100644 --- a/e2e/journeys/onboarding-free.spec.ts +++ b/e2e/journeys/onboarding-free.spec.ts @@ -1,8 +1,7 @@ -import { test } from '@playwright/test'; -import { registerUserStep } from '../steps/account'; -import { createFreeProject } from '../steps/free-project'; +import { test, expect } from '../fixtures/base'; -test('onboarding - free tier', async ({ page }) => { - await registerUserStep(page); - await createFreeProject(page); +test('onboarding - free tier', async ({ page, project }) => { + await expect(page).toHaveURL( + new RegExp(`/project-${project.region}-${project.id}/get-started`) + ); }); diff --git a/e2e/journeys/onboarding-pro.spec.ts b/e2e/journeys/onboarding-pro.spec.ts index 53c22ef051..d808308539 100644 --- a/e2e/journeys/onboarding-pro.spec.ts +++ b/e2e/journeys/onboarding-pro.spec.ts @@ -1,8 +1,9 @@ -import { test } from '@playwright/test'; -import { registerUserStep } from '../steps/account'; -import { createProProject } from '../steps/pro-project'; +import { test, expect } from '../fixtures/base'; -test('onboarding - pro', async ({ page }) => { - await registerUserStep(page); - await createProProject(page); +test.use({ tier: 'pro' }); + +test('onboarding - pro', async ({ page, project }) => { + await expect(page).toHaveURL( + new RegExp(`/project-${project.region}-${project.id}/get-started`) + ); }); diff --git a/e2e/journeys/upgrade-free-tier.spec.ts b/e2e/journeys/upgrade-free-tier.spec.ts index 55c759a661..ce213a2b9f 100644 --- a/e2e/journeys/upgrade-free-tier.spec.ts +++ b/e2e/journeys/upgrade-free-tier.spec.ts @@ -1,11 +1,7 @@ -import { test } from '@playwright/test'; -import { registerUserStep } from '../steps/account'; -import { createFreeProject } from '../steps/free-project'; +import { test } from '../fixtures/base'; import { enterCreditCard } from '../steps/pro-project'; test('upgrade - free tier', async ({ page }) => { - await registerUserStep(page); - await createFreeProject(page); await test.step('upgrade project', async () => { await page.getByRole('link', { name: 'Upgrade', exact: true }).click(); await page.waitForURL(/\/organization-[^/]+\/change-plan/); diff --git a/e2e/steps/free-project.ts b/e2e/steps/free-project.ts index cf0972e478..52c9405f98 100644 --- a/e2e/steps/free-project.ts +++ b/e2e/steps/free-project.ts @@ -1,44 +1,38 @@ +import type { ProjectMetadata } from '../fixtures/base'; +import { selectRandomRegion } from '../helpers/region'; import { test, expect, type Page } from '@playwright/test'; import { getOrganizationIdFromUrl, getProjectIdFromUrl } from '../helpers/url'; -type Metadata = { - id: string; - organizationId: string; -}; - -export async function createFreeProject(page: Page): Promise { +export async function createFreeProject(page: Page): Promise { const organizationId = await test.step('create organization', async () => { await page.goto('./'); await page.waitForURL(/\/organization-[^/]+/); return getOrganizationIdFromUrl(page.url()); }); - const projectId = await test.step('create project', async () => { + const { projectId, region } = await test.step('create project', async () => { await page.waitForURL(/\/organization-[^/]+/); await page.getByRole('button', { name: 'create project' }).first().click(); const dialog = page.locator('dialog[open]'); await dialog.getByPlaceholder('Project name').fill('test project'); - let region = 'fra'; // for fallback - const regionPicker = dialog.locator('button[role="combobox"]'); - if (await regionPicker.isVisible()) { - await regionPicker.click(); - await page.getByRole('option', { name: /New York/i }).click(); - - region = 'nyc'; - } + const region = await selectRandomRegion(page, dialog); await dialog.getByRole('button', { name: 'create' }).click(); - await page.waitForURL(new RegExp(`/project-${region}-[^/]+`)); + await page.waitForURL(/\/project-[^/]+-[^/]+/); expect(page.url()).toContain(`/console/project-${region}-`); - return getProjectIdFromUrl(page.url()); + return { + projectId: getProjectIdFromUrl(page.url()), + region + }; }); return { id: projectId, - organizationId + organizationId, + region }; } diff --git a/e2e/steps/pro-project.ts b/e2e/steps/pro-project.ts index 3f3a7ec1fe..a99a64dd0e 100644 --- a/e2e/steps/pro-project.ts +++ b/e2e/steps/pro-project.ts @@ -1,10 +1,7 @@ +import type { ProjectMetadata } from '../fixtures/base'; import { test, expect, type Page } from '@playwright/test'; import { getOrganizationIdFromUrl, getProjectIdFromUrl } from '../helpers/url'; - -type Metadata = { - id: string; - organizationId: string; -}; +import { selectRandomRegion } from '../helpers/region'; export async function enterCreditCard(page: Page) { // click the `add` button inside correct view layer @@ -31,7 +28,7 @@ export async function enterCreditCard(page: Page) { }); } -export async function createProProject(page: Page): Promise { +export async function createProProject(page: Page): Promise { const organizationId = await test.step('create organization', async () => { await page.goto('./create-organization'); await page.locator('id=name').fill('test org'); @@ -46,31 +43,26 @@ export async function createProProject(page: Page): Promise { return getOrganizationIdFromUrl(page.url()); }); - const projectId = await test.step('create project', async () => { + const { projectId, region } = await test.step('create project', async () => { await page.waitForURL(/\/organization-[^/]+/); await page.getByRole('button', { name: 'create project' }).first().click(); const dialog = page.locator('dialog[open]'); await dialog.getByPlaceholder('Project name').fill('test project'); - let region = 'fra'; // for fallback - const regionPicker = dialog.locator('button[role="combobox"]'); - if (await regionPicker.isVisible()) { - await regionPicker.click(); - await page.getByRole('option', { name: /New York/i }).click(); - - region = 'nyc'; - } + const region = await selectRandomRegion(page, dialog); await dialog.getByRole('button', { name: 'create' }).click(); - await page.waitForURL(new RegExp(`/project-${region}-[^/]+`)); + + await page.waitForURL(/\/project-[^/]+-[^/]+/); expect(page.url()).toContain(`/console/project-${region}-`); - return getProjectIdFromUrl(page.url()); + return { projectId: getProjectIdFromUrl(page.url()), region }; }); return { id: projectId, - organizationId + organizationId, + region }; } diff --git a/package.json b/package.json index 5a2e77773d..307eb6fde9 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,12 @@ "clean": "rm -rf node_modules && rm -rf .svelte_kit && pnpm i --force", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", - "format": "prettier --write .", + "format": "prettier --write --cache .", "lint": "prettier --check . && eslint .", "test": "TZ=EST vitest run", "test:ui": "TZ=EST vitest --ui", "test:watch": "TZ=EST vitest watch", - "e2e": "playwright test", + "e2e": "playwright test --reporter=list", "e2e:ui": "playwright test --ui" }, "dependencies": { @@ -48,6 +48,7 @@ "tippy.js": "^6.3.7" }, "devDependencies": { + "dotenv": "^17.2.3", "@eslint/compat": "^1.3.1", "@eslint/js": "^9.31.0", "@melt-ui/pp": "^0.3.2", @@ -85,7 +86,8 @@ "typescript": "^5.8.2", "typescript-eslint": "^8.30.1", "vite": "^7.0.6", - "vitest": "^3.2.4" + "vitest": "^3.2.4", + "@types/node": "^24.9.2" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/playwright.config.ts b/playwright.config.ts index fbd977757f..fe6952ed6a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,8 @@ +import { config as loadEnv } from 'dotenv'; import { type PlaywrightTestConfig } from '@playwright/test'; +loadEnv(); + const config: PlaywrightTestConfig = { timeout: 120000, reportSlowTests: null, @@ -7,20 +10,23 @@ const config: PlaywrightTestConfig = { retries: 3, testDir: 'e2e', use: { - baseURL: 'http://localhost:4173/console/', + baseURL: 'http://localhost:3000/console/', trace: 'on-first-retry' }, webServer: { timeout: 120000, env: { - PUBLIC_APPWRITE_ENDPOINT: 'https://stage.cloud.appwrite.io/v1', - PUBLIC_CONSOLE_MODE: 'cloud', - PUBLIC_APPWRITE_MULTI_REGION: 'true', + PUBLIC_APPWRITE_ENDPOINT: + process.env.PUBLIC_APPWRITE_ENDPOINT || 'https://stage.cloud.appwrite.io/v1', + PUBLIC_CONSOLE_MODE: process.env.PUBLIC_CONSOLE_MODE || 'cloud', + PUBLIC_APPWRITE_MULTI_REGION: process.env.PUBLIC_APPWRITE_MULTI_REGION || 'true', PUBLIC_STRIPE_KEY: + process.env.PUBLIC_STRIPE_KEY || 'pk_test_51LT5nsGYD1ySxNCyd7b304wPD8Y1XKKWR6hqo6cu3GIRwgvcVNzoZv4vKt5DfYXL1gRGw4JOqE19afwkJYJq1g3K004eVfpdWn' }, - command: 'pnpm run build && pnpm run preview', - port: 4173 + command: 'pnpm run dev', + port: 3000, + reuseExistingServer: true } }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82b9a6afc8..c470f9a29c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,7 +34,7 @@ importers: version: 2.11.8 '@sentry/sveltekit': specifier: ^8.38.0 - version: 8.55.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.30.0)(@sveltejs/kit@2.42.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)) + version: 8.55.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.30.0)(@sveltejs/kit@2.42.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)) '@stripe/stripe-js': specifier: ^3.5.0 version: 3.5.0 @@ -101,13 +101,13 @@ importers: version: 1.56.1 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.8(@sveltejs/kit@2.42.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0))) + version: 3.0.8(@sveltejs/kit@2.42.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0))) '@sveltejs/kit': specifier: ^2.42.1 - version: 2.42.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)) + version: 2.42.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)) '@sveltejs/vite-plugin-svelte': specifier: ^5.0.3 - version: 5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)) + version: 5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)) '@testing-library/dom': specifier: ^10.4.0 version: 10.4.0 @@ -116,13 +116,16 @@ importers: version: 6.6.3 '@testing-library/svelte': specifier: ^5.2.8 - version: 5.2.8(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0))(vitest@3.2.4) + version: 5.2.8(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0))(vitest@3.2.4) '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.0) '@types/deep-equal': specifier: ^1.0.4 version: 1.0.4 + '@types/node': + specifier: ^24.9.2 + version: 24.9.2 '@types/prismjs': specifier: ^1.26.5 version: 1.26.5 @@ -141,6 +144,9 @@ importers: color: specifier: ^5.0.0 version: 5.0.0 + dotenv: + specifier: ^17.2.3 + version: 17.2.3 eslint: specifier: ^9.31.0 version: 9.31.0 @@ -194,10 +200,10 @@ importers: version: 8.30.1(eslint@9.31.0)(typescript@5.8.2) vite: specifier: ^7.0.6 - version: 7.0.6(@types/node@22.13.14)(sass@1.86.0) + version: 7.0.6(@types/node@24.9.2)(sass@1.86.0) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@22.13.14)(@vitest/ui@3.2.4)(jsdom@26.1.0)(sass@1.86.0) + version: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jsdom@26.1.0)(sass@1.86.0) packages: @@ -1369,8 +1375,8 @@ packages: '@types/mysql@2.15.26': resolution: {integrity: sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==} - '@types/node@22.13.14': - resolution: {integrity: sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==} + '@types/node@24.9.2': + resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==} '@types/pg-pool@2.0.6': resolution: {integrity: sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==} @@ -2035,6 +2041,10 @@ packages: resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} engines: {node: '>=12'} + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -3387,8 +3397,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@6.20.0: - resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} unist-util-is@6.0.0: resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} @@ -4674,19 +4684,19 @@ snapshots: magic-string: 0.30.7 svelte: 5.25.3 - '@sentry/sveltekit@8.55.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.30.0)(@sveltejs/kit@2.42.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0))': + '@sentry/sveltekit@8.55.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.30.0)(@sveltejs/kit@2.42.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0))': dependencies: '@sentry/core': 8.55.0 '@sentry/node': 8.55.0 '@sentry/opentelemetry': 8.55.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.30.0) '@sentry/svelte': 8.55.0(svelte@5.25.3) '@sentry/vite-plugin': 2.22.6 - '@sveltejs/kit': 2.42.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)) + '@sveltejs/kit': 2.42.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)) magic-string: 0.30.7 magicast: 0.2.8 sorcery: 1.0.0 optionalDependencies: - vite: 7.0.6(@types/node@22.13.14)(sass@1.86.0) + vite: 7.0.6(@types/node@24.9.2)(sass@1.86.0) transitivePeerDependencies: - '@opentelemetry/api' - '@opentelemetry/context-async-hooks' @@ -4753,15 +4763,15 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/adapter-static@3.0.8(@sveltejs/kit@2.42.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)))': + '@sveltejs/adapter-static@3.0.8(@sveltejs/kit@2.42.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)))': dependencies: - '@sveltejs/kit': 2.42.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)) + '@sveltejs/kit': 2.42.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)) - '@sveltejs/kit@2.42.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0))': + '@sveltejs/kit@2.42.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0))': dependencies: '@standard-schema/spec': 1.0.0 '@sveltejs/acorn-typescript': 1.0.5(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)) + '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 @@ -4774,29 +4784,29 @@ snapshots: set-cookie-parser: 2.7.1 sirv: 3.0.1 svelte: 5.25.3 - vite: 7.0.6(@types/node@22.13.14)(sass@1.86.0) + vite: 7.0.6(@types/node@24.9.2)(sass@1.86.0) optionalDependencies: '@opentelemetry/api': 1.9.0 - '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0))': + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0))': dependencies: - '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)) + '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)) debug: 4.4.0 svelte: 5.25.3 - vite: 7.0.6(@types/node@22.13.14)(sass@1.86.0) + vite: 7.0.6(@types/node@24.9.2)(sass@1.86.0) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0))': + '@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)) + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)) debug: 4.4.0 deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.17 svelte: 5.25.3 - vite: 7.0.6(@types/node@22.13.14)(sass@1.86.0) - vitefu: 1.0.6(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)) + vite: 7.0.6(@types/node@24.9.2)(sass@1.86.0) + vitefu: 1.0.6(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)) transitivePeerDependencies: - supports-color @@ -4832,13 +4842,13 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 - '@testing-library/svelte@5.2.8(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0))(vitest@3.2.4)': + '@testing-library/svelte@5.2.8(svelte@5.25.3)(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0))(vitest@3.2.4)': dependencies: '@testing-library/dom': 10.4.0 svelte: 5.25.3 optionalDependencies: - vite: 7.0.6(@types/node@22.13.14)(sass@1.86.0) - vitest: 3.2.4(@types/node@22.13.14)(@vitest/ui@3.2.4)(jsdom@26.1.0)(sass@1.86.0) + vite: 7.0.6(@types/node@24.9.2)(sass@1.86.0) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jsdom@26.1.0)(sass@1.86.0) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.0)': dependencies: @@ -4852,7 +4862,7 @@ snapshots: '@types/connect@3.4.36': dependencies: - '@types/node': 22.13.14 + '@types/node': 24.9.2 '@types/cookie@0.6.0': {} @@ -4878,11 +4888,11 @@ snapshots: '@types/mysql@2.15.26': dependencies: - '@types/node': 22.13.14 + '@types/node': 24.9.2 - '@types/node@22.13.14': + '@types/node@24.9.2': dependencies: - undici-types: 6.20.0 + undici-types: 7.16.0 '@types/pg-pool@2.0.6': dependencies: @@ -4890,7 +4900,7 @@ snapshots: '@types/pg@8.6.1': dependencies: - '@types/node': 22.13.14 + '@types/node': 24.9.2 pg-protocol: 1.8.0 pg-types: 2.2.0 @@ -4909,7 +4919,7 @@ snapshots: '@types/tedious@4.0.14': dependencies: - '@types/node': 22.13.14 + '@types/node': 24.9.2 '@types/unist@3.0.3': {} @@ -5077,13 +5087,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0))': + '@vitest/mocker@3.2.4(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 7.0.6(@types/node@22.13.14)(sass@1.86.0) + vite: 7.0.6(@types/node@24.9.2)(sass@1.86.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -5114,7 +5124,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@22.13.14)(@vitest/ui@3.2.4)(jsdom@26.1.0)(sass@1.86.0) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jsdom@26.1.0)(sass@1.86.0) '@vitest/utils@3.2.4': dependencies: @@ -5672,6 +5682,8 @@ snapshots: dotenv@16.4.7: {} + dotenv@17.2.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -7048,7 +7060,7 @@ snapshots: typescript@5.8.2: {} - undici-types@6.20.0: {} + undici-types@7.16.0: {} unist-util-is@6.0.0: dependencies: @@ -7106,13 +7118,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@3.2.4(@types/node@22.13.14)(sass@1.86.0): + vite-node@3.2.4(@types/node@24.9.2)(sass@1.86.0): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.0.6(@types/node@22.13.14)(sass@1.86.0) + vite: 7.0.6(@types/node@24.9.2)(sass@1.86.0) transitivePeerDependencies: - '@types/node' - jiti @@ -7127,7 +7139,7 @@ snapshots: - tsx - yaml - vite@7.0.6(@types/node@22.13.14)(sass@1.86.0): + vite@7.0.6(@types/node@24.9.2)(sass@1.86.0): dependencies: esbuild: 0.25.1 fdir: 6.4.6(picomatch@4.0.3) @@ -7136,19 +7148,19 @@ snapshots: rollup: 4.46.2 tinyglobby: 0.2.14 optionalDependencies: - '@types/node': 22.13.14 + '@types/node': 24.9.2 fsevents: 2.3.3 sass: 1.86.0 - vitefu@1.0.6(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)): + vitefu@1.0.6(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)): optionalDependencies: - vite: 7.0.6(@types/node@22.13.14)(sass@1.86.0) + vite: 7.0.6(@types/node@24.9.2)(sass@1.86.0) - vitest@3.2.4(@types/node@22.13.14)(@vitest/ui@3.2.4)(jsdom@26.1.0)(sass@1.86.0): + vitest@3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jsdom@26.1.0)(sass@1.86.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)) + '@vitest/mocker': 3.2.4(vite@7.0.6(@types/node@24.9.2)(sass@1.86.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -7166,11 +7178,11 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.0.6(@types/node@22.13.14)(sass@1.86.0) - vite-node: 3.2.4(@types/node@22.13.14)(sass@1.86.0) + vite: 7.0.6(@types/node@24.9.2)(sass@1.86.0) + vite-node: 3.2.4(@types/node@24.9.2)(sass@1.86.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 22.13.14 + '@types/node': 24.9.2 '@vitest/ui': 3.2.4(vitest@3.2.4) jsdom: 26.1.0 transitivePeerDependencies: diff --git a/src/routes/(console)/account/delete.svelte b/src/routes/(console)/account/delete.svelte index b7bf6fa151..e391ea1463 100644 --- a/src/routes/(console)/account/delete.svelte +++ b/src/routes/(console)/account/delete.svelte @@ -16,7 +16,7 @@ showDelete = false; addNotification({ type: 'success', - message: `Account was deleted ` + message: 'Account was deleted' }); trackEvent(Submit.AccountDelete); } catch (e) { diff --git a/src/routes/(console)/organization-[organization]/settings/+page.svelte b/src/routes/(console)/organization-[organization]/settings/+page.svelte index b5bf05fc0d..ca84716806 100644 --- a/src/routes/(console)/organization-[organization]/settings/+page.svelte +++ b/src/routes/(console)/organization-[organization]/settings/+page.svelte @@ -89,7 +89,9 @@ -
{$organization.name}
+
+ {$organization.name} +

{orgMembers}, {orgProjects}

diff --git a/src/routes/(console)/project-[region]-[project]/auth/user-[user]/updateStatus.svelte b/src/routes/(console)/project-[region]-[project]/auth/user-[user]/updateStatus.svelte index ffbd35e642..08da73ee25 100644 --- a/src/routes/(console)/project-[region]-[project]/auth/user-[user]/updateStatus.svelte +++ b/src/routes/(console)/project-[region]-[project]/auth/user-[user]/updateStatus.svelte @@ -18,20 +18,21 @@ async function updateVerificationEmail() { showVerificationDropdown = false; try { - await sdk + const userUpdated = await sdk .forProject(page.params.region, page.params.project) .users.updateEmailVerification({ userId: $user.$id, emailVerification: !$user.emailVerification }); - await invalidate(Dependencies.USER); + addNotification({ - message: `${$user.name || $user.email || $user.phone || 'The account'} has been ${ - !$user.emailVerification ? 'unverified' : 'verified' + message: `${userUpdated.name || userUpdated.email || userUpdated.phone || 'The account'} has been ${ + !userUpdated.emailVerification ? 'unverified' : 'verified' }`, type: 'success' }); trackEvent(Submit.UserUpdateVerificationEmail); + await invalidate(Dependencies.USER); } catch (error) { addNotification({ message: error.message, @@ -43,19 +44,20 @@ async function updateVerificationPhone() { showVerificationDropdown = false; try { - await sdk + const userUpdated = await sdk .forProject(page.params.region, page.params.project) .users.updatePhoneVerification({ userId: $user.$id, phoneVerification: !$user.phoneVerification }); - await invalidate(Dependencies.USER); + addNotification({ - message: `${$user.name || $user.email || $user.phone || 'The account'} has been ${ - $user.phoneVerification ? 'unverified' : 'verified' + message: `${userUpdated.name || userUpdated.email || userUpdated.phone || 'The account'} has been ${ + !userUpdated.phoneVerification ? 'unverified' : 'verified' }`, type: 'success' }); + await invalidate(Dependencies.USER); trackEvent(Submit.UserUpdateVerificationPhone); } catch (error) { addNotification({ @@ -120,7 +122,7 @@

Joined: {toLocaleDateTime($user.registration)}

Last activity: {accessedAt ? toLocaleDate(accessedAt) : 'never'}

-
+
{#if !$user.status} {:else if $user.email && $user.phone} diff --git a/src/routes/(console)/project-[region]-[project]/settings/deleteProject.svelte b/src/routes/(console)/project-[region]-[project]/settings/deleteProject.svelte index 0af70f0083..2464f33961 100644 --- a/src/routes/(console)/project-[region]-[project]/settings/deleteProject.svelte +++ b/src/routes/(console)/project-[region]-[project]/settings/deleteProject.svelte @@ -49,7 +49,7 @@ -
{$project.name}
+
{$project.name}
{#if isCloud && $projectRegion}

Region: {$projectRegion.name}